feat(help): the discovery command family — P4 Stage 1
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
parent
513a7dfa58
commit
44bd2201e7
|
|
@ -1274,7 +1274,15 @@ function pmacs.editor._show_help(text)
|
||||||
show_help_text(text)
|
show_help_text(text)
|
||||||
end
|
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*.",
|
description = "Prompt for a command name and render its description in *help*.",
|
||||||
fn = function()
|
fn = function()
|
||||||
pmacs.minibuffer.read {
|
pmacs.minibuffer.read {
|
||||||
|
|
@ -1303,7 +1311,7 @@ cmd { name = "editor.describe-command",
|
||||||
lines[#lines + 1] = " " .. tostring(seq)
|
lines[#lines + 1] = " " .. tostring(seq)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
show_help_text(table.concat(lines, "\n"))
|
pmacs.editor._show_help(table.concat(lines, "\n"))
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
end }
|
end }
|
||||||
|
|
@ -1315,12 +1323,17 @@ cmd { name = "editor.describe-command",
|
||||||
-- way in, modeled on `editor.describe-command` directly above and sharing
|
-- way in, modeled on `editor.describe-command` directly above and sharing
|
||||||
-- its `*help*` buffer handling.
|
-- its `*help*` buffer handling.
|
||||||
--
|
--
|
||||||
-- The prompt takes free text: `pmacs.minibuffer.read`'s `source` is a
|
-- The prompt now completes. That comment used to say `source` is "a fixed
|
||||||
-- fixed vocabulary ("commands", "buffers") resolved in Rust, and adding a
|
-- vocabulary ("commands", "buffers") resolved in Rust" — it is not:
|
||||||
-- settings source means touching the minibuffer candidate machinery,
|
-- `parse_completion_source` also accepts a Lua **function**, which
|
||||||
-- which this arc deliberately stays out of. `pmacs.config.list()` is the
|
-- becomes `CompletionSource::Custom` and is called for candidates. So a
|
||||||
-- programmatic way to enumerate names meanwhile; a completion source (and
|
-- settings source needs no Rust at all (discovery Stage 1).
|
||||||
-- an M-x list-settings panel) are named deferrals in the framing.
|
--
|
||||||
|
-- **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)
|
local function describe_setting_lines(name, info)
|
||||||
-- Header block mirrors help.rs's `format_hook_text`: aligned label
|
-- Header block mirrors help.rs's `format_hook_text`: aligned label
|
||||||
|
|
@ -1354,12 +1367,28 @@ local function describe_setting_lines(name, info)
|
||||||
return lines
|
return lines
|
||||||
end
|
end
|
||||||
|
|
||||||
cmd { name = "editor.describe-setting",
|
cmd { name = "help.describe-setting",
|
||||||
description = "Prompt for a setting name and render its definition in *help*.",
|
description = "Prompt for a setting name and render its definition in *help*.",
|
||||||
fn = function()
|
fn = function()
|
||||||
pmacs.minibuffer.read {
|
pmacs.minibuffer.read {
|
||||||
prompt = "Describe setting: ",
|
prompt = "Describe setting: ",
|
||||||
history = "command",
|
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)
|
on_accept = function(name)
|
||||||
if name == nil or name == "" then return end
|
if name == nil or name == "" then return end
|
||||||
-- An undefined name raises NotFound rather than returning nil
|
-- 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)
|
pmacs.editor.set_status("describe-setting: no such setting: " .. name)
|
||||||
return
|
return
|
||||||
end
|
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,
|
||||||
}
|
}
|
||||||
end }
|
end }
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -69,34 +69,6 @@ end
|
||||||
-- M-x help
|
-- M-x help
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
--
|
--
|
||||||
-- The smallest version of §18's second item, included because the
|
-- The `help` command itself lives in `runtime/help.lua`, which owns the
|
||||||
-- welcome would otherwise point at nothing. It is the ROOT of the
|
-- whole discovery family and loads after this file so its index can read
|
||||||
-- eventual family: when the discovery arc adds `help.keys` and friends,
|
-- `pmacs.welcome.entries` above. This file keeps only the greeting.
|
||||||
-- `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,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -716,6 +716,16 @@ impl EditorState {
|
||||||
include_str!("../builtin/runtime/welcome.lua"),
|
include_str!("../builtin/runtime/welcome.lua"),
|
||||||
)
|
)
|
||||||
.expect("load welcome builtin chunk");
|
.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
|
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
|
||||||
// was loaded directly via `eval(include_str!(...))`; the
|
// was loaded directly via `eval(include_str!(...))`; the
|
||||||
// M7.11 deliverable migrates it to the package system so it
|
// M7.11 deliverable migrates it to the package system so it
|
||||||
|
|
|
||||||
|
|
@ -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<T: mlua::FromLuaMulti>(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::<Option<String>>(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::<String>(&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<String> = 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::<Option<String>>(&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<String> = 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::<String>(&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::<i64>(&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::<bool>(&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::<bool>(&s, "return _ran"),
|
||||||
|
"the command must run: predicates are stored and exposed but \
|
||||||
|
never evaluated (framing §2.4)"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue