pmacs/src/command.rs

466 lines
17 KiB
Rust

// command.rs --- Command registry: every editor action is a named, introspectable command.
//! Editor commands.
//!
//! Per spec §4.2 every editor action is a named, introspectable
//! [`Command`]. Commands carry name, description (R42 makes this
//! mandatory), source location (file + line where they were defined),
//! a Lua function body, and an optional availability predicate.
//!
//! # Storage and lookup
//!
//! [`CommandRegistry`] owns the live commands. Insertion is rejected on
//! duplicate names rather than silently overwriting --- silent overwrite
//! makes refactoring bugs invisible. Lookup is `O(1)`; iteration via
//! [`CommandRegistry::names`] preserves insertion order so the command
//! palette renders consistently across launches.
//!
//! # Threading
//!
//! Single-threaded. Lives behind `Rc<RefCell<...>>` next to the Lua
//! state and the buffer registry.
use std::collections::HashMap;
use mlua::Function;
use thiserror::Error;
// ---------------------------------------------------------------------------
// Source location
// ---------------------------------------------------------------------------
/// Where in the source a command was defined.
///
/// Captured at registration time via Lua's debug info. Surfaced
/// verbatim by `pmacs.describe.command`.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SourceLocation {
/// `short_src` from Lua's debug info: typically a file path or
/// `[C]` for native code or `=[string "..."]` for an inline chunk.
pub file: String,
/// Line where the call was made. `0` if unavailable.
pub line: i32,
}
impl SourceLocation {
/// Render as `file:line` (or just `file` if `line == 0`).
#[must_use]
pub fn render(&self) -> String {
if self.line > 0 {
format!("{}:{}", self.file, self.line)
} else {
self.file.clone()
}
}
}
// ---------------------------------------------------------------------------
// Command + errors
// ---------------------------------------------------------------------------
/// A registered editor command.
///
/// Cloning is cheap: `String`s clone trivially and `mlua::Function` is
/// reference-counted internally.
#[derive(Clone)]
pub struct Command {
/// Unique name (e.g. `buffer.save`).
pub name: String,
/// Human-readable description. Required and non-empty after trim
/// (R42), but otherwise **free-form, and legitimately multi-line**.
///
/// # Do not add a registration-time one-line guard
///
/// This doc used to read "one-line human-readable description",
/// which was an aspiration rather than the contract: MCP tool
/// registration renders a whole schema block in here — the tool's
/// text, a blank line, `Arguments:`, then one line per argument
/// (`tests/fixtures/pmacs-mcp-tools/init.lua:272`, a
/// `table.concat(lines, "\n")`) — and `m9_6_acceptance.rs:583-598`
/// asserts all four of those lines. Rejecting CR/LF in
/// [`CommandRegistry::define`] was tried, measured, and abandoned:
/// it fails 36 tests across `m9_6`/`m9_7`/`m9_8` and could only be
/// made green by deleting a shipped acceptance criterion.
///
/// The one-line constraint belongs to the **surfaces that have
/// it**, so a consumer rendering into a single row clips with
/// [`Self::description_first_line`] — the minibuffer band and the
/// completion dropdown both do. The full text stays intact for
/// `describe-command` and `help.list-commands`, which is what keeps
/// this a rendering decision rather than data loss.
pub description: String,
/// Where the command was defined.
pub source: SourceLocation,
/// The Lua function body. Invoked by [`crate::lua::LuaHost`] and by
/// keymap dispatch (T M2.4).
pub body: Function,
/// Optional availability predicate. Returns `true` when the command
/// applies in the current state. The command palette (T M2.7) uses
/// it to gray out unavailable entries.
pub predicate: Option<Function>,
}
impl Command {
/// [`Self::description`] clipped to its first line, for a consumer
/// rendering into a surface that has exactly one row.
///
/// The description is free-form and may carry a whole schema block
/// (see that field). Two surfaces cannot show one: the grid TUI
/// writes the selected candidate into a single-row suffix on the
/// minibuffer band, and the GPU dropdown derives its height, its
/// visible window and its selection-highlight offset from
/// `rows.len()` — **one logical row per candidate** — so a detail
/// that shapes into more physical lines than that misaligns every
/// row below it and the highlight with it.
///
/// Clipping here rather than refusing at registration follows the
/// precedent already in this tree: the MCP fixture's result
/// delivery keeps only the first line of a tool result because
/// *"a multi-line `set_status` would corrupt the row layout"*
/// (`tests/fixtures/pmacs-mcp-tools/init.lua:277-285`), leaving
/// width clipping to the frontend. Same hazard class, same
/// resolution.
///
/// **No ellipsis or truncation marker**, matching that precedent
/// and the minibuffer's own width rule, which rejects stub markers
/// for the same reason: the full text is one `describe-command`
/// away, and a marker in a candidate row reads as part of the
/// candidate.
#[must_use]
pub fn description_first_line(&self) -> &str {
first_line(&self.description)
}
}
/// The prefix of `text` before its first line break.
///
/// Breaks on CR **or** LF, not LF alone: a lone CR ends a line on
/// classic-Mac-era input and is the leading half of a CRLF, so an
/// LF-only clip would pass a bare `\r` straight through to a
/// single-row surface — and a CR-only clip would do the same for `\n`.
/// Splitting on the first of either handles all three forms with one
/// scan, since CRLF's `\r` comes first.
fn first_line(text: &str) -> &str {
match text.find(['\n', '\r']) {
Some(break_at) => &text[..break_at],
None => text,
}
}
/// Errors raised by the command registry.
#[derive(Debug, Error)]
pub enum CommandError {
/// `define` was called with no name or an empty string.
#[error("command name must be non-empty")]
EmptyName,
/// R42: `define` was called without a description, or with one that
/// is empty after trimming.
#[error("command \"{name}\" requires a non-empty description (R42)")]
MissingDescription {
/// The offending command name.
name: String,
},
/// `define` was called without a `fn` field.
#[error("command \"{name}\" requires an `fn` field")]
MissingFn {
/// The offending command name.
name: String,
},
/// A command with this name is already registered.
#[error("command \"{name}\" is already defined (refusing to overwrite)")]
DuplicateName {
/// The offending command name.
name: String,
},
/// Lookup or invoke targeted a name that has no registered command.
#[error("command \"{name}\" not found")]
NotFound {
/// The offending command name.
name: String,
},
/// R50: the spec table contained a key the registry doesn't know
/// about. Typo-detection.
#[error("unknown field `{field}` in command spec; supported: name, description, fn, predicate")]
UnknownField {
/// The offending key.
field: String,
},
}
// ---------------------------------------------------------------------------
// CommandRegistry
// ---------------------------------------------------------------------------
/// Registry of named commands.
///
/// Construction goes through [`Self::new`] / [`Self::default`]. Insert
/// via [`Self::define`], which validates the metadata and rejects
/// duplicates. Look up via [`Self::get`], iterate names via
/// [`Self::names`], remove via [`Self::remove`].
#[derive(Default)]
pub struct CommandRegistry {
by_name: HashMap<String, Command>,
/// Insertion order. Mirrors the buffer registry's design --- lets
/// `pmacs.command.list()` return a deterministic sequence.
order: Vec<String>,
}
impl CommandRegistry {
/// An empty registry.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Insert `cmd`. Returns [`CommandError::DuplicateName`] if a
/// command with the same name already exists. Validates metadata
/// per R42 / R50: the name must be non-empty and the description
/// must be non-empty after trim.
pub fn define(&mut self, cmd: Command) -> Result<(), CommandError> {
if cmd.name.is_empty() {
return Err(CommandError::EmptyName);
}
if cmd.description.trim().is_empty() {
return Err(CommandError::MissingDescription { name: cmd.name });
}
if self.by_name.contains_key(&cmd.name) {
return Err(CommandError::DuplicateName { name: cmd.name });
}
self.order.push(cmd.name.clone());
self.by_name.insert(cmd.name.clone(), cmd);
Ok(())
}
/// Look up by name. Returns `None` for unknown commands.
#[must_use]
pub fn get(&self, name: &str) -> Option<&Command> {
self.by_name.get(name)
}
/// True iff `name` is registered.
#[must_use]
pub fn contains(&self, name: &str) -> bool {
self.by_name.contains_key(name)
}
/// Remove a command by name. Returns the removed command, or
/// [`CommandError::NotFound`].
pub fn remove(&mut self, name: &str) -> Result<Command, CommandError> {
let cmd = self
.by_name
.remove(name)
.ok_or_else(|| CommandError::NotFound {
name: name.to_owned(),
})?;
self.order.retain(|n| n != name);
Ok(cmd)
}
/// Names in insertion order.
#[must_use]
pub fn names(&self) -> &[String] {
&self.order
}
/// Number of registered commands.
#[must_use]
pub fn len(&self) -> usize {
self.by_name.len()
}
/// True iff no commands are registered.
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_name.is_empty()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use mlua::Lua;
fn make_command(lua: &Lua, name: &str, description: &str) -> Command {
Command {
name: name.to_owned(),
description: description.to_owned(),
source: SourceLocation {
file: "test.lua".into(),
line: 1,
},
body: lua.create_function(|_, ()| Ok(())).unwrap(),
predicate: None,
}
}
#[test]
fn define_then_get_round_trips() {
let lua = Lua::new();
let mut r = CommandRegistry::new();
r.define(make_command(&lua, "buffer.save", "Save the buffer."))
.unwrap();
assert!(r.contains("buffer.save"));
assert_eq!(
r.get("buffer.save").unwrap().description,
"Save the buffer."
);
assert_eq!(r.len(), 1);
}
#[test]
fn missing_description_is_a_registration_error() {
let lua = Lua::new();
let mut r = CommandRegistry::new();
let mut c = make_command(&lua, "x", "");
c.description.clear();
match r.define(c) {
Err(CommandError::MissingDescription { name }) => assert_eq!(name, "x"),
other => panic!("expected MissingDescription, got {other:?}"),
}
}
#[test]
fn whitespace_only_description_is_rejected() {
let lua = Lua::new();
let mut r = CommandRegistry::new();
let c = make_command(&lua, "x", " \n\t ");
assert!(matches!(
r.define(c),
Err(CommandError::MissingDescription { .. })
));
}
#[test]
fn a_multi_line_description_registers_and_clips_to_its_first_line() {
// Registration accepts it — MCP tool registration renders a
// whole schema block into `description` and
// `m9_6_acceptance.rs:583-598` asserts four of its lines, so a
// one-line guard here would delete a shipped contract. The
// one-line constraint lives at the single-row surfaces, which
// read `description_first_line`.
//
// All three break forms: a clip that split on `\n` alone would
// pass a bare `\r` through, and one that split on `\r` alone
// would pass `\n` through.
let lua = Lua::new();
for (label, description) in [
(
"LF",
"Greet someone.\n\nArguments:\n name (string, required)",
),
(
"CR",
"Greet someone.\r\rArguments:\r name (string, required)",
),
(
"CRLF",
"Greet someone.\r\n\r\nArguments:\r\n name (string, required)",
),
] {
let mut r = CommandRegistry::new();
r.define(make_command(&lua, "mcp.greet", description))
.unwrap_or_else(|e| panic!("{label}: a schema block must still register: {e}"));
let cmd = r.get("mcp.greet").expect("registered");
assert_eq!(
cmd.description, description,
"{label}: the registry stores the description verbatim — the clip is a \
rendering decision, so `describe-command` must still see every line"
);
assert_eq!(
cmd.description_first_line(),
"Greet someone.",
"{label}: a single-row surface gets the first line only"
);
assert!(
!cmd.description_first_line().contains(['\n', '\r']),
"{label}: the clipped form must carry no break at all"
);
}
}
#[test]
fn a_single_line_description_is_byte_identical_after_the_clip() {
// The other half: the clip must not tighten past its purpose.
// Interior whitespace, punctuation and non-ASCII all survive,
// and there is no ellipsis or truncation marker.
let lua = Lua::new();
let mut r = CommandRegistry::new();
let description = "Write the buffer to its file — with a dash, and \ttabs.";
r.define(make_command(&lua, "buffer.save", description))
.expect("registers");
assert_eq!(
r.get("buffer.save").unwrap().description_first_line(),
description,
"a description with no break is returned unchanged"
);
}
#[test]
fn a_description_whose_first_line_is_empty_clips_to_empty() {
// The case the producer turns into `None` rather than
// `Some("")`: a leading break leaves nothing to render, and a
// `Some("")` detail would draw trailing padding after the label.
let lua = Lua::new();
let mut r = CommandRegistry::new();
r.define(make_command(&lua, "x", "\nArguments:\n a (string)"))
.expect("registers");
assert_eq!(r.get("x").unwrap().description_first_line(), "");
}
#[test]
fn empty_name_is_rejected() {
let lua = Lua::new();
let mut r = CommandRegistry::new();
let c = make_command(&lua, "", "valid desc");
assert!(matches!(r.define(c), Err(CommandError::EmptyName)));
}
#[test]
fn duplicate_name_is_rejected() {
let lua = Lua::new();
let mut r = CommandRegistry::new();
r.define(make_command(&lua, "x", "first")).unwrap();
let err = r.define(make_command(&lua, "x", "second")).unwrap_err();
assert!(matches!(err, CommandError::DuplicateName { name } if name == "x"));
}
#[test]
fn names_preserve_insertion_order() {
let lua = Lua::new();
let mut r = CommandRegistry::new();
r.define(make_command(&lua, "a", "a")).unwrap();
r.define(make_command(&lua, "b", "b")).unwrap();
r.define(make_command(&lua, "c", "c")).unwrap();
assert_eq!(r.names(), &["a".to_owned(), "b".into(), "c".into()]);
r.remove("b").unwrap();
assert_eq!(r.names(), &["a".to_owned(), "c".into()]);
}
#[test]
fn source_location_renders_file_and_line() {
let s = SourceLocation {
file: "@init.lua".into(),
line: 7,
};
assert_eq!(s.render(), "@init.lua:7");
}
#[test]
fn source_location_omits_zero_line() {
let s = SourceLocation {
file: "[C]".into(),
line: 0,
};
assert_eq!(s.render(), "[C]");
}
}