Compare commits

..

4 Commits

Author SHA1 Message Date
Hayden Hargreaves
04c247cc8e fix: looks like we resolved the issues with pasting.
All checks were successful
Run Test Suite / test (push) Successful in 46s
We means me and Claude (heavy on the Claude). Originally, if we copied a
many line segment into a charwise register, the paste op would error,
this is not right, it should paste, just differently.
2026-03-30 18:27:53 -07:00
Hayden Hargreaves
ffad4f86f6 doc: wrote about tradeoff for undo functionality in README.md 2026-03-30 18:19:37 -07:00
Hayden Hargreaves
9960d5c4e2 fix: added multi line delimiter support 2026-03-30 18:13:08 -07:00
Hayden Hargreaves
21ed76bed5 fix: fixed the delimiter "same-line" issue.
With the help of Claude. I want this to be over with so I can move onto
more fun things than actions.
2026-03-30 17:58:06 -07:00
5 changed files with 1058 additions and 60 deletions

View File

@ -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
### 🎭 Six Editor Modes

View File

@ -57,29 +57,56 @@ func (a Paste) Execute(m Model) tea.Cmd {
{
lines := reg.Content
// Shouldn't happen, just a check
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)
if len(lines) == 0 {
break
}
x := win.Cursor.Col
y := win.Cursor.Line
cnt := strings.Repeat(lines[0], max(1, a.Count))
curLine := buf.Lines[y]
// Catch edge cases, end of line, start of blank line
insertAt := min(x+1, len(curLine))
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:
out := core.CommandOutput{
@ -142,29 +169,56 @@ func (a PasteBefore) Execute(m Model) tea.Cmd {
{
lines := reg.Content
// Shouldn't happen, just a check
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)
if len(lines) == 0 {
break
}
x := win.Cursor.Col
y := win.Cursor.Line
cnt := strings.Repeat(lines[0], max(1, a.Count))
curLine := buf.Lines[y]
// Catch edge cases, end of line, start of blank line
insertAt := min(x, len(curLine))
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:
out := core.CommandOutput{

View File

@ -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) {
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,
WithLines([]string{"hello"}),
WithCursorPos(core.Position{Line: 0, Col: 0}),
WithRegister('"', core.CharwiseRegister, []string{"line1", "line2"}),
WithCursorPos(core.Position{Line: 0, Col: 1}), // on 'e'
WithRegister('"', core.CharwiseRegister, []string{"AAA", "BBB"}),
)
sendKeys(tm, "p")
m := getFinalModel(t, tm)
// Current implementation errors - line should be unchanged
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %q, want 'hello' (unchanged due to error)", m.ActiveBuffer().Lines[0])
// Should paste after 'e': "heAAA\nBBBllo"
if m.ActiveBuffer().LineCount() != 2 {
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,
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}),
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")
m := getFinalModel(t, tm)
// Current implementation errors - line should be unchanged
if m.ActiveBuffer().Lines[0] != "hello" {
t.Errorf("Line(0) = %q, want 'hello' (unchanged due to error)", m.ActiveBuffer().Lines[0])
// Before 'l': "heX\nYllo"
if m.ActiveBuffer().Lines[0] != "heX" {
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())
}
})
}

View File

@ -582,9 +582,9 @@ func TestTextObjectEdgeCases(t *testing.T) {
}
})
t.Run("test text object cursor outside delimiters does nothing", func(t *testing.T) {
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: 0, Line: 0})
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 15, Line: 0})
sendKeys(tm, "d", "i", "(")
m := getFinalModel(t, tm)
@ -593,4 +593,387 @@ func TestTextObjectEdgeCases(t *testing.T) {
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
}

View File

@ -46,13 +46,8 @@ type Delimiter struct {
Char rune
}
// TODO: This should allow for many lines, not just a single line
//
// BUG: This does not work properly when the cursor is not inside a delimiter. If the cursor
// does not fall inside a delimiter range, it should search forward and find the delimiter.
func (to Delimiter) GetRange(m action.Model, cursor core.Position, modifier string) (core.Position, core.Position, core.MotionType) {
buf := m.ActiveBuffer()
line := buf.Lines[cursor.Line]
// Determine which is a starting delimiter and which ends
_, isStartingDelimiter := DirectionalDelimiterMap[to.Char]
@ -80,24 +75,18 @@ func (to Delimiter) GetRange(m action.Model, cursor core.Position, modifier stri
return cursor, cursor, core.CharwiseExclusive
}
// Find object boundaries
start, sOk := findDelimiterStart(line, startDelim, cursor.Col, modifier == "a")
end, eOk := findDelimiterEnd(line, endDelim, cursor.Col, modifier == "a")
// Use multi-line delimiter pair finding
start, end, found := findMultiLineDelimiterPair(buf.Lines, startDelim, endDelim, cursor, modifier == "a")
// Handle the case where they are not found
if !sOk || !eOk {
if !found {
return cursor, cursor, core.CharwiseExclusive
}
// This happens when nothing is between the delimiter, fixes the bugs we found
if start.Col > end.Col {
// Check if positions are valid
if start.Line > end.Line || (start.Line == end.Line && start.Col > end.Col) {
return cursor, cursor, core.CharwiseExclusive
}
// Word object's don't span lines
start.Line = cursor.Line
end.Line = cursor.Line
return start, end, core.CharwiseInclusive
}
@ -124,3 +113,406 @@ func findDelimiterEnd(line string, delimiter rune, col int, includeDelimiter boo
}
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
}