b8e1c138eb
improvise-z05h, three bugs stacked on one surface.
`CmdRegistry::resolve` rewrites `formula` to `add-formula` *before* the arity
check runs, so `formula Product Total = Widget + Gadget` was refused with
"add-formula requires 1-2 argument(s), got 6" — a command nobody typed, and
an arity where the user needed to be told to quote.
The name now lives in the one place that knows which of the two was written:
`require_args` no longer takes a `word` and `CmdRegistry::parse` prefixes
`"{typed}: "` onto any `Err`. That removes 41 name literals from `cmd/`,
which is the argument gotcha 15 already makes about `registry.rs`. The
`AddFormulaCmd` validator states the shape rather than the arity.
`interactive` deliberately does not prefix — `dispatch` in
`command/keymap.rs` already formats `"{name}: {e}"` with the bound name
(improvise-55l5, PR #126), and doing both would double it.
All three documented spellings of `:formula` taught the unquoted form and so
were ones the parser refuses. help.rs carried the quick-start line **twice**,
on two different pages, which is gotcha 48's "changing a fact stated twice is
a grep, not an edit".
`every_ex_command_the_help_pages_show_is_one_the_parser_accepts` closes the
surface rather than the instance: `HelpLine::Keys` rows are derived from the
keymap and cannot go stale, but `Literal` and `Accent` rows are prose with
nothing checking them, and that is the whole unguarded surface. Run against
bf83294 it fails naming the Welcome page and the exact line.
What it still cannot see, stated in `context/code/cli.md` §6:
`draw.rs::draw_welcome` builds its lines inside a ratatui render function, so
no walk reaches them; its line was corrected by hand.
Checked and not needed: none of the four asciinema casts in `docs/` shows the
`:formula` ex-command — `formulas.cast` uses the formula panel — so none
inherits this and none needs re-recording.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
339 lines
12 KiB
Rust
339 lines
12 KiB
Rust
use std::collections::HashMap;
|
|
use std::fmt::Debug;
|
|
|
|
use crate::command::key::KeyCode;
|
|
|
|
use crate::model::Model;
|
|
use crate::model::cell::{CellKey, CellValue};
|
|
use crate::ui::app::AppMode;
|
|
use crate::ui::effect::{Effect, Panel};
|
|
use crate::view::{Axis, GridLayout, View};
|
|
use crate::workbook::Workbook;
|
|
|
|
/// Read-only context available to commands for decision-making: a `&Model`, the
|
|
/// active `&View`, and the `&Workbook` for the rare command that enumerates views.
|
|
pub struct CmdContext<'a> {
|
|
pub model: &'a Model,
|
|
pub workbook: &'a Workbook,
|
|
pub view: &'a View,
|
|
pub layout: &'a GridLayout,
|
|
pub registry: &'a CmdRegistry,
|
|
pub mode: &'a AppMode,
|
|
pub selected: (usize, usize),
|
|
pub row_offset: usize,
|
|
pub col_offset: usize,
|
|
pub search_query: &'a str,
|
|
pub yanked: &'a Option<CellValue>,
|
|
pub dirty: bool,
|
|
pub search_mode: bool,
|
|
pub formula_panel_open: bool,
|
|
pub category_panel_open: bool,
|
|
pub view_panel_open: bool,
|
|
/// Panel cursors
|
|
pub formula_cursor: usize,
|
|
pub cat_panel_cursor: usize,
|
|
pub view_panel_cursor: usize,
|
|
/// Tile select cursor (which category is selected)
|
|
pub tile_cat_idx: usize,
|
|
/// Named text buffers
|
|
pub buffers: &'a HashMap<String, String>,
|
|
/// View navigation stacks (for drill back/forward)
|
|
pub view_back_stack: &'a [crate::ui::app::ViewFrame],
|
|
pub view_forward_stack: &'a [crate::ui::app::ViewFrame],
|
|
/// Whether the app currently has an active drill snapshot.
|
|
pub has_drill_state: bool,
|
|
/// Display value at the cursor — works uniformly for pivot and records mode.
|
|
pub display_value: String,
|
|
/// How many data rows/cols fit on screen (for viewport scrolling).
|
|
pub visible_rows: usize,
|
|
pub visible_cols: usize,
|
|
/// Expanded categories in the tree panel
|
|
pub expanded_cats: &'a std::collections::HashSet<String>,
|
|
/// The open import wizard, when one is running. Commands read its step
|
|
/// to decide what a key means (improvise-2wm).
|
|
pub wizard: Option<&'a crate::import::wizard::ImportWizard>,
|
|
/// The key that triggered this command
|
|
pub key_code: KeyCode,
|
|
}
|
|
|
|
impl<'a> CmdContext<'a> {
|
|
/// Return true when the current layout is a records-mode layout.
|
|
pub fn is_records_mode(&self) -> bool {
|
|
self.layout.is_records_mode()
|
|
}
|
|
|
|
pub fn cell_key(&self) -> Option<CellKey> {
|
|
self.layout.cell_key(self.selected.0, self.selected.1)
|
|
}
|
|
|
|
/// The formula that owns the cell under the cursor, if one does. In records
|
|
/// mode the synthetic key names no measure, so the record's key is resolved.
|
|
pub fn owning_formula(&self) -> Option<&'a crate::formula::Formula> {
|
|
let key = self.cell_key()?;
|
|
let real = self.layout.record_cell_key_of(&key).unwrap_or(key);
|
|
self.model.formula_for(&real)
|
|
}
|
|
|
|
/// Return synthetic record coordinates for the current cursor, if any.
|
|
pub fn synthetic_record_at_cursor(&self) -> Option<(usize, usize)> {
|
|
self.cell_key()
|
|
.as_ref()
|
|
.and_then(crate::view::synthetic_record_info)
|
|
}
|
|
|
|
/// Whether `panel` is open. The one `Panel` → flag mapping on this side of
|
|
/// the wall, mirroring `ViewState::panel_open` (improvise-y4xg).
|
|
pub fn panel_open(&self, panel: Panel) -> bool {
|
|
match panel {
|
|
Panel::Formula => self.formula_panel_open,
|
|
Panel::Category => self.category_panel_open,
|
|
Panel::View => self.view_panel_open,
|
|
}
|
|
}
|
|
|
|
pub fn row_count(&self) -> usize {
|
|
self.layout.row_count()
|
|
}
|
|
pub fn col_count(&self) -> usize {
|
|
self.layout.col_count()
|
|
}
|
|
pub fn none_cats(&self) -> &[String] {
|
|
&self.layout.none_cats
|
|
}
|
|
}
|
|
|
|
impl<'a> CmdContext<'a> {
|
|
/// Resolve the category panel tree entry at the current cursor.
|
|
pub fn cat_tree_entry(&self) -> Option<crate::ui::cat_tree::CatTreeEntry> {
|
|
self.cat_tree_entry_at(self.cat_panel_cursor)
|
|
}
|
|
|
|
/// The entry at an arbitrary row, which a delete needs to see what its
|
|
/// cursor would be left sitting on (improvise-uej8).
|
|
pub fn cat_tree_entry_at(&self, index: usize) -> Option<crate::ui::cat_tree::CatTreeEntry> {
|
|
let tree = crate::ui::cat_tree::build_cat_tree(self.model, self.view, self.expanded_cats);
|
|
tree.into_iter().nth(index)
|
|
}
|
|
|
|
/// The category name at the current tree cursor (whether on a
|
|
/// category header or an item).
|
|
pub fn cat_at_cursor(&self) -> Option<String> {
|
|
self.cat_tree_entry().map(|e| e.cat_name().to_string())
|
|
}
|
|
|
|
/// Total number of entries in the category tree.
|
|
pub fn cat_tree_len(&self) -> usize {
|
|
crate::ui::cat_tree::build_cat_tree(self.model, self.view, self.expanded_cats).len()
|
|
}
|
|
}
|
|
|
|
/// A command that reads state and produces effects.
|
|
pub trait Cmd: Debug + Send + Sync {
|
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
|
|
/// The canonical name of this command (matches its registry key).
|
|
/// Used by the parser tests and for introspection.
|
|
#[allow(dead_code)]
|
|
fn name(&self) -> &'static str;
|
|
}
|
|
|
|
/// Factory that constructs a Cmd from text arguments (headless/script).
|
|
pub type ParseFn = fn(&[String]) -> Result<Box<dyn Cmd>, String>;
|
|
|
|
/// Factory constructing a Cmd from the interactive context. It takes both the keymap
|
|
/// args and the context, so a command can combine text args with runtime state.
|
|
pub type InteractiveFn = fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>;
|
|
|
|
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
|
|
type BoxInteractiveFn = Box<dyn Fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>>;
|
|
|
|
/// A registered command entry with both text and interactive constructors.
|
|
struct CmdEntry {
|
|
name: &'static str,
|
|
parse: BoxParseFn,
|
|
interactive: BoxInteractiveFn,
|
|
}
|
|
|
|
/// Registry of commands constructible from text or from interactive context.
|
|
#[derive(Default)]
|
|
pub struct CmdRegistry {
|
|
entries: Vec<CmdEntry>,
|
|
aliases: Vec<(&'static str, &'static str)>,
|
|
}
|
|
|
|
impl CmdRegistry {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
entries: Vec::new(),
|
|
aliases: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Register a short name that resolves to a canonical command name.
|
|
pub fn alias(&mut self, short: &'static str, canonical: &'static str) {
|
|
self.aliases.push((short, canonical));
|
|
}
|
|
|
|
/// Resolve a command name through the alias table.
|
|
fn resolve<'a>(&'a self, name: &'a str) -> &'a str {
|
|
for (alias, canonical) in &self.aliases {
|
|
if *alias == name {
|
|
return canonical;
|
|
}
|
|
}
|
|
name
|
|
}
|
|
|
|
/// Register a command with both a text parser and an interactive constructor.
|
|
/// The name is derived from a prototype command instance.
|
|
pub fn register(&mut self, prototype: &dyn Cmd, parse: ParseFn, interactive: InteractiveFn) {
|
|
self.entries.push(CmdEntry {
|
|
name: prototype.name(),
|
|
parse: Box::new(parse),
|
|
interactive: Box::new(interactive),
|
|
});
|
|
}
|
|
|
|
/// Register a command that needs no interactive context: with args it delegates
|
|
/// to `parse`, and without them it errors.
|
|
pub fn register_pure(&mut self, prototype: &dyn Cmd, parse: ParseFn) {
|
|
self.entries.push(CmdEntry {
|
|
name: prototype.name(),
|
|
parse: Box::new(parse),
|
|
interactive: Box::new(move |args, _ctx| {
|
|
if args.is_empty() {
|
|
Err("this command requires arguments".into())
|
|
} else {
|
|
parse(args)
|
|
}
|
|
}),
|
|
});
|
|
}
|
|
|
|
/// Register a zero-arg command (same instance for parse and interactive).
|
|
/// The name is derived by calling `f()` once.
|
|
pub fn register_nullary(&mut self, f: fn() -> Box<dyn Cmd>) {
|
|
let name = f().name();
|
|
self.entries.push(CmdEntry {
|
|
name,
|
|
parse: Box::new(move |_| Ok(f())),
|
|
interactive: Box::new(move |_, _| Ok(f())),
|
|
});
|
|
}
|
|
|
|
/// Construct a command from text arguments (script/headless).
|
|
pub fn parse(&self, name: &str, args: &[String]) -> Result<Box<dyn Cmd>, String> {
|
|
// The registry is the only thing that knows the word the user typed, so
|
|
// it is the only thing that may name it — `context/code/command.md`.
|
|
let canonical = self.resolve(name);
|
|
for e in &self.entries {
|
|
if e.name == canonical {
|
|
return (e.parse)(args).map_err(|err| format!("{name}: {err}"));
|
|
}
|
|
}
|
|
Err(format!("Unknown command: {name}"))
|
|
}
|
|
|
|
/// Construct a command from interactive context, always with both args and
|
|
/// `ctx`, so commands can combine text arguments with runtime state.
|
|
pub fn interactive(
|
|
&self,
|
|
name: &str,
|
|
args: &[String],
|
|
ctx: &CmdContext,
|
|
) -> Result<Box<dyn Cmd>, String> {
|
|
let name = self.resolve(name);
|
|
for e in &self.entries {
|
|
if e.name == name {
|
|
return (e.interactive)(args, ctx);
|
|
}
|
|
}
|
|
Err(format!("Unknown command: {name}"))
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
|
|
self.entries.iter().map(|e| e.name)
|
|
}
|
|
}
|
|
|
|
pub(super) fn require_args(args: &[String], n: usize) -> Result<(), String> {
|
|
if args.len() < n {
|
|
Err(format!("requires {n} argument(s), got {}", args.len()))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Parse Cat/Item coordinate args into a CellKey.
|
|
pub(super) fn parse_cell_key_from_args(args: &[String]) -> crate::model::cell::CellKey {
|
|
let coords: Vec<(String, String)> = args
|
|
.iter()
|
|
.filter_map(|s| {
|
|
let (cat, item) = s.split_once('/')?;
|
|
Some((cat.to_string(), item.to_string()))
|
|
})
|
|
.collect();
|
|
crate::model::cell::CellKey::new(coords)
|
|
}
|
|
|
|
/// Read the current value of a named buffer from context.
|
|
pub(super) fn read_buffer(ctx: &CmdContext, name: &str) -> String {
|
|
if name == "search" {
|
|
ctx.search_query.to_string()
|
|
} else {
|
|
ctx.buffers.get(name).cloned().unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
/// The effect that writes a named buffer — the mirror of [`read_buffer`], and the
|
|
/// only place that knows "search" is not in the buffers map (improvise-gdl).
|
|
pub(super) fn write_buffer(name: &str, value: String) -> Box<dyn Effect> {
|
|
if name == "search" {
|
|
Box::new(crate::ui::effect::SetSearchQuery(value))
|
|
} else {
|
|
Box::new(crate::ui::effect::SetBuffer {
|
|
name: name.to_string(),
|
|
value,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub(super) fn parse_panel(s: &str) -> Result<Panel, String> {
|
|
match s {
|
|
"formula" => Ok(Panel::Formula),
|
|
"category" => Ok(Panel::Category),
|
|
"view" => Ok(Panel::View),
|
|
other => Err(format!("Unknown panel: {other}")),
|
|
}
|
|
}
|
|
|
|
pub(super) fn parse_axis(s: &str) -> Result<Axis, String> {
|
|
match s.to_lowercase().as_str() {
|
|
"row" => Ok(Axis::Row),
|
|
"column" | "col" => Ok(Axis::Column),
|
|
"page" => Ok(Axis::Page),
|
|
"none" => Ok(Axis::None),
|
|
other => Err(format!("Unknown axis: {other}")),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_axis_recognizes_all_variants() {
|
|
assert!(parse_axis("row").is_ok());
|
|
assert!(parse_axis("column").is_ok());
|
|
assert!(parse_axis("col").is_ok());
|
|
assert!(parse_axis("page").is_ok());
|
|
assert!(parse_axis("none").is_ok());
|
|
assert!(parse_axis("ROW").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_axis_rejects_unknown() {
|
|
assert!(parse_axis("diagonal").is_err());
|
|
}
|
|
}
|