pmacs/docs/comment-toggle-framing.md

6.6 KiB

Comment/uncomment — framing (Arc 2, editing table stakes)

pmacs has no way to comment code out. This adds the table-stakes toggle: M-; comments or uncomments the current line or the selected lines, language-aware, as one undoable edit.

Roadmap: docs/roadmap-2026-07.md Arc 2 ("comment/uncomment").

Ground truth (as of 2dde4b8)

  • No comment-syntax knowledge exists anywhere — not in the grammar registry, not in LSP config, not in Lua. A language → prefix table is new surface.
  • Language detection: active_buffer_language() in builtin/runtime/lsp.lua chains grammar detection (pmacs.parse.language_for_path) with the user-extensible pmacs.lsp.filetypes map — but it is a local. Known languages today: rust/lua/c/cpp + js/ts via grammars; python, c, go, tsx/jsx, lua, bash, toml, zig via filetypes.
  • Bindings: M-; (Emacs comment-dwim's home) is free. C-/ is taken by undo (terminal Ctrl+/ ambiguity — three undo bindings exist), so the VSCode-style toggle chord is unavailable.
  • Undo granularity: every applied edit pushes one UndoEntry (src/buffer.rs:130) — there is no grouping/transaction. N per-line edits would need N undos.
  • From Arc 2 (merged): the buffer mutators return the effective post-intercept edit (start, end, inserted_len); keybound commands rotate the command boundary and get buffer.after-edit from dispatch; M-x gets both via invoke_interactive + with_after_edit_check. Comment-toggle inherits all of it for free.

Decisions

Q#CT1 — Pure Lua, zero Rust changes

builtin/runtime/comment.lua. Everything needed exists: buf:slice, the effective-edit-returning buf:replace, ed.region/cursor/ goto_byte/clear_selection, and the language chain. The one lsp.lua touch: export the existing local as pmacs.lsp.active_buffer_language() (one line) rather than replicating its grammar+filetypes chain and drifting.

Q#CT2 — Command + binding

One command, edit.toggle-comment, bound M-;:

  • Region active → toggle the whole lines the region touches, then clear the selection (CUA convention after a region op) and leave the cursor at the span start.
  • No region → toggle the current line, then move to the next line — Emacs's comment-line behavior, which makes repeated M-; walk down a block toggling as it goes.

Named deviation from Emacs: M-; in Emacs is comment-dwim, whose no-region case appends an empty comment at end of line. That mode is rarely what modern muscle memory wants from the toggle key; pmacs's M-; behaves like Emacs C-x C-; (comment-line). DWIM's append-comment can come later under its own name.

Q#CT3 — The comment-string table

pmacs.comment.strings — a public, user-extensible map (the pmacs.lsp.filetypes pattern), language → line-comment prefix:

//: rust, c, cpp, go, zig, javascript, typescript, javascriptreact, typescriptreact · --: lua · #: python, bash, toml, yaml, sh.

Unknown language (or no language): status "no comment syntax known for <lang>", no edit. Users add entries from init.lua: pmacs.comment.strings.mylang = ";;". Block comments are deferred — line comments cover the table-stakes use, and block toggling has real edge cases (nesting, mid-line spans) that don't belong in v1.

Q#CT4 — Toggle semantics

Over the span's lines:

  • Uncomment when every non-blank line starts (after its indentation) with the prefix; removal strips the prefix plus one following space if present.
  • Comment otherwise: insert prefix + " " at the minimum indentation column of the span's non-blank lines (Emacs comment-region style — the comments line up instead of hugging each line's own indent). Blank lines are skipped in both directions and don't influence the min-indent computation.
  • A span that is entirely blank is a no-op with a status.

Mixed spans (some commented, some not) therefore comment — the double-prefix on already-commented lines round-trips back out, which is Emacs's behavior and preserves inner commented-out code.

Q#CT5 — One edit, one undo step, one CRDT op

The whole toggle is a single buf:replace(span_start, span_end, new_text): Lua builds the rewritten span, one edit applies it. Consequences, all deliberate:

  • One C-/ undoes the whole toggle (there is no undo grouping to lean on; N per-line edits would need N undos).
  • One CRDT op for replica frontends.
  • One effective-edit verification: the kill-ring discipline — if the returned (start, end, inserted_len) deviates from the request (a buffer intercept rewrote it), report "comment toggle altered by buffer intercept" and skip the cursor fix-up; the interceptor's result stands. pcall'd, so a rejecting intercept reports rather than throws.

Q#CT6 — Chain/hook plumbing: nothing to build

Keybound M-; rotates the command boundary (breaking kill chains — correct) and fires buffer.after-edit from dispatch's revision check; M-x edit.toggle-comment gets the same via invoke_interactive and the accept-path hook wrapper. Both are the Arc 2 substrate working as designed — the acceptance suite asserts the hook fires once anyway.

Bets

  1. Single-replace is the right granularity — no complaint about whole-span replaces (vs. per-line edits) from CRDT replicas or LSP didChange (full-text sync makes this moot today).
  2. Min-indent + skip-blank matches expectation — no "why is my comment at column 0" or "why did my blank line get a //".

Deferred (named)

  • Block comments (/* */) and mid-line spans.
  • comment-dwim's append-comment-at-EOL mode.
  • Doc-comment continuation on newline (belongs to auto-indent).
  • Per-language padding config (always one space in v1).

Acceptance (tests/comment_toggle_acceptance.rs, dispatch-driven)

  • Rust buffer: M-; comments the line (// at indent), cursor moves to the next line; M-; on a commented line uncomments (round-trip, including the space).
  • Region across mixed-indent lines → prefixes at min indent, aligned; blank line inside the span untouched; selection cleared.
  • All-commented span → uncomments; mixed span → comments (inner prefix preserved, round-trips).
  • Lua buffer gets --, Python #; unknown/no language → status, no edit.
  • One undo step: multi-line toggle then a single buffer.undo restores the original text exactly.
  • Intercept discipline: rejecting intercept → status, no throw; transforming intercept → reported, no cursor fix-up.
  • after-edit probe fires exactly once per toggle (keybound and M-x).
  • Kill-chain break: C-k, M-;, C-k → two ring entries.