-- 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")