pmacs/builtin/runtime/help.lua

403 lines
14 KiB
Lua

-- 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.
-- `invoke`, NOT `invoke_interactive`. The forwarder must work however it
-- was itself reached, and `pmacs.command.invoke('editor.describe-setting')`
-- is a real caller (`tests/config_registry_acceptance.rs`) — CI caught
-- that, because the acceptance pin here only drove the M-x path.
--
-- Plain `invoke` is also the correct semantics, not merely the working
-- one: the interactive-command boundary is rotated once, by whatever
-- entry point the user actually used, for the name the user actually
-- typed. Rotating again on the inner call would record a second
-- boundary for a command the user never invoked.
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(new_name) end,
}
end
forward("editor.describe-command", "help.describe-command")
forward("editor.describe-setting", "help.describe-setting")