package textobject import ( "fmt" "slices" "git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/core" ) // Map opposite char var DirectionalDelimiterMap map[rune]rune = map[rune]rune{ '(': ')', '[': ']', '{': '}', '<': '>', } var singleDelimiterList []rune = []rune{'"', '\'', '`'} func getStartDelimiterFromEnd(d rune) (rune, bool) { if slices.Contains(singleDelimiterList, d) { return d, true } for start, end := range DirectionalDelimiterMap { if end == d { return start, true } } return ' ', false } func getEndDelimiterFromStart(d rune) (rune, bool) { if slices.Contains(singleDelimiterList, d) { return d, true } end, found := DirectionalDelimiterMap[d] return end, found } // Delimiter implements text object for words (iw/aw) type Delimiter struct { Char rune } func (to Delimiter) GetRange(m action.Model, cursor core.Position, modifier string) (core.Position, core.Position, core.MotionType) { buf := m.ActiveBuffer() // Determine which is a starting delimiter and which ends _, isStartingDelimiter := DirectionalDelimiterMap[to.Char] var ( startDelim rune endDelim rune startFound bool = true endFound bool = true ) if isStartingDelimiter { startDelim = to.Char endDelim, endFound = getEndDelimiterFromStart(to.Char) } else { endDelim = to.Char startDelim, startFound = getStartDelimiterFromEnd(to.Char) } if !endFound || !startFound { m.SetCommandOutput(&core.CommandOutput{ Lines: []string{fmt.Sprintf("Could not find delimiters from '%c'", to.Char)}, Inline: true, IsError: false, }) return cursor, cursor, core.CharwiseExclusive } // Convert buffer lines to strings for delimiter finding var lines []string for i := 0; i < buf.LineCount(); i++ { lines = append(lines, buf.Line(i)) } // Use multi-line delimiter pair finding start, end, found := findMultiLineDelimiterPair(lines, startDelim, endDelim, cursor, modifier == "a") if !found { return cursor, cursor, core.CharwiseExclusive } // Check if positions are valid if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) { return cursor, cursor, core.CharwiseExclusive } return start, end, core.CharwiseInclusive } func findDelimiterStart(line string, delimiter rune, col int, includeDelimiter bool) (core.Position, bool) { for i := col; i >= 0; i-- { if rune(line[i]) == delimiter { if includeDelimiter { return core.Position{Line: 0, Col: i}, true } return core.Position{Line: 0, Col: i + 1}, true } } return core.Position{Line: 0, Col: 0}, false } func findDelimiterEnd(line string, delimiter rune, col int, includeDelimiter bool) (core.Position, bool) { for i := col; i < len(line); i++ { if rune(line[i]) == delimiter { if includeDelimiter { return core.Position{Line: 0, Col: i}, true } return core.Position{Line: 0, Col: i - 1}, true } } 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 := range startPos { 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 } // ============================================================================ // 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 := range col { 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 }