diff --git a/internal/editor/integration_textobject_test.go b/internal/editor/integration_textobject_test.go index 17c3299..a4767a2 100644 --- a/internal/editor/integration_textobject_test.go +++ b/internal/editor/integration_textobject_test.go @@ -706,3 +706,274 @@ func TestTextObjectEdgeCases(t *testing.T) { } }) } + +// ============================================================================ +// Multi-line Delimiter Tests +// ============================================================================ + +func TestTextObjectMultiLineDelimiters(t *testing.T) { + t.Run("test 'di{' on multi-line braces", func(t *testing.T) { + lines := []string{ + "func test() {", + " body", + "}", + } + // Cursor on "body" + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 1}) + sendKeys(tm, "d", "i", "{") + + m := getFinalModel(t, tm) + expected := []string{ + "func test() {", + "}", + } + if !slicesEqual(m.ActiveBuffer().Lines, expected) { + t.Errorf("lines = %v, want %v", m.ActiveBuffer().Lines, expected) + } + }) + + t.Run("test 'da{' on multi-line braces", func(t *testing.T) { + lines := []string{ + "func test() {", + " body", + "}", + } + // Cursor on "body" + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 1}) + sendKeys(tm, "d", "a", "{") + + m := getFinalModel(t, tm) + expected := []string{ + "func test() ", + } + if !slicesEqual(m.ActiveBuffer().Lines, expected) { + t.Errorf("lines = %v, want %v", m.ActiveBuffer().Lines, expected) + } + }) + + t.Run("test 'vi(' on multi-line parentheses", func(t *testing.T) { + lines := []string{ + "function(", + " arg1,", + " arg2", + ")", + } + // Cursor on "arg1" + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 1}) + sendKeys(tm, "v", "i", "(") + + m := getFinalModel(t, tm) + // Should select from after '(' to before ')' + // Line 0, col 9 (after '(') to line 3, col -1 (before ')') + // But since we're in visual mode, check the anchor and cursor + if m.ActiveWindow().Anchor.Line != 0 || m.ActiveWindow().Cursor.Line != 2 { + t.Errorf("anchor.Line=%d, cursor.Line=%d, want anchor.Line=0, cursor.Line=2", + m.ActiveWindow().Anchor.Line, m.ActiveWindow().Cursor.Line) + } + // Anchor should be at col 9 (after '('), cursor at end of line 2 + if m.ActiveWindow().Anchor.Col != 9 { + t.Errorf("anchor.Col=%d, want 9", m.ActiveWindow().Anchor.Col) + } + }) + + t.Run("test 'di(' on multi-line parentheses", func(t *testing.T) { + lines := []string{ + "function(", + " arg1,", + " arg2", + ")", + } + // Cursor on "arg1" + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 1}) + sendKeys(tm, "d", "i", "(") + + m := getFinalModel(t, tm) + expected := []string{ + "function(", + ")", + } + if !slicesEqual(m.ActiveBuffer().Lines, expected) { + t.Errorf("lines = %v, want %v", m.ActiveBuffer().Lines, expected) + } + }) + + t.Run("test nested multi-line braces - cursor in outer", func(t *testing.T) { + lines := []string{ + "outer {", + " inner {", + " content", + " }", + " more", + "}", + } + // Cursor on "more" (inside outer, outside inner) + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 4}) + sendKeys(tm, "d", "i", "{") + + m := getFinalModel(t, tm) + expected := []string{ + "outer {", + "}", + } + if !slicesEqual(m.ActiveBuffer().Lines, expected) { + t.Errorf("lines = %v, want %v", m.ActiveBuffer().Lines, expected) + } + }) + + t.Run("test nested multi-line braces - cursor in inner", func(t *testing.T) { + lines := []string{ + "outer {", + " inner {", + " content", + " }", + " more", + "}", + } + // Cursor on "content" (inside inner block) + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 8, Line: 2}) + sendKeys(tm, "d", "i", "{") + + m := getFinalModel(t, tm) + expected := []string{ + "outer {", + " inner {", + " }", + " more", + "}", + } + if !slicesEqual(m.ActiveBuffer().Lines, expected) { + t.Errorf("lines = %v, want %v", m.ActiveBuffer().Lines, expected) + } + }) + + t.Run("test nested multi-line braces with multiple nesting levels", func(t *testing.T) { + lines := []string{ + "level1 {", + " level2 {", + " level3 {", + " target", + " }", + " }", + "}", + } + // Cursor on "target" + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 12, Line: 3}) + sendKeys(tm, "d", "i", "{") + + m := getFinalModel(t, tm) + expected := []string{ + "level1 {", + " level2 {", + " level3 {", + " }", + " }", + "}", + } + if !slicesEqual(m.ActiveBuffer().Lines, expected) { + t.Errorf("lines = %v, want %v", m.ActiveBuffer().Lines, expected) + } + }) + + t.Run("test multi-line delimiters - cursor on opening line", func(t *testing.T) { + lines := []string{ + "function(arg) {", + " body", + "}", + } + // Cursor on opening line, after '{' + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 14, Line: 0}) + sendKeys(tm, "d", "i", "{") + + m := getFinalModel(t, tm) + expected := []string{ + "function(arg) {", + "}", + } + if !slicesEqual(m.ActiveBuffer().Lines, expected) { + t.Errorf("lines = %v, want %v", m.ActiveBuffer().Lines, expected) + } + }) + + t.Run("test multi-line delimiters - cursor on closing line", func(t *testing.T) { + lines := []string{ + "function(arg) {", + " body", + "}", + } + // Cursor on closing line, before '}' + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 2}) + sendKeys(tm, "d", "i", "{") + + m := getFinalModel(t, tm) + expected := []string{ + "function(arg) {", + "}", + } + if !slicesEqual(m.ActiveBuffer().Lines, expected) { + t.Errorf("lines = %v, want %v", m.ActiveBuffer().Lines, expected) + } + }) + + t.Run("test multi-line delimiters - cursor before delimiters searches forward", func(t *testing.T) { + lines := []string{ + "before", + "function(arg) {", + " body", + "}", + "after", + } + // Cursor on "before" + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0}) + sendKeys(tm, "d", "i", "{") + + m := getFinalModel(t, tm) + expected := []string{ + "before", + "function(arg) {", + "}", + "after", + } + if !slicesEqual(m.ActiveBuffer().Lines, expected) { + t.Errorf("lines = %v, want %v", m.ActiveBuffer().Lines, expected) + } + }) + + t.Run("test nested parentheses across lines", func(t *testing.T) { + lines := []string{ + "outer(", + " inner(", + " content", + " ),", + " more", + ")", + } + // Cursor on "content" + tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 8, Line: 2}) + sendKeys(tm, "d", "i", "(") + + m := getFinalModel(t, tm) + expected := []string{ + "outer(", + " inner(", + " ),", + " more", + ")", + } + if !slicesEqual(m.ActiveBuffer().Lines, expected) { + t.Errorf("lines = %v, want %v", m.ActiveBuffer().Lines, expected) + } + }) +} + +// Helper function to compare slices +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/textobject/delimiter.go b/internal/textobject/delimiter.go index 9ad6777..8ded26b 100644 --- a/internal/textobject/delimiter.go +++ b/internal/textobject/delimiter.go @@ -46,10 +46,8 @@ type Delimiter struct { Char rune } -// TODO: This should allow for many lines, not just a single line 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] // Determine which is a starting delimiter and which ends _, isStartingDelimiter := DirectionalDelimiterMap[to.Char] @@ -77,22 +75,18 @@ func (to Delimiter) GetRange(m action.Model, cursor core.Position, modifier stri return cursor, cursor, core.CharwiseExclusive } - // Try to find delimiters around the cursor position - start, end, found := findDelimiterPair(line, startDelim, endDelim, cursor.Col, modifier == "a") + // Use multi-line delimiter pair finding + start, end, found := findMultiLineDelimiterPair(buf.Lines, startDelim, endDelim, cursor, modifier == "a") if !found { return cursor, cursor, core.CharwiseExclusive } - // This happens when nothing is between the delimiter - if start.Col > end.Col { + // Check if positions are valid + if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) { return cursor, cursor, core.CharwiseExclusive } - // Delimiter objects don't span lines (for now) - start.Line = cursor.Line - end.Line = cursor.Line - return start, end, core.CharwiseInclusive } @@ -247,3 +241,278 @@ func findNextDelimiter(line string, delimiter rune, startCol int) (int, bool) { } return 0, false } + +// ============================================================================ +// Multi-line Delimiter Functions +// ============================================================================ + +// findMultiLineDelimiterPair finds a matching pair of delimiters across multiple lines. +// It handles proper nesting for directional delimiters (parens, brackets, braces). +func findMultiLineDelimiterPair(lines []string, startDelim, endDelim rune, cursor core.Position, includeDelimiters bool) (core.Position, core.Position, bool) { + // First, try to find delimiters around the cursor position (innermost pair) + start, end, found := findEnclosingDelimiterPair(lines, startDelim, endDelim, cursor, includeDelimiters) + if found { + return start, end, true + } + + // Not inside delimiters, search forward for the next pair + start, end, found = findNextDelimiterPair(lines, startDelim, endDelim, cursor, includeDelimiters) + return start, end, found +} + +// findEnclosingDelimiterPair finds the innermost delimiter pair that encloses the cursor. +func findEnclosingDelimiterPair(lines []string, startDelim, endDelim rune, cursor core.Position, includeDelimiters bool) (core.Position, core.Position, bool) { + // Search backward from cursor to find opening delimiter + startPos, foundStart := searchBackwardForDelimiter(lines, startDelim, endDelim, cursor) + if !foundStart { + return core.Position{}, core.Position{}, false + } + + // Search forward from the opening delimiter to find matching closing delimiter + endPos, foundEnd := searchForwardForMatchingDelimiter(lines, startDelim, endDelim, startPos) + if !foundEnd { + return core.Position{}, core.Position{}, false + } + + // Check if cursor is between these delimiters + if !isCursorBetween(cursor, startPos, endPos) { + return core.Position{}, core.Position{}, false + } + + // Adjust positions based on includeDelimiters + start, end, ok := adjustDelimiterPositions(lines, startPos, endPos, includeDelimiters) + return start, end, ok +} + +// findNextDelimiterPair searches forward from cursor for the next delimiter pair. +func findNextDelimiterPair(lines []string, startDelim, endDelim rune, cursor core.Position, includeDelimiters bool) (core.Position, core.Position, bool) { + // Search forward from cursor for opening delimiter + startPos, foundStart := searchForwardSimple(lines, startDelim, cursor) + if !foundStart { + return core.Position{}, core.Position{}, false + } + + // Search forward from opening for matching closing delimiter + endPos, foundEnd := searchForwardForMatchingDelimiter(lines, startDelim, endDelim, startPos) + if !foundEnd { + return core.Position{}, core.Position{}, false + } + + // Adjust positions based on includeDelimiters + start, end, ok := adjustDelimiterPositions(lines, startPos, endPos, includeDelimiters) + return start, end, ok +} + +// searchBackwardForDelimiter searches backward from cursor to find the nearest opening delimiter +// that could enclose the cursor position. +func searchBackwardForDelimiter(lines []string, startDelim, endDelim rune, cursor core.Position) (core.Position, bool) { + // For quote-like delimiters, only search current line + if startDelim == endDelim { + return searchBackwardForDelimiterSingleLine(lines[cursor.Line], startDelim, endDelim, cursor.Col, cursor.Line) + } + + // Start from cursor position and go backward + line := cursor.Line + col := cursor.Col + + // Search current line from cursor backwards + for i := col; i >= 0; i-- { + if i < len(lines[line]) && rune(lines[line][i]) == startDelim { + // Found a potential start delimiter, verify it could enclose cursor + pos := core.Position{Line: line, Col: i} + // Check if this delimiter's matching pair is after the cursor + endPos, found := searchForwardForMatchingDelimiter(lines, startDelim, endDelim, pos) + if found && isCursorBetween(cursor, pos, endPos) { + return pos, true + } + } + } + + // Search previous lines + for line = cursor.Line - 1; line >= 0; line-- { + for i := len(lines[line]) - 1; i >= 0; i-- { + if rune(lines[line][i]) == startDelim { + pos := core.Position{Line: line, Col: i} + endPos, found := searchForwardForMatchingDelimiter(lines, startDelim, endDelim, pos) + if found && isCursorBetween(cursor, pos, endPos) { + return pos, true + } + } + } + } + + return core.Position{}, false +} + +// searchBackwardForDelimiterSingleLine searches backward on a single line (for quotes). +func searchBackwardForDelimiterSingleLine(line string, startDelim, endDelim rune, col int, lineNum int) (core.Position, bool) { + for i := col; i >= 0; i-- { + if i < len(line) && rune(line[i]) == startDelim { + // For quotes, verify it's an opening quote by checking if it has a matching closing quote + // Count quotes before this position + quotesBefore := 0 + for j := 0; j < i; j++ { + if rune(line[j]) == startDelim { + quotesBefore++ + } + } + // If even number of quotes before, this is an opening quote + if quotesBefore%2 == 0 { + // Find matching closing quote + for j := i + 1; j < len(line); j++ { + if rune(line[j]) == endDelim { + // Check if cursor is between i and j + if col > i && col < j { + return core.Position{Line: lineNum, Col: i}, true + } + break + } + } + } + } + } + return core.Position{}, false +} + +// searchForwardForMatchingDelimiter searches forward from startPos to find the matching closing delimiter. +// It properly handles nesting for directional delimiters. +func searchForwardForMatchingDelimiter(lines []string, startDelim, endDelim rune, startPos core.Position) (core.Position, bool) { + nestLevel := 0 + line := startPos.Line + startCol := startPos.Col + 1 // Start after the opening delimiter + + // For quote-like delimiters (same start and end) + if startDelim == endDelim { + // Simple search for next occurrence on same line + if line < len(lines) { + for i := startCol; i < len(lines[line]); i++ { + if rune(lines[line][i]) == endDelim { + return core.Position{Line: line, Col: i}, true + } + } + } + return core.Position{}, false + } + + // For directional delimiters with nesting + // Search current line from startCol + for i := startCol; i < len(lines[line]); i++ { + ch := rune(lines[line][i]) + if ch == startDelim { + nestLevel++ + } else if ch == endDelim { + if nestLevel == 0 { + return core.Position{Line: line, Col: i}, true + } + nestLevel-- + } + } + + // Search subsequent lines + for line = startPos.Line + 1; line < len(lines); line++ { + for i := 0; i < len(lines[line]); i++ { + ch := rune(lines[line][i]) + if ch == startDelim { + nestLevel++ + } else if ch == endDelim { + if nestLevel == 0 { + return core.Position{Line: line, Col: i}, true + } + nestLevel-- + } + } + } + + return core.Position{}, false +} + +// searchForwardSimple searches forward from cursor for the next occurrence of delimiter. +func searchForwardSimple(lines []string, delimiter rune, cursor core.Position) (core.Position, bool) { + // Search current line from cursor + for i := cursor.Col; i < len(lines[cursor.Line]); i++ { + if rune(lines[cursor.Line][i]) == delimiter { + return core.Position{Line: cursor.Line, Col: i}, true + } + } + + // Search subsequent lines + for line := cursor.Line + 1; line < len(lines); line++ { + for i := 0; i < len(lines[line]); i++ { + if rune(lines[line][i]) == delimiter { + return core.Position{Line: line, Col: i}, true + } + } + } + + return core.Position{}, false +} + +// isCursorBetween checks if cursor is between start and end positions. +func isCursorBetween(cursor, start, end core.Position) bool { + // Check if cursor is after or at start + if cursor.Line < start.Line || (cursor.Line == start.Line && cursor.Col < start.Col) { + return false + } + + // Check if cursor is before or at end + if cursor.Line > end.Line || (cursor.Line == end.Line && cursor.Col > end.Col) { + return false + } + + return true +} + +// 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++ { + if !isWhitespace(rune(line[i])) { + return false + } + } + return true +} + +// isWhitespace checks if a rune is whitespace. +func isWhitespace(r rune) bool { + return r == ' ' || r == '\t' +} + +// adjustDelimiterPositions adjusts the delimiter positions based on whether to include delimiters. +func adjustDelimiterPositions(lines []string, startPos, endPos core.Position, includeDelimiters bool) (core.Position, core.Position, bool) { + if includeDelimiters { + // Include the delimiters themselves + return startPos, endPos, true + } + + // Exclude the delimiters - start after opening, end before closing + start := core.Position{Line: startPos.Line, Col: startPos.Col + 1} + end := core.Position{Line: endPos.Line, Col: endPos.Col - 1} + + // Handle special cases + if startPos.Line == endPos.Line { + // Same line - check if there's content between delimiters + if startPos.Col+1 > endPos.Col-1 { + // Empty delimiters like "()" - return positions that will be detected as invalid + return start, end, true + } + } else { + // Multi-line case + // If start position is beyond the line (delimiter was last char on its line) + if start.Col >= len(lines[start.Line]) { + // Position at end of line to include the newline in deletion + start.Col = len(lines[start.Line]) + } + + // If end position is negative (delimiter was first char on its line) OR + // delimiter has only whitespace before it on its line + if end.Col < 0 || hasOnlyWhitespaceBefore(lines[endPos.Line], endPos.Col) { + // Position at end of previous line to include that line's newline + if end.Line > 0 { + end.Line-- + end.Col = len(lines[end.Line]) + } + } + } + + return start, end, true +}