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 } // 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] 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 } // Try to find delimiters around the cursor position start, end, found := findDelimiterPair(line, startDelim, endDelim, cursor.Col, modifier == "a") if !found { return cursor, cursor, core.CharwiseExclusive } // This happens when nothing is between the delimiter if 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 } 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 := 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 }