diff --git a/internal/commands/commands.go b/internal/commands/commands.go index dbf026b..e36e8cd 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -86,8 +86,8 @@ type Context struct { Mgr *buffers.Manager // Ring is the shared kill ring that kill/copy/yank commands use. Ring *killring.Ring - // Expand is the expand-region state machine (word, line, paragraph, - // buffer selection levels). + // Expand is the expand-region state machine (word, syntax, sentence, + // line, paragraph, buffer selection levels). Expand *expand.State // Mark reports whether the mark is set. The boolean is shared with the // application so commands and key handling observe the same state. @@ -195,7 +195,7 @@ func init() { Register("select-line", "Select the line containing the cursor.", "selection", runSelectLine) Register("select-paragraph", "Select the paragraph containing the cursor.", "selection", runSelectParagraph) Register("select-all", "Select the whole buffer.", "selection", runSelectAll) - Register("expand-region", "Expand the selection: word, line, paragraph, buffer.", "selection", runExpandRegion) + Register("expand-region", "Expand the selection: word, syntax, sentence, line, paragraph, buffer.", "selection", runExpandRegion) Register("mark-syntax-node", "Select the smallest substantial syntax node at the cursor.", "mark", runMarkSyntaxNode) Register("drag-forward", "Move the selected text object forward one step (after the next object of its kind).", "selection", runDragForward) Register("drag-backward", "Move the selected text object backward one step (before the previous object of its kind).", "selection", runDragBackward) diff --git a/internal/expand/expand.go b/internal/expand/expand.go index 4250398..c746540 100644 --- a/internal/expand/expand.go +++ b/internal/expand/expand.go @@ -1,7 +1,7 @@ // Package expand implements an expand-region step machine: it grows the // current selection outward one level at a time, from the word under the -// cursor, to the sentence, up the tree-sitter syntax nodes at the cursor -// (smallest named node outward through its ancestors), then to the whole +// cursor, up the tree-sitter syntax nodes at the cursor (smallest named +// node outward through its ancestors), then to the sentence, the whole // line, the surrounding paragraph, and finally the entire document. package expand @@ -14,8 +14,8 @@ import ( // Expansion levels, in the order Step advances through them. const ( levelWord = 1 - levelSentence = 2 - levelSyntax = 3 + levelSyntax = 2 + levelSentence = 3 levelLine = 4 levelParagraph = 5 levelDocument = 6 @@ -78,15 +78,14 @@ func (s *State) SetExtent(extent buffer.Range) { s.extent = extent } // Step advances the expansion by one level and applies the resulting // selection to b: level 1 selects the word under the cursor, level 2 the -// sentence, level 3 the syntax node at the cursor (then its named -// ancestors on repeated Steps), level 4 the whole line, level 5 the -// surrounding paragraph, and level 6 the entire document; levels beyond -// the document stay at the document level. When st is nil or unusable the -// syntax level falls back to the line range and is skipped. When no -// selection is active, Step starts a fresh session from the word level. If -// no word contains the cursor at the word level, or the computed range -// turns out to be empty, the session is reset and the selection is left -// untouched. +// syntax node at the cursor (then its named ancestors on repeated Steps), +// level 3 the sentence, level 4 the whole line, level 5 the surrounding +// paragraph, and level 6 the entire document; levels beyond the document +// stay at the document level. When st is nil or unusable the syntax level +// falls back to the line range and is skipped. When no selection is +// active, Step starts a fresh session from the word level. If no word +// contains the cursor at the word level, or the computed range turns out +// to be empty, the session is reset and the selection is left untouched. func (s *State) Step(b *buffer.Buffer, st *syntax.State) { if _, ok := b.Selection(); !ok { s.Reset() @@ -104,10 +103,10 @@ func (s *State) Step(b *buffer.Buffer, st *syntax.State) { rng, ok := expand(b, st, s) if !ok || rng.IsEmpty() { // The sentence level finds nothing on code with no sentence - // terminators. When the syntax climb is usable, stay at the - // sentence level instead of abandoning the session: the next Step - // advances into the climb (the selection stays as it was). - if s.level == levelSentence && st != nil && st.Err == nil && st.Tree != nil { + // terminators. Hold at the sentence level instead of abandoning + // the session: the next Step advances to the line level (the + // selection stays as it was). + if s.level == levelSentence { return } s.Reset() @@ -125,10 +124,10 @@ func kindFinder(level int) objects.Finder { switch level { case levelWord: return objects.WordAt - case levelSentence: - return objects.SentenceAt case levelSyntax: return objects.SyntaxAt + case levelSentence: + return objects.SentenceAt case levelLine: return objects.LineAt case levelParagraph: @@ -155,15 +154,9 @@ func expand(b *buffer.Buffer, st *syntax.State, s *State) (buffer.Range, bool) { return buffer.Range{}, false } return buffer.Range{Start: ext.Start, End: ext.End}, true - case levelSentence: - ext := objects.SentenceAt(objects.Env{B: b}, cur, objects.At, objects.Forward) - if !ext.Found { - return buffer.Range{}, false - } - return buffer.Range{Start: ext.Start, End: ext.End}, true case levelSyntax: if st == nil || st.Err != nil || st.Tree == nil { - s.syntaxDepth = -1 // unusable: skip the climb, next Step advances to the line level + s.syntaxDepth = -1 // unusable: skip the climb, next Step advances to the sentence level ext := objects.LineAt(objects.Env{B: b}, cur, objects.At, objects.Forward) if !ext.Found { return buffer.Range{}, false @@ -202,6 +195,12 @@ func expand(b *buffer.Buffer, st *syntax.State, s *State) (buffer.Range, bool) { s.syntaxDepth = -1 // root reached: the climb is exhausted } return buffer.Range{Start: ext.Start, End: ext.End}, true + case levelSentence: + ext := objects.SentenceAt(objects.Env{B: b}, cur, objects.At, objects.Forward) + if !ext.Found { + return buffer.Range{}, false + } + return buffer.Range{Start: ext.Start, End: ext.End}, true case levelLine: ext := objects.LineAt(objects.Env{B: b}, cur, objects.At, objects.Forward) if !ext.Found { diff --git a/internal/expand/expand_test.go b/internal/expand/expand_test.go index 17d42ae..9cc1bac 100644 --- a/internal/expand/expand_test.go +++ b/internal/expand/expand_test.go @@ -31,8 +31,8 @@ func TestExpandSentenceOrder(t *testing.T) { st := &State{} want := []buffer.Range{ {Start: pos(0, 0), End: pos(0, 5)}, // word - {Start: pos(0, 0), End: pos(0, 12)}, // sentence {Start: pos(0, 0), End: pos(1, 0)}, // syntax level with a nil state: falls back to the line + {Start: pos(0, 0), End: pos(0, 12)}, // sentence {Start: pos(0, 0), End: pos(1, 0)}, // line, through the newline {Start: pos(0, 0), End: pos(1, 21)}, // paragraph {Start: pos(0, 0), End: pos(3, 13)}, // buffer @@ -81,23 +81,32 @@ func TestExpandResetsAtBufferEnd(t *testing.T) { } } -func TestExpandResetsWithoutSentenceStructure(t *testing.T) { +func TestExpandWithoutSentenceStructureHolds(t *testing.T) { + // "abc def" has no sentence terminator, so the sentence level finds + // nothing and holds (selection untouched) instead of resetting; the + // next Step advances to the line level. The syntax level (nil state) + // falls back to the line range before the sentence level. b := mk("abc def") b.SetCursor(pos(0, 1)) st := &State{} st.Step(b, nil) // word level selects "abc" - wordSel, ok := b.Selection() + st.Step(b, nil) // syntax level with nil state: falls back to the line + lineSel, ok := b.Selection() if !ok { - t.Fatal("word step selected nothing") + t.Fatal("syntax step selected nothing") + } + if lineSel != (buffer.Range{Start: pos(0, 0), End: pos(0, 7)}) { + t.Fatalf("syntax step: selection = %v, want the line", lineSel) } - st.Step(b, nil) // sentence level: no terminators -> reset, selection untouched - if sel, ok := b.Selection(); !ok || sel != wordSel { - t.Errorf("after failed sentence step selection = %v (ok=%v), want %v untouched", sel, ok, wordSel) + st.Step(b, nil) // sentence level: no terminators -> hold, selection untouched + if sel, ok := b.Selection(); !ok || sel != lineSel { + t.Errorf("after failed sentence step selection = %v (ok=%v), want %v untouched", sel, ok, lineSel) } - // The next Step restarts from the word level. + // The next Step advances to the line level (same range as the syntax + // fallback), not a reset to the word. st.Step(b, nil) - if sel, ok := b.Selection(); !ok || sel != wordSel { - t.Errorf("after restart selection = %v (ok=%v), want %v", sel, ok, wordSel) + if sel, ok := b.Selection(); !ok || sel != lineSel { + t.Errorf("after line step selection = %v (ok=%v), want %v", sel, ok, lineSel) } } @@ -106,9 +115,10 @@ func TestExpandSyntaxClimb(t *testing.T) { // syntax level selects the smallest named node strictly bigger than // the word — call_expression — then climbs one node bigger per Step: // call_expression -> expression_statement -> statement_list -> block - // -> function_declaration -> source_file (root), then the line level. - // The node kinds and byte ranges were confirmed by printing n.Kind() - // for each named ancestor at the cursor byte during bring-up. + // -> function_declaration -> source_file (root), then the sentence + // level (which holds on Go code), then the line level. The node kinds + // and byte ranges were confirmed by printing n.Kind() for each named + // ancestor at the cursor byte during bring-up. text := "package main\n\nfunc main() {\n\tprintln(\"hi\")\n}\n" b := mk(text) st, err := syntax.NewState("go") @@ -128,16 +138,6 @@ func TestExpandSyntaxClimb(t *testing.T) { t.Fatalf("word step: selection = %v (ok=%v), want the word println", sel, ok) } - // The sentence level is punctuation-gated: Go source has no sentence - // terminator, so the sentence Step finds nothing. With a usable - // syntax state the machine stays at the sentence level (selection - // untouched) so the next Step advances into the climb instead of - // abandoning the session. - s.Step(b, st) - if sel, ok := b.Selection(); !ok || sel != (buffer.Range{Start: pos(3, 1), End: pos(3, 8)}) { - t.Fatalf("sentence step: selection = %v (ok=%v), want it unchanged", sel, ok) - } - // The climb itself, one strictly-bigger span per Step. Wrappers with // an identical span are skipped: expression_statement wraps // call_expression at the same byte range, so the climb goes straight @@ -163,7 +163,13 @@ func TestExpandSyntaxClimb(t *testing.T) { } } // The source_file step reached the root, so the climb's depth was - // exhausted: the next Step advances to the line level. + // exhausted: the next Step advances to the sentence level, which + // finds nothing on Go code and holds (selection untouched), then the + // line level. + s.Step(b, st) + if sel, ok := b.Selection(); !ok || sel != (buffer.Range{Start: pos(0, 0), End: pos(5, 0)}) { + t.Errorf("post-root sentence hold: selection = %v (ok=%v), want %v unchanged", sel, ok, buffer.Range{Start: pos(0, 0), End: pos(5, 0)}) + } s.Step(b, st) wantLine := buffer.Range{Start: pos(3, 0), End: pos(4, 0)} if sel, ok := b.Selection(); !ok || sel != wantLine { @@ -179,16 +185,16 @@ func TestExpandSyntaxClimb(t *testing.T) { func TestExpandSyntaxSkipsClimbWhenNil(t *testing.T) { // A nil state disables the climb: the syntax level falls back to the - // line range and the machine advances on to the line, paragraph and - // document levels. The text needs a sentence terminator so the machine - // reaches the syntax level at all. + // line range and the machine advances on to the sentence, line, + // paragraph and document levels. The text needs a sentence terminator + // so the sentence level finds a range (otherwise it would hold). b := mk("one. two\nthree\n") b.SetCursor(pos(0, 0)) st := &State{} got := []buffer.Range{ {Start: pos(0, 0), End: pos(0, 3)}, // word "one" - {Start: pos(0, 0), End: pos(0, 4)}, // sentence "one." {Start: pos(0, 0), End: pos(1, 0)}, // syntax level: nil -> line fallback + {Start: pos(0, 0), End: pos(0, 4)}, // sentence "one." {Start: pos(0, 0), End: pos(1, 0)}, // line level {Start: pos(0, 0), End: pos(1, 5)}, // paragraph {Start: pos(0, 0), End: pos(2, 0)}, // document @@ -220,9 +226,8 @@ func TestExpandSyntaxStartsAboveSelection(t *testing.T) { // STRICTLY containing the selected word, not re-select the identifier: // at println the word is the identifier (pos(3,1)-pos(3,8)) and the // node strictly containing it is the call_expression println("hi") - // (pos(3,1)-pos(3,14)). Sentence holds (no terminator), so Step 2 is - // the sentence hold and Step 3 is the syntax level anchored above the - // selection. + // (pos(3,1)-pos(3,14)). Syntax comes right after word, so Step 2 is + // the syntax level anchored above the selection. text := "package main\n\nfunc main() {\n\tprintln(\"hi\")\n}\n" b := mk(text) tree := goTree(t, text) @@ -239,13 +244,6 @@ func TestExpandSyntaxStartsAboveSelection(t *testing.T) { t.Fatalf("word step: FinderFor() = nil, want objects.WordAt") } - // Sentence level: Go code has no sentence terminator, so with a usable - // tree the machine holds at the sentence level (selection untouched). - s.Step(b, &syntax.State{Tree: tree}) - if sel, ok := b.Selection(); !ok || sel != (buffer.Range{Start: pos(3, 1), End: pos(3, 8)}) { - t.Fatalf("sentence hold: selection = %v (ok=%v), want it unchanged", sel, ok) - } - // Syntax level: anchored above the selected word, the smallest strictly // bigger node is the call_expression, NOT the identifier again. s.Step(b, &syntax.State{Tree: tree}) @@ -259,9 +257,11 @@ func TestExpandSyntaxStartsAboveSelection(t *testing.T) { } func TestExpandSyntaxBeforeLine(t *testing.T) { - // Pin the CURRENT level order: syntax is level 3, line level 4. - // After the syntax climb reaches the root (source_file), the next Step - // selects the LINE, then the paragraph — no order regression. + // Pin the CURRENT level order: syntax is level 2, sentence level 3, + // line level 4. After the syntax climb reaches the root (source_file), + // the next Step holds at the sentence level (no terminator on Go + // code), then selects the LINE, then the paragraph — no order + // regression. text := "package main\n\nfunc main() {\n\tprintln(\"hi\")\n}\n" b := mk(text) tree := goTree(t, text) @@ -284,8 +284,14 @@ func TestExpandSyntaxBeforeLine(t *testing.T) { if sel, ok := b.Selection(); !ok || sel != root { t.Fatalf("climb did not reach the root: selection = %v (ok=%v), want %v", sel, ok, root) } - // Exhausted: the next Step advances out of the syntax level and - // selects the LINE at the cursor. + // Exhausted: the next Step advances out of the syntax level to the + // sentence level, which finds nothing on Go code and holds (selection + // untouched). + s.Step(b, &syntax.State{Tree: tree}) + if sel, ok := b.Selection(); !ok || sel != root { + t.Fatalf("sentence hold: selection = %v (ok=%v), want %v unchanged", sel, ok, root) + } + // Then the LINE at the cursor. s.Step(b, &syntax.State{Tree: tree}) wantLine := buffer.Range{Start: pos(3, 0), End: pos(4, 0)} if sel, ok := b.Selection(); !ok || sel != wantLine {