Compare commits
6 Commits
b0b885d57d
...
04c247cc8e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c247cc8e | ||
|
|
ffad4f86f6 | ||
|
|
9960d5c4e2 | ||
|
|
21ed76bed5 | ||
|
|
5405d5a6bd | ||
|
|
aa156971ad |
10
README.md
10
README.md
@ -56,6 +56,16 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Trade Offs
|
||||||
|
|
||||||
|
#### Undo Tree vs. Undo Stack
|
||||||
|
|
||||||
|
While the undo tree method that vim uses is powerful, I rarely find myself using it. A stack terminal-based
|
||||||
|
approach is more natural to "non-vim" users and much simpler to implement. Implementing a feature similar
|
||||||
|
to Vims undo tree would many times longer than a simple stack.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🎯 Features
|
## 🎯 Features
|
||||||
|
|
||||||
### 🎭 Six Editor Modes
|
### 🎭 Six Editor Modes
|
||||||
|
|||||||
@ -108,3 +108,9 @@ type Resolvable interface {
|
|||||||
Motion
|
Motion
|
||||||
Resolve(m Model) Motion
|
Resolve(m Model) Motion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TextObject interface {
|
||||||
|
// GetRange calculates both endpoints for the text object
|
||||||
|
// modifier: "i" (inner) or "a" (around)
|
||||||
|
GetRange(m Model, cursor core.Position, modifier string) (start, end core.Position, mtype core.MotionType)
|
||||||
|
}
|
||||||
|
|||||||
@ -57,29 +57,56 @@ func (a Paste) Execute(m Model) tea.Cmd {
|
|||||||
{
|
{
|
||||||
lines := reg.Content
|
lines := reg.Content
|
||||||
|
|
||||||
// Shouldn't happen, just a check
|
if len(lines) == 0 {
|
||||||
if len(lines) != 1 {
|
|
||||||
out := core.CommandOutput{
|
|
||||||
Lines: []string{"Charwise register should only have a single line of content."},
|
|
||||||
Inline: true,
|
|
||||||
IsError: true,
|
|
||||||
}
|
|
||||||
m.SetCommandOutput(&out)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
x := win.Cursor.Col
|
x := win.Cursor.Col
|
||||||
y := win.Cursor.Line
|
y := win.Cursor.Line
|
||||||
|
|
||||||
cnt := strings.Repeat(lines[0], max(1, a.Count))
|
|
||||||
curLine := buf.Lines[y]
|
curLine := buf.Lines[y]
|
||||||
|
|
||||||
// Catch edge cases, end of line, start of blank line
|
|
||||||
insertAt := min(x+1, len(curLine))
|
insertAt := min(x+1, len(curLine))
|
||||||
newLine := curLine[:insertAt] + cnt + curLine[insertAt:]
|
|
||||||
buf.SetLine(y, newLine)
|
|
||||||
|
|
||||||
win.SetCursorCol(x + len(cnt))
|
if len(lines) == 1 {
|
||||||
|
// Single-line charwise paste
|
||||||
|
cnt := strings.Repeat(lines[0], max(1, a.Count))
|
||||||
|
newLine := curLine[:insertAt] + cnt + curLine[insertAt:]
|
||||||
|
buf.SetLine(y, newLine)
|
||||||
|
win.SetCursorCol(x + len(cnt))
|
||||||
|
} else {
|
||||||
|
// Multi-line charwise paste (e.g., from vi{ yank)
|
||||||
|
suffix := curLine[insertAt:] // Save the part after cursor
|
||||||
|
|
||||||
|
// For count > 1, we paste the content multiple times
|
||||||
|
// Each paste continues from where the previous one ended
|
||||||
|
var content strings.Builder
|
||||||
|
for i := 0; i < a.Count; i++ {
|
||||||
|
for j, line := range lines {
|
||||||
|
if j > 0 {
|
||||||
|
content.WriteString("\n")
|
||||||
|
}
|
||||||
|
content.WriteString(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the pasted content into lines
|
||||||
|
pastedLines := strings.Split(content.String(), "\n")
|
||||||
|
|
||||||
|
// First line: append to current line
|
||||||
|
buf.SetLine(y, curLine[:insertAt]+pastedLines[0])
|
||||||
|
|
||||||
|
// Middle lines: insert as new lines
|
||||||
|
for i := 1; i < len(pastedLines); i++ {
|
||||||
|
buf.InsertLine(y+i, pastedLines[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last line: append the suffix
|
||||||
|
lastLineIdx := y + len(pastedLines) - 1
|
||||||
|
buf.SetLine(lastLineIdx, buf.Lines[lastLineIdx]+suffix)
|
||||||
|
|
||||||
|
// Set cursor to end of last pasted content (before suffix)
|
||||||
|
win.SetCursorLine(lastLineIdx)
|
||||||
|
win.SetCursorCol(len(buf.Lines[lastLineIdx]) - len(suffix) - 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
out := core.CommandOutput{
|
out := core.CommandOutput{
|
||||||
@ -142,29 +169,56 @@ func (a PasteBefore) Execute(m Model) tea.Cmd {
|
|||||||
{
|
{
|
||||||
lines := reg.Content
|
lines := reg.Content
|
||||||
|
|
||||||
// Shouldn't happen, just a check
|
if len(lines) == 0 {
|
||||||
if len(lines) != 1 {
|
|
||||||
out := core.CommandOutput{
|
|
||||||
Lines: []string{"Charwise register should only have a single line of content."},
|
|
||||||
Inline: true,
|
|
||||||
IsError: true,
|
|
||||||
}
|
|
||||||
m.SetCommandOutput(&out)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
x := win.Cursor.Col
|
x := win.Cursor.Col
|
||||||
y := win.Cursor.Line
|
y := win.Cursor.Line
|
||||||
|
|
||||||
cnt := strings.Repeat(lines[0], max(1, a.Count))
|
|
||||||
curLine := buf.Lines[y]
|
curLine := buf.Lines[y]
|
||||||
|
|
||||||
// Catch edge cases, end of line, start of blank line
|
|
||||||
insertAt := min(x, len(curLine))
|
insertAt := min(x, len(curLine))
|
||||||
newLine := curLine[:insertAt] + cnt + curLine[insertAt:]
|
|
||||||
buf.SetLine(y, newLine)
|
|
||||||
|
|
||||||
win.SetCursorCol(x + len(cnt))
|
if len(lines) == 1 {
|
||||||
|
// Single-line charwise paste before cursor
|
||||||
|
cnt := strings.Repeat(lines[0], max(1, a.Count))
|
||||||
|
newLine := curLine[:insertAt] + cnt + curLine[insertAt:]
|
||||||
|
buf.SetLine(y, newLine)
|
||||||
|
win.SetCursorCol(x + len(cnt))
|
||||||
|
} else {
|
||||||
|
// Multi-line charwise paste before cursor
|
||||||
|
suffix := curLine[insertAt:] // Save the part after cursor
|
||||||
|
|
||||||
|
// For count > 1, we paste the content multiple times
|
||||||
|
// Each paste continues from where the previous one ended
|
||||||
|
var content strings.Builder
|
||||||
|
for i := 0; i < a.Count; i++ {
|
||||||
|
for j, line := range lines {
|
||||||
|
if j > 0 {
|
||||||
|
content.WriteString("\n")
|
||||||
|
}
|
||||||
|
content.WriteString(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the pasted content into lines
|
||||||
|
pastedLines := strings.Split(content.String(), "\n")
|
||||||
|
|
||||||
|
// First line: insert at cursor position
|
||||||
|
buf.SetLine(y, curLine[:insertAt]+pastedLines[0])
|
||||||
|
|
||||||
|
// Middle lines: insert as new lines
|
||||||
|
for i := 1; i < len(pastedLines); i++ {
|
||||||
|
buf.InsertLine(y+i, pastedLines[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last line: append the suffix
|
||||||
|
lastLineIdx := y + len(pastedLines) - 1
|
||||||
|
buf.SetLine(lastLineIdx, buf.Lines[lastLineIdx]+suffix)
|
||||||
|
|
||||||
|
// Set cursor to end of last pasted content (before suffix)
|
||||||
|
win.SetCursorLine(lastLineIdx)
|
||||||
|
win.SetCursorCol(len(buf.Lines[lastLineIdx]) - len(suffix) - 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
out := core.CommandOutput{
|
out := core.CommandOutput{
|
||||||
|
|||||||
@ -821,37 +821,196 @@ func TestPasteBeforeCharwiseWithCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Multi-line Charwise Paste Tests (from visual mode yank)
|
// Multi-line Charwise Paste Tests (from visual mode yank like vi{)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
func TestPasteCharwiseMultiLine(t *testing.T) {
|
func TestPasteCharwiseMultiLine(t *testing.T) {
|
||||||
t.Run("p with multi-line charwise content errors gracefully", func(t *testing.T) {
|
t.Run("p with 2-line charwise content inserts correctly", func(t *testing.T) {
|
||||||
tm := newTestModel(t,
|
tm := newTestModel(t,
|
||||||
WithLines([]string{"hello"}),
|
WithLines([]string{"hello"}),
|
||||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
WithCursorPos(core.Position{Line: 0, Col: 1}), // on 'e'
|
||||||
WithRegister('"', core.CharwiseRegister, []string{"line1", "line2"}),
|
WithRegister('"', core.CharwiseRegister, []string{"AAA", "BBB"}),
|
||||||
)
|
)
|
||||||
sendKeys(tm, "p")
|
sendKeys(tm, "p")
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
m := getFinalModel(t, tm)
|
||||||
// Current implementation errors - line should be unchanged
|
// Should paste after 'e': "heAAA\nBBBllo"
|
||||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
if m.ActiveBuffer().LineCount() != 2 {
|
||||||
t.Errorf("Line(0) = %q, want 'hello' (unchanged due to error)", m.ActiveBuffer().Lines[0])
|
t.Errorf("LineCount() = %d, want 2", m.ActiveBuffer().LineCount())
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Lines[0] != "heAAA" {
|
||||||
|
t.Errorf("Line(0) = %q, want 'heAAA'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Lines[1] != "BBBllo" {
|
||||||
|
t.Errorf("Line(1) = %q, want 'BBBllo'", m.ActiveBuffer().Lines[1])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("P with multi-line charwise content errors gracefully", func(t *testing.T) {
|
t.Run("p with 3-line charwise content inserts correctly", func(t *testing.T) {
|
||||||
|
tm := newTestModel(t,
|
||||||
|
WithLines([]string{"test"}),
|
||||||
|
WithCursorPos(core.Position{Line: 0, Col: 1}), // on 'e'
|
||||||
|
WithRegister('"', core.CharwiseRegister, []string{"AAA", "BBB", "CCC"}),
|
||||||
|
)
|
||||||
|
sendKeys(tm, "p")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should paste: "teAAA\nBBB\nCCCst"
|
||||||
|
if m.ActiveBuffer().LineCount() != 3 {
|
||||||
|
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Lines[0] != "teAAA" {
|
||||||
|
t.Errorf("Line(0) = %q, want 'teAAA'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Lines[1] != "BBB" {
|
||||||
|
t.Errorf("Line(1) = %q, want 'BBB'", m.ActiveBuffer().Lines[1])
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Lines[2] != "CCCst" {
|
||||||
|
t.Errorf("Line(2) = %q, want 'CCCst'", m.ActiveBuffer().Lines[2])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("p with multi-line at start of line", func(t *testing.T) {
|
||||||
tm := newTestModel(t,
|
tm := newTestModel(t,
|
||||||
WithLines([]string{"hello"}),
|
WithLines([]string{"hello"}),
|
||||||
|
WithCursorPos(core.Position{Line: 0, Col: 0}), // on 'h'
|
||||||
|
WithRegister('"', core.CharwiseRegister, []string{"X", "Y"}),
|
||||||
|
)
|
||||||
|
sendKeys(tm, "p")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// After 'h': "hX\nYello"
|
||||||
|
if m.ActiveBuffer().Lines[0] != "hX" {
|
||||||
|
t.Errorf("Line(0) = %q, want 'hX'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Lines[1] != "Yello" {
|
||||||
|
t.Errorf("Line(1) = %q, want 'Yello'", m.ActiveBuffer().Lines[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("p with multi-line at end of line", func(t *testing.T) {
|
||||||
|
tm := newTestModel(t,
|
||||||
|
WithLines([]string{"hello"}),
|
||||||
|
WithCursorPos(core.Position{Line: 0, Col: 4}), // on 'o'
|
||||||
|
WithRegister('"', core.CharwiseRegister, []string{"X", "Y"}),
|
||||||
|
)
|
||||||
|
sendKeys(tm, "p")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// After 'o': "helloX\nY"
|
||||||
|
if m.ActiveBuffer().Lines[0] != "helloX" {
|
||||||
|
t.Errorf("Line(0) = %q, want 'helloX'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Lines[1] != "Y" {
|
||||||
|
t.Errorf("Line(1) = %q, want 'Y'", m.ActiveBuffer().Lines[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("p with multi-line on empty line", func(t *testing.T) {
|
||||||
|
tm := newTestModel(t,
|
||||||
|
WithLines([]string{""}),
|
||||||
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||||
WithRegister('"', core.CharwiseRegister, []string{"line1", "line2"}),
|
WithRegister('"', core.CharwiseRegister, []string{"AAA", "BBB"}),
|
||||||
|
)
|
||||||
|
sendKeys(tm, "p")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "AAA" {
|
||||||
|
t.Errorf("Line(0) = %q, want 'AAA'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Lines[1] != "BBB" {
|
||||||
|
t.Errorf("Line(1) = %q, want 'BBB'", m.ActiveBuffer().Lines[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("P with multi-line charwise content before cursor", func(t *testing.T) {
|
||||||
|
tm := newTestModel(t,
|
||||||
|
WithLines([]string{"hello"}),
|
||||||
|
WithCursorPos(core.Position{Line: 0, Col: 2}), // on first 'l'
|
||||||
|
WithRegister('"', core.CharwiseRegister, []string{"X", "Y"}),
|
||||||
)
|
)
|
||||||
sendKeys(tm, "P")
|
sendKeys(tm, "P")
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
m := getFinalModel(t, tm)
|
||||||
// Current implementation errors - line should be unchanged
|
// Before 'l': "heX\nYllo"
|
||||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
if m.ActiveBuffer().Lines[0] != "heX" {
|
||||||
t.Errorf("Line(0) = %q, want 'hello' (unchanged due to error)", m.ActiveBuffer().Lines[0])
|
t.Errorf("Line(0) = %q, want 'heX'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Lines[1] != "Yllo" {
|
||||||
|
t.Errorf("Line(1) = %q, want 'Yllo'", m.ActiveBuffer().Lines[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("P with multi-line at start of line", func(t *testing.T) {
|
||||||
|
tm := newTestModel(t,
|
||||||
|
WithLines([]string{"hello"}),
|
||||||
|
WithCursorPos(core.Position{Line: 0, Col: 0}),
|
||||||
|
WithRegister('"', core.CharwiseRegister, []string{"X", "Y"}),
|
||||||
|
)
|
||||||
|
sendKeys(tm, "P")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Before 'h': "X\nYhello"
|
||||||
|
if m.ActiveBuffer().Lines[0] != "X" {
|
||||||
|
t.Errorf("Line(0) = %q, want 'X'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Lines[1] != "Yhello" {
|
||||||
|
t.Errorf("Line(1) = %q, want 'Yhello'", m.ActiveBuffer().Lines[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("p with multi-line and count", func(t *testing.T) {
|
||||||
|
tm := newTestModel(t,
|
||||||
|
WithLines([]string{"test"}),
|
||||||
|
WithCursorPos(core.Position{Line: 0, Col: 1}),
|
||||||
|
WithRegister('"', core.CharwiseRegister, []string{"A", "B"}),
|
||||||
|
)
|
||||||
|
sendKeys(tm, "2", "p")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// 2p should paste twice: "teA\nBA\nBst"
|
||||||
|
if m.ActiveBuffer().LineCount() != 3 {
|
||||||
|
t.Errorf("LineCount() = %d, want 3", m.ActiveBuffer().LineCount())
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Lines[0] != "teA" {
|
||||||
|
t.Errorf("Line(0) = %q, want 'teA'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Lines[1] != "BA" {
|
||||||
|
t.Errorf("Line(1) = %q, want 'BA'", m.ActiveBuffer().Lines[1])
|
||||||
|
}
|
||||||
|
if m.ActiveBuffer().Lines[2] != "Bst" {
|
||||||
|
t.Errorf("Line(2) = %q, want 'Bst'", m.ActiveBuffer().Lines[2])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("real world: vi{ then y then p", func(t *testing.T) {
|
||||||
|
tm := newTestModel(t,
|
||||||
|
WithLines([]string{
|
||||||
|
"function() {",
|
||||||
|
" body",
|
||||||
|
"}",
|
||||||
|
"test",
|
||||||
|
}),
|
||||||
|
WithCursorPos(core.Position{Line: 1, Col: 0}),
|
||||||
|
)
|
||||||
|
// Yank the content inside braces
|
||||||
|
sendKeys(tm, "v", "i", "{", "y")
|
||||||
|
// Move to test line and paste
|
||||||
|
sendKeys(tm, "j", "j", "$", "p")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// The yanked content should be multi-line charwise
|
||||||
|
reg, ok := m.GetRegister('"')
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("register not found")
|
||||||
|
}
|
||||||
|
if reg.Type != core.CharwiseRegister {
|
||||||
|
t.Errorf("register type = %v, want CharwiseRegister", reg.Type)
|
||||||
|
}
|
||||||
|
// Should paste after 't' in "test"
|
||||||
|
// Depending on what vi{ yanks, this verifies multi-line paste works
|
||||||
|
if m.ActiveBuffer().LineCount() < 4 {
|
||||||
|
t.Errorf("LineCount() = %d, want at least 4", m.ActiveBuffer().LineCount())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
979
internal/editor/integration_textobject_test.go
Normal file
979
internal/editor/integration_textobject_test.go
Normal file
@ -0,0 +1,979 @@
|
|||||||
|
package editor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Word Text Object Tests (iw/aw)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func TestTextObjectInnerWord(t *testing.T) {
|
||||||
|
t.Run("test 'viw' selects inner word", func(t *testing.T) {
|
||||||
|
lines := []string{"hello world"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", "w")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if !m.Mode().IsVisualMode() {
|
||||||
|
t.Errorf("Expected visual mode")
|
||||||
|
}
|
||||||
|
// Should select "hello" (cols 0-4)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 4 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=4",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'diw' deletes inner word", func(t *testing.T) {
|
||||||
|
lines := []string{"hello world"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "w")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != " world" {
|
||||||
|
t.Errorf("lines[0] = %q, want ' world'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'ciw' changes inner word", func(t *testing.T) {
|
||||||
|
lines := []string{"hello world"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||||
|
sendKeys(tm, "c", "i", "w")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != " world" {
|
||||||
|
t.Errorf("lines[0] = %q, want ' world'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
if m.Mode() != core.InsertMode {
|
||||||
|
t.Errorf("Expected insert mode after ciw")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'yiw' yanks inner word", func(t *testing.T) {
|
||||||
|
lines := []string{"hello world"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||||
|
sendKeys(tm, "y", "i", "w")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
reg, ok := m.GetRegister('"')
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Default register not found")
|
||||||
|
}
|
||||||
|
if len(reg.Content) != 1 || reg.Content[0] != "hello" {
|
||||||
|
t.Errorf("register content = %v, want ['hello']", reg.Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'viw' at start of word", func(t *testing.T) {
|
||||||
|
lines := []string{"hello world"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", "w")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 4 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=4",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'viw' at end of word", func(t *testing.T) {
|
||||||
|
lines := []string{"hello world"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 4, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", "w")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 4 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=4",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'viw' on underscore word", func(t *testing.T) {
|
||||||
|
lines := []string{"hello_world test"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 7, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", "w")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should select "hello_world" (cols 0-10)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 10 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=10",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'viw' on punctuation", func(t *testing.T) {
|
||||||
|
lines := []string{"foo-bar baz"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", "w")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should select just "-" (col 3)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 3 || m.ActiveWindow().Cursor.Col != 3 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=3, cursor=3",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextObjectAroundWord(t *testing.T) {
|
||||||
|
t.Run("test 'vaw' selects around word with trailing space", func(t *testing.T) {
|
||||||
|
lines := []string{"hello world"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||||
|
sendKeys(tm, "v", "a", "w")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should select "hello " (cols 0-5, includes trailing space)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 5 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=5",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'daw' deletes around word", func(t *testing.T) {
|
||||||
|
lines := []string{"hello world"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||||
|
sendKeys(tm, "d", "a", "w")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "world" {
|
||||||
|
t.Errorf("lines[0] = %q, want 'world'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'daw' on last word (no trailing space)", func(t *testing.T) {
|
||||||
|
lines := []string{"hello world"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 7, Line: 0})
|
||||||
|
sendKeys(tm, "d", "a", "w")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "hello " {
|
||||||
|
t.Errorf("lines[0] = %q, want 'hello '", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WORD Text Object Tests (iW/aW)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func TestTextObjectInnerWORD(t *testing.T) {
|
||||||
|
t.Run("test 'viW' selects inner WORD", func(t *testing.T) {
|
||||||
|
lines := []string{"foo-bar baz"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", "W")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should select "foo-bar" (cols 0-6)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 6 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=6",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'diW' deletes inner WORD", func(t *testing.T) {
|
||||||
|
lines := []string{"foo-bar.baz test"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "W")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != " test" {
|
||||||
|
t.Errorf("lines[0] = %q, want ' test'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextObjectAroundWORD(t *testing.T) {
|
||||||
|
t.Run("test 'vaW' selects around WORD with trailing space", func(t *testing.T) {
|
||||||
|
lines := []string{"foo-bar baz"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||||
|
sendKeys(tm, "v", "a", "W")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should select "foo-bar " (cols 0-7, includes trailing space)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 0 || m.ActiveWindow().Cursor.Col != 7 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=0, cursor=7",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'daW' deletes around WORD", func(t *testing.T) {
|
||||||
|
lines := []string{"foo-bar baz"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||||
|
sendKeys(tm, "d", "a", "W")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "baz" {
|
||||||
|
t.Errorf("lines[0] = %q, want 'baz'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Delimiter Text Object Tests (i</a<, i(/a(, etc.)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func TestTextObjectAngleBrackets(t *testing.T) {
|
||||||
|
t.Run("test 'vi<' selects inner angle brackets", func(t *testing.T) {
|
||||||
|
lines := []string{"<hello>"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", "<")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should select "hello" (cols 1-5)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'vi>' works same as 'vi<'", func(t *testing.T) {
|
||||||
|
lines := []string{"<hello>"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", ">")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should select "hello" (cols 1-5)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'di<' deletes inner angle brackets", func(t *testing.T) {
|
||||||
|
lines := []string{"<hello>"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "<")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "<>" {
|
||||||
|
t.Errorf("lines[0] = %q, want '<>'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'da<' deletes around angle brackets", func(t *testing.T) {
|
||||||
|
lines := []string{"<hello>"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "d", "a", "<")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "" {
|
||||||
|
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'vi<' on empty brackets does nothing", func(t *testing.T) {
|
||||||
|
lines := []string{"<>"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "<")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should remain unchanged
|
||||||
|
if m.ActiveBuffer().Lines[0] != "<>" {
|
||||||
|
t.Errorf("lines[0] = %q, want '<>'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'vi<' in nested brackets", func(t *testing.T) {
|
||||||
|
lines := []string{"<foo<bar>>"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", "<")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should select "bar" (cols 5-7, the innermost pair)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 5 || m.ActiveWindow().Cursor.Col != 7 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=5, cursor=7",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextObjectParentheses(t *testing.T) {
|
||||||
|
t.Run("test 'vi(' selects inner parentheses", func(t *testing.T) {
|
||||||
|
lines := []string{"(hello)"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", "(")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'vi)' works same as 'vi('", func(t *testing.T) {
|
||||||
|
lines := []string{"(hello)"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", ")")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'di(' deletes inner parentheses", func(t *testing.T) {
|
||||||
|
lines := []string{"func(hello)"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 7, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "(")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "func()" {
|
||||||
|
t.Errorf("lines[0] = %q, want 'func()'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'da(' deletes around parentheses", func(t *testing.T) {
|
||||||
|
lines := []string{"func(hello)"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 7, Line: 0})
|
||||||
|
sendKeys(tm, "d", "a", "(")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "func" {
|
||||||
|
t.Errorf("lines[0] = %q, want 'func'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'vi(' on empty parens does nothing", func(t *testing.T) {
|
||||||
|
lines := []string{"()"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "(")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "()" {
|
||||||
|
t.Errorf("lines[0] = %q, want '()'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextObjectBraces(t *testing.T) {
|
||||||
|
t.Run("test 'vi{' selects inner braces", func(t *testing.T) {
|
||||||
|
lines := []string{"{hello}"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", "{")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'di{' deletes inner braces", func(t *testing.T) {
|
||||||
|
lines := []string{"{hello}"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "{")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "{}" {
|
||||||
|
t.Errorf("lines[0] = %q, want '{}'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'da{' deletes around braces", func(t *testing.T) {
|
||||||
|
lines := []string{"{hello}"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "d", "a", "{")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "" {
|
||||||
|
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextObjectBrackets(t *testing.T) {
|
||||||
|
t.Run("test 'vi[' selects inner brackets", func(t *testing.T) {
|
||||||
|
lines := []string{"[hello]"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", "[")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'di[' deletes inner brackets", func(t *testing.T) {
|
||||||
|
lines := []string{"[hello]"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "[")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "[]" {
|
||||||
|
t.Errorf("lines[0] = %q, want '[]'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'da[' deletes around brackets", func(t *testing.T) {
|
||||||
|
lines := []string{"[hello]"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "d", "a", "[")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "" {
|
||||||
|
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextObjectDoubleQuotes(t *testing.T) {
|
||||||
|
t.Run("test 'vi\"' selects inner double quotes", func(t *testing.T) {
|
||||||
|
lines := []string{`"hello"`}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", `"`)
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'di\"' deletes inner double quotes", func(t *testing.T) {
|
||||||
|
lines := []string{`"hello"`}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", `"`)
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != `""` {
|
||||||
|
t.Errorf("lines[0] = %q, want '\"\"'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'da\"' deletes around double quotes", func(t *testing.T) {
|
||||||
|
lines := []string{`"hello"`}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "d", "a", `"`)
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "" {
|
||||||
|
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'vi\"' on empty quotes does nothing", func(t *testing.T) {
|
||||||
|
lines := []string{`""`}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", `"`)
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != `""` {
|
||||||
|
t.Errorf("lines[0] = %q, want '\"\"'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextObjectSingleQuotes(t *testing.T) {
|
||||||
|
t.Run("test 'vi'' selects inner single quotes", func(t *testing.T) {
|
||||||
|
lines := []string{"'hello'"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", "'")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'di'' deletes inner single quotes", func(t *testing.T) {
|
||||||
|
lines := []string{"'hello'"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "'")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "''" {
|
||||||
|
t.Errorf("lines[0] = %q, want \"''\"", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'da'' deletes around single quotes", func(t *testing.T) {
|
||||||
|
lines := []string{"'hello'"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "d", "a", "'")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "" {
|
||||||
|
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextObjectBackticks(t *testing.T) {
|
||||||
|
t.Run("test 'vi`' selects inner backticks", func(t *testing.T) {
|
||||||
|
lines := []string{"`hello`"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "v", "i", "`")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveWindow().Anchor.Col != 1 || m.ActiveWindow().Cursor.Col != 5 {
|
||||||
|
t.Errorf("anchor=%d, cursor=%d, want anchor=1, cursor=5",
|
||||||
|
m.ActiveWindow().Anchor.Col, m.ActiveWindow().Cursor.Col)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'di`' deletes inner backticks", func(t *testing.T) {
|
||||||
|
lines := []string{"`hello`"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "`")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "``" {
|
||||||
|
t.Errorf("lines[0] = %q, want '``'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'da`' deletes around backticks", func(t *testing.T) {
|
||||||
|
lines := []string{"`hello`"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "d", "a", "`")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "" {
|
||||||
|
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Edge Cases and Complex Scenarios
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func TestTextObjectEdgeCases(t *testing.T) {
|
||||||
|
t.Run("test 'diw' on single character word", func(t *testing.T) {
|
||||||
|
lines := []string{"a b c"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "w")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != " b c" {
|
||||||
|
t.Errorf("lines[0] = %q, want ' b c'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'ci<' then type replacement", func(t *testing.T) {
|
||||||
|
lines := []string{"<hello>"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 3, Line: 0})
|
||||||
|
sendKeys(tm, "c", "i", "<")
|
||||||
|
sendKeyString(tm, "world")
|
||||||
|
sendKeys(tm, "esc")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "<world>" {
|
||||||
|
t.Errorf("lines[0] = %q, want '<world>'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test 'yi(' then paste", func(t *testing.T) {
|
||||||
|
lines := []string{"func(arg)", "test"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
|
||||||
|
sendKeys(tm, "y", "i", "(")
|
||||||
|
sendKeys(tm, "j", "p")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// 'p' pastes after cursor, so "arg" is pasted after 't' -> "testarg"
|
||||||
|
if m.ActiveBuffer().Lines[1] != "testarg" {
|
||||||
|
t.Errorf("lines[1] = %q, want 'testarg'", m.ActiveBuffer().Lines[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test text object cursor after delimiters does nothing", func(t *testing.T) {
|
||||||
|
lines := []string{"before (hello) after"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "(")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should remain unchanged since cursor is not inside parens
|
||||||
|
if m.ActiveBuffer().Lines[0] != "before (hello) after" {
|
||||||
|
t.Errorf("lines[0] = %q, want 'before (hello) after'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test text object cursor before delimiters selects inside", func(t *testing.T) {
|
||||||
|
lines := []string{"before (hello) after"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "(")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
if m.ActiveBuffer().Lines[0] != "before () after" {
|
||||||
|
t.Errorf("lines[0] = %q, want 'before () after'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test text object cursor before delimiters with 'a' modifier", func(t *testing.T) {
|
||||||
|
lines := []string{"before (hello) after"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
|
||||||
|
sendKeys(tm, "d", "a", "(")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// 'a' should delete including the delimiters
|
||||||
|
if m.ActiveBuffer().Lines[0] != "before after" {
|
||||||
|
t.Errorf("lines[0] = %q, want 'before after'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test text object cursor on opening delimiter", func(t *testing.T) {
|
||||||
|
lines := []string{"text (hello) more"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 5, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "(")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Cursor on '(' at position 5, should still select inside
|
||||||
|
if m.ActiveBuffer().Lines[0] != "text () more" {
|
||||||
|
t.Errorf("lines[0] = %q, want 'text () more'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test text object cursor on closing delimiter", func(t *testing.T) {
|
||||||
|
lines := []string{"text (hello) more"}
|
||||||
|
// "text (hello) more"
|
||||||
|
// 01234567891011 <- ')' is at position 11
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 11, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "(")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Cursor on ')', should still select inside
|
||||||
|
if m.ActiveBuffer().Lines[0] != "text () more" {
|
||||||
|
t.Errorf("lines[0] = %q, want 'text () more'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test multiple delimiter pairs - cursor before first", func(t *testing.T) {
|
||||||
|
lines := []string{"(foo) bar (baz)"}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "(")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should select the first pair it finds
|
||||||
|
if m.ActiveBuffer().Lines[0] != "() bar (baz)" {
|
||||||
|
t.Errorf("lines[0] = %q, want '() bar (baz)'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test multiple delimiter pairs - cursor between pairs", func(t *testing.T) {
|
||||||
|
lines := []string{"(foo) bar (baz)"}
|
||||||
|
// Cursor on 'b' in "bar"
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "(")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should search forward and find the second pair
|
||||||
|
if m.ActiveBuffer().Lines[0] != "(foo) bar ()" {
|
||||||
|
t.Errorf("lines[0] = %q, want '(foo) bar ()'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test multiple delimiter pairs - cursor inside first", func(t *testing.T) {
|
||||||
|
lines := []string{"(foo) bar (baz)"}
|
||||||
|
// Cursor on 'o' in "foo"
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", "(")
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should select the first pair since cursor is inside it
|
||||||
|
if m.ActiveBuffer().Lines[0] != "() bar (baz)" {
|
||||||
|
t.Errorf("lines[0] = %q, want '() bar (baz)'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test multiple quoted strings - cursor before first", func(t *testing.T) {
|
||||||
|
lines := []string{`foo "bar" baz "qux"`}
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 0, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", `"`)
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should find and select first quoted string
|
||||||
|
if m.ActiveBuffer().Lines[0] != `foo "" baz "qux"` {
|
||||||
|
t.Errorf("lines[0] = %q, want 'foo \"\" baz \"qux\"'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("test multiple quoted strings - cursor between pairs", func(t *testing.T) {
|
||||||
|
lines := []string{`"foo" bar "baz"`}
|
||||||
|
// Cursor on 'b' in "bar"
|
||||||
|
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
|
||||||
|
sendKeys(tm, "d", "i", `"`)
|
||||||
|
|
||||||
|
m := getFinalModel(t, tm)
|
||||||
|
// Should search forward and find second string
|
||||||
|
if m.ActiveBuffer().Lines[0] != `"foo" bar ""` {
|
||||||
|
t.Errorf("lines[0] = %q, want '\"foo\" bar \"\"'", m.ActiveBuffer().Lines[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@ -1,83 +1,83 @@
|
|||||||
package editor
|
package editor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Model.Update: Handles BubbleTea messages including window resizes and key
|
// Model.Update: Handles BubbleTea messages including window resizes and key
|
||||||
// presses. Routes input to the handler and adjusts scroll after updates.
|
// presses. Routes input to the handler and adjusts scroll after updates.
|
||||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.termHeight = msg.Height
|
m.termHeight = msg.Height
|
||||||
m.termWidth = msg.Width
|
m.termWidth = msg.Width
|
||||||
|
|
||||||
// TODO: Implement a layout method that handles this
|
// TODO: Implement a layout method that handles this
|
||||||
//
|
//
|
||||||
// func (m *Model) layoutWindows() {
|
// func (m *Model) layoutWindows() {
|
||||||
// if len(m.windows) == 0 {
|
// if len(m.windows) == 0 {
|
||||||
// return
|
// return
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// if len(m.windows) == 1 {
|
// if len(m.windows) == 1 {
|
||||||
// // Single window - full screen
|
// // Single window - full screen
|
||||||
// m.windows[0].Width = m.termWidth
|
// m.windows[0].Width = m.termWidth
|
||||||
// m.windows[0].Height = m.termHeight
|
// m.windows[0].Height = m.termHeight
|
||||||
// return
|
// return
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// // Multiple windows - distribute space
|
// // Multiple windows - distribute space
|
||||||
// // This is where you'd implement split layout logic
|
// // This is where you'd implement split layout logic
|
||||||
// // For example, horizontal split:
|
// // For example, horizontal split:
|
||||||
// halfHeight := m.termHeight / 2
|
// halfHeight := m.termHeight / 2
|
||||||
// for i, win := range m.windows {
|
// for i, win := range m.windows {
|
||||||
// win.Width = m.termWidth
|
// win.Width = m.termWidth
|
||||||
// if i < len(m.windows)-1 {
|
// if i < len(m.windows)-1 {
|
||||||
// win.Height = halfHeight
|
// win.Height = halfHeight
|
||||||
// } else {
|
// } else {
|
||||||
// // Last window gets remainder
|
// // Last window gets remainder
|
||||||
// win.Height = m.termHeight - (halfHeight * (len(m.windows) - 1))
|
// win.Height = m.termHeight - (halfHeight * (len(m.windows) - 1))
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
for i := range m.windows {
|
for i := range m.windows {
|
||||||
m.windows[i].Height = msg.Height
|
m.windows[i].Height = msg.Height
|
||||||
m.windows[i].Width = msg.Width
|
m.windows[i].Width = msg.Width
|
||||||
}
|
}
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
// TODO: This needs to be removed, but for now its required for the tests.
|
// TODO: This needs to be removed, but for now its required for the tests.
|
||||||
// Ctrl+C always quits regardless of mode
|
// Ctrl+C always quits regardless of mode
|
||||||
if msg.Type == tea.KeyCtrlC {
|
if msg.Type == tea.KeyCtrlC {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: This is not great
|
// TODO: This is not great
|
||||||
// TODO: Any vim action should exit also
|
// TODO: Any vim action should exit also
|
||||||
// Simple override for command output mode for now
|
// Simple override for command output mode for now
|
||||||
if m.Mode() == core.CommandOutputMode {
|
if m.Mode() == core.CommandOutputMode {
|
||||||
// TODO: Implement g/G/d/u
|
// TODO: Implement g/G/d/u
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "enter":
|
case "enter":
|
||||||
m.SetMode(core.NormalMode)
|
m.SetMode(core.NormalMode)
|
||||||
m.SetCommandOutput(&core.CommandOutput{})
|
m.SetCommandOutput(&core.CommandOutput{})
|
||||||
case "j":
|
case "j":
|
||||||
m.CommandOutput().ScrollDown(m.termHeight)
|
m.CommandOutput().ScrollDown(m.termHeight)
|
||||||
case "k":
|
case "k":
|
||||||
m.CommandOutput().ScrollUp()
|
m.CommandOutput().ScrollUp()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cmd = m.input.Handle(m, msg.String())
|
cmd = m.input.Handle(m, msg.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep cursor in view after any update
|
// Keep cursor in view after any update
|
||||||
win := m.ActiveWindow()
|
win := m.ActiveWindow()
|
||||||
win.AdjustScroll()
|
win.AdjustScroll()
|
||||||
|
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,8 @@ const (
|
|||||||
StateCount
|
StateCount
|
||||||
StateOperatorPending
|
StateOperatorPending
|
||||||
StateMotionCount
|
StateMotionCount
|
||||||
StateWaitingForChar // Waiting for character argument (f/t/F/T)
|
StateWaitingForChar // Waiting for character argument (f/t/F/T)
|
||||||
|
StateWaitingForTextObject // Waiting for text object (after i/a)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler: Manages input processing with a state machine for vim-style commands.
|
// Handler: Manages input processing with a state machine for vim-style commands.
|
||||||
@ -28,6 +29,7 @@ type Handler struct {
|
|||||||
buffer string // for display (what user has typed)
|
buffer string // for display (what user has typed)
|
||||||
pending string // partial key sequence (e.g., "g" waiting for second key)
|
pending string // partial key sequence (e.g., "g" waiting for second key)
|
||||||
charMotionType string // which char motion is waiting: "f", "t", "F", or "T"
|
charMotionType string // which char motion is waiting: "f", "t", "F", or "T"
|
||||||
|
modifier string // which modifier used for text object: "i" or "a"
|
||||||
|
|
||||||
// Keymaps
|
// Keymaps
|
||||||
normalKeymap *Keymap
|
normalKeymap *Keymap
|
||||||
@ -77,6 +79,22 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
|
|||||||
return h.handleCharMotion(m, key)
|
return h.handleCharMotion(m, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.state == StateWaitingForTextObject {
|
||||||
|
return h.handleTextObjectKey(m, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// i/a after operator or in visual mode = text object modifier
|
||||||
|
if (key == "i" || key == "a") && h.pending == "" {
|
||||||
|
if h.state == StateOperatorPending ||
|
||||||
|
h.state == StateMotionCount ||
|
||||||
|
m.Mode().IsVisualMode() {
|
||||||
|
h.modifier = key
|
||||||
|
h.state = StateWaitingForTextObject
|
||||||
|
h.buffer += key
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try to accumulate count (only if no pending sequence)
|
// Try to accumulate count (only if no pending sequence)
|
||||||
if h.pending == "" && h.tryAccumulateCount(key) {
|
if h.pending == "" && h.tryAccumulateCount(key) {
|
||||||
return nil
|
return nil
|
||||||
@ -139,6 +157,8 @@ func (h *Handler) dispatch(m action.Model, kind string, binding any, key string)
|
|||||||
return h.handleInitial(m, kind, binding, key)
|
return h.handleInitial(m, kind, binding, key)
|
||||||
case StateOperatorPending, StateMotionCount:
|
case StateOperatorPending, StateMotionCount:
|
||||||
return h.handleAfterOperator(m, kind, binding, key)
|
return h.handleAfterOperator(m, kind, binding, key)
|
||||||
|
case StateWaitingForTextObject:
|
||||||
|
return h.handleTextObject(m, kind, binding, key)
|
||||||
}
|
}
|
||||||
h.Reset()
|
h.Reset()
|
||||||
return nil
|
return nil
|
||||||
@ -220,6 +240,11 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Do not quit when we see a/i (allow for text objects)
|
||||||
|
if kind == "modifier" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Motion after operator
|
// Motion after operator
|
||||||
if kind == "motion" {
|
if kind == "motion" {
|
||||||
mot := binding.(action.Motion)
|
mot := binding.(action.Motion)
|
||||||
@ -321,6 +346,60 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handler.handleTextObject: Handles input when waiting for text object after i/a.
|
||||||
|
// Processes text objects like 'w', ')', '"', etc. and applies pending operator if any.
|
||||||
|
func (h *Handler) handleTextObject(m action.Model, kind string, binding any, key string) tea.Cmd {
|
||||||
|
// Not sure what count is fore
|
||||||
|
// count := h.effectiveCount()
|
||||||
|
|
||||||
|
if kind != "text_object" {
|
||||||
|
// Invalid - expected a text object
|
||||||
|
h.Reset()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
textObj := binding.(action.TextObject)
|
||||||
|
win := m.ActiveWindow()
|
||||||
|
|
||||||
|
// Calculate the region
|
||||||
|
start, end, mtype := textObj.GetRange(m, win.Cursor, h.modifier)
|
||||||
|
|
||||||
|
// If we have an operator pending (e.g., "diw")
|
||||||
|
if h.operator != nil {
|
||||||
|
cmd := h.operator.Operate(m, start, end, mtype)
|
||||||
|
h.Reset()
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// In visual mode (e.g., "viw")
|
||||||
|
if m.Mode().IsVisualMode() {
|
||||||
|
// Set anchor and cursor to define the selection
|
||||||
|
win.Anchor = start
|
||||||
|
win.Cursor = end
|
||||||
|
h.Reset()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shouldn't reach here - text object without operator or visual mode
|
||||||
|
h.Reset()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler.handleTextObjectKey: Handles the key press when waiting for a text object.
|
||||||
|
// Looks up the text object directly (bypassing normal motion lookup).
|
||||||
|
func (h *Handler) handleTextObjectKey(m action.Model, key string) tea.Cmd {
|
||||||
|
// Look up text object directly
|
||||||
|
textObj, ok := h.currentKeymap.textObjects[key]
|
||||||
|
if !ok {
|
||||||
|
// Not a valid text object
|
||||||
|
h.Reset()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the existing handleTextObject with the found text object
|
||||||
|
return h.handleTextObject(m, "text_object", textObj, key)
|
||||||
|
}
|
||||||
|
|
||||||
// Handler.tryAccumulateCount: Attempts to add a digit to the count. Returns
|
// Handler.tryAccumulateCount: Attempts to add a digit to the count. Returns
|
||||||
// true if successful, false if the key is not a digit or is an invalid count.
|
// true if successful, false if the key is not a digit or is an invalid count.
|
||||||
func (h *Handler) tryAccumulateCount(key string) bool {
|
func (h *Handler) tryAccumulateCount(key string) bool {
|
||||||
@ -379,6 +458,7 @@ func (h *Handler) Reset() {
|
|||||||
h.buffer = ""
|
h.buffer = ""
|
||||||
h.pending = ""
|
h.pending = ""
|
||||||
h.charMotionType = ""
|
h.charMotionType = ""
|
||||||
|
h.modifier = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler.Pending: Returns the accumulated input buffer for display.
|
// Handler.Pending: Returns the accumulated input buffer for display.
|
||||||
|
|||||||
@ -5,14 +5,17 @@ import (
|
|||||||
"git.gophernest.net/azpect/TextEditor/internal/command"
|
"git.gophernest.net/azpect/TextEditor/internal/command"
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/motion"
|
"git.gophernest.net/azpect/TextEditor/internal/motion"
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/operator"
|
"git.gophernest.net/azpect/TextEditor/internal/operator"
|
||||||
|
"git.gophernest.net/azpect/TextEditor/internal/textobject"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Keymap: Maps key sequences to motions, operators, and actions.
|
// Keymap: Maps key sequences to motions, operators, and actions.
|
||||||
type Keymap struct {
|
type Keymap struct {
|
||||||
motions map[string]action.Motion
|
motions map[string]action.Motion
|
||||||
operators map[string]action.Operator
|
operators map[string]action.Operator
|
||||||
actions map[string]action.Action // standalone actions: i.e., 'i', 'a'
|
actions map[string]action.Action // standalone actions: i.e., 'i', 'a'
|
||||||
charMotions map[string]action.Motion // motions that need character argument: f/t/F/T
|
charMotions map[string]action.Motion // motions that need character argument: f/t/F/T
|
||||||
|
modifiers map[string]any // modifiers for text objects: i/a
|
||||||
|
textObjects map[string]action.TextObject // motions that need text objects: i.e., 'viw'
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewNormalKeymap: Creates a keymap for normal mode with all standard vim bindings.
|
// NewNormalKeymap: Creates a keymap for normal mode with all standard vim bindings.
|
||||||
@ -73,6 +76,28 @@ func NewNormalKeymap() *Keymap {
|
|||||||
"t": action.FindChar{Forward: true, Inclusive: false, Repeated: false},
|
"t": action.FindChar{Forward: true, Inclusive: false, Repeated: false},
|
||||||
"T": action.FindChar{Forward: false, Inclusive: false, Repeated: false},
|
"T": action.FindChar{Forward: false, Inclusive: false, Repeated: false},
|
||||||
},
|
},
|
||||||
|
modifiers: map[string]any{
|
||||||
|
"i": nil,
|
||||||
|
"a": nil,
|
||||||
|
},
|
||||||
|
textObjects: map[string]action.TextObject{
|
||||||
|
"w": textobject.Word{},
|
||||||
|
"W": textobject.WORD{},
|
||||||
|
// TODO: 's' and 'p'
|
||||||
|
"{": textobject.Delimiter{Char: '{'},
|
||||||
|
"}": textobject.Delimiter{Char: '}'},
|
||||||
|
"(": textobject.Delimiter{Char: '('},
|
||||||
|
")": textobject.Delimiter{Char: ')'},
|
||||||
|
"[": textobject.Delimiter{Char: '['},
|
||||||
|
"]": textobject.Delimiter{Char: ']'},
|
||||||
|
"<": textobject.Delimiter{Char: '<'},
|
||||||
|
">": textobject.Delimiter{Char: '>'},
|
||||||
|
"\"": textobject.Delimiter{Char: '"'},
|
||||||
|
"'": textobject.Delimiter{Char: '\''},
|
||||||
|
"`": textobject.Delimiter{Char: '`'},
|
||||||
|
"b": textobject.Delimiter{Char: '('},
|
||||||
|
"B": textobject.Delimiter{Char: '{'},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,6 +139,28 @@ func NewVisualKeymap() *Keymap {
|
|||||||
"t": action.FindChar{Forward: true, Inclusive: false},
|
"t": action.FindChar{Forward: true, Inclusive: false},
|
||||||
"T": action.FindChar{Forward: false, Inclusive: false},
|
"T": action.FindChar{Forward: false, Inclusive: false},
|
||||||
},
|
},
|
||||||
|
modifiers: map[string]any{
|
||||||
|
"i": nil,
|
||||||
|
"a": nil,
|
||||||
|
},
|
||||||
|
textObjects: map[string]action.TextObject{
|
||||||
|
"w": textobject.Word{},
|
||||||
|
"W": textobject.WORD{},
|
||||||
|
// TODO: 's' and 'p'
|
||||||
|
"{": textobject.Delimiter{Char: '{'},
|
||||||
|
"}": textobject.Delimiter{Char: '}'},
|
||||||
|
"(": textobject.Delimiter{Char: '('},
|
||||||
|
")": textobject.Delimiter{Char: ')'},
|
||||||
|
"[": textobject.Delimiter{Char: '['},
|
||||||
|
"]": textobject.Delimiter{Char: ']'},
|
||||||
|
"<": textobject.Delimiter{Char: '<'},
|
||||||
|
">": textobject.Delimiter{Char: '>'},
|
||||||
|
"\"": textobject.Delimiter{Char: '"'},
|
||||||
|
"'": textobject.Delimiter{Char: '\''},
|
||||||
|
"`": textobject.Delimiter{Char: '`'},
|
||||||
|
"b": textobject.Delimiter{Char: '('},
|
||||||
|
"B": textobject.Delimiter{Char: '{'},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,6 +220,12 @@ func (km *Keymap) Lookup(key string) (kind string, value any) {
|
|||||||
if cm, ok := km.charMotions[key]; ok {
|
if cm, ok := km.charMotions[key]; ok {
|
||||||
return "char_motion", cm
|
return "char_motion", cm
|
||||||
}
|
}
|
||||||
|
if mo, ok := km.modifiers[key]; ok {
|
||||||
|
return "modifier", mo
|
||||||
|
}
|
||||||
|
if to, ok := km.textObjects[key]; ok {
|
||||||
|
return "text_object", to
|
||||||
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,6 +251,16 @@ func (km *Keymap) HasPrefix(prefix string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for key := range km.modifiers {
|
||||||
|
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key := range km.textObjects {
|
||||||
|
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
518
internal/textobject/delimiter.go
Normal file
518
internal/textobject/delimiter.go
Normal file
@ -0,0 +1,518 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use multi-line delimiter pair finding
|
||||||
|
start, end, found := findMultiLineDelimiterPair(buf.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 := 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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
|
||||||
|
}
|
||||||
205
internal/textobject/word.go
Normal file
205
internal/textobject/word.go
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
package textobject
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||||
|
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Word implements text object for words (iw/aw)
|
||||||
|
type Word struct{}
|
||||||
|
|
||||||
|
func (to Word) GetRange(m action.Model, cursor core.Position, modifier string) (core.Position, core.Position, core.MotionType) {
|
||||||
|
buf := m.ActiveBuffer()
|
||||||
|
line := buf.Lines[cursor.Line]
|
||||||
|
|
||||||
|
// Find word boundaries
|
||||||
|
start := findWordStart(line, cursor.Col)
|
||||||
|
end := findWordEnd(line, cursor.Col, modifier == "a")
|
||||||
|
|
||||||
|
// Word object's don't span lines
|
||||||
|
start.Line = cursor.Line
|
||||||
|
end.Line = cursor.Line
|
||||||
|
|
||||||
|
return start, end, core.CharwiseInclusive
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word implements text object for WORDs (iW/aW)
|
||||||
|
type WORD struct{}
|
||||||
|
|
||||||
|
func (to WORD) GetRange(m action.Model, cursor core.Position, modifier string) (core.Position, core.Position, core.MotionType) {
|
||||||
|
buf := m.ActiveBuffer()
|
||||||
|
line := buf.Lines[cursor.Line]
|
||||||
|
|
||||||
|
// Find word boundaries
|
||||||
|
start := findWORDStart(line, cursor.Col)
|
||||||
|
end := findWORDEnd(line, cursor.Col, modifier == "a")
|
||||||
|
|
||||||
|
// Word object's don't span lines
|
||||||
|
start.Line = cursor.Line
|
||||||
|
end.Line = cursor.Line
|
||||||
|
|
||||||
|
return start, end, core.CharwiseInclusive
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWordChar: Returns true if the character is a word character (alphanumeric
|
||||||
|
// or underscore). COPIED FROM internal/motion/word.go
|
||||||
|
func isWordChar(c byte) bool {
|
||||||
|
return (c >= 'a' && c <= 'z') ||
|
||||||
|
(c >= 'A' && c <= 'Z') ||
|
||||||
|
(c >= '0' && c <= '9') ||
|
||||||
|
c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
func findWordStart(line string, col int) core.Position {
|
||||||
|
if col >= len(line) || col < 0 {
|
||||||
|
return core.Position{Line: 0, Col: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
curChar := line[col]
|
||||||
|
|
||||||
|
// Don't start on whitespace (shouldn't happen for text objects)
|
||||||
|
if curChar == ' ' || curChar == '\t' {
|
||||||
|
return core.Position{Line: 0, Col: col}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if we're on word char or punctuation
|
||||||
|
onWordChar := isWordChar(curChar)
|
||||||
|
|
||||||
|
// Move backwards while in the same character class
|
||||||
|
i := col
|
||||||
|
for i > 0 {
|
||||||
|
prevChar := line[i-1]
|
||||||
|
|
||||||
|
// Stop at whitespace
|
||||||
|
if prevChar == ' ' || prevChar == '\t' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop if character class changes
|
||||||
|
if isWordChar(prevChar) != onWordChar {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
|
||||||
|
return core.Position{Line: 0, Col: i}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findWordEnd(line string, col int, includeWhitespace bool) core.Position {
|
||||||
|
if col >= len(line) || col < 0 {
|
||||||
|
return core.Position{Line: 0, Col: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
curChar := line[col]
|
||||||
|
|
||||||
|
// Don't start on whitespace
|
||||||
|
if curChar == ' ' || curChar == '\t' {
|
||||||
|
return core.Position{Line: 0, Col: col}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if we're on word char or punctuation
|
||||||
|
onWordChar := isWordChar(curChar)
|
||||||
|
|
||||||
|
// Move forward while in the same character class
|
||||||
|
i := col
|
||||||
|
for i < len(line) {
|
||||||
|
c := line[i]
|
||||||
|
|
||||||
|
// Stop at whitespace
|
||||||
|
if c == ' ' || c == '\t' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop if character class changes
|
||||||
|
if isWordChar(c) != onWordChar {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
// i is now one past the end, so back up
|
||||||
|
i--
|
||||||
|
|
||||||
|
// If including whitespace, skip trailing spaces/tabs
|
||||||
|
if includeWhitespace {
|
||||||
|
i++ // Move forward to whitespace
|
||||||
|
for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
i-- // Back to last whitespace
|
||||||
|
}
|
||||||
|
|
||||||
|
return core.Position{Line: 0, Col: i}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findWORDStart(line string, col int) core.Position {
|
||||||
|
if col >= len(line) || col < 0 {
|
||||||
|
return core.Position{Line: 0, Col: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
curChar := line[col]
|
||||||
|
|
||||||
|
// Don't start on whitespace (shouldn't happen for text objects)
|
||||||
|
if curChar == ' ' || curChar == '\t' {
|
||||||
|
return core.Position{Line: 0, Col: col}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For WORD, all non-whitespace is one class
|
||||||
|
// Just move backwards until we hit whitespace or start of line
|
||||||
|
i := col
|
||||||
|
for i > 0 {
|
||||||
|
prevChar := line[i-1]
|
||||||
|
|
||||||
|
// Stop at whitespace
|
||||||
|
if prevChar == ' ' || prevChar == '\t' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
|
||||||
|
return core.Position{Line: 0, Col: i}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findWORDEnd(line string, col int, includeWhitespace bool) core.Position {
|
||||||
|
if col >= len(line) || col < 0 {
|
||||||
|
return core.Position{Line: 0, Col: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
curChar := line[col]
|
||||||
|
|
||||||
|
// Don't start on whitespace
|
||||||
|
if curChar == ' ' || curChar == '\t' {
|
||||||
|
return core.Position{Line: 0, Col: col}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For WORD, all non-whitespace is one class
|
||||||
|
// Move forward until we hit whitespace or end of line
|
||||||
|
i := col
|
||||||
|
for i < len(line) {
|
||||||
|
c := line[i]
|
||||||
|
|
||||||
|
// Stop at whitespace
|
||||||
|
if c == ' ' || c == '\t' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
// i is now one past the end, so back up
|
||||||
|
i--
|
||||||
|
|
||||||
|
// If including whitespace, skip trailing spaces/tabs
|
||||||
|
if includeWhitespace {
|
||||||
|
i++ // Move forward to whitespace
|
||||||
|
for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
i-- // Back to last whitespace
|
||||||
|
}
|
||||||
|
|
||||||
|
return core.Position{Line: 0, Col: i}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user