Merge pull request #107 from levineuwirth/comment-toggle
feat(edit): comment/uncomment toggle on M-; (Arc 2)
This commit is contained in:
commit
430718b573
|
|
@ -0,0 +1,208 @@
|
|||
-- comment.lua --- language-aware comment/uncomment (Arc 2).
|
||||
--
|
||||
-- `M-;` (`edit.toggle-comment`) comments or uncomments the current
|
||||
-- line — or every line the region touches — using the language's line
|
||||
-- prefix from the public `pmacs.comment.strings` table. Semantics
|
||||
-- (Q#CT4): uncomment iff every non-blank line already starts (after
|
||||
-- its indentation) with the prefix; otherwise comment, inserting
|
||||
-- `prefix .. " "` at the minimum indentation of the span's non-blank
|
||||
-- lines (Emacs comment-region alignment). Blank lines are skipped in
|
||||
-- both directions. The whole toggle is ONE `buf:replace` (Q#CT5): one
|
||||
-- undo step, one CRDT op, one effective-edit verification.
|
||||
--
|
||||
-- Named deviation (Q#CT2): the no-region case is Emacs `comment-line`
|
||||
-- (toggle, then move to the next line so repeated `M-;` walks a
|
||||
-- block), not `comment-dwim`'s append-comment-at-EOL.
|
||||
--
|
||||
-- Framing: docs/comment-toggle-framing.md.
|
||||
|
||||
pmacs.comment = pmacs.comment or {}
|
||||
|
||||
local ed = pmacs.editor
|
||||
|
||||
-- Language → line-comment prefix (Q#CT3). Public and user-extensible,
|
||||
-- like `pmacs.lsp.filetypes`: `pmacs.comment.strings.mylang = ";;"`.
|
||||
-- Block comments are a named deferral.
|
||||
pmacs.comment.strings = {
|
||||
rust = "//",
|
||||
c = "//",
|
||||
cpp = "//",
|
||||
go = "//",
|
||||
zig = "//",
|
||||
javascript = "//",
|
||||
typescript = "//",
|
||||
javascriptreact = "//",
|
||||
typescriptreact = "//",
|
||||
lua = "--",
|
||||
python = "#",
|
||||
bash = "#",
|
||||
sh = "#",
|
||||
toml = "#",
|
||||
yaml = "#",
|
||||
}
|
||||
|
||||
-- Start of the line containing `pos`: chunked backward scan for the
|
||||
-- last newline strictly before it (same chunk discipline as
|
||||
-- killring's forward scan — giant lines stay safe).
|
||||
local function line_start_before(buf, pos)
|
||||
local p = pos
|
||||
while p > 0 do
|
||||
local from = math.max(0, p - 4096)
|
||||
local chunk = buf:slice(from, p)
|
||||
local nl = chunk:match("()\n[^\n]*$")
|
||||
if nl then return from + nl end
|
||||
p = from
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- Byte offset of the first newline at or after `pos`, or `len`.
|
||||
local function line_end_at(buf, pos, len)
|
||||
local p = pos
|
||||
while p < len do
|
||||
local chunk_to = math.min(p + 4096, len)
|
||||
local chunk = buf:slice(p, chunk_to)
|
||||
local nl = chunk:find("\n", 1, true)
|
||||
if nl then return p + nl - 1 end
|
||||
p = chunk_to
|
||||
end
|
||||
return len
|
||||
end
|
||||
|
||||
-- Split span text (no trailing newline) into lines, preserving empties.
|
||||
local function split_lines(text)
|
||||
local lines = {}
|
||||
local i = 1
|
||||
while true do
|
||||
local nl = text:find("\n", i, true)
|
||||
if not nl then
|
||||
table.insert(lines, text:sub(i))
|
||||
break
|
||||
end
|
||||
table.insert(lines, text:sub(i, nl - 1))
|
||||
i = nl + 1
|
||||
end
|
||||
return lines
|
||||
end
|
||||
|
||||
local function is_blank(line)
|
||||
return line:match("^%s*$") ~= nil
|
||||
end
|
||||
|
||||
-- Leading indentation in BYTES. `[ \t]` rather than `%s` so a CR on a
|
||||
-- CRLF line never counts as indent.
|
||||
local function indent_of(line)
|
||||
return line:match("^[ \t]*")
|
||||
end
|
||||
|
||||
-- edit.toggle-comment body.
|
||||
function pmacs.comment.toggle()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
ed.set_status("no buffer")
|
||||
return false
|
||||
end
|
||||
local lang = pmacs.lsp.active_buffer_language()
|
||||
local prefix = lang and pmacs.comment.strings[lang]
|
||||
if not prefix then
|
||||
ed.set_status("no comment syntax known for " .. (lang or "this buffer"))
|
||||
return false
|
||||
end
|
||||
|
||||
local len = buf:len()
|
||||
local region = ed.region()
|
||||
local has_region = region ~= nil and region["end"] > region.start
|
||||
local span_first, span_last_end
|
||||
if has_region then
|
||||
span_first = line_start_before(buf, region.start)
|
||||
-- The last line the region TOUCHES: a region ending at column 0
|
||||
-- stops at the previous line (Emacs comment-region), hence end-1.
|
||||
span_last_end = line_end_at(buf, region["end"] - 1, len)
|
||||
else
|
||||
local cursor = ed.cursor()
|
||||
span_first = line_start_before(buf, cursor)
|
||||
span_last_end = line_end_at(buf, cursor, len)
|
||||
end
|
||||
|
||||
local lines = split_lines(buf:slice(span_first, span_last_end))
|
||||
|
||||
-- Classify (Q#CT4): uncomment iff every non-blank line is commented;
|
||||
-- blank lines neither count nor contribute to the min indent.
|
||||
local any_nonblank = false
|
||||
local all_commented = true
|
||||
local min_indent = nil
|
||||
for _, line in ipairs(lines) do
|
||||
if not is_blank(line) then
|
||||
any_nonblank = true
|
||||
local ind = indent_of(line)
|
||||
if line:sub(#ind + 1, #ind + #prefix) ~= prefix then
|
||||
all_commented = false
|
||||
end
|
||||
if min_indent == nil or #ind < min_indent then min_indent = #ind end
|
||||
end
|
||||
end
|
||||
if not any_nonblank then
|
||||
ed.set_status("nothing to comment")
|
||||
return false
|
||||
end
|
||||
|
||||
for i, line in ipairs(lines) do
|
||||
if not is_blank(line) then
|
||||
if all_commented then
|
||||
local ind = indent_of(line)
|
||||
local rest = line:sub(#ind + 1 + #prefix)
|
||||
if rest:sub(1, 1) == " " then rest = rest:sub(2) end
|
||||
lines[i] = ind .. rest
|
||||
else
|
||||
lines[i] = line:sub(1, min_indent)
|
||||
.. prefix
|
||||
.. " "
|
||||
.. line:sub(min_indent + 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
local new_text = table.concat(lines, "\n")
|
||||
|
||||
-- One replace = one undo step, one CRDT op (Q#CT5). Same intercept
|
||||
-- discipline as killring: a rejection reports rather than throws,
|
||||
-- and any deviation of the EFFECTIVE edit from the request means an
|
||||
-- intercept rewrote it — the interceptor's result stands and the
|
||||
-- cursor fix-up is skipped (a moved span makes it meaningless).
|
||||
local ok, estart, estop, einserted = pcall(function()
|
||||
return buf:replace(span_first, span_last_end, new_text)
|
||||
end)
|
||||
if not ok then
|
||||
ed.set_status("comment toggle rejected by buffer intercept")
|
||||
return false
|
||||
end
|
||||
if estart ~= span_first or estop ~= span_last_end or einserted ~= #new_text then
|
||||
ed.set_status("comment toggle altered by buffer intercept")
|
||||
return false
|
||||
end
|
||||
|
||||
if has_region then
|
||||
-- CUA convention after a region op: selection off, cursor at the
|
||||
-- span start (Q#CT2).
|
||||
ed.clear_selection()
|
||||
ed.goto_byte(span_first)
|
||||
else
|
||||
-- comment-line behavior: move to the next line so repeated M-;
|
||||
-- walks down the block. The byte after the rewritten span is the
|
||||
-- old trailing newline iff one existed.
|
||||
local new_end = span_first + #new_text
|
||||
if new_end < buf:len() then
|
||||
ed.goto_byte(new_end + 1)
|
||||
else
|
||||
ed.goto_byte(new_end)
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
pmacs.command.define {
|
||||
name = "edit.toggle-comment",
|
||||
description = "Comment or uncomment the current line or selected lines.",
|
||||
fn = function() pmacs.comment.toggle() end,
|
||||
}
|
||||
|
||||
pmacs.keymap.bind { scope = "global", sequence = "M-;", command = "edit.toggle-comment" }
|
||||
|
|
@ -344,6 +344,9 @@ local function active_buffer_language()
|
|||
local ext = path:match("%.([%w_]+)$")
|
||||
return ext and pmacs.lsp.filetypes[ext] or nil
|
||||
end
|
||||
-- Public: the comment-toggle module (and future language-aware Lua)
|
||||
-- reuses this grammar+filetypes chain instead of replicating it.
|
||||
pmacs.lsp.active_buffer_language = active_buffer_language
|
||||
|
||||
-- Directory component of a path, or nil if it has none.
|
||||
local function dir_of(path)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
# 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.
|
||||
|
|
@ -333,6 +333,12 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/killring.lua"),
|
||||
)
|
||||
.expect("load killring builtin chunk");
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/comment.lua"),
|
||||
include_str!("../builtin/runtime/comment.lua"),
|
||||
)
|
||||
.expect("load comment builtin chunk");
|
||||
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
|
||||
// was loaded directly via `eval(include_str!(...))`; the
|
||||
// M7.11 deliverable migrates it to the package system so it
|
||||
|
|
|
|||
|
|
@ -0,0 +1,368 @@
|
|||
//! Comment-toggle acceptance (Arc 2, docs/comment-toggle-framing.md).
|
||||
//!
|
||||
//! Dispatch-driven: `M-;` through `dispatch_key`, `M-x` through the
|
||||
//! real minibuffer. Buffers are file-backed (language detection needs
|
||||
//! a path); each editor gets a private tempdir `StateDir` and an
|
||||
//! emptied `pmacs.lsp.config` so opening `.rs`/`.py` fixtures never
|
||||
//! spawns a real language server.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::lua_bindings::StateDir;
|
||||
use pmacs::protocol::FrontendId;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn fresh_state_dir() -> PathBuf {
|
||||
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pmacs-comment-{}-{}",
|
||||
std::process::id(),
|
||||
SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn editor(state_dir: &std::path::Path) -> EditorState {
|
||||
let s = EditorState::new();
|
||||
s.lua_host.lua().remove_app_data::<StateDir>();
|
||||
s.lua_host
|
||||
.lua()
|
||||
.set_app_data(StateDir(state_dir.to_path_buf()));
|
||||
// Language DETECTION must work (filetypes/grammars); server
|
||||
// SPAWNING must not (rust/python have default configs).
|
||||
exec(&s, "pmacs.lsp.config = {}");
|
||||
s
|
||||
}
|
||||
|
||||
fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, body).unwrap();
|
||||
p.display().to_string()
|
||||
}
|
||||
|
||||
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent {
|
||||
code,
|
||||
modifiers: mods,
|
||||
kind: KeyEventKind::Press,
|
||||
state: KeyEventState::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
fn ctrl(s: &mut EditorState, c: char) {
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char(c), KeyModifiers::CONTROL),
|
||||
);
|
||||
}
|
||||
|
||||
fn alt(s: &mut EditorState, c: char) {
|
||||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT));
|
||||
}
|
||||
|
||||
fn press(s: &mut EditorState, code: KeyCode) {
|
||||
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn type_str(s: &mut EditorState, text: &str) {
|
||||
for ch in text.chars() {
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char(ch), KeyModifiers::NONE),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn m_x(s: &mut EditorState, name: &str) {
|
||||
alt(s, 'x');
|
||||
type_str(s, name);
|
||||
press(s, KeyCode::Enter);
|
||||
}
|
||||
|
||||
fn exec(s: &EditorState, src: &str) {
|
||||
s.lua_host.lua().load(src.to_string()).exec().unwrap();
|
||||
}
|
||||
|
||||
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
|
||||
s.lua_host.lua().load(src.to_string()).eval().unwrap()
|
||||
}
|
||||
|
||||
fn buffer_text(s: &EditorState) -> String {
|
||||
let b: mlua::String = eval(
|
||||
s,
|
||||
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
|
||||
);
|
||||
String::from_utf8_lossy(&b.as_bytes()).into_owned()
|
||||
}
|
||||
|
||||
fn cursor(s: &EditorState) -> i64 {
|
||||
eval(s, "return pmacs.editor.cursor()")
|
||||
}
|
||||
|
||||
fn status(s: &EditorState) -> String {
|
||||
s.core.borrow().status.clone()
|
||||
}
|
||||
|
||||
/// Fresh editor visiting `name` (created in the state tempdir) with
|
||||
/// `body` on disk, cursor at 0.
|
||||
fn editor_visiting(name: &str, body: &str) -> EditorState {
|
||||
let dir = fresh_state_dir();
|
||||
let s = editor(&dir);
|
||||
let f = write_file(&dir, name, body);
|
||||
exec(&s, &format!("pmacs.buffer.find_or_open({f:?})"));
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single-line toggle (comment-line behavior, Q#CT2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn rust_line_toggle_round_trips_and_cursor_walks_to_the_next_line() {
|
||||
let mut s = editor_visiting("t.rs", "fn main() {\n let x = 1;\n}\n");
|
||||
exec(&s, "pmacs.editor.goto_byte(16)"); // inside " let x = 1;"
|
||||
alt(&mut s, ';');
|
||||
assert_eq!(buffer_text(&s), "fn main() {\n // let x = 1;\n}\n");
|
||||
assert_eq!(cursor(&s), 30, "cursor moved to the next line's start");
|
||||
// Toggle back from anywhere in the commented line: exact round
|
||||
// trip, including the padding space.
|
||||
exec(&s, "pmacs.editor.goto_byte(14)");
|
||||
alt(&mut s, ';');
|
||||
assert_eq!(buffer_text(&s), "fn main() {\n let x = 1;\n}\n");
|
||||
assert_eq!(cursor(&s), 27);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lua_buffer_gets_the_dash_dash_prefix() {
|
||||
let mut s = editor_visiting("t.lua", "local x = 1\nreturn x\n");
|
||||
alt(&mut s, ';');
|
||||
assert_eq!(buffer_text(&s), "-- local x = 1\nreturn x\n");
|
||||
assert_eq!(cursor(&s), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_line_without_newline_toggles_and_clamps_the_cursor() {
|
||||
let mut s = editor_visiting("e.py", "x = 1");
|
||||
alt(&mut s, ';');
|
||||
assert_eq!(buffer_text(&s), "# x = 1");
|
||||
assert_eq!(cursor(&s), 7, "no next line: cursor clamps to buffer end");
|
||||
alt(&mut s, ';');
|
||||
assert_eq!(buffer_text(&s), "x = 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_line_is_a_noop_with_a_status() {
|
||||
let mut s = editor_visiting("b.py", "\n \n");
|
||||
alt(&mut s, ';');
|
||||
assert!(
|
||||
status(&s).contains("nothing to comment"),
|
||||
"got: {:?}",
|
||||
status(&s)
|
||||
);
|
||||
assert_eq!(buffer_text(&s), "\n \n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Region toggles (Q#CT4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn region_comments_at_min_indent_skips_blanks_and_clears_the_selection() {
|
||||
let mut s = editor_visiting("t.py", " two\nzero\n\n four\n");
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.begin_selection(0); pmacs.editor.goto_byte(20)",
|
||||
);
|
||||
alt(&mut s, ';');
|
||||
// Min indent across non-blank lines is 0 (line "zero"), so every
|
||||
// prefix lands at column 0; the blank line is untouched.
|
||||
assert_eq!(buffer_text(&s), "# two\n# zero\n\n# four\n");
|
||||
let region_active: bool = eval(&s, "return pmacs.editor.region() ~= nil");
|
||||
assert!(!region_active, "selection clears after a region toggle");
|
||||
assert_eq!(cursor(&s), 0, "cursor lands at the span start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_region_comments_preserving_inner_prefixes_and_round_trips() {
|
||||
let mut s = editor_visiting("t2.py", "# a\nb\n");
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.begin_selection(0); pmacs.editor.goto_byte(5)",
|
||||
);
|
||||
alt(&mut s, ';');
|
||||
// Mixed span COMMENTS (Q#CT4): the already-commented line gets a
|
||||
// second prefix, preserving the inner commented-out code.
|
||||
assert_eq!(buffer_text(&s), "# # a\n# b\n");
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.begin_selection(0); pmacs.editor.goto_byte(9)",
|
||||
);
|
||||
alt(&mut s, ';');
|
||||
// Now every line is commented → uncomment strips the outer layer.
|
||||
assert_eq!(buffer_text(&s), "# a\nb\n", "double-prefix round-trips");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_ending_at_column_zero_excludes_that_line() {
|
||||
let mut s = editor_visiting("c.py", "one\ntwo\n");
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.begin_selection(0); pmacs.editor.goto_byte(4)",
|
||||
);
|
||||
alt(&mut s, ';');
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
"# one\ntwo\n",
|
||||
"a region stopping at a line's column 0 does not touch that line"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unknown language (Q#CT3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn unknown_language_reports_and_edits_nothing() {
|
||||
let mut s = editor_visiting("t.txt", "hello\n");
|
||||
alt(&mut s, ';');
|
||||
assert!(
|
||||
status(&s).contains("no comment syntax known"),
|
||||
"got: {:?}",
|
||||
status(&s)
|
||||
);
|
||||
assert_eq!(buffer_text(&s), "hello\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathless_scratch_buffer_reports_and_edits_nothing() {
|
||||
let dir = fresh_state_dir();
|
||||
let mut s = editor(&dir);
|
||||
type_str(&mut s, "hello");
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
alt(&mut s, ';');
|
||||
assert!(
|
||||
status(&s).contains("no comment syntax known"),
|
||||
"got: {:?}",
|
||||
status(&s)
|
||||
);
|
||||
assert_eq!(buffer_text(&s), "hello");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One edit, one undo step (Q#CT5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_multi_line_toggle_is_one_undo_step() {
|
||||
let mut s = editor_visiting("u.py", "a = 1\nb = 2\n");
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.begin_selection(0); pmacs.editor.goto_byte(11)",
|
||||
);
|
||||
alt(&mut s, ';');
|
||||
assert_eq!(buffer_text(&s), "# a = 1\n# b = 2\n");
|
||||
ctrl(&mut s, '/'); // buffer.undo, exactly once
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
"a = 1\nb = 2\n",
|
||||
"one undo restores the whole multi-line toggle"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intercept discipline (Q#CT5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn rejecting_intercept_reports_without_throwing() {
|
||||
let mut s = editor_visiting("i.py", "a\nb\n");
|
||||
exec(
|
||||
&s,
|
||||
r#"
|
||||
_G.reject_once = true
|
||||
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
|
||||
if _G.reject_once then
|
||||
_G.reject_once = false
|
||||
error("rejected by test intercept")
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
"#,
|
||||
);
|
||||
alt(&mut s, ';'); // rejected: reported, nothing changed, no throw
|
||||
assert!(status(&s).contains("rejected"), "got: {:?}", status(&s));
|
||||
assert_eq!(buffer_text(&s), "a\nb\n");
|
||||
assert_eq!(cursor(&s), 0, "no cursor fix-up on a rejected toggle");
|
||||
alt(&mut s, ';'); // allowed again: the command still works
|
||||
assert_eq!(buffer_text(&s), "# a\nb\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforming_intercept_is_reported_and_skips_the_cursor_fixup() {
|
||||
let mut s = editor_visiting("j.py", "ab\ncd\n");
|
||||
// Enlarges every replace's end by one byte — the effective edit
|
||||
// deviates from the request, so the toggle must report and leave
|
||||
// the cursor alone (the span it would fix up toward moved).
|
||||
exec(
|
||||
&s,
|
||||
r#"
|
||||
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
|
||||
if op.kind == "replace" then
|
||||
return {
|
||||
kind = "replace",
|
||||
start = op.start,
|
||||
["end"] = op["end"] + 1,
|
||||
bytes = op.bytes,
|
||||
}
|
||||
end
|
||||
return nil
|
||||
end)
|
||||
"#,
|
||||
);
|
||||
alt(&mut s, ';');
|
||||
assert!(status(&s).contains("altered"), "got: {:?}", status(&s));
|
||||
// The interceptor's result stands (accepted post-hoc semantics):
|
||||
// it swallowed the newline after "ab".
|
||||
assert_eq!(buffer_text(&s), "# abcd\n");
|
||||
assert_eq!(cursor(&s), 0, "cursor fix-up skipped");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Substrate plumbing (Q#CT6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn after_edit_fires_exactly_once_per_toggle_keybound_and_m_x() {
|
||||
let mut s = editor_visiting("h.py", "a\nb\n");
|
||||
exec(
|
||||
&s,
|
||||
"_G.ae = 0; pmacs.hook.add('buffer.after-edit', function() _G.ae = _G.ae + 1 end)",
|
||||
);
|
||||
alt(&mut s, ';'); // keybound path
|
||||
let n: i64 = eval(&s, "return _G.ae");
|
||||
assert_eq!(n, 1, "keybound toggle fires after-edit once");
|
||||
// Cursor walked to line 2; M-x path must fire it too (via
|
||||
// invoke_interactive + with_after_edit_check), and the minibuffer
|
||||
// typing itself must not inflate the count.
|
||||
m_x(&mut s, "edit.toggle-comment");
|
||||
assert_eq!(buffer_text(&s), "# a\n# b\n");
|
||||
let n: i64 = eval(&s, "return _G.ae");
|
||||
assert_eq!(n, 2, "M-x toggle fires after-edit exactly once more");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_between_kills_breaks_the_kill_chain() {
|
||||
let mut s = editor_visiting("k.rs", "one\ntwo\nthree\n");
|
||||
ctrl(&mut s, 'k'); // kills "one"; line now blank, cursor 0
|
||||
alt(&mut s, ';'); // no-op on the blank line, but the command ROTATES
|
||||
ctrl(&mut s, 'k'); // kills "\n" — must push fresh, not append
|
||||
let ring: Vec<String> = eval(&s, "return pmacs.killring.list()");
|
||||
assert_eq!(
|
||||
ring,
|
||||
vec!["\n", "one"],
|
||||
"C-k, M-;, C-k yields two ring entries (chain broken)"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue