From 21ed76bed549a02aa4387217410681352387c7c2 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Mon, 30 Mar 2026 17:58:06 -0700 Subject: [PATCH] fix: fixed the delimiter "same-line" issue. With the help of Claude. I want this to be over with so I can move onto more fun things than actions. --- .../editor/integration_textobject_test.go | 116 +++++++++++++- internal/textobject/delimiter.go | 143 ++++++++++++++++-- 2 files changed, 247 insertions(+), 12 deletions(-) diff --git a/internal/editor/integration_textobject_test.go b/internal/editor/integration_textobject_test.go index b0a5a42..17c3299 100644 --- a/internal/editor/integration_textobject_test.go +++ b/internal/editor/integration_textobject_test.go @@ -582,9 +582,9 @@ func TestTextObjectEdgeCases(t *testing.T) { } }) - t.Run("test text object cursor outside delimiters does nothing", func(t *testing.T) { + t.Run("test text object cursor after delimiters does nothing", func(t *testing.T) { lines := []string{"before (hello) after"} - tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0}) sendKeys(tm, "d", "i", "(") m := getFinalModel(t, tm) @@ -593,4 +593,116 @@ func TestTextObjectEdgeCases(t *testing.T) { t.Errorf("lines[0] = %q, want 'before (hello) after'", m.ActiveBuffer().Lines[0]) } }) + + t.Run("test text object cursor before delimiters selects inside", func(t *testing.T) { + lines := []string{"before (hello) after"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) + sendKeys(tm, "d", "i", "(") + + m := getFinalModel(t, tm) + if m.ActiveBuffer().Lines[0] != "before () after" { + t.Errorf("lines[0] = %q, want 'before () after'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test text object cursor before delimiters with 'a' modifier", func(t *testing.T) { + lines := []string{"before (hello) after"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) + sendKeys(tm, "d", "a", "(") + + m := getFinalModel(t, tm) + // 'a' should delete including the delimiters + if m.ActiveBuffer().Lines[0] != "before after" { + t.Errorf("lines[0] = %q, want 'before after'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test text object cursor on opening delimiter", func(t *testing.T) { + lines := []string{"text (hello) more"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0}) + sendKeys(tm, "d", "i", "(") + + m := getFinalModel(t, tm) + // Cursor on '(' at position 5, should still select inside + if m.ActiveBuffer().Lines[0] != "text () more" { + t.Errorf("lines[0] = %q, want 'text () more'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test text object cursor on closing delimiter", func(t *testing.T) { + lines := []string{"text (hello) more"} + // "text (hello) more" + // 01234567891011 <- ')' is at position 11 + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 11, Line: 0}) + sendKeys(tm, "d", "i", "(") + + m := getFinalModel(t, tm) + // Cursor on ')', should still select inside + if m.ActiveBuffer().Lines[0] != "text () more" { + t.Errorf("lines[0] = %q, want 'text () more'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test multiple delimiter pairs - cursor before first", func(t *testing.T) { + lines := []string{"(foo) bar (baz)"} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) + sendKeys(tm, "d", "i", "(") + + m := getFinalModel(t, tm) + // Should select the first pair it finds + if m.ActiveBuffer().Lines[0] != "() bar (baz)" { + t.Errorf("lines[0] = %q, want '() bar (baz)'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test multiple delimiter pairs - cursor between pairs", func(t *testing.T) { + lines := []string{"(foo) bar (baz)"} + // Cursor on 'b' in "bar" + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0}) + sendKeys(tm, "d", "i", "(") + + m := getFinalModel(t, tm) + // Should search forward and find the second pair + if m.ActiveBuffer().Lines[0] != "(foo) bar ()" { + t.Errorf("lines[0] = %q, want '(foo) bar ()'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test multiple delimiter pairs - cursor inside first", func(t *testing.T) { + lines := []string{"(foo) bar (baz)"} + // Cursor on 'o' in "foo" + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0}) + sendKeys(tm, "d", "i", "(") + + m := getFinalModel(t, tm) + // Should select the first pair since cursor is inside it + if m.ActiveBuffer().Lines[0] != "() bar (baz)" { + t.Errorf("lines[0] = %q, want '() bar (baz)'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test multiple quoted strings - cursor before first", func(t *testing.T) { + lines := []string{`foo "bar" baz "qux"`} + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) + sendKeys(tm, "d", "i", `"`) + + m := getFinalModel(t, tm) + // Should find and select first quoted string + if m.ActiveBuffer().Lines[0] != `foo "" baz "qux"` { + t.Errorf("lines[0] = %q, want 'foo \"\" baz \"qux\"'", m.ActiveBuffer().Lines[0]) + } + }) + + t.Run("test multiple quoted strings - cursor between pairs", func(t *testing.T) { + lines := []string{`"foo" bar "baz"`} + // Cursor on 'b' in "bar" + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0}) + sendKeys(tm, "d", "i", `"`) + + m := getFinalModel(t, tm) + // Should search forward and find second string + if m.ActiveBuffer().Lines[0] != `"foo" bar ""` { + t.Errorf("lines[0] = %q, want '\"foo\" bar \"\"'", m.ActiveBuffer().Lines[0]) + } + }) } diff --git a/internal/textobject/delimiter.go b/internal/textobject/delimiter.go index 0a1ae3c..9ad6777 100644 --- a/internal/textobject/delimiter.go +++ b/internal/textobject/delimiter.go @@ -47,9 +47,6 @@ type Delimiter struct { } // TODO: This should allow for many lines, not just a single line -// -// BUG: This does not work properly when the cursor is not inside a delimiter. If the cursor -// does not fall inside a delimiter range, it should search forward and find the delimiter. func (to Delimiter) GetRange(m action.Model, cursor core.Position, modifier string) (core.Position, core.Position, core.MotionType) { buf := m.ActiveBuffer() line := buf.Lines[cursor.Line] @@ -80,21 +77,19 @@ func (to Delimiter) GetRange(m action.Model, cursor core.Position, modifier stri return cursor, cursor, core.CharwiseExclusive } - // Find object boundaries - start, sOk := findDelimiterStart(line, startDelim, cursor.Col, modifier == "a") - end, eOk := findDelimiterEnd(line, endDelim, cursor.Col, modifier == "a") + // Try to find delimiters around the cursor position + start, end, found := findDelimiterPair(line, startDelim, endDelim, cursor.Col, modifier == "a") - // Handle the case where they are not found - if !sOk || !eOk { + if !found { return cursor, cursor, core.CharwiseExclusive } - // This happens when nothing is between the delimiter, fixes the bugs we found + // This happens when nothing is between the delimiter if start.Col > end.Col { return cursor, cursor, core.CharwiseExclusive } - // Word object's don't span lines + // Delimiter objects don't span lines (for now) start.Line = cursor.Line end.Line = cursor.Line @@ -124,3 +119,131 @@ func findDelimiterEnd(line string, delimiter rune, col int, includeDelimiter boo } return core.Position{Line: 0, Col: 0}, false } + +// findDelimiterPair tries to find a matching pair of delimiters. +// First it tries to find delimiters around the cursor. +// If that fails, it searches forward for the next pair. +func findDelimiterPair(line string, startDelim, endDelim rune, cursorCol int, includeDelimiters bool) (core.Position, core.Position, bool) { + // First, try to find delimiters around cursor + start, sOk := findDelimiterStart(line, startDelim, cursorCol, includeDelimiters) + end, eOk := findDelimiterEnd(line, endDelim, cursorCol, includeDelimiters) + + if sOk && eOk { + // Verify this is actually a valid pair by checking there are no + // unmatched delimiters between start and end + var startDelimPos, endDelimPos int + if includeDelimiters { + startDelimPos = start.Col + endDelimPos = end.Col + } else { + startDelimPos = start.Col - 1 + endDelimPos = end.Col + 1 + } + + // For proper pair validation, check if this is the nearest matching pair + // by ensuring the end delimiter we found is the first one after the start + if isValidPair(line, startDelim, endDelim, startDelimPos, endDelimPos) { + // Cursor should be at or between the delimiters + if startDelimPos <= cursorCol && cursorCol <= endDelimPos { + return start, end, true + } + } + } + + // Not inside delimiters, search forward for next pair + startDelimPos, foundStart := findNextDelimiter(line, startDelim, cursorCol) + if !foundStart { + return core.Position{}, core.Position{}, false + } + + endDelimPos, foundEnd := findNextDelimiter(line, endDelim, startDelimPos+1) + if !foundEnd { + return core.Position{}, core.Position{}, false + } + + // Validate this pair as well + if !isValidPair(line, startDelim, endDelim, startDelimPos, endDelimPos) { + return core.Position{}, core.Position{}, false + } + + // Calculate start and end positions based on modifier + if includeDelimiters { + start = core.Position{Line: 0, Col: startDelimPos} + end = core.Position{Line: 0, Col: endDelimPos} + } else { + start = core.Position{Line: 0, Col: startDelimPos + 1} + end = core.Position{Line: 0, Col: endDelimPos - 1} + } + + return start, end, true +} + +// isValidPair checks if the delimiters at startPos and endPos form a valid matching pair. +// For same-delimiter pairs (quotes), it checks they form an opening/closing pair. +// For directional pairs (parens, brackets), it ensures the end is the matching closer for the start. +func isValidPair(line string, startDelim, endDelim rune, startPos, endPos int) bool { + if startPos >= endPos { + return false + } + + // For quote-like delimiters where start and end are the same + if startDelim == endDelim { + // For quotes, we need to determine if startPos is an opening quote and endPos is a closing quote + // We do this by counting quotes before each position + // An opening quote has an even number of quotes before it (0, 2, 4, ...) + // A closing quote has an odd number of quotes before it (1, 3, 5, ...) + + quotesBeforeStart := 0 + for i := 0; i < startPos; i++ { + if rune(line[i]) == startDelim { + quotesBeforeStart++ + } + } + + quotesBeforeEnd := quotesBeforeStart + 1 // We know there's at least the startPos quote + for i := startPos + 1; i < endPos; i++ { + if rune(line[i]) == startDelim { + quotesBeforeEnd++ + } + } + + // startPos should be an opening quote (even number before it) + // endPos should be a closing quote (odd number before it) + // AND there should be no quotes between them for a simple pair + startIsOpening := quotesBeforeStart%2 == 0 + endIsClosing := quotesBeforeEnd%2 == 1 + noQuotesBetween := quotesBeforeEnd == quotesBeforeStart+1 + + return startIsOpening && endIsClosing && noQuotesBetween + } + + // For directional delimiters, check that endPos has the first unmatched closing delimiter + // Simple approach: ensure there's no end delimiter between startPos and endPos that would + // close an earlier start delimiter + nestLevel := 0 + for i := startPos + 1; i < endPos; i++ { + if rune(line[i]) == startDelim { + nestLevel++ + } else if rune(line[i]) == endDelim { + if nestLevel > 0 { + nestLevel-- + } else { + // Found an unmatched end delimiter before our endPos + return false + } + } + } + + // The endPos should close the startPos + return nestLevel == 0 +} + +// findNextDelimiter searches forward from startCol for the next occurrence of delimiter +func findNextDelimiter(line string, delimiter rune, startCol int) (int, bool) { + for i := startCol; i < len(line); i++ { + if rune(line[i]) == delimiter { + return i, true + } + } + return 0, false +}