From 1aa1954d3507f315f995329e7926e9a9ea07568b Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Thu, 9 Apr 2026 09:32:25 -0700 Subject: [PATCH] feat: implemented the % motion! tested as well --- FEATURES.md | 2 +- .../editor/integration_motion_jump_test.go | 375 ++++++++++++++++++ internal/input/keymap.go | 2 + internal/motion/command_test.go | 124 +++--- internal/motion/delimiter_utils.go | 113 ++++++ internal/motion/jump.go | 45 ++- internal/textobject/delimiter.go | 4 +- v0.1.md | 4 +- 8 files changed, 598 insertions(+), 71 deletions(-) create mode 100644 internal/motion/delimiter_utils.go diff --git a/FEATURES.md b/FEATURES.md index 9b90add..2cf15b7 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -57,7 +57,7 @@ - [ ] `#` - Search word under cursor backward ### Other Movement -- [ ] `%` - Jump to matching bracket +- [x] `%` - Jump to matching bracket - [ ] `{` - Jump to previous paragraph - [ ] `}` - Jump to next paragraph - [ ] `(` - Jump to previous sentence diff --git a/internal/editor/integration_motion_jump_test.go b/internal/editor/integration_motion_jump_test.go index 1ed46d5..cb4f204 100644 --- a/internal/editor/integration_motion_jump_test.go +++ b/internal/editor/integration_motion_jump_test.go @@ -725,3 +725,378 @@ func TestMoveToColumnInVisualMode(t *testing.T) { } }) } + +func TestJumpToMatchingDelimiter(t *testing.T) { + tests := []struct { + name string + lines []string + start core.Position + expected core.Position + }{ + { + name: "opening paren jumps forward to matching paren", + lines: []string{"a(b(c)d)e"}, + start: core.Position{Line: 0, Col: 1}, + expected: core.Position{Line: 0, Col: 7}, + }, + { + name: "closing paren jumps backward to matching opening paren", + lines: []string{"a(b(c)d)e"}, + start: core.Position{Line: 0, Col: 7}, + expected: core.Position{Line: 0, Col: 1}, + }, + { + name: "opening square bracket jumps forward with nesting", + lines: []string{"x[ab[c]d]y"}, + start: core.Position{Line: 0, Col: 1}, + expected: core.Position{Line: 0, Col: 8}, + }, + { + name: "closing square bracket jumps backward with nesting", + lines: []string{"x[ab[c]d]y"}, + start: core.Position{Line: 0, Col: 8}, + expected: core.Position{Line: 0, Col: 1}, + }, + { + name: "opening brace jumps forward across lines", + lines: []string{"if (ok) {", " call()", "}"}, + start: core.Position{Line: 0, Col: 8}, + expected: core.Position{Line: 2, Col: 0}, + }, + { + name: "closing brace jumps backward across lines", + lines: []string{"if (ok) {", " call()", "}"}, + start: core.Position{Line: 2, Col: 0}, + expected: core.Position{Line: 0, Col: 8}, + }, + { + name: "searches forward on current line when not on delimiter", + lines: []string{"xx (a(b)c) yy"}, + start: core.Position{Line: 0, Col: 0}, + expected: core.Position{Line: 0, Col: 9}, + }, + { + name: "no delimiter at or after cursor does nothing", + lines: []string{"xx (a(b)c) yy"}, + start: core.Position{Line: 0, Col: 10}, + expected: core.Position{Line: 0, Col: 10}, + }, + { + name: "unmatched opening delimiter does nothing", + lines: []string{"x (abc"}, + start: core.Position{Line: 0, Col: 2}, + expected: core.Position{Line: 0, Col: 2}, + }, + { + name: "unmatched closing delimiter does nothing", + lines: []string{"abc)"}, + start: core.Position{Line: 0, Col: 3}, + expected: core.Position{Line: 0, Col: 3}, + }, + { + name: "backward matching across lines handles nested delimiters", + lines: []string{"if (a +", " (b * c)", ")"}, + start: core.Position{Line: 2, Col: 0}, + expected: core.Position{Line: 0, Col: 3}, + }, + { + name: "forward matching across lines handles nested delimiters", + lines: []string{"if (a +", " (b * c)", ")"}, + start: core.Position{Line: 0, Col: 3}, + expected: core.Position{Line: 2, Col: 0}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tm := newTestModelWithLinesAndCursorPos(t, tt.lines, tt.start) + sendKeys(tm, "%") + + m := getFinalModel(t, tm) + if m.ActiveWindow().Cursor.Line != tt.expected.Line { + t.Errorf("CursorY() = %d, want %d", m.ActiveWindow().Cursor.Line, tt.expected.Line) + } + if m.ActiveWindow().Cursor.Col != tt.expected.Col { + t.Errorf("CursorX() = %d, want %d", m.ActiveWindow().Cursor.Col, tt.expected.Col) + } + }) + } +} + +func TestJumpToMatchingDelimiterInVisualMode(t *testing.T) { + t.Run("test 'v%' selects to matching delimiter", func(t *testing.T) { + lines := []string{"foo(bar)baz"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 3}) + sendKeys(tm, "v", "%") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + if m.ActiveWindow().Anchor.Col != 3 { + t.Errorf("AnchorX() = %d, want 3", m.ActiveWindow().Anchor.Col) + } + if m.ActiveWindow().Cursor.Col != 7 { + t.Errorf("CursorX() = %d, want 7", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'v%' from closing delimiter selects backward", func(t *testing.T) { + lines := []string{"foo(bar)baz"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 7}) + sendKeys(tm, "v", "%") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + if m.ActiveWindow().Anchor.Col != 7 { + t.Errorf("AnchorX() = %d, want 7", m.ActiveWindow().Anchor.Col) + } + if m.ActiveWindow().Cursor.Col != 3 { + t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'v%' searches forward on current line before selecting", func(t *testing.T) { + lines := []string{"xx (a(b)c) yy"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 0}) + sendKeys(tm, "v", "%") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + if m.ActiveWindow().Anchor.Col != 0 { + t.Errorf("AnchorX() = %d, want 0", m.ActiveWindow().Anchor.Col) + } + if m.ActiveWindow().Cursor.Col != 9 { + t.Errorf("CursorX() = %d, want 9", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'v%' with no delimiter after cursor keeps selection in place", func(t *testing.T) { + lines := []string{"xx (a(b)c) yy"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 10}) + sendKeys(tm, "v", "%") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + if m.ActiveWindow().Anchor.Col != 10 { + t.Errorf("AnchorX() = %d, want 10", m.ActiveWindow().Anchor.Col) + } + if m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("CursorX() = %d, want 10", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'v%' on unmatched opening delimiter keeps selection in place", func(t *testing.T) { + lines := []string{"x (abc"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 2}) + sendKeys(tm, "v", "%") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + if m.ActiveWindow().Anchor.Col != 2 { + t.Errorf("AnchorX() = %d, want 2", m.ActiveWindow().Anchor.Col) + } + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("CursorX() = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'v%d' deletes the matched delimiter range", func(t *testing.T) { + lines := []string{"foo(bar)baz"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 3}) + sendKeys(tm, "v", "%", "d") + + m := getFinalModel(t, tm) + if m.Mode() != core.NormalMode { + t.Errorf("Mode() = %v, want NormalMode", m.Mode()) + } + if m.ActiveBuffer().Lines[0].String() != "foobaz" { + t.Errorf("Line(0) = %q, want 'foobaz'", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'V%' spans matching lines", func(t *testing.T) { + lines := []string{"if (ok) {", " call()", "}"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 8}) + sendKeys(tm, "V", "%") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualLineMode { + t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) + } + if m.ActiveWindow().Anchor.Line != 0 { + t.Errorf("AnchorY() = %d, want 0", m.ActiveWindow().Anchor.Line) + } + if m.ActiveWindow().Cursor.Line != 2 { + t.Errorf("CursorY() = %d, want 2", m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("test 'V%' from closing delimiter selects backward across lines", func(t *testing.T) { + lines := []string{"if (ok) {", " call()", "}"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 2, Col: 0}) + sendKeys(tm, "V", "%") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualLineMode { + t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) + } + if m.ActiveWindow().Anchor.Line != 2 { + t.Errorf("AnchorY() = %d, want 2", m.ActiveWindow().Anchor.Line) + } + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("CursorY() = %d, want 0", m.ActiveWindow().Cursor.Line) + } + }) + + t.Run("test 'V%' with no delimiter after cursor keeps selection in place", func(t *testing.T) { + lines := []string{"xx (a(b)c) yy"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 10}) + sendKeys(tm, "V", "%") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualLineMode { + t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) + } + if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Anchor.Col != 10 { + t.Errorf("anchor = (%d,%d), want (0,10)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col) + } + if m.ActiveWindow().Cursor.Line != 0 || m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("cursor = (%d,%d), want (0,10)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'V%' on unmatched opening delimiter keeps selection in place", func(t *testing.T) { + lines := []string{"if (abc"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 3}) + sendKeys(tm, "V", "%") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualLineMode { + t.Errorf("Mode() = %v, want VisualLineMode", m.Mode()) + } + if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Anchor.Col != 3 { + t.Errorf("anchor = (%d,%d), want (0,3)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col) + } + if m.ActiveWindow().Cursor.Line != 0 || m.ActiveWindow().Cursor.Col != 3 { + t.Errorf("cursor = (%d,%d), want (0,3)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'V%d' deletes linewise matched range", func(t *testing.T) { + lines := []string{"if (ok) {", " call()", "}"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 8}) + sendKeys(tm, "V", "%", "d") + + m := getFinalModel(t, tm) + if m.Mode() != core.NormalMode { + t.Errorf("Mode() = %v, want NormalMode", m.Mode()) + } + if m.ActiveBuffer().LineCount() != 1 { + t.Errorf("LineCount() = %d, want 1", m.ActiveBuffer().LineCount()) + } + if m.ActiveBuffer().Lines[0].String() != "" { + t.Errorf("Line(0) = %q, want ''", m.ActiveBuffer().Lines[0].String()) + } + }) + + t.Run("test 'ctrl+v%' updates block selection to matching delimiter", func(t *testing.T) { + lines := []string{"if (ok) {", " call()", "}"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 8}) + sendKeys(tm, "ctrl+v", "%") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualBlockMode { + t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode()) + } + if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Anchor.Col != 8 { + t.Errorf("anchor = (%d,%d), want (0,8)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col) + } + if m.ActiveWindow().Cursor.Line != 2 || m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("cursor = (%d,%d), want (2,0)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ctrl+v%' from closing delimiter selects backward", func(t *testing.T) { + lines := []string{"if (ok) {", " call()", "}"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 2, Col: 0}) + sendKeys(tm, "ctrl+v", "%") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualBlockMode { + t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode()) + } + if m.ActiveWindow().Anchor.Line != 2 || m.ActiveWindow().Anchor.Col != 0 { + t.Errorf("anchor = (%d,%d), want (2,0)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col) + } + if m.ActiveWindow().Cursor.Line != 0 || m.ActiveWindow().Cursor.Col != 8 { + t.Errorf("cursor = (%d,%d), want (0,8)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ctrl+v%' with no delimiter after cursor keeps block selection in place", func(t *testing.T) { + lines := []string{"xx (a(b)c) yy"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 10}) + sendKeys(tm, "ctrl+v", "%") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualBlockMode { + t.Errorf("Mode() = %v, want VisualBlockMode", m.Mode()) + } + if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Anchor.Col != 10 { + t.Errorf("anchor = (%d,%d), want (0,10)", m.ActiveWindow().Anchor.Line, m.ActiveWindow().Anchor.Col) + } + if m.ActiveWindow().Cursor.Line != 0 || m.ActiveWindow().Cursor.Col != 10 { + t.Errorf("cursor = (%d,%d), want (0,10)", m.ActiveWindow().Cursor.Line, m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'ctrl+v%y' yanks block and exits visual mode", func(t *testing.T) { + lines := []string{"if (ok) {", " call()", "}"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 8}) + sendKeys(tm, "ctrl+v", "%", "y") + + m := getFinalModel(t, tm) + if m.Mode() != core.NormalMode { + t.Errorf("Mode() = %v, want NormalMode", m.Mode()) + } + }) +} + +func TestJumpToMatchingDelimiterIgnoresCount(t *testing.T) { + t.Run("test '5%' in normal mode still performs delimiter matching", func(t *testing.T) { + lines := []string{"a(b)c"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 1}) + sendKeys(tm, "5", "%") + + m := getFinalModel(t, tm) + if m.ActiveWindow().Cursor.Col != 3 { + t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("test 'v5%' in visual mode still performs delimiter matching", func(t *testing.T) { + lines := []string{"a(b)c"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 0, Col: 1}) + sendKeys(tm, "v", "5", "%") + + m := getFinalModel(t, tm) + if m.Mode() != core.VisualMode { + t.Errorf("Mode() = %v, want VisualMode", m.Mode()) + } + if m.ActiveWindow().Anchor.Col != 1 { + t.Errorf("AnchorX() = %d, want 1", m.ActiveWindow().Anchor.Col) + } + if m.ActiveWindow().Cursor.Col != 3 { + t.Errorf("CursorX() = %d, want 3", m.ActiveWindow().Cursor.Col) + } + }) +} diff --git a/internal/input/keymap.go b/internal/input/keymap.go index cc3a2ba..e4ce00a 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -50,6 +50,7 @@ func NewNormalKeymap() *Keymap { "ctrl+f": motion.ScrollDownPage{Divisor: 1}, ";": action.RepeatFind{Count: 1, Reverse: false}, ",": action.RepeatFind{Count: 1, Reverse: true}, + "%": motion.JumpToMatchingDelimiter{}, }, operators: map[string]action.Operator{ "d": operator.DeleteOperator{}, @@ -148,6 +149,7 @@ func NewVisualKeymap() *Keymap { "ctrl+f": motion.ScrollDownPage{Divisor: 1}, ";": action.RepeatFind{Count: 1, Reverse: false}, ",": action.RepeatFind{Count: 1, Reverse: true}, + "%": motion.JumpToMatchingDelimiter{}, // TODO: O and o. These are fun ones! Should be simple too }, operators: map[string]action.Operator{ diff --git a/internal/motion/command_test.go b/internal/motion/command_test.go index 7ae3f42..564eeb3 100644 --- a/internal/motion/command_test.go +++ b/internal/motion/command_test.go @@ -14,10 +14,10 @@ import ( func TestMoveCommandHistoryUp(t *testing.T) { t.Run("navigate up from start", func(t *testing.T) { m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "", - CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, - CommandHistoryCur: 0, + ModeVal: core.CommandMode, + CommandVal: "", + CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, + CommandHistoryCur: 0, } action := MoveCommandHistoryUp{} @@ -41,10 +41,10 @@ func TestMoveCommandHistoryUp(t *testing.T) { t.Run("navigate up multiple times", func(t *testing.T) { m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "", - CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, - CommandHistoryCur: 0, + ModeVal: core.CommandMode, + CommandVal: "", + CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, + CommandHistoryCur: 0, } action := MoveCommandHistoryUp{} @@ -74,10 +74,10 @@ func TestMoveCommandHistoryUp(t *testing.T) { t.Run("cannot navigate past end of history", func(t *testing.T) { m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "", - CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, - CommandHistoryCur: 3, // Already at end + ModeVal: core.CommandMode, + CommandVal: "", + CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, + CommandHistoryCur: 3, // Already at end } action := MoveCommandHistoryUp{} @@ -96,10 +96,10 @@ func TestMoveCommandHistoryUp(t *testing.T) { t.Run("empty history does nothing", func(t *testing.T) { m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "current", - CommandHistoryList: []string{}, - CommandHistoryCur: 0, + ModeVal: core.CommandMode, + CommandVal: "current", + CommandHistoryList: []string{}, + CommandHistoryCur: 0, } action := MoveCommandHistoryUp{} @@ -117,11 +117,11 @@ func TestMoveCommandHistoryUp(t *testing.T) { t.Run("command cursor moves to end", func(t *testing.T) { m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "", - CommandCursorVal: 0, - CommandHistoryList: []string{"long command here"}, - CommandHistoryCur: 0, + ModeVal: core.CommandMode, + CommandVal: "", + CommandCursorVal: 0, + CommandHistoryList: []string{"long command here"}, + CommandHistoryCur: 0, } action := MoveCommandHistoryUp{} @@ -141,10 +141,10 @@ func TestMoveCommandHistoryUp(t *testing.T) { func TestMoveCommandHistoryDown(t *testing.T) { t.Run("navigate down from middle of history", func(t *testing.T) { m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "cmd2", - CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, - CommandHistoryCur: 2, // At cmd2 + ModeVal: core.CommandMode, + CommandVal: "cmd2", + CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, + CommandHistoryCur: 2, // At cmd2 } action := MoveCommandHistoryDown{} @@ -163,10 +163,10 @@ func TestMoveCommandHistoryDown(t *testing.T) { t.Run("navigate down to empty command", func(t *testing.T) { m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "cmd3", - CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, - CommandHistoryCur: 1, // At first command + ModeVal: core.CommandMode, + CommandVal: "cmd3", + CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, + CommandHistoryCur: 1, // At first command } action := MoveCommandHistoryDown{} @@ -188,10 +188,10 @@ func TestMoveCommandHistoryDown(t *testing.T) { t.Run("navigate down from cursor 0 does nothing", func(t *testing.T) { m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "", - CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, - CommandHistoryCur: 0, // Already at bottom + ModeVal: core.CommandMode, + CommandVal: "", + CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, + CommandHistoryCur: 0, // Already at bottom } action := MoveCommandHistoryDown{} @@ -209,11 +209,11 @@ func TestMoveCommandHistoryDown(t *testing.T) { t.Run("command cursor moves to end", func(t *testing.T) { m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "", - CommandCursorVal: 0, - CommandHistoryList: []string{"cmd3", "long command here"}, - CommandHistoryCur: 2, + ModeVal: core.CommandMode, + CommandVal: "", + CommandCursorVal: 0, + CommandHistoryList: []string{"cmd3", "long command here"}, + CommandHistoryCur: 2, } action := MoveCommandHistoryDown{} @@ -233,10 +233,10 @@ func TestMoveCommandHistoryDown(t *testing.T) { func TestCommandHistoryIntegration(t *testing.T) { t.Run("up down up sequence", func(t *testing.T) { m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "", - CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, - CommandHistoryCur: 0, + ModeVal: core.CommandMode, + CommandVal: "", + CommandHistoryList: []string{"cmd3", "cmd2", "cmd1"}, + CommandHistoryCur: 0, } upAction := MoveCommandHistoryUp{} @@ -262,10 +262,10 @@ func TestCommandHistoryIntegration(t *testing.T) { t.Run("navigate up past end does not crash", func(t *testing.T) { m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "", - CommandHistoryList: []string{"cmd1", "cmd2"}, - CommandHistoryCur: 0, + ModeVal: core.CommandMode, + CommandVal: "", + CommandHistoryList: []string{"cmd1", "cmd2"}, + CommandHistoryCur: 0, } upAction := MoveCommandHistoryUp{} @@ -284,10 +284,10 @@ func TestCommandHistoryIntegration(t *testing.T) { t.Run("navigate down past bottom does not crash", func(t *testing.T) { m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "", - CommandHistoryList: []string{"cmd1"}, - CommandHistoryCur: 0, + ModeVal: core.CommandMode, + CommandVal: "", + CommandHistoryList: []string{"cmd1"}, + CommandHistoryCur: 0, } downAction := MoveCommandHistoryDown{} @@ -315,21 +315,21 @@ func TestCommandHistoryIntegration(t *testing.T) { func TestCommandHistoryWithLongHistory(t *testing.T) { t.Run("navigate through 20 commands", func(t *testing.T) { history := make([]string, 20) - for i := 0; i < 20; i++ { + for i := range 20 { history[i] = string(rune('A' + i)) } m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "", - CommandHistoryList: history, - CommandHistoryCur: 0, + ModeVal: core.CommandMode, + CommandVal: "", + CommandHistoryList: history, + CommandHistoryCur: 0, } upAction := MoveCommandHistoryUp{} // Navigate to 10th command - for i := 0; i < 10; i++ { + for range 10 { upAction.Execute(m) } @@ -344,21 +344,21 @@ func TestCommandHistoryWithLongHistory(t *testing.T) { t.Run("navigate to very end of long history", func(t *testing.T) { history := make([]string, 50) - for i := 0; i < 50; i++ { + for i := range 50 { history[i] = string(rune('0' + (i % 10))) } m := &action.MockModel{ - ModeVal: core.CommandMode, - CommandVal: "", - CommandHistoryList: history, - CommandHistoryCur: 0, + ModeVal: core.CommandMode, + CommandVal: "", + CommandHistoryList: history, + CommandHistoryCur: 0, } upAction := MoveCommandHistoryUp{} // Navigate all the way to the end - for i := 0; i < 100; i++ { // Try to go past end + for range 100 { // Try to go past end upAction.Execute(m) } diff --git a/internal/motion/delimiter_utils.go b/internal/motion/delimiter_utils.go new file mode 100644 index 0000000..1789b56 --- /dev/null +++ b/internal/motion/delimiter_utils.go @@ -0,0 +1,113 @@ +package motion + +import "git.gophernest.net/azpect/TextEditor/internal/core" + +// Delimiter matching helpers operate on byte-indexed columns, which matches the +// rest of the editor cursor model. + +func isDelimiter(ch byte) bool { + switch ch { + case '{', '}', '[', ']', '(', ')': + return true + default: + return false + } +} + +func isOpeningDelimiter(ch byte) bool { + return ch == '{' || ch == '[' || ch == '(' +} + +func getOppositeDelimiter(ch byte) (byte, bool) { + switch ch { + case '{': + return '}', true + case '}': + return '{', true + case '[': + return ']', true + case ']': + return '[', true + case '(': + return ')', true + case ')': + return '(', true + default: + return 0, false + } +} + +func findDelimiterOnLine(line string, startCol int) (int, byte, bool) { + if len(line) == 0 { + return 0, 0, false + } + + col := max(0, startCol) + for col < len(line) { + ch := line[col] + if isDelimiter(ch) { + return col, ch, true + } + col++ + } + + return 0, 0, false +} + +func findMatchingForward(buf *core.Buffer, line, col int, startDelim, matchDelim byte) (core.Position, bool) { + depth := 0 + + for y := line; y < buf.LineCount(); y++ { + text := buf.Line(y) + xStart := 0 + if y == line { + xStart = col + 1 + } + + for x := xStart; x < len(text); x++ { + ch := text[x] + if ch == startDelim { + depth++ + continue + } + + if ch == matchDelim { + if depth == 0 { + return core.Position{Line: y, Col: x}, true + } + depth-- + } + } + } + + return core.Position{}, false +} + +func findMatchingBackward(buf *core.Buffer, line, col int, startDelim, matchDelim byte) (core.Position, bool) { + depth := 0 + + for y := line; y >= 0; y-- { + text := buf.Line(y) + xStart := len(text) - 1 + if y == line { + xStart = col - 1 + } + + for x := xStart; x >= 0; x-- { + ch := text[x] + if ch == startDelim { + depth++ + continue + } + + if ch == matchDelim { + if depth == 0 { + return core.Position{Line: y, Col: x}, true + } + depth-- + } + } + } + + return core.Position{}, false +} diff --git a/internal/motion/jump.go b/internal/motion/jump.go index 4d8518e..2bfd80b 100644 --- a/internal/motion/jump.go +++ b/internal/motion/jump.go @@ -22,10 +22,7 @@ func visibleLineBounds(win *core.Window, buf *core.Buffer) (int, int) { start := win.ScrollY end := start + win.ViewportHeight() - 1 - end = min(end, buf.LineCount()-1) - if end < start { - end = start - } + end = max(min(end, buf.LineCount()-1), start) return start, end } @@ -293,3 +290,43 @@ func (a MoveToScreenBottom) Type() core.MotionType { return core.Linewise } func (a MoveToScreenBottom) WithCount(n int) action.Action { return MoveToScreenBottom{Count: n} } + +// Used for the % motion, not countable +type JumpToMatchingDelimiter struct{} + +func (a JumpToMatchingDelimiter) Execute(m action.Model) tea.Cmd { + buf := m.ActiveBuffer() + if buf.LineCount() == 0 { + return nil + } + + win := m.ActiveWindow() + lineIdx := win.Cursor.Line + line := buf.Line(lineIdx) + + col, startDelim, found := findDelimiterOnLine(line, win.Cursor.Col) + if !found { + return nil + } + + matchDelim, ok := getOppositeDelimiter(startDelim) + if !ok { + return nil + } + + var target core.Position + if isOpeningDelimiter(startDelim) { + target, found = findMatchingForward(buf, lineIdx, col, startDelim, matchDelim) + } else { + target, found = findMatchingBackward(buf, lineIdx, col, startDelim, matchDelim) + } + + if !found { + return nil + } + + win.SetCursorPos(target.Line, target.Col) + return nil +} + +func (a JumpToMatchingDelimiter) Type() core.MotionType { return core.CharwiseInclusive } diff --git a/internal/textobject/delimiter.go b/internal/textobject/delimiter.go index 8bece7b..e5da04a 100644 --- a/internal/textobject/delimiter.go +++ b/internal/textobject/delimiter.go @@ -194,7 +194,7 @@ func isValidPair(line string, startDelim, endDelim rune, startPos, endPos int) b // A closing quote has an odd number of quotes before it (1, 3, 5, ...) quotesBeforeStart := 0 - for i := 0; i < startPos; i++ { + for i := range startPos { if rune(line[i]) == startDelim { quotesBeforeStart++ } @@ -470,7 +470,7 @@ func isCursorBetween(cursor, start, end core.Position) bool { // hasOnlyWhitespaceBefore checks if there is only whitespace before the character at col. func hasOnlyWhitespaceBefore(line string, col int) bool { - for i := 0; i < col; i++ { + for i := range col { if !isWhitespace(rune(line[i])) { return false } diff --git a/v0.1.md b/v0.1.md index 02c45e0..72590dc 100644 --- a/v0.1.md +++ b/v0.1.md @@ -9,7 +9,7 @@ - [x] Buffer switching - [ ] Search (/, ?, n, N) with highlighting - [x] Syntax highlighting via treesitter -- [ ] % (matching bracket) +- [x] % (matching bracket) - [x] J (join lines) - [x] H/M/L (screen movement) - [ ] Status line (mode, filename, position, modified flag) @@ -18,7 +18,6 @@ ## Should Have (Makes it Usable) - [ ] :substitute (%s/old/new/g) - at least basic version - [ ] Better command-mode autocomplete/hints -- [ ] Configuration file support (~/.gimrc or similar) - [ ] ~Persistent undo history~ - [ ] ~Line wrapping display option~ - [x] Horizontal scroll @@ -40,3 +39,4 @@ - Macros → v0.2 - Git integration → v0.3 - Plugin system → v1.0 +- Configuration file -> v0.3