393 lines
14 KiB
Lua
393 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.
|
|
|
|
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")
|