From 44bd2201e7867c990458fd1ec8d33e834e0750dd Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 31 Jul 2026 19:04:48 -0400 Subject: [PATCH] =?UTF-8?q?feat(help):=20the=20discovery=20command=20famil?= =?UTF-8?q?y=20=E2=80=94=20P4=20Stage=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `docs/discovery-stage1-command-family-framing.md` (approved at revision 6). `COHERENCE.md` §5 graded discoverability "substrate without surface": the registries already carried descriptions, source locations and reverse key lookup, and almost none of it was reachable. Eleven commands under one `help.*` prefix, so typing `help` at M-x surfaces the whole family. Nine are new; `editor.describe-command` and `editor.describe-setting` are renamed in, with the old names retained as forwarders so nothing documented breaks. No Rust. Every command renders data `pmacs.describe.*`, `pmacs.keymap.list()`, `pmacs.command.list()` and `pmacs.config.list()` already return, and `describe-setting`'s completion source is a Lua function via `CompletionSource::Custom`, which needed no binding work either — correcting a comment in `default.lua` that claimed `source` was a fixed Rust-side vocabulary. `apropos` matches by substring, not fuzzy: `fuzzy_score` is subsequence-based and descriptions are long sentences, so fuzzy would match nearly every command. Two disciplines the file keeps. Every command renders through the public `pmacs.editor._show_help`, which buys one owner for the shared `*help*` policy — reuse-by-name, wholesale replacement, `q`, and the foreign-buffer hazard. It does NOT buy a one-site migration to `src/help.rs`, which has no renderer for settings, lists or apropos; so rendering is a named per-subject function, and the future Rust work is enumerated per subject rather than discovered per call site. The seam-counting pin earned its place immediately: the two renamed commands were still calling the file-local `show_help_text`, so the funnel was fiction for exactly the two commands that predate it. They now call the public seam, with a comment saying why the local is not used from the same file. Moves `help` out of `welcome.lua` into the new `runtime/help.lua`, which owns the family and loads after it so the index can read `pmacs.welcome.entries`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- builtin/commands/default.lua | 49 +++- builtin/runtime/help.lua | 392 ++++++++++++++++++++++++++++ builtin/runtime/welcome.lua | 34 +-- src/editor.rs | 10 + tests/discovery_acceptance.rs | 463 ++++++++++++++++++++++++++++++++++ 5 files changed, 907 insertions(+), 41 deletions(-) create mode 100644 builtin/runtime/help.lua create mode 100644 tests/discovery_acceptance.rs diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 48d6384..bcf54df 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -1274,7 +1274,15 @@ function pmacs.editor._show_help(text) show_help_text(text) end -cmd { name = "editor.describe-command", +-- The two family commands below call `pmacs.editor._show_help`, NOT the +-- local `show_help_text`, even though they are in the same file and the +-- local is in scope. That is deliberate: discovery Stage 1's funnel +-- ("one owner for `*help*` writes") is only real if every command goes +-- through the PUBLIC seam — a command calling the local bypasses any +-- later change made at the seam, and bypassed the acceptance pin that +-- counts seam calls, which is how this was caught. + +cmd { name = "help.describe-command", description = "Prompt for a command name and render its description in *help*.", fn = function() pmacs.minibuffer.read { @@ -1303,7 +1311,7 @@ cmd { name = "editor.describe-command", lines[#lines + 1] = " " .. tostring(seq) end end - show_help_text(table.concat(lines, "\n")) + pmacs.editor._show_help(table.concat(lines, "\n")) end, } end } @@ -1315,12 +1323,17 @@ cmd { name = "editor.describe-command", -- way in, modeled on `editor.describe-command` directly above and sharing -- its `*help*` buffer handling. -- --- The prompt takes free text: `pmacs.minibuffer.read`'s `source` is a --- fixed vocabulary ("commands", "buffers") resolved in Rust, and adding a --- settings source means touching the minibuffer candidate machinery, --- which this arc deliberately stays out of. `pmacs.config.list()` is the --- programmatic way to enumerate names meanwhile; a completion source (and --- an M-x list-settings panel) are named deferrals in the framing. +-- The prompt now completes. That comment used to say `source` is "a fixed +-- vocabulary ("commands", "buffers") resolved in Rust" — it is not: +-- `parse_completion_source` also accepts a Lua **function**, which +-- becomes `CompletionSource::Custom` and is called for candidates. So a +-- settings source needs no Rust at all (discovery Stage 1). +-- +-- **Completion here is assistance, not validation.** +-- `resolve_accepted_value` returns the literal typed text whenever no +-- candidate is selected, so a non-matching typo still reaches +-- `on_accept` and the `no such setting` path below still earns its +-- keep. Refusing a non-candidate outright is Rust work and is deferred. local function describe_setting_lines(name, info) -- Header block mirrors help.rs's `format_hook_text`: aligned label @@ -1354,12 +1367,28 @@ local function describe_setting_lines(name, info) return lines end -cmd { name = "editor.describe-setting", +cmd { name = "help.describe-setting", description = "Prompt for a setting name and render its definition in *help*.", fn = function() pmacs.minibuffer.read { prompt = "Describe setting: ", history = "command", + -- Sorted for DETERMINISTIC POOL CONSTRUCTION, not display + -- order: `recompute_candidates` runs `filter_and_sort`, which + -- ranks by fuzzy score and tie-breaks lexically, so this order + -- never reaches the user. It matters because + -- `.take(CANDIDATE_LIMIT)` is applied to the filtered iterator + -- BEFORE that sort, so pool order decides which candidates + -- survive truncation; registration order would make that vary + -- with an unrelated config edit. + source = function() + local names = {} + for _, d in ipairs(pmacs.config.list()) do + names[#names + 1] = d.name + end + table.sort(names) + return names + end, on_accept = function(name) if name == nil or name == "" then return end -- An undefined name raises NotFound rather than returning nil @@ -1370,7 +1399,7 @@ cmd { name = "editor.describe-setting", pmacs.editor.set_status("describe-setting: no such setting: " .. name) return end - show_help_text(table.concat(describe_setting_lines(name, info), "\n")) + pmacs.editor._show_help(table.concat(describe_setting_lines(name, info), "\n")) end, } end } diff --git a/builtin/runtime/help.lua b/builtin/runtime/help.lua new file mode 100644 index 0000000..5ac5096 --- /dev/null +++ b/builtin/runtime/help.lua @@ -0,0 +1,392 @@ +-- help.lua --- the discovery command family (P4 Stage 1). +-- Framing: docs/discovery-stage1-command-family-framing.md. +-- +-- `COHERENCE.md` §5 grades discoverability "substrate without surface — +-- the sharpest instance of §1.1": the registries already carry +-- descriptions, source locations and reverse key lookup, and almost none +-- of it was reachable. This file is the surface. It adds NO Rust: every +-- command below renders data `pmacs.describe.*`, `pmacs.keymap.list()`, +-- `pmacs.command.list()` and `pmacs.config.list()` already return. +-- +-- ORDERING CONTRACT: loads after `commands/default.lua` (for +-- `pmacs.editor._show_help` and the two renamed commands it forwards to) +-- and after `runtime/welcome.lua` (whose `pmacs.welcome.entries` the +-- index reads). +-- +-- TWO DISCIPLINES THIS FILE KEEPS +-- +-- 1. **One owner for `*help*` writes.** Every command renders through +-- `pmacs.editor._show_help` and never touches a buffer itself. That +-- does NOT make a later migration to `src/help.rs` a one-site change +-- — that layer has renderers for command/key/buffer/mode/hook/view +-- and none for settings, lists or apropos, and `_show_help` takes +-- already-flattened text. What the funnel buys is the shared policy +-- in one place: reuse-by-name, wholesale replacement, the `q` +-- binding, and the foreign-`*help*` hazard (found-by-name is not +-- ownership — a user's own `*help*` is adopted and cleared; the +-- missing guarantee is ownership identity, which `listview` has as +-- `panels` and dired as its handle table, and this does not). +-- +-- 2. **Rendering is a named per-subject function**, and the command body +-- does nothing but call it and hand the result to `_show_help`. That +-- keeps the semantics addressable, so the future help-unification +-- stage is enumerated per subject — replace the four `src/help.rs` +-- already covers, write three new Rust renderers for settings, lists +-- and apropos — rather than discovered per call site. + +pmacs.help = pmacs.help or {} + +local function show(text) + pmacs.editor._show_help(text) +end + +-- Sorted command names. `pmacs.command.list()` returns registration +-- order, which is not meaningful to a reader. +local function sorted_command_names() + local names = {} + for _, n in ipairs(pmacs.command.list()) do names[#names + 1] = n end + table.sort(names) + return names +end + +local function description_of(name) + local ok, info = pcall(pmacs.describe.command, name) + if ok and type(info) == "table" and type(info.description) == "string" then + return info.description + end + return "(no description)" +end + +-- --------------------------------------------------------------------- +-- Per-subject renderers (discipline 2) +-- --------------------------------------------------------------------- + +function pmacs.help.render_key(seq, info) + if type(info) ~= "table" then + return string.format("Key: %s\n\n (unbound in this buffer)\n", seq) + end + local lines = { + "Key: " .. seq, + "", + " Command: " .. tostring(info.command), + " Scope: " .. tostring(info.scope), + } + if info.source then lines[#lines + 1] = " Source: " .. tostring(info.source) end + lines[#lines + 1] = "" + lines[#lines + 1] = description_of(info.command) + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_mode(info) + if type(info) ~= "table" then return "Mode: (none)\n" end + local lines = { "Mode: " .. tostring(info.name or "(none)"), "" } + for k, v in pairs(info) do + if k ~= "name" then + lines[#lines + 1] = string.format(" %-12s %s", k .. ":", tostring(v)) + end + end + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_buffer(info) + if type(info) ~= "table" then return "Buffer: (none)\n" end + local lines = { "Buffer: " .. tostring(info.name), "" } + for _, k in ipairs({ "path", "major_mode", "modified", "read_only", "length" }) do + if info[k] ~= nil then + lines[#lines + 1] = string.format(" %-12s %s", k .. ":", tostring(info[k])) + end + end + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_hook(name, info) + local lines = { "Hook: " .. name, "" } + if type(info) ~= "table" then + lines[#lines + 1] = " (no listeners)" + return table.concat(lines, "\n") .. "\n" + end + lines[#lines + 1] = string.format(" %-12s %s", "kind:", tostring(info.kind)) + local listeners = info.listeners + if type(listeners) == "table" then + lines[#lines + 1] = string.format(" %-12s %d", "listeners:", #listeners) + for _, l in ipairs(listeners) do + local src = (type(l) == "table" and l.source) or l + lines[#lines + 1] = " " .. tostring(src) + end + end + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_where_is(name, bindings) + local lines = { "Where is: " .. name, "" } + if type(bindings) ~= "table" or #bindings == 0 then + lines[#lines + 1] = " (not bound to any key)" + lines[#lines + 1] = "" + lines[#lines + 1] = " Run it with M-x " .. name + return table.concat(lines, "\n") .. "\n" + end + for _, b in ipairs(bindings) do + local seq = (type(b) == "table" and b.sequence) or tostring(b) + local scope = (type(b) == "table" and b.scope) and (" (" .. tostring(b.scope) .. ")") or "" + lines[#lines + 1] = " " .. tostring(seq) .. scope + end + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_command_list(names) + local lines = { string.format("Commands (%d)", #names), "" } + for _, n in ipairs(names) do + lines[#lines + 1] = string.format(" %-34s %s", n, description_of(n)) + end + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_keybinding_list(rows) + -- Grouped by scope so buffer-local bindings are not mixed in with the + -- global map; sorted within a group by sequence. + local by_scope = {} + local scopes = {} + for _, r in ipairs(rows) do + local scope = tostring(r.scope) + if not by_scope[scope] then + by_scope[scope] = {} + scopes[#scopes + 1] = scope + end + table.insert(by_scope[scope], r) + end + table.sort(scopes) + local lines = { string.format("Key bindings (%d)", #rows), "" } + for _, scope in ipairs(scopes) do + local group = by_scope[scope] + table.sort(group, function(a, b) return tostring(a.sequence) < tostring(b.sequence) end) + lines[#lines + 1] = scope .. ":" + for _, r in ipairs(group) do + lines[#lines + 1] = string.format(" %-18s %s", tostring(r.sequence), tostring(r.command)) + end + lines[#lines + 1] = "" + end + return table.concat(lines, "\n") +end + +function pmacs.help.render_settings_list(rows) + local lines = { string.format("Settings (%d)", #rows), "" } + for _, d in ipairs(rows) do + lines[#lines + 1] = string.format(" %-34s %s", tostring(d.name), + tostring(d.description or "(no description)")) + end + return table.concat(lines, "\n") .. "\n" +end + +function pmacs.help.render_apropos(needle, hits) + local lines = { string.format("Apropos %q (%d)", needle, #hits), "" } + if #hits == 0 then + lines[#lines + 1] = " (nothing matched)" + return table.concat(lines, "\n") .. "\n" + end + for _, h in ipairs(hits) do + lines[#lines + 1] = string.format(" %-34s %s", h.name, h.description) + end + return table.concat(lines, "\n") .. "\n" +end + +--- Commands whose name or description CONTAINS `needle`, case-insensitively. +--- +--- **Substring, deliberately, not fuzzy** (framing Q#D3). `fuzzy_score` +--- is subsequence-based and descriptions are long sentences, so a short +--- query's letters almost always appear in order — fuzzy here would match +--- nearly every command and destroy the precision that makes apropos +--- worth having. +function pmacs.help.apropos_hits(needle) + local lowered = tostring(needle):lower() + local hits = {} + if lowered == "" then return hits end + for _, name in ipairs(sorted_command_names()) do + local desc = description_of(name) + if name:lower():find(lowered, 1, true) or desc:lower():find(lowered, 1, true) then + hits[#hits + 1] = { name = name, description = desc } + end + end + return hits +end + +-- --------------------------------------------------------------------- +-- The index +-- --------------------------------------------------------------------- + +--- Every command in the family, in the order the index lists them. +--- Public so the acceptance suite can assert the index is complete as a +--- PROPERTY — adding a twelfth canonical command without indexing it +--- must fail, not silently pass. +pmacs.help.family = { + "help.describe-command", + "help.describe-setting", + "help.describe-key", + "help.describe-mode", + "help.describe-buffer", + "help.describe-hook", + "help.where-is", + "help.list-commands", + "help.list-keybindings", + "help.list-settings", + "help.apropos", +} + +local function index_text() + local lines = { "pmacs help", "" } + if type(pmacs.welcome) == "table" and type(pmacs.welcome.entries) == "table" then + lines[#lines + 1] = "Keys" + lines[#lines + 1] = "" + lines[#lines + 1] = string.format(" %-18s %s", "M-x", "run a command by name") + for _, e in ipairs(pmacs.welcome.entries) do + lines[#lines + 1] = string.format(" %-18s %s", e.keys, e.label) + end + lines[#lines + 1] = "" + end + lines[#lines + 1] = "Discovery commands" + lines[#lines + 1] = "" + for _, name in ipairs(pmacs.help.family) do + lines[#lines + 1] = string.format(" %-26s %s", name, description_of(name)) + end + lines[#lines + 1] = "" + lines[#lines + 1] = "The full keymap reference is docs/keybindings.md." + return table.concat(lines, "\n") .. "\n" +end + +pmacs.command.define { + name = "help", + description = "Index of the pmacs help and discovery commands.", + fn = function() show(index_text()) end, +} + +-- --------------------------------------------------------------------- +-- The family +-- --------------------------------------------------------------------- + +pmacs.command.define { + name = "help.describe-key", + description = "Describe what a key sequence is bound to in this buffer.", + fn = function() + pmacs.minibuffer.read { + prompt = "Describe key: ", + history = "command", + on_accept = function(seq) + if seq == nil or seq == "" then return end + local ok, info = pcall(pmacs.describe.key, seq) + show(pmacs.help.render_key(seq, ok and info or nil)) + end, + } + end, +} + +pmacs.command.define { + name = "help.describe-mode", + description = "Describe the active buffer's major mode.", + fn = function() + local buf = pmacs.window.buffer() + local ok, info = pcall(pmacs.describe.mode, buf) + show(pmacs.help.render_mode(ok and info or nil)) + end, +} + +pmacs.command.define { + name = "help.describe-buffer", + description = "Describe the active buffer.", + fn = function() + local buf = pmacs.window.buffer() + local ok, info = pcall(pmacs.describe.buffer, buf) + show(pmacs.help.render_buffer(ok and info or nil)) + end, +} + +pmacs.command.define { + name = "help.describe-hook", + description = "Describe a hook and list its listeners.", + fn = function() + pmacs.minibuffer.read { + prompt = "Describe hook: ", + history = "command", + on_accept = function(name) + if name == nil or name == "" then return end + local ok, info = pcall(pmacs.describe.hook, name) + show(pmacs.help.render_hook(name, ok and info or nil)) + end, + } + end, +} + +pmacs.command.define { + name = "help.where-is", + description = "Show which keys run a command.", + fn = function() + pmacs.minibuffer.read { + prompt = "Where is command: ", + source = "commands", + history = "command", + on_accept = function(name) + if name == nil or name == "" then return end + local ok, info = pcall(pmacs.describe.command, name) + if not ok or type(info) ~= "table" then + pmacs.editor.set_status("where-is: no such command: " .. name) + return + end + show(pmacs.help.render_where_is(name, info.key_bindings)) + end, + } + end, +} + +pmacs.command.define { + name = "help.list-commands", + description = "List every registered command with its description.", + fn = function() show(pmacs.help.render_command_list(sorted_command_names())) end, +} + +pmacs.command.define { + name = "help.list-keybindings", + description = "List every key binding, grouped by scope.", + fn = function() show(pmacs.help.render_keybinding_list(pmacs.keymap.list())) end, +} + +pmacs.command.define { + name = "help.list-settings", + description = "List every registered setting with its description.", + fn = function() show(pmacs.help.render_settings_list(pmacs.config.list())) end, +} + +pmacs.command.define { + name = "help.apropos", + description = "Search command names and descriptions by substring.", + fn = function() + pmacs.minibuffer.read { + prompt = "Apropos (substring): ", + history = "command", + on_accept = function(needle) + if needle == nil or needle == "" then return end + show(pmacs.help.render_apropos(needle, pmacs.help.apropos_hits(needle))) + end, + } + end, +} + +-- --------------------------------------------------------------------- +-- Forwarders (framing Q#D2) +-- --------------------------------------------------------------------- +-- +-- `help.*` is canonical, so typing `help` at M-x surfaces the whole +-- family. These two keep the documented names working for users whose +-- muscle memory and whose `docs/keybindings.md` predate the rename. +-- +-- Two names for one thing is the duplication §5 complains about; it is +-- the bounded price of not breaking documented commands, and it carries +-- a deprecation path a later stage can take. + +local function forward(old_name, new_name) + pmacs.command.define { + name = old_name, + description = string.format("Deprecated alias for `%s`.", new_name), + fn = function() pmacs.command.invoke_interactive(new_name) end, + } +end + +forward("editor.describe-command", "help.describe-command") +forward("editor.describe-setting", "help.describe-setting") diff --git a/builtin/runtime/welcome.lua b/builtin/runtime/welcome.lua index fa55b3c..e2468cf 100644 --- a/builtin/runtime/welcome.lua +++ b/builtin/runtime/welcome.lua @@ -69,34 +69,6 @@ end -- M-x help -- --------------------------------------------------------------------- -- --- The smallest version of §18's second item, included because the --- welcome would otherwise point at nothing. It is the ROOT of the --- eventual family: when the discovery arc adds `help.keys` and friends, --- `help` stays the index they are reached from, so no rename is owed. --- --- Renders through `editor.describe-command`'s existing `*help*` --- mechanism rather than growing a second help surface. -local function help_text() - local lines = { - "pmacs help", - "", - " M-x run a command by name", - } - for _, e in ipairs(pmacs.welcome.entries) do - lines[#lines + 1] = string.format(" %-18s %s", e.keys, e.label) - end - lines[#lines + 1] = "" - lines[#lines + 1] = " M-x editor.describe-command what a command does" - lines[#lines + 1] = " M-x editor.list-buffers every open buffer" - lines[#lines + 1] = "" - lines[#lines + 1] = "The full keymap reference is docs/keybindings.md." - return table.concat(lines, "\n") .. "\n" -end - -pmacs.command.define { - name = "help", - description = "Show the pmacs key and command cheat sheet.", - fn = function() - pmacs.editor._show_help(help_text()) - end, -} +-- The `help` command itself lives in `runtime/help.lua`, which owns the +-- whole discovery family and loads after this file so its index can read +-- `pmacs.welcome.entries` above. This file keeps only the greeting. diff --git a/src/editor.rs b/src/editor.rs index b089973..433ce80 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -716,6 +716,16 @@ impl EditorState { include_str!("../builtin/runtime/welcome.lua"), ) .expect("load welcome builtin chunk"); + // Discovery Stage 1: the help/describe/list family. After + // `welcome.lua` so its index can read `pmacs.welcome.entries`, + // and after `commands/default.lua` (run by `attach_editor`) for + // `pmacs.editor._show_help` and the two commands it forwards to. + lua_host + .eval( + Some("@pmacs/builtin/runtime/help.lua"), + include_str!("../builtin/runtime/help.lua"), + ) + .expect("load help 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 diff --git a/tests/discovery_acceptance.rs b/tests/discovery_acceptance.rs new file mode 100644 index 0000000..7b4d021 --- /dev/null +++ b/tests/discovery_acceptance.rs @@ -0,0 +1,463 @@ +// tests/discovery_acceptance.rs --- P4 Stage 1, the discovery family. + +//! `COHERENCE.md` §5 graded discoverability "substrate without surface". +//! These pins cover the surface: +//! `docs/discovery-stage1-command-family-framing.md` §4. +//! +//! **Every command is driven through the real M-x path**, stated once in +//! `run_from_palette` below. `pmacs.command.invoke_interactive` is *not* +//! M-x — it rotates the interactive-command boundary and calls the body; +//! it opens no palette. Journey Stage 1b-2 established this and Stage +//! 1b-3 re-established it, so it is encoded in a helper here rather than +//! left to each pin to remember. +//! +//! Six of the eleven canonical commands open a **second** prompt. A pin +//! that stops after the first RET has tested the palette, not the +//! command. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_owned()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_owned()).eval().unwrap() +} + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +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 minibuffer_active(s: &EditorState) -> bool { + eval(s, "return pmacs.minibuffer.is_active()") +} + +fn named_text(s: &EditorState, name: &str) -> String { + eval( + s, + &format!( + r#" + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == {name:?} then + return id:slice(0, id:len()) + end + end + return "" + "# + ), + ) +} + +fn help_text(s: &EditorState) -> String { + named_text(s, "*help*") +} + +/// Drive the **real** M-x path: dispatch `M-x`, type the command name, +/// assert the palette selected exactly that command *before* RET — the +/// only moment it is observable, since `accept()` does `session.take()` +/// and a selected candidate shadows typed text — then accept. +/// +/// `second` is the argument for the six commands that open another +/// prompt; `None` for the five that do not. +fn run_from_palette(s: &mut EditorState, command: &str, second: Option<&str>) { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('x'), KeyModifiers::ALT), + ); + assert!(minibuffer_active(s), "M-x must open the palette"); + type_str(s, command); + assert_eq!( + eval::>(s, "return pmacs.minibuffer.selected()").as_deref(), + Some(command), + "the palette must have {command} selected; a different candidate \ + would run a different command" + ); + press(s, KeyCode::Enter); + + if let Some(arg) = second { + assert!( + minibuffer_active(s), + "{command} takes an argument and must open a second prompt" + ); + exec(s, &format!("pmacs.minibuffer.set_contents({arg:?})")); + press(s, KeyCode::Enter); + } else { + assert!( + !minibuffer_active(s), + "{command} takes no argument; a second prompt means the census is wrong" + ); + } +} + +fn editor() -> EditorState { + EditorState::new() +} + +/// The eleven canonical commands and the argument each needs, if any. +/// **Six take one** — `describe-command` is easy to forget, because it +/// joined the family by rename rather than by being new. +fn family() -> Vec<(&'static str, Option<&'static str>)> { + vec![ + ("help.describe-command", Some("help.list-commands")), + ("help.describe-setting", None), // supplied per-test: needs a real setting + ("help.describe-key", Some("C-x C-f")), + ("help.describe-mode", None), + ("help.describe-buffer", None), + ("help.describe-hook", Some("buffer.after-load")), + ("help.where-is", Some("help.list-commands")), + ("help.list-commands", None), + ("help.list-keybindings", None), + ("help.list-settings", None), + ("help.apropos", Some("compile")), + ] +} + +// --------------------------------------------------------------------------- +// The family runs +// --------------------------------------------------------------------------- + +/// **N (acceptance 1)** — every canonical command runs from M-x and +/// renders content, including the second prompt where it takes one. +#[test] +fn d1_every_command_runs_from_the_palette_and_renders() { + for (name, arg) in family() { + let mut s = editor(); + // `describe-setting` needs a setting that exists; take the first. + let arg = if name == "help.describe-setting" { + Some(eval::(&s, "return pmacs.config.list()[1].name")) + } else { + arg.map(str::to_owned) + }; + run_from_palette(&mut s, name, arg.as_deref()); + let text = help_text(&s); + assert!( + !text.is_empty(), + "{name} must render content into *help*; got empty" + ); + } +} + +/// **N (acceptance 2)** — `where-is` agrees with the keymap. +/// +/// Falsified by rendering a static string. +#[test] +fn d2_where_is_reports_the_real_binding() { + let mut s = editor(); + exec( + &s, + "pmacs.command.define { name = 'test.whereis-probe', + description = 'probe', fn = function() end } + pmacs.keymap.bind { scope = 'global', sequence = 'C-c Q', + command = 'test.whereis-probe' }", + ); + run_from_palette(&mut s, "help.where-is", Some("test.whereis-probe")); + let text = help_text(&s); + assert!( + text.contains("C-c Q"), + "where-is must report the chord actually bound; got:\n{text}" + ); +} + +/// **N (acceptance 3)** — `list-keybindings` covers every binding +/// `keymap.list()` reports. A property over the data, not a fixed list. +#[test] +fn d3_list_keybindings_covers_every_binding() { + let mut s = editor(); + let sequences: Vec = eval( + &s, + "local out = {} + for _, r in ipairs(pmacs.keymap.list()) do out[#out+1] = r.sequence end + return out", + ); + assert!( + !sequences.is_empty(), + "precondition: the keymap must be non-empty or this loop is vacuous" + ); + run_from_palette(&mut s, "help.list-keybindings", None); + let text = help_text(&s); + for seq in sequences { + assert!( + text.contains(&seq), + "list-keybindings omits {seq:?}; got:\n{text}" + ); + } +} + +// --------------------------------------------------------------------------- +// apropos — substring, not fuzzy +// --------------------------------------------------------------------------- + +/// **N (acceptance 4)** — apropos matches descriptions, not only names, +/// **and does so by substring**. +/// +/// The negative half is the one that pins Q#D3. A bare "a subsequence +/// finds nothing" assertion would pass as an ordinary no-match; this +/// registers a fixture whose description contains `qzjx` **only** as the +/// non-contiguous sequence `q z j x`, and first proves no registered +/// command contains `qzjx` as a substring. A fuzzy implementation finds +/// the fixture, so the pin fails under fuzzy rather than passing. +#[test] +fn d4_apropos_matches_descriptions_by_substring_not_subsequence() { + let mut s = editor(); + exec( + &s, + "pmacs.command.define { name = 'test.apropos-description-probe', + description = 'zzyzx marker for the description-search pin', + fn = function() end } + pmacs.command.define { name = 'test.apropos-subsequence-fixture', + description = 'q z j x letters spaced apart on purpose', + fn = function() end }", + ); + + // Positive: a word in exactly one DESCRIPTION and no NAME. + let name_hits: i64 = eval( + &s, + "local n = 0 + for _, c in ipairs(pmacs.command.list()) do + if c:lower():find('zzyzx', 1, true) then n = n + 1 end + end + return n", + ); + assert_eq!(name_hits, 0, "precondition: 'zzyzx' is in no command NAME"); + run_from_palette(&mut s, "help.apropos", Some("zzyzx")); + assert!( + help_text(&s).contains("test.apropos-description-probe"), + "apropos must search descriptions, not only names; got:\n{}", + help_text(&s) + ); + + // Negative, discriminating: `qzjx` is a subsequence of the fixture's + // description but a substring of nothing. + let substring_hits: i64 = eval( + &s, + "local n = 0 + for _, c in ipairs(pmacs.command.list()) do + local d = pmacs.describe.command(c) + local desc = (d and d.description) or '' + if c:lower():find('qzjx', 1, true) or desc:lower():find('qzjx', 1, true) then + n = n + 1 + end + end + return n", + ); + assert_eq!( + substring_hits, 0, + "precondition: 'qzjx' must be a substring of nothing, or the \ + negative below proves nothing" + ); + + let mut s2 = editor(); + exec( + &s2, + "pmacs.command.define { name = 'test.apropos-subsequence-fixture', + description = 'q z j x letters spaced apart on purpose', + fn = function() end }", + ); + run_from_palette(&mut s2, "help.apropos", Some("qzjx")); + assert!( + !help_text(&s2).contains("test.apropos-subsequence-fixture"), + "substring matching must NOT find a subsequence — a fuzzy \ + implementation finds the fixture here; got:\n{}", + help_text(&s2) + ); +} + +// --------------------------------------------------------------------------- +// describe-setting completion +// --------------------------------------------------------------------------- + +/// **N (acceptance 5)** — completion assists, and a non-matching typo +/// still reaches the existing error path. +/// +/// Both halves, because §3.2 has two outcomes and rev 1 asserted only a +/// third that does not exist ("a typo cannot reach `on_accept`"). +#[test] +fn d5_describe_setting_completes_and_a_typo_still_errors() { + let mut s = editor(); + let first: String = eval(&s, "return pmacs.config.list()[1].name"); + + // (a) typing a real setting's full name selects it. + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('x'), KeyModifiers::ALT), + ); + type_str(&mut s, "help.describe-setting"); + press(&mut s, KeyCode::Enter); + assert!(minibuffer_active(&s), "the setting prompt must open"); + exec(&s, &format!("pmacs.minibuffer.set_contents({first:?})")); + assert_eq!( + eval::>(&s, "return pmacs.minibuffer.selected()").as_deref(), + Some(first.as_str()), + "a real setting name must be the selected candidate" + ); + press(&mut s, KeyCode::Enter); + assert!( + help_text(&s).contains(&first), + "accepting a completed setting describes it" + ); + + // (b) a name matching nothing still reaches `on_accept` and errors — + // completion is assistance, not validation. + let mut s2 = editor(); + run_from_palette( + &mut s2, + "help.describe-setting", + Some("qqzz-no-such-setting"), + ); + assert!( + s2.core.borrow().status.contains("no such setting"), + "a non-matching typo reaches the existing error path; status: {:?}", + s2.core.borrow().status + ); +} + +// --------------------------------------------------------------------------- +// The index +// --------------------------------------------------------------------------- + +/// **N (acceptance 6)** — `M-x help` lists the family, as a property. +/// +/// Targeted mutation: adding a twelfth canonical command without +/// indexing it. +#[test] +fn d6_the_help_index_lists_every_family_command() { + let mut s = editor(); + let family: Vec = eval(&s, "return pmacs.help.family"); + assert_eq!( + family.len(), + 11, + "the canonical family is eleven commands; update the index and \ + this pin together" + ); + run_from_palette(&mut s, "help", None); + let text = help_text(&s); + for name in family { + assert!(text.contains(&name), "the index omits {name}; got:\n{text}"); + } +} + +// --------------------------------------------------------------------------- +// Preservation +// --------------------------------------------------------------------------- + +/// **P (acceptance 7)** — every command's `*help*` write goes through +/// `_show_help`. +/// +/// Pins §3.4's actual claim: one owner for `*help*` writes. Not a +/// one-site migration claim — `src/help.rs` has no renderer for +/// settings, lists or apropos. +#[test] +fn d7_preservation_every_render_goes_through_the_one_seam() { + let mut s = editor(); + exec( + &s, + "_seam_calls = 0 + local real = pmacs.editor._show_help + pmacs.editor._show_help = function(text) + _seam_calls = _seam_calls + 1 + return real(text) + end", + ); + for (name, arg) in family() { + let arg = if name == "help.describe-setting" { + Some(eval::(&s, "return pmacs.config.list()[1].name")) + } else { + arg.map(str::to_owned) + }; + run_from_palette(&mut s, name, arg.as_deref()); + } + assert_eq!( + eval::(&s, "return _seam_calls"), + 11, + "all eleven commands must render through _show_help; a command \ + writing its own buffer would not be counted" + ); +} + +/// **P (acceptance 8)** — the old names still work, as forwarders. +/// +/// Targeted mutation: dropping the forwarders after the rename, which is +/// the failure a user with muscle memory hits first. +#[test] +fn d8_preservation_the_old_names_forward() { + let mut s = editor(); + run_from_palette( + &mut s, + "editor.describe-command", + Some("help.list-commands"), + ); + let forwarded = help_text(&s); + assert!( + forwarded.contains("help.list-commands"), + "editor.describe-command must still describe the command it is \ + given; got:\n{forwarded}" + ); + + let mut s2 = editor(); + run_from_palette(&mut s2, "help.describe-command", Some("help.list-commands")); + assert_eq!( + forwarded, + help_text(&s2), + "the forwarder must render the same subject as its target" + ); +} + +/// **P (acceptance 8, cont.)** — the untouched list commands still work. +#[test] +fn d8b_preservation_list_buffers_and_workers_are_untouched() { + let s = editor(); + for name in ["editor.list-buffers", "editor.list-workers"] { + assert!( + eval::(&s, &format!("return pmacs.command.exists({name:?})")), + "{name} must still be registered" + ); + } +} + +/// **P (acceptance 9)** — no command's predicate is evaluated. +/// +/// Driven through the real palette, not `invoke_interactive` directly: +/// otherwise it would pass even if M-x itself grew predicate filtering. +/// A stage that starts evaluating predicates must change this pin +/// knowingly. +#[test] +fn d9_preservation_a_raising_predicate_does_not_block_a_command() { + let mut s = editor(); + exec( + &s, + "_ran = false + pmacs.command.define { + name = 'test.predicate-probe', + description = 'probe whose predicate raises', + predicate = function() error('predicate evaluated') end, + fn = function() _ran = true end, + }", + ); + run_from_palette(&mut s, "test.predicate-probe", None); + assert!( + eval::(&s, "return _ran"), + "the command must run: predicates are stored and exposed but \ + never evaluated (framing §2.4)" + ); +}