525 lines
17 KiB
Go
525 lines
17 KiB
Go
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
|
|
}
|