diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bb04e25 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +SpiceEdit is a Go terminal editor module at `github.com/cloudmanic/spice-edit`; the CLI entry point is `main.go` and the binary is `spiceedit`. Core packages live under `internal/`: `app` owns the event loop and rendering, `editor` owns buffers/tabs/editing behavior, `filetree` manages the sidebar tree, and supporting packages cover clipboard, formatting, icons, config, theme, finder, and versioning. Tests sit beside source files as `*_test.go`. Website assets and docs live in `website/` as a Hugo + Tailwind site. Release packaging includes `Formula/spice-edit.rb`, `install.sh`, and samples under `samples/`. + +## Build, Test, and Development Commands + +- `make run`: run the editor in the current directory with `go run .`. +- `make build`: compile `./bin/spiceedit`. +- `make build-linux`: cross-compile a static `linux/amd64` binary. +- `make test`: run `go test -race ./...`; use before PRs. +- `make test-short`: quick `go test -short ./...` loop while iterating. +- `make coverage`: write `coverage.out` and `coverage.html`. +- `make tidy`: sync `go.mod` and `go.sum`. +- `make site-install`, `make site-dev`, `make site-build`: manage website deps, local Hugo, and production builds. + +## Coding Style & Naming Conventions + +Use `gofmt`/`go test` defaults and idiomatic Go names: exported identifiers in `CamelCase`, unexported in `camelCase`, package names short and lowercase. New Go source files should follow the existing header block style. Keep short doc comments above functions, including private helpers, explaining intent. Avoid adding `Ctrl+` shortcuts; editor actions must stay reachable from the main `≡` menu because SSH/tmux workflows may swallow shortcuts or right-click events. + +## Testing Guidelines + +Every non-trivial source file should have a same-package test file, for example `internal/editor/buffer.go` and `internal/editor/buffer_test.go`. Add regression tests for bug fixes and cover happy paths and obvious failures. Use `t.TempDir()` for filesystem state. For drawing tests, use `tcell.NewSimulationScreen("UTF-8")` and assert screen contents. + +## Commit & Pull Request Guidelines + +Recent commits use concise, imperative summaries, often with PR numbers, such as `Mute dotfiles in tree + per-tab Nerd Font icons (#32)`. Release automation uses `[skip ci]`; preserve that marker when editing generated release commits or workflows. PRs should describe behavior changes, mention tests run, link issues, and include screenshots or terminal captures for UI/website changes. + +## Security & Configuration Tips + +Format-on-save commands are project config and require trust prompts; do not bypass that flow. Keep generated artifacts (`bin/`, `coverage.out`, `coverage.html`, `website/public/`, built CSS) out of normal feature commits unless the release or website workflow explicitly requires them. diff --git a/README.md b/README.md index 49687db..ea68654 100644 --- a/README.md +++ b/README.md @@ -178,20 +178,21 @@ SpiceEdit deliberately avoids `Ctrl+`-style shortcuts (they fight `tmux`, real terminal). Instead, **`Esc` is the leader key**: tap `Esc`, then within half a second tap one of the letters below. -| Combo | Action | -| ----------- | --------------- | -| `Esc Esc` | Open ≡ menu | -| `Esc s` | Save | -| `Esc u` | Undo | -| `Esc r` | Redo | -| `Esc w` | Close tab | -| `Esc q` | Quit | -| `Esc n` | New file | -| `Esc t` | Toggle sidebar | -| `Esc f` | Find in file | +| Combo | Action | +| ----------- | -------------------- | +| `Esc Esc` | Open ≡ menu | +| `Esc s` | Save | +| `Esc u` | Undo | +| `Esc r` | Redo | +| `Esc w` | Close tab | +| `Esc q` | Quit | +| `Esc n` | New file | +| `Esc t` | Toggle sidebar | +| `Esc /` | Toggle line comment | +| `Esc f` | Find in file | | `Esc p` | Find file in project | -A lone `Esc` is harmless — if you don't follow it with a bound letter +A lone `Esc` is harmless — if you don't follow it with a bound key within the window, your next keystroke goes to the editor as normal, so accidental `Esc` taps never swallow a real character. diff --git a/internal/app/app.go b/internal/app/app.go index d14b77e..594ffdf 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -46,10 +46,10 @@ const ( minEditorAfterDrag = 40 minWidth = 50 minHeight = 24 - statusFlashFor = 3 * time.Second - doubleClickMs = 500 * time.Millisecond - doubleEscMs = 500 * time.Millisecond - wheelLines = 3 + statusFlashFor = 3 * time.Second + doubleClickMs = 500 * time.Millisecond + doubleEscMs = 500 * time.Millisecond + wheelLines = 3 // treeRefreshInterval is how often the background goroutine kicks off // a file-tree reload so the sidebar stays in sync with on-disk changes @@ -193,6 +193,7 @@ func builtinMenuGroups() [][]menuItemDef { {label: "Copy selection", action: (*App).menuCopy, enabled: (*App).hasSelection}, {label: "Cut selection", action: (*App).menuCut, enabled: (*App).hasSelection}, {label: "Paste", action: (*App).menuPaste, enabled: (*App).hasClipboard}, + {label: "Toggle line comment", action: (*App).menuToggleLineComment, enabled: (*App).hasCommentableTab}, }, // View toggle { @@ -230,7 +231,7 @@ func (a *App) menuLayout() (items []menuItemDef, dividers []int, modalHeight int // the menu; if a $FILE-dependent command is invoked with // no tab open it'll fail with a real error and our info // modal surfaces it. Better that than getting the - // heuristic wrong half the time. + // heuristic wrong half the time. ca = append(ca, menuItemDef{ label: a.customActions[i].Label, action: func(app *App) { app.runCustomAction(i) }, @@ -299,9 +300,9 @@ type App struct { lastClick clickRecord lastTabRects []tabRect - menuOpen bool - hoveredMenuRow int // index into menuItems of the row under the mouse, or -1. - lastEscape time.Time // timestamp of the previous Esc press, for double-tap detection. + menuOpen bool + hoveredMenuRow int // index into menuItems of the row under the mouse, or -1. + lastEscape time.Time // timestamp of the previous Esc press, for double-tap detection. // Prompt modal — single-line text input with OK / Cancel. Used by // Rename and New File. See modals.go for render + event handling. @@ -1704,6 +1705,17 @@ func (a *App) hasSelection() bool { return t != nil && t.HasSelection() } +// hasCommentableTab reports whether the active tab is editable text with a +// known single-line comment marker. +func (a *App) hasCommentableTab() bool { + t := a.activeTabPtr() + if t == nil || t.IsImage() { + return false + } + _, ok := editor.LineCommentPrefix(t.Path) + return ok +} + // hasClipboard reports whether the editor's internal clipboard has content // to paste. func (a *App) hasClipboard() bool { return a.clipBuf != "" } @@ -1891,6 +1903,25 @@ func (a *App) menuPaste() { a.pasteClipboard() } +// menuToggleLineComment comments or uncomments the active line selection. +func (a *App) menuToggleLineComment() { + a.closeMenu() + tab := a.activeTabPtr() + if tab == nil || tab.IsImage() { + return + } + changed, ok := tab.ToggleLineComment() + if !ok { + a.flash("No line comment syntax for this file") + return + } + if !changed { + a.flash("No non-blank lines to comment") + return + } + a.flash("Toggled line comment") +} + // menuRefreshTree forces an immediate sidebar reload. Currently unwired // from the menu — the 10s background poller covers the common case — but // the method is kept so re-adding the menu row (see menuItems) only diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 15fcdbe..192ca4a 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -231,10 +231,10 @@ func TestResizeSidebar_TinyWindow(t *testing.T) { // TestDetectLangLabel covers the language label helper's three cases. func TestDetectLangLabel(t *testing.T) { cases := map[string]string{ - "": "text", - "foo.go": "go", - "foo": "text", - "path/to/x.py": "py", + "": "text", + "foo.go": "go", + "foo": "text", + "path/to/x.py": "py", "archive.tar.gz": "gz", } for in, want := range cases { @@ -396,8 +396,8 @@ func TestCloseTab_OutOfRange(t *testing.T) { a.requestCloseTab(99) } -// TestHasTab_Predicates covers the four "is X available?" checks used to -// dim menu rows. +// TestHasTab_Predicates covers the "is X available?" checks used to dim menu +// rows. func TestHasTab_Predicates(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "f.txt") @@ -405,8 +405,8 @@ func TestHasTab_Predicates(t *testing.T) { t.Fatalf("seed: %v", err) } a := newTestApp(t, dir) - if a.hasTab() || a.hasSavableTab() || a.hasSelection() || a.hasClipboard() { - t.Fatal("fresh app should have no tab/selection/clipboard") + if a.hasTab() || a.hasSavableTab() || a.hasSelection() || a.hasClipboard() || a.hasCommentableTab() { + t.Fatal("fresh app should have no tab/selection/clipboard/comment action") } a.openFile(target) @@ -416,6 +416,9 @@ func TestHasTab_Predicates(t *testing.T) { if a.hasSelection() { t.Fatal("no selection on a fresh tab") } + if a.hasCommentableTab() { + t.Fatal(".txt should not expose the line-comment action") + } // Make a synthetic selection. tab := a.activeTabPtr() @@ -431,6 +434,31 @@ func TestHasTab_Predicates(t *testing.T) { } } +// TestHasCommentableTab_Predicate checks that line-comment actions only enable +// on editable text tabs with known comment syntax. +func TestHasCommentableTab_Predicate(t *testing.T) { + dir := t.TempDir() + goFile := filepath.Join(dir, "main.go") + htmlFile := filepath.Join(dir, "index.html") + if err := os.WriteFile(goFile, []byte("package main"), 0644); err != nil { + t.Fatalf("seed go: %v", err) + } + if err := os.WriteFile(htmlFile, []byte("
"), 0644); err != nil { + t.Fatalf("seed html: %v", err) + } + a := newTestApp(t, dir) + + a.openFile(goFile) + if !a.hasCommentableTab() { + t.Fatal(".go tab should expose the line-comment action") + } + + a.openFile(htmlFile) + if a.hasCommentableTab() { + t.Fatal(".html tab should not expose the line-comment action") + } +} + // TestSidebarToggleLabel flips between Show/Hide based on sidebarShown. func TestSidebarToggleLabel(t *testing.T) { a := newTestApp(t, t.TempDir()) @@ -623,6 +651,48 @@ func TestMenuToggleSidebar(t *testing.T) { } } +// TestMenuToggleLineComment runs the menu action against the active tab so the +// app layer and editor-layer primitive stay wired together. +func TestMenuToggleLineComment(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "main.go") + if err := os.WriteFile(target, []byte("one\ntwo"), 0644); err != nil { + t.Fatalf("seed: %v", err) + } + a := newTestApp(t, dir) + a.openFile(target) + + a.menuToggleLineComment() + + if got := a.activeTabPtr().Buffer.String(); got != "// one\ntwo" { + t.Fatalf("buffer = %q, want current line commented", got) + } + if a.statusMsg != "Toggled line comment" { + t.Fatalf("statusMsg = %q", a.statusMsg) + } +} + +// TestMenuToggleLineComment_Unsupported flashes a clear no-op instead of +// guessing at block-comment-only formats. +func TestMenuToggleLineComment_Unsupported(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "index.html") + if err := os.WriteFile(target, []byte("
"), 0644); err != nil { + t.Fatalf("seed: %v", err) + } + a := newTestApp(t, dir) + a.openFile(target) + + a.menuToggleLineComment() + + if got := a.activeTabPtr().Buffer.String(); got != "
" { + t.Fatalf("unsupported buffer changed to %q", got) + } + if a.statusMsg != "No line comment syntax for this file" { + t.Fatalf("statusMsg = %q", a.statusMsg) + } +} + // TestTabBarClick_OpensMenu clicks the ≡ button cell and verifies the menu // opens. func TestTabBarClick_OpensMenu(t *testing.T) { @@ -999,9 +1069,9 @@ func TestScrollAt(t *testing.T) { } a := newTestApp(t, dir) a.openFile(target) - a.scrollAt(1, 5, 1) // sidebar - a.scrollAt(60, 5, 1) // editor - a.scrollAt(60, a.height-1, 1) // status bar (no-op-ish) + a.scrollAt(1, 5, 1) // sidebar + a.scrollAt(60, 5, 1) // editor + a.scrollAt(60, a.height-1, 1) // status bar (no-op-ish) } // TestSidebarClick_File opens a file when a file row is clicked. @@ -1281,9 +1351,20 @@ func TestHandleMenuMouse_ClicksRowAndOutside(t *testing.T) { a := newTestApp(t, t.TempDir()) a.openMenu() mx, my, _, _ := a.menuModalRect() - // Click on the toggle row (relY=26) — flips the sidebar. + // Click on the sidebar toggle row — flips the sidebar. + items, _, _ := a.menuLayout() + toggleRelY := -1 + for _, item := range items { + if item.labelFor != nil && item.labelFor(a) == "Hide file explorer" { + toggleRelY = item.relY + break + } + } + if toggleRelY < 0 { + t.Fatal("sidebar toggle row not found") + } before := a.sidebarShown - a.handleMenuMouse(mx+5, my+26, tcell.Button1) + a.handleMenuMouse(mx+5, my+toggleRelY, tcell.Button1) if a.sidebarShown == before { t.Fatal("expected toggle to fire") } @@ -1408,7 +1489,7 @@ func TestDrawStatusBar_OmitsBranchWhenEmpty(t *testing.T) { } // TestMenuLayout_NoCustomActions pins down the baseline geometry: with -// zero custom actions the modal still has six built-in groups and the +// zero custom actions the modal still has seven built-in groups and the // height matches the expected layout total. Catches accidental // off-by-one regressions when someone tweaks the layout helper. func TestMenuLayout_NoCustomActions(t *testing.T) { @@ -1416,13 +1497,13 @@ func TestMenuLayout_NoCustomActions(t *testing.T) { a.customActions = nil items, dividers, h := a.menuLayout() - if h != 30 { - t.Errorf("modalHeight = %d, want 30", h) + if h != 31 { + t.Errorf("modalHeight = %d, want 31", h) } - if got := len(items); got != 20 { - t.Errorf("item count = %d, want 20 built-ins", got) + if got := len(items); got != 21 { + t.Errorf("item count = %d, want 21 built-ins", got) } - wantDiv := []int{2, 6, 10, 13, 21, 25, 27} + wantDiv := []int{2, 6, 10, 13, 21, 26, 28} if len(dividers) != len(wantDiv) { t.Fatalf("dividers = %v, want %v", dividers, wantDiv) } @@ -1433,6 +1514,42 @@ func TestMenuLayout_NoCustomActions(t *testing.T) { } } +// TestMenuLayout_ToggleLineCommentRow ensures the comment action is present +// and uses the same enablement predicate as the direct app method. +func TestMenuLayout_ToggleLineCommentRow(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "main.go") + if err := os.WriteFile(target, []byte("package main"), 0644); err != nil { + t.Fatalf("seed: %v", err) + } + a := newTestApp(t, dir) + + item := menuItemByLabel(t, a, "Toggle line comment") + if item.enabled(a) { + t.Fatal("comment row should be disabled without an active tab") + } + + a.openFile(target) + item = menuItemByLabel(t, a, "Toggle line comment") + if !item.enabled(a) { + t.Fatal("comment row should be enabled for a .go tab") + } +} + +// menuItemByLabel finds a menu row by static label for tests that care about +// one action without hard-coding its row index. +func menuItemByLabel(t *testing.T, a *App, label string) menuItemDef { + t.Helper() + items, _, _ := a.menuLayout() + for _, item := range items { + if item.label == label { + return item + } + } + t.Fatalf("menu item %q not found", label) + return menuItemDef{} +} + // TestMenuLayout_WithCustomActions checks the splice-before-Quit // behaviour: two custom actions land as their own group sitting // directly above the Quit row, with a divider on each side. Modal @@ -1445,8 +1562,8 @@ func TestMenuLayout_WithCustomActions(t *testing.T) { } items, _, h := a.menuLayout() - if h != 33 { // 30 + 2 items + 1 divider - t.Errorf("modalHeight = %d, want 33", h) + if h != 34 { // 31 + 2 items + 1 divider + t.Errorf("modalHeight = %d, want 34", h) } // Custom actions should be the second-to-last and third-to-last // rows, with Quit as the final row. diff --git a/internal/app/leader.go b/internal/app/leader.go index 4b5f7e9..dba3770 100644 --- a/internal/app/leader.go +++ b/internal/app/leader.go @@ -7,18 +7,18 @@ // leader.go defines the editor's Esc-leader hotkey table. Esc-Esc still opens // the action menu (handled in handleKey); the bindings here handle the -// "Esc, then a single letter within doubleEscMs" sequences for common +// "Esc, then one rune within doubleEscMs" sequences for common // actions. We deliberately avoid Ctrl-key shortcuts because they fight // tmux/zellij prefixes and the terminal's own bindings — Esc is the only // modifier we trust over SSH. package app -// leaderBinding is one Esc-leader entry: the trigger rune (lowercase ASCII) -// and the App method that fires when the user presses Esc, in quick -// succession. Each method already handles its own preconditions — calling -// menuUndo with no active tab, for example, is a safe no-op — so the leader -// dispatch doesn't need to re-check enable predicates. +// leaderBinding is one Esc-leader entry: the trigger rune and the App method +// that fires when the user presses Esc, in quick succession. Each method +// already handles its own preconditions — calling menuUndo with no active tab, +// for example, is a safe no-op — so the leader dispatch doesn't need to +// re-check enable predicates. type leaderBinding struct { key rune action func(*App) @@ -26,9 +26,9 @@ type leaderBinding struct { // leaderBindings is the editor's full Esc-leader table. The order is purely // presentational: tests iterate it to assert every binding fires, and a -// future help screen can render the table directly. Letters are chosen to -// be mnemonic and avoid collisions — capital letters are reserved in case -// we ever want a "shift-flavored" variant for destructive twins. +// future help screen can render the table directly. Letter bindings are +// chosen to be mnemonic and avoid collisions; punctuation bindings mirror +// familiar editor gestures where they make sense. // // Intentionally not bound: // - c / x / v (clipboard) — the host terminal's Cmd+C/V already covers @@ -44,6 +44,7 @@ func leaderBindings() []leaderBinding { {'q', (*App).menuQuit}, {'n', (*App).menuNewFile}, {'t', (*App).menuToggleSidebar}, + {'/', (*App).menuToggleLineComment}, {'f', (*App).openFind}, {'p', (*App).openFinder}, } diff --git a/internal/app/leader_test.go b/internal/app/leader_test.go index cb6d159..5ee5046 100644 --- a/internal/app/leader_test.go +++ b/internal/app/leader_test.go @@ -99,6 +99,25 @@ func TestHandleKey_LeaderToggleSidebar(t *testing.T) { } } +// TestHandleKey_LeaderToggleLineComment binds Esc-/ to the same action menu +// path, giving keyboard users a fast toggle without adding Ctrl shortcuts. +func TestHandleKey_LeaderToggleLineComment(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "main.go") + if err := os.WriteFile(target, []byte("one\ntwo"), 0644); err != nil { + t.Fatalf("seed: %v", err) + } + a := newTestApp(t, dir) + a.openFile(target) + + a.handleKey(keyEv(tcell.KeyEsc, 0)) + a.handleKey(keyEv(tcell.KeyRune, '/')) + + if got := a.activeTabPtr().Buffer.String(); got != "// one\ntwo" { + t.Fatalf("Esc-/ should comment the cursor line, got %q", got) + } +} + // TestHandleKey_LeaderQuit sets a.quit via Esc-q. We test this directly // rather than through Run() so we don't have to drive the event loop — // the quit flag is what Run() polls each tick. diff --git a/internal/editor/comment.go b/internal/editor/comment.go new file mode 100644 index 0000000..ae21088 --- /dev/null +++ b/internal/editor/comment.go @@ -0,0 +1,211 @@ +// ============================================================================= +// File: internal/editor/comment.go +// Author: Spicer Matthews +// Created: 2026-05-14 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +package editor + +import ( + "path/filepath" + "strings" +) + +// lineCommentByExt maps common source file extensions to their single-line +// comment marker. Block-comment-only languages are intentionally omitted. +var lineCommentByExt = map[string]string{ + ".adb": "--", + ".ads": "--", + ".bash": "#", + ".c": "//", + ".cc": "//", + ".clj": ";", + ".cljs": ";", + ".cmake": "#", + ".conf": "#", + ".cpp": "//", + ".cs": "//", + ".csh": "#", + ".cxx": "//", + ".dart": "//", + ".el": ";", + ".elm": "--", + ".erl": "%", + ".ex": "#", + ".exs": "#", + ".env": "#", + ".fish": "#", + ".go": "//", + ".h": "//", + ".hpp": "//", + ".hs": "--", + ".ini": ";", + ".java": "//", + ".jl": "#", + ".js": "//", + ".jsx": "//", + ".kt": "//", + ".kts": "//", + ".less": "//", + ".lua": "--", + ".mjs": "//", + ".mk": "#", + ".mm": "//", + ".php": "//", + ".pl": "#", + ".pm": "#", + ".ps1": "#", + ".py": "#", + ".r": "#", + ".rb": "#", + ".rs": "//", + ".sass": "//", + ".scala": "//", + ".scss": "//", + ".sh": "#", + ".sql": "--", + ".swift": "//", + ".toml": "#", + ".ts": "//", + ".tsx": "//", + ".vim": "\"", + ".yaml": "#", + ".yml": "#", + ".zsh": "#", + ".dockerfile": "#", + ".gitignore": "#", +} + +// LineCommentPrefix returns the single-line comment marker for path. The +// boolean is false for file types that do not have a safe line-comment syntax. +func LineCommentPrefix(path string) (string, bool) { + base := strings.ToLower(filepath.Base(path)) + switch base { + case "dockerfile", "containerfile", "makefile", "gnumakefile", "rakefile", "gemfile", "justfile": + return "#", true + case "cmakelists.txt": + return "#", true + } + if prefix, ok := lineCommentByExt[base]; ok { + return prefix, true + } + prefix, ok := lineCommentByExt[strings.ToLower(filepath.Ext(base))] + return prefix, ok +} + +// ToggleLineComment comments or uncomments the selected lines. It returns +// ok=false when the active file type has no known line-comment marker. +func (t *Tab) ToggleLineComment() (changed bool, ok bool) { + if t == nil || t.IsImage() || t.Buffer == nil { + return false, false + } + prefix, ok := LineCommentPrefix(t.Path) + if !ok { + return false, false + } + start, end := t.commentLineRange() + if !hasNonBlankLine(t.Buffer.Lines, start, end) { + return false, true + } + uncomment := t.linesAreCommented(start, end, prefix) + + t.pushUndo(undoGroupStructural) + for i := start; i <= end; i++ { + line := t.Buffer.Lines[i] + if strings.TrimSpace(line) == "" { + continue + } + if uncomment { + t.Buffer.Lines[i] = uncommentLine(line, prefix) + continue + } + t.Buffer.Lines[i] = commentLine(line, prefix) + } + t.Cursor = t.Buffer.Clamp(t.Cursor) + t.Anchor = t.Buffer.Clamp(t.Anchor) + t.Dirty = true + t.StyleStale = true + t.cursorMoved = true + return true, true +} + +// commentLineRange returns the inclusive line range touched by the current +// selection, or the cursor line when there is no selection. +func (t *Tab) commentLineRange() (int, int) { + if !t.HasSelection() { + line := t.Buffer.Clamp(t.Cursor).Line + return line, line + } + start, end := PosOrdered(t.Anchor, t.Cursor) + start = t.Buffer.Clamp(start) + end = t.Buffer.Clamp(end) + if end.Col == 0 && end.Line > start.Line { + end.Line-- + } + return start.Line, end.Line +} + +// linesAreCommented reports whether every non-blank line in the range already +// starts with prefix, either at column zero or after indentation. +func (t *Tab) linesAreCommented(start, end int, prefix string) bool { + for i := start; i <= end; i++ { + line := t.Buffer.Lines[i] + if strings.TrimSpace(line) == "" { + continue + } + if !lineHasCommentPrefix(line, prefix) { + return false + } + } + return true +} + +// hasNonBlankLine reports whether any line in the inclusive range has content. +func hasNonBlankLine(lines []string, start, end int) bool { + for i := start; i <= end; i++ { + if strings.TrimSpace(lines[i]) != "" { + return true + } + } + return false +} + +// commentLine inserts prefix at column zero, leaving the line's existing +// indentation untouched after the marker. +func commentLine(line, prefix string) string { + return prefix + " " + line +} + +// uncommentLine removes prefix, plus one following space if present, from +// column zero or from after indentation for lines toggled by older builds. +func uncommentLine(line, prefix string) string { + if strings.HasPrefix(line, prefix) { + rest := strings.TrimPrefix(line, prefix) + rest = strings.TrimPrefix(rest, " ") + return rest + } + indent, rest := splitIndent(line) + rest = strings.TrimPrefix(rest, prefix) + rest = strings.TrimPrefix(rest, " ") + return indent + rest +} + +// lineHasCommentPrefix reports whether line starts with prefix at column zero +// or after indentation. +func lineHasCommentPrefix(line, prefix string) bool { + if strings.HasPrefix(line, prefix) { + return true + } + _, rest := splitIndent(line) + return strings.HasPrefix(rest, prefix) +} + +// splitIndent separates leading horizontal whitespace from the rest of a line. +func splitIndent(line string) (string, string) { + i := 0 + for i < len(line) && (line[i] == ' ' || line[i] == '\t') { + i++ + } + return line[:i], line[i:] +} diff --git a/internal/editor/comment_test.go b/internal/editor/comment_test.go new file mode 100644 index 0000000..19da433 --- /dev/null +++ b/internal/editor/comment_test.go @@ -0,0 +1,211 @@ +// ============================================================================= +// File: internal/editor/comment_test.go +// Author: Spicer Matthews +// Created: 2026-05-14 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +package editor + +import "testing" + +// TestLineCommentPrefix_CommonExtensions pins the filename and extension +// lookup used by the toggle action before it mutates a buffer. +func TestLineCommentPrefix_CommonExtensions(t *testing.T) { + cases := []struct { + path string + want string + ok bool + }{ + {"main.go", "//", true}, + {"script.py", "#", true}, + {"query.sql", "--", true}, + {"config.ini", ";", true}, + {"Dockerfile", "#", true}, + {"index.html", "", false}, + } + for _, c := range cases { + got, ok := LineCommentPrefix(c.path) + if got != c.want || ok != c.ok { + t.Fatalf("LineCommentPrefix(%q) = %q, %v; want %q, %v", c.path, got, ok, c.want, c.ok) + } + } +} + +// TestToggleLineComment_CommentsSelectedLines checks the headline path: +// every selected non-blank line gets a comment marker at column zero. +func TestToggleLineComment_CommentsSelectedLines(t *testing.T) { + tab := commentTestTab("main.go", "package main\nfunc main() {\n\tprintln(\"x\")\n}\n") + tab.Anchor = Position{Line: 1, Col: 0} + tab.Cursor = Position{Line: 3, Col: 0} + + changed, ok := tab.ToggleLineComment() + + if !ok || !changed { + t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok) + } + want := "package main\n// func main() {\n// \tprintln(\"x\")\n}\n" + if got := tab.Buffer.String(); got != want { + t.Fatalf("buffer:\n%q\nwant:\n%q", got, want) + } + if !tab.Dirty || !tab.StyleStale { + t.Fatal("toggle should dirty the tab and invalidate highlighting") + } +} + +// TestToggleLineComment_UncommentsWhenAllLinesCommented proves the toggle +// flips direction only when every non-blank target line is already commented. +func TestToggleLineComment_UncommentsWhenAllLinesCommented(t *testing.T) { + tab := commentTestTab("main.go", "// one\n// \ttwo\n") + tab.Anchor = Position{Line: 0, Col: 0} + tab.Cursor = Position{Line: 2, Col: 0} + + changed, ok := tab.ToggleLineComment() + + if !ok || !changed { + t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok) + } + want := "one\n\ttwo\n" + if got := tab.Buffer.String(); got != want { + t.Fatalf("buffer:\n%q\nwant:\n%q", got, want) + } +} + +// TestToggleLineComment_UncommentsIndentedExistingComments keeps the toggle +// tolerant of comments that already sit after indentation. +func TestToggleLineComment_UncommentsIndentedExistingComments(t *testing.T) { + tab := commentTestTab("main.go", "\t// one\n // two\n") + tab.Anchor = Position{Line: 0, Col: 0} + tab.Cursor = Position{Line: 2, Col: 0} + + changed, ok := tab.ToggleLineComment() + + if !ok || !changed { + t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok) + } + want := "\tone\n two\n" + if got := tab.Buffer.String(); got != want { + t.Fatalf("buffer:\n%q\nwant:\n%q", got, want) + } +} + +// TestToggleLineComment_MixedSelectionCommentsAllLines locks in the common +// editor rule: a mixed selection comments every non-blank line. +func TestToggleLineComment_MixedSelectionCommentsAllLines(t *testing.T) { + tab := commentTestTab("main.go", "// one\n\n two") + tab.SelectAll() + + changed, ok := tab.ToggleLineComment() + + if !ok || !changed { + t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok) + } + want := "// // one\n\n// two" + if got := tab.Buffer.String(); got != want { + t.Fatalf("buffer:\n%q\nwant:\n%q", got, want) + } +} + +// TestToggleLineComment_SelectionEndingAtColumnZeroExcludesThatLine keeps +// whole-line selections from unexpectedly changing the first untouched line. +func TestToggleLineComment_SelectionEndingAtColumnZeroExcludesThatLine(t *testing.T) { + tab := commentTestTab("main.go", "one\ntwo\nthree") + tab.Anchor = Position{Line: 0, Col: 0} + tab.Cursor = Position{Line: 2, Col: 0} + + changed, ok := tab.ToggleLineComment() + + if !ok || !changed { + t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok) + } + want := "// one\n// two\nthree" + if got := tab.Buffer.String(); got != want { + t.Fatalf("buffer:\n%q\nwant:\n%q", got, want) + } +} + +// TestToggleLineComment_NoSelectionUsesCursorLine makes the menu item useful +// even when the user has not highlighted text first. +func TestToggleLineComment_NoSelectionUsesCursorLine(t *testing.T) { + tab := commentTestTab("main.go", "one\ntwo\nthree") + tab.Cursor = Position{Line: 1, Col: 1} + tab.Anchor = tab.Cursor + + changed, ok := tab.ToggleLineComment() + + if !ok || !changed { + t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok) + } + want := "one\n// two\nthree" + if got := tab.Buffer.String(); got != want { + t.Fatalf("buffer:\n%q\nwant:\n%q", got, want) + } +} + +// TestToggleLineComment_BlankSelectionIsNoop avoids adding comment markers +// to whitespace-only lines just because they were inside the selection. +func TestToggleLineComment_BlankSelectionIsNoop(t *testing.T) { + tab := commentTestTab("main.go", " \n\t") + tab.SelectAll() + + changed, ok := tab.ToggleLineComment() + + if !ok { + t.Fatal("blank Go selection should still have a known comment syntax") + } + if changed { + t.Fatal("blank-only selection should not change the buffer") + } + if tab.Dirty || tab.CanUndo() { + t.Fatal("blank-only selection should not dirty the tab or push undo") + } +} + +// TestToggleLineComment_UnsupportedFileTypeIsNoop protects formats like HTML +// where a line-comment marker would be wrong. +func TestToggleLineComment_UnsupportedFileTypeIsNoop(t *testing.T) { + tab := commentTestTab("index.html", "
") + + changed, ok := tab.ToggleLineComment() + + if ok || changed { + t.Fatalf("ToggleLineComment() = %v, %v; want unsupported noop", changed, ok) + } + if got := tab.Buffer.String(); got != "
" { + t.Fatalf("buffer changed for unsupported type: %q", got) + } +} + +// TestToggleLineComment_UndoRestoresSelectionAndText confirms the action is +// one structural undo step, including the cursor and active selection. +func TestToggleLineComment_UndoRestoresSelectionAndText(t *testing.T) { + tab := commentTestTab("main.go", "one\ntwo") + tab.Anchor = Position{Line: 0, Col: 1} + tab.Cursor = Position{Line: 1, Col: 2} + + changed, ok := tab.ToggleLineComment() + if !ok || !changed { + t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok) + } + if !tab.Undo() { + t.Fatal("Undo should restore the pre-toggle snapshot") + } + if got := tab.Buffer.String(); got != "one\ntwo" { + t.Fatalf("undo buffer = %q, want original", got) + } + if tab.Anchor != (Position{Line: 0, Col: 1}) || tab.Cursor != (Position{Line: 1, Col: 2}) { + t.Fatalf("undo selection = anchor %+v cursor %+v", tab.Anchor, tab.Cursor) + } +} + +// commentTestTab constructs a text tab with undo initialized, without touching +// the filesystem. +func commentTestTab(path, text string) *Tab { + t := &Tab{ + Path: path, + Buffer: NewBuffer(text), + StyleStale: false, + } + t.initUndo() + return t +}