From f17573edd228c06732a3a854ca2f4d4e7b4006f8 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Mon, 6 Apr 2026 19:24:47 -0700 Subject: [PATCH] feat: impl and tested J and gJ --- .opencode/skills/gim/SKILL.md | 465 +++++++++++++++++++++++ internal/action/join.go | 60 +++ internal/editor/integration_join_test.go | 179 +++++++++ internal/input/keymap.go | 2 + 4 files changed, 706 insertions(+) create mode 100644 .opencode/skills/gim/SKILL.md create mode 100644 internal/action/join.go create mode 100644 internal/editor/integration_join_test.go diff --git a/.opencode/skills/gim/SKILL.md b/.opencode/skills/gim/SKILL.md new file mode 100644 index 0000000..86ae2e3 --- /dev/null +++ b/.opencode/skills/gim/SKILL.md @@ -0,0 +1,465 @@ +--- +name: gim +description: Guidelines for working with the Gim vim-like terminal text editor +license: Proprietary +compatibility: opencode +metadata: + language: Go 1.25.5 + framework: BubbleTea + module: git.gophernest.net/azpect/TextEditor +--- + +# Gim — Agent Skill Guide + +Gim is a vim-like terminal text editor written in Go using the BubbleTea TUI framework. This document gives AI agents everything needed to contribute effectively. + +--- + +## Project Identity + +| Field | Value | +|-------|-------| +| Module path | `git.gophernest.net/azpect/TextEditor` | +| Go version | 1.25.5 | +| Entry point | `cmd/gim/main.go` | +| TUI framework | BubbleTea v1.3.10 | +| Styling | Lipgloss v1.1.0 | +| Syntax highlighting | Chroma v2.23.1 | +| Testing | teatest (charmbracelet/x/exp/teatest) | + +--- + +## Directory Layout + +``` +Gim/ +├── cmd/gim/ +│ └── main.go # Binary entry point +├── internal/ +│ ├── action/ # Executable actions (implement Action interface) +│ │ ├── interface.go # Core interfaces: Action, Motion, Operator, etc. +│ │ ├── insert.go # Insert mode entry actions (i, a, o, O, I, A, C, s, S) +│ │ ├── delete.go # Delete actions (x, X, dd, D) +│ │ ├── replace.go # Replace mode actions (r, R) +│ │ ├── find.go # Find char motions (f, F, t, T, ;, ,) +│ │ ├── command.go # Command mode state management +│ │ ├── command_history.go # Command history navigation +│ │ ├── paste.go # Paste (p, P) +│ │ ├── yank.go # Yank (y, yy, Y) +│ │ ├── undo.go # Undo/redo (u, ctrl+r) +│ │ ├── visual.go # Visual mode entry (v, V, ctrl+v) +│ │ ├── scroll.go # Scroll actions (ctrl+d, ctrl+u, etc.) +│ │ └── repeat.go # Dot operator (.) +│ ├── command/ # Ex command mode (:) registry and handlers +│ │ ├── registry.go # Command registration and lookup +│ │ └── handlers.go # Handler functions (:w, :q, :wq, etc.) +│ ├── core/ # Shared data structures and types +│ │ ├── types.go # Mode, MotionType, RegisterType enums +│ │ ├── buffer.go # Buffer struct and operations +│ │ ├── gap_buffer.go # GapBuffer (efficient per-line editing) +│ │ ├── window.go # Window (viewport over a buffer) +│ │ ├── position.go # Position{Line, Col} type +│ │ ├── register.go # Register (vim clipboard) types +│ │ ├── undo.go # UndoStack (UndoStack, Change, ChangeBlock) +│ │ ├── settings.go # EditorSettings (NewDefaultSettings) +│ │ └── command.go # Command parsing helpers +│ ├── editor/ # BubbleTea Model — orchestrates everything +│ │ ├── model.go # Model struct definition +│ │ ├── init.go # tea.Model Init() +│ │ ├── update.go # tea.Model Update() +│ │ ├── view.go # tea.Model View() +│ │ ├── builder.go # ModelBuilder (fluent constructor) +│ │ ├── helpers_test.go # Shared test helpers (all tests use these) +│ │ └── integration_*_test.go # Integration tests by feature area +│ ├── input/ # Key-input state machine +│ │ ├── handler.go # InputState machine, key dispatch +│ │ └── keymap.go # Keybinding registries per mode +│ ├── motion/ # Motion implementations +│ │ ├── basic.go # h, j, k, l +│ │ ├── jump.go # gg, G, 0, ^, $, %, {, } +│ │ └── word.go # w, W, b, B, e, E +│ ├── operator/ # Operator implementations +│ │ ├── delete.go # d{motion}, dd +│ │ ├── yank.go # y{motion}, yy +│ │ └── change.go # c{motion}, cc +│ ├── program/ # BubbleTea program bootstrap +│ │ └── program_builder.go # ProgramBuilder (EmptyProgram, FileProgram) +│ ├── style/ # Visual rendering +│ │ └── style.go # Lipgloss styles, Chroma syntax highlighting +│ ├── textobject/ # Text object ranges +│ │ └── word.go # iw, aw, iW, aW +│ └── theme/ # Color themes +│ └── theme.go # RegisterAll(), kanagawa-wave default +``` + +--- + +## Key Interfaces (`internal/action/interface.go`) + +All extension points in the editor are interface-based. Implementing the right interface is how new features are added. + +```go +// Action — any executable editor command +type Action interface { + Execute(m Model) tea.Cmd +} + +// Motion — moves the cursor; returns a range type +type Motion interface { + Action + Type() core.MotionType // CharwiseExclusive | CharwiseInclusive | Linewise +} + +// Operator — acts on a range defined by a motion +type Operator interface { + Operate(m Model, start, end core.Position, mtype core.MotionType) tea.Cmd +} + +// DoublePresser — supports the doubled form (dd, yy, cc) +type DoublePresser interface { + DoublePress(m Model, count int) tea.Cmd +} + +// Repeatable — action that accepts a leading count (3w, 5dd) +type Repeatable interface { + WithCount(n int) Action +} + +// CharMotion — motion that needs a char argument (f, F, t, T) +type CharMotion interface { + Motion + WithChar(char string) Motion +} + +// Resolvable — motion whose behavior depends on model state at execution time +type Resolvable interface { + Motion + Resolve(m Model) Motion +} + +// TextObject — selects a structured range (iw, aw, iW, aW) +type TextObject interface { + GetRange(m Model, cursor core.Position, modifier string) (start, end core.Position, mtype core.MotionType) +} +``` + +`action.Model` is the main interface that all actions receive — it provides access to windows, buffers, mode, registers, and input dispatch. + +--- + +## Core Types (`internal/core/`) + +```go +// Position — line and column (both 0-indexed) +type Position struct{ Line, Col int } + +// Mode +type Mode int +const ( + NormalMode, InsertMode, CommandMode, CommandOutputMode, + VisualMode, VisualLineMode, VisualBlockMode, ReplaceMode, WaitingMode +) + +// MotionType — determines how operator ranges are calculated +type MotionType int +const ( + CharwiseExclusive // end col not included (w, b, h, l, 0, ^) + CharwiseInclusive // end col included (e, $, f) + Linewise // whole lines (j, k, G, gg, {, }) +) + +// RegisterType +type RegisterType int +const ( + CharwiseRegister, LinewiseRegister, BlockwiseRegister +) + +// Buffer — document held in memory +type Buffer struct { + Id, Type int + Filename string + Filetype string + Lines []*GapBuffer + Modified bool + UndoStack *UndoStack +} + +// Window — viewport over a buffer +type Window struct { + Id, Number int + Buffer *Buffer + Cursor Position + Anchor Position // visual mode anchor + ScrollY int + Height int + Width int +} +``` + +--- + +## Adding Features — Step-by-Step Patterns + +### New Normal-Mode Motion + +1. Create `internal/motion/.go`: + ```go + package motion + + type MyMotion struct{ Count int } + + func (m MyMotion) Execute(model action.Model) tea.Cmd { + // Move model.ActiveWindow().Cursor + return nil + } + + func (m MyMotion) Type() core.MotionType { return core.CharwiseExclusive } + + func (m MyMotion) WithCount(n int) action.Action { return MyMotion{Count: n} } + ``` + +2. Register in `internal/input/keymap.go` inside `NewNormalKeymap()`: + ```go + motions: map[string]action.Motion{ + "X": motion.MyMotion{}, + } + ``` + +3. Write integration tests in `internal/editor/integration_motion__test.go`. + +### New Operator + +1. Create `internal/operator/.go`: + ```go + package operator + + type MyOp struct{} + + func (o MyOp) Operate(m action.Model, start, end core.Position, mt core.MotionType) tea.Cmd { + // Act on the range [start, end) + return nil + } + + // Double-press support (e.g. "xx" acts on current line) + var _ action.DoublePresser = MyOp{} + + func (o MyOp) DoublePress(m action.Model, count int) tea.Cmd { return nil } + ``` + +2. Register in `internal/input/keymap.go`: + ```go + operators: map[string]action.Operator{ + "X": operator.MyOp{}, + } + ``` + +3. Write integration tests in `internal/editor/integration_operator__test.go`. + +### New Standalone Action + +1. Create `internal/action/.go`: + ```go + package action + + type MyAction struct{ Count int } + + func (a MyAction) Execute(m Model) tea.Cmd { + // Mutate model state + return nil + } + + func (a MyAction) WithCount(n int) Action { return MyAction{Count: n} } + ``` + +2. Register in `internal/input/keymap.go`: + ```go + actions: map[string]action.Action{ + "X": action.MyAction{}, + } + ``` + +### New Ex Command (`:mycommand`) + +1. Add handler in `internal/command/handlers.go`: + ```go + func handleMyCommand(m action.Model, args []string, force bool) tea.Cmd { + // force = true when called as :mycommand! + return nil + } + ``` + +2. Register in `internal/command/registry.go` inside `registerDefaults()`: + ```go + r.Register(Command{ + Name: "mycommand", + ShortForm: "myc", + Handler: handleMyCommand, + }) + ``` + +--- + +## Testing + +### Framework + +All tests use **teatest** — a BubbleTea test driver that runs the full model loop. Integration tests live in `internal/editor/`. Unit tests for isolated logic live alongside their package (`*_test.go` next to the source). + +### Shared Test Helpers (`internal/editor/helpers_test.go`) + +```go +// Create a default test model +newTestModel(t *testing.T) *teatest.TestModel + +// Create a model with specific initial buffer content +newTestModelWithLines(t *testing.T, lines []string) *teatest.TestModel + +// Create a model with content and cursor at a specific position +newTestModelWithLinesAndCursorPos(t *testing.T, lines []string, pos core.Position) *teatest.TestModel + +// Functional options variant (preferred for complex setups) +newTestModelWithOptions(t *testing.T, opts ...TestModelOption) *teatest.TestModel + +// Options: +WithLines(lines []string) +WithCursorPos(pos core.Position) +WithTermSize(width, height int) +WithRegister(name rune, regType core.RegisterType, content []string) + +// Send one or more keys to the model +sendKeys(tm *teatest.TestModel, keys ...string) + +// Send a string of keys as a sequence +sendKeyString(tm *teatest.TestModel, keyString string) + +// Retrieve the final model state for assertions +getFinalModel(t *testing.T, tm *teatest.TestModel) *editor.Model +``` + +### Test File Naming Convention + +| File | Coverage area | +|------|---------------| +| `integration_motion_basic_test.go` | h, j, k, l | +| `integration_motion_jump_test.go` | gg, G, 0, $, %, {, } | +| `integration_motion_word_test.go` | w, W, b, B, e, E | +| `integration_operator_delete_test.go` | d{motion}, dd | +| `integration_operator_change_test.go` | c{motion}, cc | +| `integration_operator_yank_test.go` | y{motion}, yy | +| `integration_insert_test.go` | i, a, o, O, I, A, C, s, S | +| `integration_delete_test.go` | x, X, D, dd | +| `integration_visual_test.go` | v, V, ctrl+v | +| `integration_paste_test.go` | p, P | +| `integration_yank_test.go` | y, yy, Y | +| `integration_repeat_test.go` | . (dot operator) | +| `integration_replace_test.go` | r, R | +| `integration_undo_test.go` | u, ctrl+r | +| `integration_scroll_test.go` | ctrl+d, ctrl+u, etc. | +| `integration_command_test.go` | :w, :q, :wq, etc. | +| `integration_textobject_test.go` | iw, aw, iW, aW | + +### Example Integration Test + +```go +func TestMyFeature(t *testing.T) { + t.Run("moves forward one word", func(t *testing.T) { + tm := newTestModelWithLines(t, []string{"hello world"}) + sendKeys(tm, "w") + m := getFinalModel(t, tm) + pos := m.ActiveWindow().Cursor + if pos.Col != 6 { + t.Errorf("expected col 6, got %d", pos.Col) + } + }) +} +``` + +Run all tests: +``` +go test ./... +``` + +Run a specific package: +``` +go test git.gophernest.net/azpect/TextEditor/internal/editor +``` + +--- + +## Input State Machine (`internal/input/handler.go`) + +The handler dispatches key presses through a state machine. Understanding states is critical when tracing how a key sequence is processed. + +| State | Meaning | +|-------|---------| +| `StateReady` | Waiting for first key of a command | +| `StateCount` | Accumulating leading count digits (e.g. `12`) | +| `StateOperatorPending` | Operator received, waiting for motion (e.g. after `d`) | +| `StateMotionCount` | Accumulating count after operator (e.g. `d3`) | +| `StateWaitingForChar` | f/t/F/T received, waiting for target char | +| `StateWaitingForTextObject` | i/a received in operator pending, waiting for object (w/W) | + +--- + +## BubbleTea Elm Architecture + +``` +tea.KeyMsg + ↓ +editor.Update() + ↓ +input.Handler.Handle(key) + ↓ +action.Action.Execute(model) ← motions, operators, actions all land here + ↓ +model state mutation + ↓ +editor.View() ← renders buffer with Lipgloss + Chroma +``` + +The editor model implements `tea.Model`: +- `Init() tea.Cmd` — returns nil (no startup IO) +- `Update(msg tea.Msg) (tea.Model, tea.Cmd)` — handles key, resize, and mouse messages +- `View() string` — renders current state as a string + +--- + +## Code Conventions + +- **Comment format:** `// ReceiverType.MethodName: description` on every exported method +- **Builder pattern** for complex structs — `NewBufferBuilder().WithFilename(f).Build()` +- **Functional options** for test setup — `WithLines(...)`, `WithCursorPos(...)` +- **Interface guards** for compile-time checks: `var _ action.DoublePresser = MyOp{}` +- **No panics** in normal paths — return early or propagate via `tea.Cmd` +- **No global mutable state** — all state lives inside `editor.Model` +- `tea.Cmd` return value is almost always `nil` unless async IO is needed + +--- + +## Build & Run + +```bash +# Run the editor (no file) +go run ./cmd/gim + +# Run with a file +go run ./cmd/gim myfile.go + +# Build binary +go build -o gim ./cmd/gim + +# Run all tests +go test ./... + +# Run tests with verbose output +go test -v ./internal/editor/... +``` + +--- + +## What NOT to Change Without Care + +- `internal/core/types.go` — changing `MotionType` or `Mode` constants affects the entire codebase +- `internal/action/interface.go` — changing `Action` or `Model` interfaces requires updating all implementations +- `internal/input/keymap.go` — duplicate key registrations silently shadow each other; verify no conflicts +- `internal/editor/update.go` — the central dispatch loop; changes here affect all modes diff --git a/internal/action/join.go b/internal/action/join.go new file mode 100644 index 0000000..e92182b --- /dev/null +++ b/internal/action/join.go @@ -0,0 +1,60 @@ +package action + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type JoinLines struct { + Preserve bool + Count int +} + +// WithCount sets the count (required by Repeatable interface) +func (m JoinLines) WithCount(n int) Action { + m.Count = n + return m +} + +func (a JoinLines) Execute(m Model) tea.Cmd { + win := m.ActiveWindow() + buf := m.ActiveBuffer() + + y := win.Cursor.Line + + var line strings.Builder + line.WriteString(buf.Line(y)) + spacePending := false + + for range max(1, a.Count-1) { + if (y + 1) >= buf.LineCount() { + break + } + + l := buf.Line(y + 1) + + if a.Preserve { + line.WriteString(l) + } else { + l = strings.TrimLeft(l, " ") + if l == "" { + spacePending = true + } else { + line.WriteString(" " + l) + spacePending = false + } + } + + buf.DeleteLine(y + 1) + } + + if !a.Preserve && spacePending { + line.WriteString(" ") + } + + if line.String() != buf.Line(y) { + buf.SetLine(y, line.String()) + } + return nil +} diff --git a/internal/editor/integration_join_test.go b/internal/editor/integration_join_test.go new file mode 100644 index 0000000..205d04c --- /dev/null +++ b/internal/editor/integration_join_test.go @@ -0,0 +1,179 @@ +package editor + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/core" +) + +func TestJoinLines(t *testing.T) { + t.Run("J joins current line with next line using a space", func(t *testing.T) { + tm := newTestModelWithLines(t, []string{"hello", "world"}) + sendKeys(tm, "J") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"hello world"}) + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("Cursor.Line = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 0 { + t.Errorf("Cursor.Col = %d, want 0", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("J joins from the cursor line, not always the first line", func(t *testing.T) { + tm := newTestModelWithLinesAndCursorPos(t, []string{"one", "two", "three"}, core.Position{Line: 1, Col: 2}) + sendKeys(tm, "J") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"one", "two three"}) + if m.ActiveWindow().Cursor.Line != 1 { + t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("Cursor.Col = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("J trims indentation from the joined line", func(t *testing.T) { + tm := newTestModelWithLines(t, []string{"foo", " bar"}) + sendKeys(tm, "J") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"foo bar"}) + }) +} + +func TestJoinLinesNoSpace(t *testing.T) { + t.Run("gJ joins without inserting a space", func(t *testing.T) { + tm := newTestModelWithLinesAndCursorPos(t, []string{"hello", "world"}, core.Position{Line: 0, Col: 3}) + sendKeys(tm, "g", "J") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"helloworld"}) + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("Cursor.Line = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 3 { + t.Errorf("Cursor.Col = %d, want 3", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("gJ preserves leading whitespace on the next line", func(t *testing.T) { + tm := newTestModelWithLines(t, []string{"foo", " bar"}) + sendKeys(tm, "g", "J") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"foo bar"}) + }) +} + +func TestJoinLinesWithCount(t *testing.T) { + t.Run("3J joins three lines with spaces", func(t *testing.T) { + tm := newTestModelWithLinesAndCursorPos(t, []string{"a", "b", "c", "d"}, core.Position{Line: 0, Col: 1}) + sendKeys(tm, "3", "J") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"a b c", "d"}) + if m.ActiveWindow().Cursor.Line != 0 { + t.Errorf("Cursor.Line = %d, want 0", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 1 { + t.Errorf("Cursor.Col = %d, want 1", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("3gJ joins three lines without spaces", func(t *testing.T) { + tm := newTestModelWithLines(t, []string{"a", "b", "c", "d"}) + sendKeys(tm, "3", "g", "J") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"abc", "d"}) + }) + + t.Run("count larger than remaining lines joins through end of file", func(t *testing.T) { + tm := newTestModelWithLinesAndCursorPos(t, []string{"a", "b", "c"}, core.Position{Line: 1, Col: 0}) + sendKeys(tm, "9", "J") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"a", "b c"}) + }) +} + +func TestJoinLinesEdgeCases(t *testing.T) { + t.Run("J on last line does nothing", func(t *testing.T) { + tm := newTestModelWithLinesAndCursorPos(t, []string{"one", "two"}, core.Position{Line: 1, Col: 2}) + sendKeys(tm, "J") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"one", "two"}) + if m.ActiveWindow().Cursor.Line != 1 { + t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line) + } + if m.ActiveWindow().Cursor.Col != 2 { + t.Errorf("Cursor.Col = %d, want 2", m.ActiveWindow().Cursor.Col) + } + }) + + t.Run("gJ on single-line buffer does nothing", func(t *testing.T) { + tm := newTestModelWithLines(t, []string{"solo"}) + sendKeys(tm, "g", "J") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"solo"}) + }) + + t.Run("J across many empty lines keeps a single separating space", func(t *testing.T) { + tm := newTestModelWithLines(t, []string{"foo", "", "", "", "bar"}) + sendKeys(tm, "5", "J") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"foo bar"}) + }) +} + +func TestJoinLinesRepeatAndUndo(t *testing.T) { + t.Run("dot repeats J join", func(t *testing.T) { + tm := newTestModelWithLines(t, []string{"a", "b", "c", "d"}) + sendKeys(tm, "J", "j", ".") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"a b", "c d"}) + }) + + t.Run("dot repeats gJ join", func(t *testing.T) { + tm := newTestModelWithLines(t, []string{"a", "b", "c", "d"}) + sendKeys(tm, "g", "J", "j", ".") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"ab", "cd"}) + }) + + t.Run("undo restores lines after J", func(t *testing.T) { + tm := newTestModelWithLines(t, []string{"left", "right"}) + sendKeys(tm, "J", "u") + + m := getFinalModel(t, tm) + assertBufferLines(t, m.ActiveBuffer(), []string{"left", "right"}) + }) +} + +func assertBufferLines(t *testing.T, buf *core.Buffer, want []string) { + t.Helper() + got := make([]string, buf.LineCount()) + for i := 0; i < buf.LineCount(); i++ { + got[i] = buf.Line(i) + } + + if len(got) != len(want) { + t.Errorf("lines = %q (len=%d), want %q (len=%d)", got, len(got), want, len(want)) + return + } + + for i := range got { + if got[i] != want[i] { + t.Errorf("lines = %q (len=%d), want %q (len=%d)", got, len(got), want, len(want)) + return + } + } +} diff --git a/internal/input/keymap.go b/internal/input/keymap.go index a0569a7..355f332 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -78,6 +78,8 @@ func NewNormalKeymap() *Keymap { "ctrl+r": action.Redo{}, ".": action.Repeat{Count: 1}, "R": action.EnterReplace{}, + "J": action.JoinLines{Preserve: false}, + "gJ": action.JoinLines{Preserve: true}, }, charMotions: map[string]action.Motion{ "f": action.FindChar{Forward: true, Inclusive: true, Repeated: false},