From 0de38ec837486bda869e8606d50c948cfaa876c0 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 13 Mar 2026 23:00:26 -0700 Subject: [PATCH] feat: implementing the repeat commands! Tested --- flake.nix | 1 + internal/action/find.go | 70 ++- internal/action/find_test.go | 944 +++++++++++++++++++++++++++++- internal/action/interface.go | 12 + internal/command/handlers_test.go | 5 + internal/core/find.go | 7 + internal/editor/model.go | 13 + internal/input/handler.go | 10 + internal/input/keymap.go | 10 +- 9 files changed, 1033 insertions(+), 39 deletions(-) create mode 100644 internal/core/find.go diff --git a/flake.nix b/flake.nix index ea4868c..f9e1db1 100644 --- a/flake.nix +++ b/flake.nix @@ -39,6 +39,7 @@ export GOOS=linux export GOARCH=amd64 export CGO_CFLAGS=-Wno-error=cpp; + export CGO_ENABLED=0 # Exec zsh to replace the current shell process with zsh. # This ensures your prompt and zsh configurations load correctly. diff --git a/internal/action/find.go b/internal/action/find.go index 8bb13b4..ce91e0b 100644 --- a/internal/action/find.go +++ b/internal/action/find.go @@ -10,6 +10,7 @@ type FindChar struct { Forward bool Inclusive bool Count int + Repeated bool } func (m FindChar) WithChar(char string) Motion { @@ -22,18 +23,21 @@ func (m FindChar) Type() core.MotionType { return core.CharwiseInclusive } return core.CharwiseExclusive - } // WithCount sets the count (required by Repeatable interface) -func (f FindChar) WithCount(n int) Action { - f.Count = n - return f +func (m FindChar) WithCount(n int) Action { + m.Count = n + return m } func (a FindChar) Execute(m Model) tea.Cmd { - // Get the line - // Get the current position, moved based on inputs + // Required to allow ';' and '.' + // But we should not override when we repeat the action + if !a.Repeated { + m.SetLastFind(a.Char, a.Forward, a.Inclusive) + } + win := m.ActiveWindow() buf := win.Buffer @@ -44,8 +48,13 @@ func (a FindChar) Execute(m Model) tea.Cmd { return nil } + forwardStart := col + 1 + if a.Repeated && !a.Inclusive { + forwardStart = col + 2 + } + if a.Forward { - for x := col; x < len(line); x++ { + for x := forwardStart; x < len(line); x++ { if string(line[x]) == a.Char { if a.Count == 1 { if a.Inclusive { @@ -61,8 +70,13 @@ func (a FindChar) Execute(m Model) tea.Cmd { } } + backwardStart := col - 1 + if a.Repeated && !a.Inclusive { + backwardStart = col - 2 + } + if !a.Forward { - for x := col; x >= 0; x-- { + for x := backwardStart; x >= 0; x-- { if string(line[x]) == a.Char { if a.Count == 1 { if a.Inclusive { @@ -81,3 +95,43 @@ func (a FindChar) Execute(m Model) tea.Cmd { return nil } + +type RepeatFind struct { + Count int + Reverse bool +} + +// Resolve builds the concrete FindChar that this repeat represents. +// The FSM calls this before Execute so that the subsequent Type() call +// returns the correct CharwiseInclusive / CharwiseExclusive value. +func (a RepeatFind) Resolve(m Model) Motion { + // for rev out + // T F T + // F T T + // F F F + // T T F + last := m.GetLastFind() + return FindChar{ + Char: last.Char, + Forward: last.Forward != a.Reverse, // Logical XOR + Inclusive: last.Inclusive, + Count: a.Count, + Repeated: true, + } +} + +// Type falls back to CharwiseExclusive. In practice the FSM always calls +// Resolve first and uses the returned FindChar, so this is never reached +// during normal operation. +func (a RepeatFind) Type() core.MotionType { + return core.CharwiseExclusive +} + +func (m RepeatFind) WithCount(n int) Action { + m.Count = n + return m +} + +func (a RepeatFind) Execute(m Model) tea.Cmd { + return a.Resolve(m).Execute(m) +} diff --git a/internal/action/find_test.go b/internal/action/find_test.go index 3f5a950..e1a045f 100644 --- a/internal/action/find_test.go +++ b/internal/action/find_test.go @@ -22,6 +22,7 @@ type mockModel struct { commandCursor int commandError error commandOutput string + lastFind core.LastFindCommand } func newMockModel() *mockModel { @@ -81,10 +82,14 @@ func (m *mockModel) SetBuffers(bufs []*core.Buffer) { m.buffers = bufs } func (m *mockModel) ActiveBuffer() *core.Buffer { return m.activeWindow.Buffer } // Insert Mode State -func (m *mockModel) InsertKeys() []string { return m.insertKeys } -func (m *mockModel) SetInsertKeys(keys []string) { m.insertKeys = keys } -func (m *mockModel) SetInsertRecording(count int, action Action) {} -func (m *mockModel) ExitInsertMode() {} +func (m *mockModel) InsertKeys() []string { return m.insertKeys } +func (m *mockModel) SetInsertKeys(keys []string) { m.insertKeys = keys } +func (m *mockModel) SetInsertRecording(count int, action Action) {} +func (m *mockModel) ExitInsertMode() {} +func (m *mockModel) SetLastFind(char string, forward, inclusive bool) { + m.lastFind = core.LastFindCommand{Char: char, Forward: forward, Inclusive: inclusive} +} +func (m *mockModel) GetLastFind() *core.LastFindCommand { return &m.lastFind } // Command Mode State func (m *mockModel) Command() string { return m.command } @@ -169,10 +174,9 @@ func TestFindCharForward(t *testing.T) { action.Execute(m) - // Current implementation finds char at cursor position - // In real vim, 'f' skips current position - if m.ActiveWindow().Cursor.Col != 4 { - t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) + // Cursor is on 'o' at 4; 'f' skips current position and finds next 'o' at 7 + if m.ActiveWindow().Cursor.Col != 7 { + t.Errorf("cursor col = %d, want 7", m.ActiveWindow().Cursor.Col) } }) @@ -419,9 +423,9 @@ func TestFindCharBackward(t *testing.T) { action.Execute(m) - // Current implementation finds char at cursor position - if m.ActiveWindow().Cursor.Col != 7 { - t.Errorf("cursor col = %d, want 7", m.ActiveWindow().Cursor.Col) + // Cursor is on 'o' at 7; 'F' skips current position and finds prev 'o' at 4 + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) } }) @@ -1176,9 +1180,10 @@ func TestFindCharForwardWithCount(t *testing.T) { action.Execute(m) - // Should find 5th 'a' at position 4 - if m.ActiveWindow().Cursor.Col != 4 { - t.Errorf("cursor col = %d, want 4", m.ActiveWindow().Cursor.Col) + // Cursor is on 'a' at 0; 'f' skips it, leaving only 4 more 'a's (1-4). + // Count 5 exceeds available matches, so cursor does not move. + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0 (count exceeds matches, should not move)", m.ActiveWindow().Cursor.Col) } }) @@ -1330,13 +1335,10 @@ func TestFindCharBackwardWithCount(t *testing.T) { action.Execute(m) - // Going backward from position 10, should find 5th 'b' at position 6 - // "aaaaa bbbbb" - // 01234567890 - // ^ (at 10) - // ^ (5th 'b' backward is at position 6) - if m.ActiveWindow().Cursor.Col != 6 { - t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col) + // Cursor is on 'b' at 10; 'F' skips it, leaving only 4 more 'b's (6-9). + // Count 5 exceeds available matches, so cursor does not move. + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("cursor col = %d, want 10 (count exceeds matches, should not move)", m.ActiveWindow().Cursor.Col) } }) @@ -1479,9 +1481,10 @@ func TestTillCharForwardWithCount(t *testing.T) { action.Execute(m) - // Should stop before 5th 'a' at position 3 (one before position 4) - if m.ActiveWindow().Cursor.Col != 3 { - t.Errorf("cursor col = %d, want 3", m.ActiveWindow().Cursor.Col) + // Cursor is on 'a' at 0; 't' skips it, leaving only 4 more 'a's (1-4). + // Count 5 exceeds available matches, so cursor does not move. + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor col = %d, want 0 (count exceeds matches, should not move)", m.ActiveWindow().Cursor.Col) } }) } @@ -1593,9 +1596,10 @@ func TestTillCharBackwardWithCount(t *testing.T) { action.Execute(m) - // Going backward, should stop after 5th 'b' at position 7 (one after position 6) - if m.ActiveWindow().Cursor.Col != 7 { - t.Errorf("cursor col = %d, want 7", m.ActiveWindow().Cursor.Col) + // Cursor is on 'b' at 10; 'T' skips it, leaving only 4 more 'b's (6-9). + // Count 5 exceeds available matches, so cursor does not move. + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("cursor col = %d, want 10 (count exceeds matches, should not move)", m.ActiveWindow().Cursor.Col) } }) } @@ -1703,6 +1707,892 @@ func TestFindCharWithChar(t *testing.T) { }) } +// ================================================== +// RepeatFind — ; (same direction) and , (reverse direction) +// +// Layout used throughout: "hello world" +// 01234567890 +// 'h'=0 'e'=1 'l'=2 'l'=3 'o'=4 ' '=5 'w'=6 'o'=7 'r'=8 'l'=9 'd'=10 +// ================================================== + +// -------------------------------------------------- +// ; after f (repeat forward-inclusive) +// -------------------------------------------------- + +func TestRepeatFind_Semicolon_After_f(t *testing.T) { + // `fo` from col 0 → col 4. `;` should find next 'o' at col 7. + t.Run("basic: lands on next inclusive match", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", true, true) + + FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 7 { + t.Errorf("col = %d, want 7", m.ActiveWindow().Cursor.Col) + } + }) + + // Cursor on last 'o' (col 7); no further 'o' — cursor must not move. + t.Run("no further match: cursor stays", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", true, true) + + FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 7 { + t.Errorf("col = %d, want 7 (no move)", m.ActiveWindow().Cursor.Col) + } + }) + + // Cursor at last col (10 = 'd'); searching forward finds nothing. + t.Run("cursor at end of line: no move", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 10).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", true, true) + + FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("col = %d, want 10 (no move)", m.ActiveWindow().Cursor.Col) + } + }) + + // Count=2: skip first upcoming match, land on second. + // "aXbXcX" 0123456 — cursor at 0 (before first X at 1). + // `;` with count 2 should land on X at 5. + t.Run("count=2 skips first match lands on second", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"aXbXcX"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("X", true, true) + + FindChar{Char: "X", Forward: true, Inclusive: true, Count: 2, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("col = %d, want 5", m.ActiveWindow().Cursor.Col) + } + }) + + // Repeated=true must NOT overwrite lastFind. + t.Run("does not overwrite lastFind when Repeated", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", true, true) + + FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m) + + lf := m.GetLastFind() + if lf.Char != "o" || !lf.Forward || !lf.Inclusive { + t.Errorf("lastFind modified: got {%q,%v,%v}", lf.Char, lf.Forward, lf.Inclusive) + } + }) +} + +// -------------------------------------------------- +// ; after F (repeat backward-inclusive) +// -------------------------------------------------- + +func TestRepeatFind_Semicolon_After_F(t *testing.T) { + // `Fo` from col 10 → col 7. `;` should find prev 'o' at col 4. + t.Run("basic: lands on previous inclusive match", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", false, true) + + FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + // Cursor on first 'o' (col 4); no earlier 'o' — must not move. + t.Run("no earlier match: cursor stays", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", false, true) + + FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("col = %d, want 4 (no move)", m.ActiveWindow().Cursor.Col) + } + }) + + // Cursor at col 0; searching backward finds nothing. + t.Run("cursor at start of line: no move", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", false, true) + + FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("col = %d, want 0 (no move)", m.ActiveWindow().Cursor.Col) + } + }) + + // Count=2 backward: "XaXbX" 01234 — cursor at 4 (on last X). + // `;` F count 2 → skip X at 2, land on X at 0. + t.Run("count=2 backward skips one lands on second", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"XaXbX"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("X", false, true) + + FindChar{Char: "X", Forward: false, Inclusive: true, Count: 2, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("col = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) +} + +// -------------------------------------------------- +// ; after t (repeat forward-exclusive) +// The key invariant: when Repeated && !Inclusive, start at col+2 not col+1, +// so that the char the cursor is already adjacent to is skipped. +// -------------------------------------------------- + +func TestRepeatFind_Semicolon_After_t(t *testing.T) { + // `to` col 0 → col 3 (one before 'o' at 4). + // `;` must skip 'o' at 4 and land at col 6 (one before 'o' at 7). + t.Run("basic: skips adjacent target, lands before next", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 3).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", true, false) + + FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("col = %d, want 6", m.ActiveWindow().Cursor.Col) + } + }) + + // After landing at col 6 (before 'o' at 7), another `;` looks from col 8: + // no further 'o' → cursor stays at 6. + t.Run("no further match after second repeat: cursor stays", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 6).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", true, false) + + FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("col = %d, want 6 (no move)", m.ActiveWindow().Cursor.Col) + } + }) + + // Cursor at second-to-last col: col+2 would be out of bounds → no move. + // "Xo" — cursor at 0 (just did `to` landing at 0 = x-1 where x=1). + // `;` would start at col 2 which is past end → no move. + t.Run("col+2 out of bounds: no move", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"Xo"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", true, false) + + FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("col = %d, want 0 (no move)", m.ActiveWindow().Cursor.Col) + } + }) + + // Three successive `;` after `t`: "aXbXcXd" 0123456 + // `tX` col 0 → col 0 (adjacent: x=1, land at x-1=0). + // `;`1 col 0 → col 2 (skip X at 1, find X at 3, land at 2). + // `;`2 col 2 → col 4 (skip X at 3, find X at 5, land at 4). + t.Run("three chained repeats advance correctly", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"aXbXcXd"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("X", true, false) + + // First repeat + FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("after 1st repeat: col = %d, want 2", m.ActiveWindow().Cursor.Col) + } + + // Second repeat + FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("after 2nd repeat: col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + + // Third repeat: no more X after col 6 → no move + FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("after 3rd repeat (no match): col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + // count=2 on a repeated exclusive forward find. + // "aXbXcX" 0123456 — cursor at 0 (as if `tX` just landed at 0). + // `;` count=2 must skip X at 1, find X at 3 (count--), find X at 5 (count==1) → land at 4. + t.Run("count=2 repeated exclusive forward", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"aXbXcX"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("X", true, false) + + FindChar{Char: "X", Forward: true, Inclusive: false, Count: 2, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) +} + +// -------------------------------------------------- +// ; after T (repeat backward-exclusive) +// When Repeated && !Inclusive, start at col-2 to skip the adjacent char. +// -------------------------------------------------- + +func TestRepeatFind_Semicolon_After_T(t *testing.T) { + // `To` col 10 → col 8 (one after 'o' at 7). + // `;` must skip 'o' at 7, starting search at col 6, find 'o' at 4 → land at 5. + t.Run("basic: skips adjacent target, lands after previous", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", false, false) + + FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("col = %d, want 5", m.ActiveWindow().Cursor.Col) + } + }) + + // After landing at col 5, another `;` starts at col 3: no 'o' → stays at 5. + t.Run("no earlier match after second repeat: cursor stays", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 5).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", false, false) + + FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("col = %d, want 5 (no move)", m.ActiveWindow().Cursor.Col) + } + }) + + // col-2 goes negative: col=1, backwardStart=-1 → loop never runs → no move. + t.Run("col-2 negative: no move", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"oX"}).Build() + // Cursor at col 1 (as if `TX` landed at x+1=1 where x=0). + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("X", false, false) + + FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 1 { + t.Errorf("col = %d, want 1 (no move)", m.ActiveWindow().Cursor.Col) + } + }) + + // Three chained repeats: "dXcXbXa" 0123456 + // After `TX` col 6 → col 6 (adjacent: x=5, land at x+1=6). + // `;`1 col 6 → col 4 (skip X at 5, find X at 3, land at 4). + // `;`2 col 4 → col 2 (skip X at 3, find X at 1, land at 2). + // `;`3 col 2 → no X before col 0 → stays at 2. + t.Run("three chained repeats advance correctly backward", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"dXcXbXa"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 6).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("X", false, false) + + FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("after 1st repeat: col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + + FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("after 2nd repeat: col = %d, want 2", m.ActiveWindow().Cursor.Col) + } + + FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("after 3rd repeat (no match): col = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + // count=2 repeated exclusive backward. + // "XcXbXa" 01234 — cursor at 6 (as if `TX` just landed). + // "dXcXbXa" 0123456 cursor at 6. + // `;` count=2: skip X at 5, count-- (find X at 3), count-- → find X at 1 → land at 2. + t.Run("count=2 repeated exclusive backward", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"dXcXbXa"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 6).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("X", false, false) + + FindChar{Char: "X", Forward: false, Inclusive: false, Count: 2, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("col = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) +} + +// -------------------------------------------------- +// Counted repeats: 2; and 3; equivalence with pressing ; multiple times +// +// The invariant: N; must land on the same column as pressing ; N times. +// This section verifies that the Count path through FindChar.Execute +// gives identical results to chaining individual Repeated executions. +// -------------------------------------------------- + +func TestRepeatFind_CountedRepeats(t *testing.T) { + // Line: "aXbXcXdXe" 0123456789 (wait: let's count) + // a X b X c X d X e + // 0 1 2 3 4 5 6 7 8 + // X appears at cols 1, 3, 5, 7. + + // ---- 2; after f (forward inclusive) ---- + // `fX` from col 0 → col 1. + // `;` from col 1 → col 3 (first ;) + // `2;` from col 1 should also land at col 5 (second ; equivalent), because: + // count=2 starts at col+1=2, skips X at 3 (count--), lands at X at 5. + // Wait — let me re-examine. "2;" means repeat the find 2 times from current pos. + // Pressing ; once from col 1: start at 2, find X at 3 → land 3. (not Repeated ≠ same as what we do) + // Actually after `fX` cursor is ON X at col 1. A single `;` (Repeated=true, inclusive) + // starts at col+1=2 → finds X at 3 → land at 3. + // A `2;` (Count=2, Repeated=true, inclusive) starts at col+1=2: + // X at 3 → count-- (→1); X at 5 → land at 5. + // Equivalently: press ; once (land 3), press ; again (start at 4, land at 5). ✓ + t.Run("2; after f: lands same as two ; presses (inclusive forward)", func(t *testing.T) { + line := "aXbXcXdXe" + buf := core.NewBufferBuilder().WithLines([]string{line}).Build() + + // Simulate with two sequential ; presses + win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build() + m1 := newMockModelWithWindow(&win1) + m1.SetLastFind("X", true, true) + FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) + FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) + twoSemicolons := m1.ActiveWindow().Cursor.Col + + // Simulate with a single 2; (Count=2) + win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build() + m2 := newMockModelWithWindow(&win2) + m2.SetLastFind("X", true, true) + FindChar{Char: "X", Forward: true, Inclusive: true, Count: 2, Repeated: true}.Execute(m2) + counted := m2.ActiveWindow().Cursor.Col + + if twoSemicolons != counted { + t.Errorf("2; landed at %d but two ; presses landed at %d", counted, twoSemicolons) + } + }) + + t.Run("3; after f: lands same as three ; presses (inclusive forward)", func(t *testing.T) { + line := "aXbXcXdXe" + buf := core.NewBufferBuilder().WithLines([]string{line}).Build() + + win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build() + m1 := newMockModelWithWindow(&win1) + m1.SetLastFind("X", true, true) + FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) + FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) + FindChar{Char: "X", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) + threeSemicolons := m1.ActiveWindow().Cursor.Col + + win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 1).Build() + m2 := newMockModelWithWindow(&win2) + m2.SetLastFind("X", true, true) + FindChar{Char: "X", Forward: true, Inclusive: true, Count: 3, Repeated: true}.Execute(m2) + counted := m2.ActiveWindow().Cursor.Col + + if threeSemicolons != counted { + t.Errorf("3; landed at %d but three ; presses landed at %d", counted, threeSemicolons) + } + }) + + // ---- 2; after F (backward inclusive) ---- + // X at 1,3,5,7. Cursor at col 7 (after `FX`). + // ; from 7: start at 6, find X at 5 → land 5. + // ; from 5: start at 4, find X at 3 → land 3. + // 2; from 7: count=2, start at 6: X at 5 (count--), X at 3 → land 3. ✓ + t.Run("2; after F: lands same as two ; presses (inclusive backward)", func(t *testing.T) { + line := "aXbXcXdXe" + buf := core.NewBufferBuilder().WithLines([]string{line}).Build() + + win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() + m1 := newMockModelWithWindow(&win1) + m1.SetLastFind("X", false, true) + FindChar{Char: "X", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) + FindChar{Char: "X", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m1) + twoSemicolons := m1.ActiveWindow().Cursor.Col + + win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() + m2 := newMockModelWithWindow(&win2) + m2.SetLastFind("X", false, true) + FindChar{Char: "X", Forward: false, Inclusive: true, Count: 2, Repeated: true}.Execute(m2) + counted := m2.ActiveWindow().Cursor.Col + + if twoSemicolons != counted { + t.Errorf("2; after F landed at %d but two ; presses landed at %d", counted, twoSemicolons) + } + }) + + // ---- 2; after t (forward exclusive) ---- + // Line "aXbXcXdXe", X at 1,3,5,7. + // `tX` from col 0 → land at col 0 (x=1, x-1=0, cursor stays put — adjacent). + // ; from 0: forwardStart=0+2=2, find X at 3 → land at 2. + // ; from 2: forwardStart=2+2=4, find X at 5 → land at 4. + // 2; from 0: count=2, forwardStart=2: X at 3 (count--), X at 5 → land at 4. ✓ + t.Run("2; after t: lands same as two ; presses (exclusive forward)", func(t *testing.T) { + line := "aXbXcXdXe" + buf := core.NewBufferBuilder().WithLines([]string{line}).Build() + + win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() + m1 := newMockModelWithWindow(&win1) + m1.SetLastFind("X", true, false) + FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) + FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) + twoSemicolons := m1.ActiveWindow().Cursor.Col + + win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() + m2 := newMockModelWithWindow(&win2) + m2.SetLastFind("X", true, false) + FindChar{Char: "X", Forward: true, Inclusive: false, Count: 2, Repeated: true}.Execute(m2) + counted := m2.ActiveWindow().Cursor.Col + + if twoSemicolons != counted { + t.Errorf("2; after t landed at %d but two ; presses landed at %d", counted, twoSemicolons) + } + }) + + t.Run("3; after t: lands same as three ; presses (exclusive forward)", func(t *testing.T) { + line := "aXbXcXdXe" + buf := core.NewBufferBuilder().WithLines([]string{line}).Build() + + win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() + m1 := newMockModelWithWindow(&win1) + m1.SetLastFind("X", true, false) + FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) + FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) + FindChar{Char: "X", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) + threeSemicolons := m1.ActiveWindow().Cursor.Col + + win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 0).Build() + m2 := newMockModelWithWindow(&win2) + m2.SetLastFind("X", true, false) + FindChar{Char: "X", Forward: true, Inclusive: false, Count: 3, Repeated: true}.Execute(m2) + counted := m2.ActiveWindow().Cursor.Col + + if threeSemicolons != counted { + t.Errorf("3; after t landed at %d but three ; presses landed at %d", counted, threeSemicolons) + } + }) + + // ---- 2; after T (backward exclusive) ---- + // Line "aXbXcXdXe", X at 1,3,5,7. Cursor at col 8 (after `TX`, x=7, land x+1=8). + // ; from 8: backwardStart=8-2=6, find X at 5 → land at 6. + // ; from 6: backwardStart=6-2=4, find X at 3 → land at 4. + // 2; from 8: count=2, backwardStart=6: X at 5 (count--), X at 3 → land at 4. ✓ + t.Run("2; after T: lands same as two ; presses (exclusive backward)", func(t *testing.T) { + line := "aXbXcXdXe" + buf := core.NewBufferBuilder().WithLines([]string{line}).Build() + + win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() + m1 := newMockModelWithWindow(&win1) + m1.SetLastFind("X", false, false) + FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) + FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) + twoSemicolons := m1.ActiveWindow().Cursor.Col + + win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() + m2 := newMockModelWithWindow(&win2) + m2.SetLastFind("X", false, false) + FindChar{Char: "X", Forward: false, Inclusive: false, Count: 2, Repeated: true}.Execute(m2) + counted := m2.ActiveWindow().Cursor.Col + + if twoSemicolons != counted { + t.Errorf("2; after T landed at %d but two ; presses landed at %d", counted, twoSemicolons) + } + }) + + t.Run("3; after T: lands same as three ; presses (exclusive backward)", func(t *testing.T) { + line := "aXbXcXdXe" + buf := core.NewBufferBuilder().WithLines([]string{line}).Build() + + win1 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() + m1 := newMockModelWithWindow(&win1) + m1.SetLastFind("X", false, false) + FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) + FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) + FindChar{Char: "X", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m1) + threeSemicolons := m1.ActiveWindow().Cursor.Col + + win2 := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() + m2 := newMockModelWithWindow(&win2) + m2.SetLastFind("X", false, false) + FindChar{Char: "X", Forward: false, Inclusive: false, Count: 3, Repeated: true}.Execute(m2) + counted := m2.ActiveWindow().Cursor.Col + + if threeSemicolons != counted { + t.Errorf("3; after T landed at %d but three ; presses landed at %d", counted, threeSemicolons) + } + }) +} + +// -------------------------------------------------- +// , after f (reverse of forward-inclusive = backward-inclusive) +// Reverse: true causes Forward XOR true → backward search, still inclusive. +// -------------------------------------------------- + +func TestRepeatFind_Comma_After_f(t *testing.T) { + // `fo` col 0 → col 4. `,` reverses: backward inclusive from col 4. + // The search is NOT repeated/exclusive, so it starts at col-1=3. + // No 'o' before col 4 going left → cursor stays at 4. + // + // But wait: `fo` stored Forward=true. Resolve with Reverse=true → Forward=false. + // Repeated=true, Inclusive=true → backwardStart = col-1 (no extra skip for inclusive). + // Starting at col 3 going left through "hell" — no 'o' → no move. + t.Run("no previous match after fo: cursor stays", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() + m := newMockModelWithWindow(&win) + // Simulate: lastFind was set by `fo` + m.SetLastFind("o", true, true) + + // , = RepeatFind with Reverse=true resolved to backward-inclusive + FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("col = %d, want 4 (no move)", m.ActiveWindow().Cursor.Col) + } + }) + + // `fo` from col 0 → col 4, `;` → col 7. + // `,` from col 7: backward inclusive → should find 'o' at col 4. + t.Run("after ;, comma returns to previous match", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", true, true) + + // , reversed → backward inclusive from col 7, start at col 6: finds 'o' at 4 + FindChar{Char: "o", Forward: false, Inclusive: true, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) +} + +// -------------------------------------------------- +// , after F (reverse of backward-inclusive = forward-inclusive) +// -------------------------------------------------- + +func TestRepeatFind_Comma_After_F(t *testing.T) { + // `Fo` from col 10 → col 7. `,` reverses: forward inclusive from col 7. + // Start at col+1=8: finds nothing ('r','l','d' — no 'o') → no move. + t.Run("no further match forward: cursor stays", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", false, true) + + // , reversed → forward inclusive + FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 7 { + t.Errorf("col = %d, want 7 (no move)", m.ActiveWindow().Cursor.Col) + } + }) + + // `Fo` col 10 → col 7 → `;` → col 4. `,` from col 4: forward → finds 'o' at 7. + t.Run("after ;, comma returns forward to next match", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", false, true) + + // , reversed → forward inclusive from col 4, start at col 5: finds 'o' at 7 + FindChar{Char: "o", Forward: true, Inclusive: true, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 7 { + t.Errorf("col = %d, want 7", m.ActiveWindow().Cursor.Col) + } + }) +} + +// -------------------------------------------------- +// , after t (reverse of forward-exclusive = backward-exclusive) +// Inclusive=false and Repeated=true → backwardStart = col-2. +// -------------------------------------------------- + +func TestRepeatFind_Comma_After_t(t *testing.T) { + // `to` col 0 → col 3. `,` reverses: backward exclusive from col 3. + // backwardStart = 3-2 = 1. Searching left from 1: no 'o' in "hel" → no move. + t.Run("no previous exclusive match: cursor stays", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 3).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", true, false) + + // , reversed → backward exclusive, repeated + FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 3 { + t.Errorf("col = %d, want 3 (no move)", m.ActiveWindow().Cursor.Col) + } + }) + + // `to` col 0 → col 3, `;` → col 6. + // `,` from col 6: backward exclusive → backwardStart=4, finds 'o' at 4 → land at 5. + t.Run("after ;, comma goes backward exclusive", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 6).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", true, false) + + FindChar{Char: "o", Forward: false, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("col = %d, want 5", m.ActiveWindow().Cursor.Col) + } + }) +} + +// -------------------------------------------------- +// , after T (reverse of backward-exclusive = forward-exclusive) +// Inclusive=false and Repeated=true → forwardStart = col+2. +// -------------------------------------------------- + +func TestRepeatFind_Comma_After_T(t *testing.T) { + // `To` col 10 → col 8. `,` reverses: forward exclusive from col 8. + // forwardStart = 8+2 = 10: line[10]='d' ≠ 'o' → no more 'o' → no move. + t.Run("no further exclusive match forward: cursor stays", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", false, false) + + // , reversed → forward exclusive, repeated + FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 8 { + t.Errorf("col = %d, want 8 (no move)", m.ActiveWindow().Cursor.Col) + } + }) + + // `To` col 10 → col 8. `;` → col 5. `,` from col 5: forward exclusive. + // forwardStart = 5+2 = 7: line[7]='o' → land at 6. + t.Run("after ;, comma goes forward exclusive", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 5).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", false, false) + + FindChar{Char: "o", Forward: true, Inclusive: false, Count: 1, Repeated: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("col = %d, want 6", m.ActiveWindow().Cursor.Col) + } + }) +} + +// -------------------------------------------------- +// RepeatFind.Resolve — unit tests for the Resolve helper +// -------------------------------------------------- + +func TestRepeatFind_Resolve(t *testing.T) { + makeMock := func(char string, forward, inclusive bool) *mockModel { + buf := core.NewBufferBuilder().WithLines([]string{"x"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind(char, forward, inclusive) + return m + } + + t.Run("; after f: Forward=true Reverse=false → Forward=true Inclusive=true", func(t *testing.T) { + m := makeMock("o", true, true) + fc := RepeatFind{Count: 1, Reverse: false}.Resolve(m).(FindChar) + if !fc.Forward || !fc.Inclusive || fc.Char != "o" || !fc.Repeated { + t.Errorf("unexpected resolved FindChar: %+v", fc) + } + }) + + t.Run(", after f: Forward=true Reverse=true → Forward=false Inclusive=true", func(t *testing.T) { + m := makeMock("o", true, true) + fc := RepeatFind{Count: 1, Reverse: true}.Resolve(m).(FindChar) + if fc.Forward || !fc.Inclusive || fc.Char != "o" || !fc.Repeated { + t.Errorf("unexpected resolved FindChar: %+v", fc) + } + }) + + t.Run("; after F: Forward=false Reverse=false → Forward=false Inclusive=true", func(t *testing.T) { + m := makeMock("o", false, true) + fc := RepeatFind{Count: 1, Reverse: false}.Resolve(m).(FindChar) + if fc.Forward || !fc.Inclusive || fc.Char != "o" || !fc.Repeated { + t.Errorf("unexpected resolved FindChar: %+v", fc) + } + }) + + t.Run(", after F: Forward=false Reverse=true → Forward=true Inclusive=true", func(t *testing.T) { + m := makeMock("o", false, true) + fc := RepeatFind{Count: 1, Reverse: true}.Resolve(m).(FindChar) + if !fc.Forward || !fc.Inclusive || fc.Char != "o" || !fc.Repeated { + t.Errorf("unexpected resolved FindChar: %+v", fc) + } + }) + + t.Run("; after t: Forward=true Reverse=false → Forward=true Inclusive=false", func(t *testing.T) { + m := makeMock("o", true, false) + fc := RepeatFind{Count: 1, Reverse: false}.Resolve(m).(FindChar) + if !fc.Forward || fc.Inclusive || fc.Char != "o" || !fc.Repeated { + t.Errorf("unexpected resolved FindChar: %+v", fc) + } + }) + + t.Run(", after t: Forward=true Reverse=true → Forward=false Inclusive=false", func(t *testing.T) { + m := makeMock("o", true, false) + fc := RepeatFind{Count: 1, Reverse: true}.Resolve(m).(FindChar) + if fc.Forward || fc.Inclusive || fc.Char != "o" || !fc.Repeated { + t.Errorf("unexpected resolved FindChar: %+v", fc) + } + }) + + t.Run("; after T: Forward=false Reverse=false → Forward=false Inclusive=false", func(t *testing.T) { + m := makeMock("o", false, false) + fc := RepeatFind{Count: 1, Reverse: false}.Resolve(m).(FindChar) + if fc.Forward || fc.Inclusive || fc.Char != "o" || !fc.Repeated { + t.Errorf("unexpected resolved FindChar: %+v", fc) + } + }) + + t.Run(", after T: Forward=false Reverse=true → Forward=true Inclusive=false", func(t *testing.T) { + m := makeMock("o", false, false) + fc := RepeatFind{Count: 1, Reverse: true}.Resolve(m).(FindChar) + if !fc.Forward || fc.Inclusive || fc.Char != "o" || !fc.Repeated { + t.Errorf("unexpected resolved FindChar: %+v", fc) + } + }) + + t.Run("Count is forwarded to resolved FindChar", func(t *testing.T) { + m := makeMock("x", true, true) + fc := RepeatFind{Count: 3, Reverse: false}.Resolve(m).(FindChar) + if fc.Count != 3 { + t.Errorf("Count = %d, want 3", fc.Count) + } + }) + + t.Run("Char is preserved from lastFind", func(t *testing.T) { + m := makeMock("z", true, true) + fc := RepeatFind{Count: 1, Reverse: false}.Resolve(m).(FindChar) + if fc.Char != "z" { + t.Errorf("Char = %q, want %q", fc.Char, "z") + } + }) +} + +// -------------------------------------------------- +// RepeatFind — Type, WithCount, and Execute delegation +// -------------------------------------------------- + +func TestRepeatFind_Type(t *testing.T) { + t.Run("Type returns CharwiseExclusive (fallback)", func(t *testing.T) { + rf := RepeatFind{Count: 1, Reverse: false} + if rf.Type() != core.CharwiseExclusive { + t.Errorf("Type() = %v, want CharwiseExclusive", rf.Type()) + } + }) +} + +func TestRepeatFind_WithCount(t *testing.T) { + t.Run("WithCount sets Count", func(t *testing.T) { + rf := RepeatFind{Count: 1, Reverse: false} + updated := rf.WithCount(5).(RepeatFind) + if updated.Count != 5 { + t.Errorf("Count = %d, want 5", updated.Count) + } + }) + + t.Run("WithCount preserves Reverse", func(t *testing.T) { + rf := RepeatFind{Count: 1, Reverse: true} + updated := rf.WithCount(2).(RepeatFind) + if !updated.Reverse { + t.Error("Reverse should be preserved as true") + } + }) +} + +func TestRepeatFind_Execute(t *testing.T) { + // Execute should delegate to Resolve(m).Execute(m) and actually move the cursor. + t.Run("Execute via ; after f moves cursor correctly", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 4).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", true, true) + + // ; = RepeatFind{Reverse: false} + RepeatFind{Count: 1, Reverse: false}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 7 { + t.Errorf("col = %d, want 7", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("Execute via , after f reverses and moves cursor correctly", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 7).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", true, true) + + // , = RepeatFind{Reverse: true} + RepeatFind{Count: 1, Reverse: true}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 4 { + t.Errorf("col = %d, want 4", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("Execute via ; after t skips adjacent and moves cursor", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 3).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", true, false) + + RepeatFind{Count: 1, Reverse: false}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 6 { + t.Errorf("col = %d, want 6", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("Execute via ; after T skips adjacent backward and moves cursor", func(t *testing.T) { + buf := core.NewBufferBuilder().WithLines([]string{"hello world"}).Build() + win := core.NewWindowBuilder().WithBuffer(&buf).WithCursorPos(0, 8).Build() + m := newMockModelWithWindow(&win) + m.SetLastFind("o", false, false) + + RepeatFind{Count: 1, Reverse: false}.Execute(m) + + if m.ActiveWindow().Cursor.Col != 5 { + t.Errorf("col = %d, want 5", m.ActiveWindow().Cursor.Col) + } + }) +} + // ================================================== // WithCount Tests // ================================================== diff --git a/internal/action/interface.go b/internal/action/interface.go index 07cca43..2608455 100644 --- a/internal/action/interface.go +++ b/internal/action/interface.go @@ -24,6 +24,8 @@ type Model interface { // Insert recording (for count replay) SetInsertRecording(count int, action Action) + SetLastFind(char string, forward, inclusive bool) + GetLastFind() *core.LastFindCommand // ExitInsertMode handles replay, cursor step-back, and mode transition on esc ExitInsertMode() @@ -90,3 +92,13 @@ type CharMotion interface { Motion WithChar(char string) Motion } + +// Resolvable is an optional interface for motions that need to consult the +// model to fully determine their behaviour (e.g. RepeatFind must look up the +// last find to know whether it is inclusive or exclusive). The FSM calls +// Resolve(m) first and uses the returned Motion in place of the original, so +// that the subsequent Type() call returns the correct value. +type Resolvable interface { + Motion + Resolve(m Model) Motion +} diff --git a/internal/command/handlers_test.go b/internal/command/handlers_test.go index 6e99a31..64e7495 100644 --- a/internal/command/handlers_test.go +++ b/internal/command/handlers_test.go @@ -27,6 +27,7 @@ type mockModel struct { commandCursor int commandError error commandOutput string + lastFind core.LastFindCommand } func newMockModel() *mockModel { @@ -79,6 +80,10 @@ func (m *mockModel) InsertKeys() []string { return func (m *mockModel) SetInsertKeys(keys []string) { m.insertKeys = keys } func (m *mockModel) SetInsertRecording(count int, action action.Action) {} func (m *mockModel) ExitInsertMode() {} +func (m *mockModel) SetLastFind(char string, forward, inclusive bool) { + m.lastFind = core.LastFindCommand{Char: char, Forward: forward, Inclusive: inclusive} +} +func (m *mockModel) GetLastFind() *core.LastFindCommand { return &m.lastFind } // Command Mode State func (m *mockModel) Command() string { return m.command } diff --git a/internal/core/find.go b/internal/core/find.go new file mode 100644 index 0000000..9bc0b40 --- /dev/null +++ b/internal/core/find.go @@ -0,0 +1,7 @@ +package core + +type LastFindCommand struct { + Char string + Forward bool + Inclusive bool +} diff --git a/internal/editor/model.go b/internal/editor/model.go index 178d473..6b7249d 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -34,6 +34,7 @@ type Model struct { insertCount int insertKeys []string insertAction action.Action + lastFind core.LastFindCommand // Command line state command string @@ -106,6 +107,18 @@ func (m *Model) SetInsertRecording(count int, act action.Action) { m.insertAction = act } +func (m *Model) SetLastFind(char string, forward, inclusive bool) { + m.lastFind = core.LastFindCommand{ + Char: char, + Forward: forward, + Inclusive: inclusive, + } +} + +func (m *Model) GetLastFind() *core.LastFindCommand { + return &m.lastFind +} + func (m *Model) ExitInsertMode() { win := m.ActiveWindow() if m.insertCount > 1 { diff --git a/internal/input/handler.go b/internal/input/handler.go index 94c4c83..3102be4 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -155,6 +155,11 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st if r, ok := mot.(action.Repeatable); ok { mot = r.WithCount(count).(action.Motion) } + // Resolve late-binding motions (e.g. RepeatFind) before executing so + // that Type() on the resolved motion is accurate. + if res, ok := mot.(action.Resolvable); ok { + mot = res.Resolve(m) + } cmd := mot.Execute(m) h.Reset() return cmd @@ -221,6 +226,11 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any, if r, ok := mot.(action.Repeatable); ok { mot = r.WithCount(count).(action.Motion) } + // Resolve late-binding motions (e.g. RepeatFind) before executing so + // that Type() on the resolved motion is accurate. + if res, ok := mot.(action.Resolvable); ok { + mot = res.Resolve(m) + } // Get range and motion type start := win.Cursor mot.Execute(m) diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 4a20ec1..3b4252f 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -37,6 +37,8 @@ func NewNormalKeymap() *Keymap { "b": motion.MoveBackwardWord{Count: 1}, "ctrl+u": motion.ScrollUpHalfPage{}, "ctrl+d": motion.ScrollDownHalfPage{}, + ";": action.RepeatFind{Count: 1, Reverse: false}, + ".": action.RepeatFind{Count: 1, Reverse: true}, }, operators: map[string]action.Operator{ "d": operator.DeleteOperator{}, @@ -65,10 +67,10 @@ func NewNormalKeymap() *Keymap { "P": action.PasteBefore{Count: 1}, }, charMotions: map[string]action.Motion{ - "f": action.FindChar{Forward: true, Inclusive: true}, - "F": action.FindChar{Forward: false, Inclusive: true}, - "t": action.FindChar{Forward: true, Inclusive: false}, - "T": action.FindChar{Forward: false, Inclusive: false}, + "f": action.FindChar{Forward: true, Inclusive: true, Repeated: false}, + "F": action.FindChar{Forward: false, Inclusive: true, Repeated: false}, + "t": action.FindChar{Forward: true, Inclusive: false, Repeated: false}, + "T": action.FindChar{Forward: false, Inclusive: false, Repeated: false}, }, } } -- 2.47.2