Gim/internal/textobject/delimiter.go
Hayden Hargreaves 21ed76bed5 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.
2026-03-30 17:58:06 -07:00

250 lines
7.3 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
}
// 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
}