ashlar-command 0.1.0

Commands: registry, typed params, one execute chokepoint, discovery
Documentation

ashlar-command

Everything is a command. An app declares its commands once in a Registry; menus, shortcuts, the command palette, the CLI, the control port and MCP all run them through one chokepoint, Registry::execute, and discover them through Dispatch::commands. Headless, builds for wasm32.

Declaring commands

use ashlar_command::{always, cmd, query, CommandError, CommandSpec, Host, Journal, Param, Registry};

fn build() -> Vec<CommandSpec<Doc>> {
    vec![
        // legacy free-text params doc: accepted, unknown keys refused
        cmd!("file.new", "New…", ["File"], Some("Mod+N"), r#"{"name":str}"#, always, |d, p| { … }),
        // typed params: validation + JSON Schema
        cmd!("image.resize", "Resize…", ["Image"], None,
             [Param::int_in("width", 1, 16384).required(), Param::one_of("fit", &["contain", "cover"])],
             has_image, |d, p| { … }),
        query!("image.info", "Info", "{}", |d, _| { … }),
        // or without macros (const-friendly)
        CommandSpec::new("view.fit", "Fit", fit).menu(&["View"]).shortcut("Mod+0").query(),
    ]
}
static REGISTRY: LazyLock<Registry<Doc>> = LazyLock::new(|| Registry::new(build()));

cmd!/query! take the same arguments as existing cmd!/query! command tables, so those port by search and replace. A command body returns the host's own error type (Host::Error, anything Into<CommandError>; leave the id empty and the chokepoint fills it in), so bodies written against an app's Result alias port unchanged.

The chokepoint

Registry::execute(&self, state: &mut S, id, params) runs, in order:

  1. lookup (hash index; Unknown)
  2. nesting limit (MAX_DEPTH = 64)
  3. Host::before: may rewrite params (resolve names to ids, settle an ongoing gesture)
  4. parameter validation (BadParams, every problem in one message): the registry's policy (Registry::with_validation(Validation::Strict | TypedOnly | Off), default Strict), then the Host::validate hook (default: nothing). With Off, a disabled command reports Disabled whatever its params, and the host validates where it wants (the hook, or Registry::validate)
  5. Host::enabled_for (default: the spec's predicate; override to check targets named in params)
  6. the command under catch_unwind (a panic becomes Panicked, the app keeps running)
  7. Host::after (every run, ok or not, at every depth: sync persistence, log errors at depth 0…)
  8. journal: mutating commands that succeeded, params passed through Host::scrub first

A call refused in steps 1–5 goes to Host::rejected instead of after.

execute converts a body's error into CommandError. A host whose callers match on its own error type uses Registry::execute_host (needs Host::Error: Clone): Err(ExecError::Host(e)) is the body's error unchanged, Err(ExecError::Command(e)) the chokepoint's own refusal or a panic; ExecError::into_command(id) gives what execute would have returned.

Where the execution state lives. The registry is immutable and usually a static. The mutable part (current nesting depth and the journal) is a Journal the host owns and hands out through Host::journal(). One registry therefore serves any number of documents, and commands can run other commands (REGISTRY.execute(state, …) from inside a body) with the depth tracked per state. Every hook gets depth (0 = called from outside), so a host can log or sync once per top-level call.

The journal is a capped ring (10,000 entries, the oldest 1,000 dropped at once). By default only top-level calls are recorded; Journal::new().record_nested(true) records nested calls too. len, entries, since, truncate, retain and pop let the host keep it in step with its undo history. The chokepoint manages depth() itself; a host calls Journal::set_depth only to run a command from one of its hooks as part of the current call (save the depth, set it one higher, execute, restore).

Parameters

  • Params::Static(&[Param]) / Params::Typed(Vec<ParamSpec>): unknown keys, missing required keys, types, integer/float ranges and enum values are checked; params_schema gives a JSON Schema (CommandInfo.params_schema, cached per registry).
  • Params::Text("{…}"): legacy docs ({"name":str,"fps":f64=23.976,"mix":"A|B"?}, {layers: [id|name], add?}). Keys the doc does not mention are refused; the schema lists the documented keys with types where the doc states them plainly (str, u32, "A|B", […]).
  • Registry::with_common_params(&["sequence"]): keys every command accepts; with_param_aliases(&[("layers", "layer")]): alternative spellings for legacy docs.

Discovery

registry.bind(&mut state) is a Bound implementing Dispatch: commands() returns CommandInfo with enabled + disabled_reason, shortcuts in portable form and schemas; execute() goes through the chokepoint.

Shortcuts (shortcut, keymap)

  • Portable syntax Mod+Shift+S (Mod = ⌘ on macOS, Ctrl elsewhere; Ctrl = the literal Control key). Accepts Cmd+Z, CmdOrCtrl+Z, Option+X, glyphs ⌘⇧S, alternatives Mod+Y / Mod+Shift+Z.
  • Chord {mods, key} is UI-independent; the UI layer maps its key events onto it.
  • chord.display(Platform::Mac) → ⇧⌘S; Platform::Other → Ctrl+Shift+S.
  • Keymap: defaults from the registry plus user overrides (serde Overrides, read leniently), lookup(chord), keys_for(id), conflicts(), would_conflict(id, chord). Off macOS Mod and Ctrl are one key, so Mod+K and Ctrl+K conflict there and not on macOS.

Palette matching

fuzzy_match(query, candidate) -> Option<FuzzyMatch {score, positions}> (char positions for highlighting; word starts, camelCase and runs score higher) and fuzzy_rank. Bounded: queries over 64 chars never match, candidates are cut at 512 chars.