feat: text objects initial impl, tested
However, it does not work for multi line delimiters.
This commit is contained in:
parent
b0b885d57d
commit
aa156971ad
@ -108,3 +108,9 @@ type Resolvable interface {
|
||||
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)
|
||||
}
|
||||
|
||||
596
internal/editor/integration_textobject_test.go
Normal file
596
internal/editor/integration_textobject_test.go
Normal file
@ -0,0 +1,596 @@
|
||||
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 outside delimiters does nothing", 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)
|
||||
// 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])
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -15,6 +15,7 @@ const (
|
||||
StateOperatorPending
|
||||
StateMotionCount
|
||||
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.
|
||||
@ -28,6 +29,7 @@ type Handler struct {
|
||||
buffer string // for display (what user has typed)
|
||||
pending string // partial key sequence (e.g., "g" waiting for second key)
|
||||
charMotionType string // which char motion is waiting: "f", "t", "F", or "T"
|
||||
modifier string // which modifier used for text object: "i" or "a"
|
||||
|
||||
// Keymaps
|
||||
normalKeymap *Keymap
|
||||
@ -77,6 +79,22 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
|
||||
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)
|
||||
if h.pending == "" && h.tryAccumulateCount(key) {
|
||||
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)
|
||||
case StateOperatorPending, StateMotionCount:
|
||||
return h.handleAfterOperator(m, kind, binding, key)
|
||||
case StateWaitingForTextObject:
|
||||
return h.handleTextObject(m, kind, binding, key)
|
||||
}
|
||||
h.Reset()
|
||||
return nil
|
||||
@ -220,6 +240,11 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do not quit when we see a/i (allow for text objects)
|
||||
if kind == "modifier" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Motion after operator
|
||||
if kind == "motion" {
|
||||
mot := binding.(action.Motion)
|
||||
@ -321,6 +346,60 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.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
|
||||
// true if successful, false if the key is not a digit or is an invalid count.
|
||||
func (h *Handler) tryAccumulateCount(key string) bool {
|
||||
@ -379,6 +458,7 @@ func (h *Handler) Reset() {
|
||||
h.buffer = ""
|
||||
h.pending = ""
|
||||
h.charMotionType = ""
|
||||
h.modifier = ""
|
||||
}
|
||||
|
||||
// Handler.Pending: Returns the accumulated input buffer for display.
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"git.gophernest.net/azpect/TextEditor/internal/command"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/motion"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/operator"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/textobject"
|
||||
)
|
||||
|
||||
// Keymap: Maps key sequences to motions, operators, and actions.
|
||||
@ -13,6 +14,8 @@ type Keymap struct {
|
||||
operators map[string]action.Operator
|
||||
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
|
||||
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.
|
||||
@ -73,6 +76,26 @@ func NewNormalKeymap() *Keymap {
|
||||
"t": action.FindChar{Forward: true, 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: '`'},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,6 +137,25 @@ func NewVisualKeymap() *Keymap {
|
||||
"t": action.FindChar{Forward: true, 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{},
|
||||
"{": 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: '`'},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -173,6 +215,12 @@ func (km *Keymap) Lookup(key string) (kind string, value any) {
|
||||
if cm, ok := km.charMotions[key]; ok {
|
||||
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
|
||||
}
|
||||
|
||||
@ -198,6 +246,16 @@ func (km *Keymap) HasPrefix(prefix string) bool {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
123
internal/textobject/delimiter.go
Normal file
123
internal/textobject/delimiter.go
Normal file
@ -0,0 +1,123 @@
|
||||
package textobject
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
)
|
||||
|
||||
// Map opposite char
|
||||
var DirectionalDelimiterMap map[rune]rune = map[rune]rune{
|
||||
'(': ')',
|
||||
'[': ']',
|
||||
'{': '}',
|
||||
'<': '>',
|
||||
}
|
||||
|
||||
var singleDelimiterList []rune = []rune{'"', '\'', '`'}
|
||||
|
||||
func getStartDelimiterFromEnd(d rune) (rune, bool) {
|
||||
if slices.Contains(singleDelimiterList, d) {
|
||||
return d, true
|
||||
}
|
||||
|
||||
for start, end := range DirectionalDelimiterMap {
|
||||
if end == d {
|
||||
return start, true
|
||||
}
|
||||
}
|
||||
|
||||
return ' ', false
|
||||
}
|
||||
|
||||
func getEndDelimiterFromStart(d rune) (rune, bool) {
|
||||
if slices.Contains(singleDelimiterList, d) {
|
||||
return d, true
|
||||
}
|
||||
|
||||
end, found := DirectionalDelimiterMap[d]
|
||||
return end, found
|
||||
}
|
||||
|
||||
// Delimiter implements text object for words (iw/aw)
|
||||
type Delimiter struct {
|
||||
Char rune
|
||||
}
|
||||
|
||||
// TODO: This should allow for many lines, not just a single line
|
||||
func (to Delimiter) GetRange(m action.Model, cursor core.Position, modifier string) (core.Position, core.Position, core.MotionType) {
|
||||
buf := m.ActiveBuffer()
|
||||
line := buf.Lines[cursor.Line]
|
||||
|
||||
// Determine which is a starting delimiter and which ends
|
||||
_, isStartingDelimiter := DirectionalDelimiterMap[to.Char]
|
||||
var (
|
||||
startDelim rune
|
||||
endDelim rune
|
||||
startFound bool = true
|
||||
endFound bool = true
|
||||
)
|
||||
|
||||
if isStartingDelimiter {
|
||||
startDelim = to.Char
|
||||
endDelim, endFound = getEndDelimiterFromStart(to.Char)
|
||||
} else {
|
||||
endDelim = to.Char
|
||||
startDelim, startFound = getStartDelimiterFromEnd(to.Char)
|
||||
}
|
||||
|
||||
if !endFound || !startFound {
|
||||
m.SetCommandOutput(&core.CommandOutput{
|
||||
Lines: []string{fmt.Sprintf("Could not find delimiters from '%c'", to.Char)},
|
||||
Inline: true,
|
||||
IsError: false,
|
||||
})
|
||||
return cursor, cursor, core.CharwiseExclusive
|
||||
}
|
||||
|
||||
// Find object boundaries
|
||||
start, sOk := findDelimiterStart(line, startDelim, cursor.Col, modifier == "a")
|
||||
end, eOk := findDelimiterEnd(line, endDelim, cursor.Col, modifier == "a")
|
||||
|
||||
// Handle the case where they are not found
|
||||
if !sOk || !eOk {
|
||||
return cursor, cursor, core.CharwiseExclusive
|
||||
}
|
||||
|
||||
// This happens when nothing is between the delimiter, fixes the bugs we found
|
||||
if 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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