Skip to main content

lattice_grammar/
args.rs

1//! Per-command argument values.
2//!
3//! Each registered command declares an `args_schema: Vec<ArgSpec>` (DESIGN.md
4//! §5.11, §B.1) describing the kinds, names, prompts and defaults for its
5//! arguments. The dispatcher carries the concrete values through `Args`.
6//!
7//! Four Args shapes coexist:
8//!
9//! - `Args::None` -- universal "no args" form (most motions / operators).
10//! - `Args::Char(char)` / `Args::String(String)` -- single-arg shortcuts
11//!   for the common cases (vim's `f<x>` takes a char; `:set <opt>` takes a
12//!   string). Predates B.1 and stays for ergonomic registration.
13//! - `Args::List(Vec<ArgValue>)` -- multi-arg form, positional values
14//!   matching the command's `args_schema` in declaration order. This is
15//!   what palette-driven entry, plugin invocations, and the `:`-line
16//!   parser front-end produce for ex-commands with structured args
17//!   (`:s/pat/repl/flags`, `:g/pat/body`).
18//! - `Args::Bytes(Vec<u8>)` -- escape hatch for plugin-supplied richer
19//!   args: opaque bytes (msgpack by convention) that cross the WIT boundary
20//!   unchanged. Built-in commands never read it; host paths that need
21//!   positional values drop it.
22//!
23//! `ArgValue` is a small typed enum -- not a dynamic value bag -- so callers
24//! get static type checks at the boundary.
25
26use std::borrow::Cow;
27
28use serde::{Deserialize, Serialize};
29
30use crate::command::CommandInvocation;
31
32/// The concrete argument values carried by a [`CommandInvocation`]. See the
33/// [module docs](self) for when each shape is used.
34///
35/// # Examples
36///
37/// ```
38/// use lattice_grammar::{ArgKind, ArgValue, Args};
39///
40/// // `fx` -- a motion that takes one char.
41/// let f = Args::Char('x');
42/// assert!(!f.is_none());
43/// assert!(f.as_list().is_none());
44///
45/// // A structured ex-command: positional values in `args_schema` order.
46/// let s = Args::List(vec![
47///     ArgValue::Pattern("foo".into()),
48///     ArgValue::String("bar".into()),
49///     ArgValue::Bool(true),
50/// ]);
51/// let list = s.as_list().unwrap();
52/// assert_eq!(list[0].kind(), ArgKind::Pattern);
53/// assert_eq!(list[0].as_str(), Some("foo"));
54/// assert_eq!(list[2].as_bool(), Some(true));
55///
56/// assert!(Args::default().is_none());
57/// ```
58#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
59pub enum Args {
60    /// No arguments -- most motions and operators.
61    #[default]
62    None,
63    /// A single char: `f<c>`, `t<c>`, `r<c>`, `m<c>`, `'<c>`.
64    Char(char),
65    /// A single free-form string (`:set <opt>`, `:e <path>`).
66    String(String),
67    /// Opaque plugin-defined payload; see the [module docs](self).
68    Bytes(Vec<u8>),
69    /// Multi-arg form. Values appear in the order declared by the
70    /// command's `args_schema`.
71    List(Vec<ArgValue>),
72}
73
74impl Args {
75    /// `true` for [`Args::None`] only (an empty [`Args::List`] is not
76    /// `None`).
77    pub fn is_none(&self) -> bool {
78        matches!(self, Args::None)
79    }
80
81    /// Borrow the multi-arg list, if present. Returns `None` for any
82    /// other variant.
83    pub fn as_list(&self) -> Option<&[ArgValue]> {
84        match self {
85            Args::List(v) => Some(v),
86            _ => None,
87        }
88    }
89}
90
91/// One argument's typed value. The variants mirror [`ArgKind`] one-for-one.
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub enum ArgValue {
94    /// Free-form text.
95    String(String),
96    /// A single character.
97    Char(char),
98    /// A flag (e.g. a transient switch).
99    Bool(bool),
100    /// A signed integer.
101    Int(i64),
102    /// A pattern -- regex when v1 grows regex; literal substring today.
103    Pattern(String),
104    /// A keyboard chord in canonical notation (`<C-c>`, `<Esc>`, `gg`,
105    /// `<C-S-x>`). Stored as a string so the value layer doesn't need
106    /// to know about crossterm / KeyEvent; the UI captures raw key
107    /// events and renders them via `format_chord` before the value
108    /// reaches here. Used by `:describe-key` and (later) `:map`,
109    /// `:nnoremap`, etc.
110    Chord(String),
111    /// A nested invocation. Used by `:g/.../body` where the body is a
112    /// command in its own right; the parser front-end produces a
113    /// `CommandInvocation` and the host dispatches it per match. Boxed
114    /// because Args lives inside CommandInvocation.
115    Invocation(Box<CommandInvocation>),
116    /// Raw text whose parsing was deferred. v1 uses this for `:g`'s
117    /// body string (re-parsed per line by the host) until we promote
118    /// body to a parsed `Invocation`.
119    Raw(String),
120}
121
122impl ArgValue {
123    /// The [`ArgKind`] tag for this value ([`ArgValue::Invocation`] maps to
124    /// [`ArgKind::Body`]; every other variant to its namesake).
125    pub fn kind(&self) -> ArgKind {
126        match self {
127            ArgValue::String(_) => ArgKind::String,
128            ArgValue::Char(_) => ArgKind::Char,
129            ArgValue::Bool(_) => ArgKind::Bool,
130            ArgValue::Int(_) => ArgKind::Int,
131            ArgValue::Pattern(_) => ArgKind::Pattern,
132            ArgValue::Chord(_) => ArgKind::Chord,
133            ArgValue::Invocation(_) => ArgKind::Body,
134            ArgValue::Raw(_) => ArgKind::Raw,
135        }
136    }
137
138    /// Borrow the text of any string-shaped value (`String`, `Pattern`,
139    /// `Chord`, `Raw`); `None` for `Char`, `Bool`, `Int`, `Invocation`.
140    pub fn as_str(&self) -> Option<&str> {
141        match self {
142            ArgValue::String(s) | ArgValue::Pattern(s) | ArgValue::Chord(s) | ArgValue::Raw(s) => {
143                Some(s)
144            }
145            _ => None,
146        }
147    }
148
149    /// The flag of an [`ArgValue::Bool`]; `None` for every other variant
150    /// (no truthiness coercion).
151    pub fn as_bool(&self) -> Option<bool> {
152        match self {
153            ArgValue::Bool(b) => Some(*b),
154            _ => None,
155        }
156    }
157
158    /// Extract a parsed sub-invocation. Used by `:g`'s body slot to
159    /// pull the pre-parsed `CommandInvocation` out at apply time.
160    pub fn as_invocation(&self) -> Option<&CommandInvocation> {
161        match self {
162            ArgValue::Invocation(inv) => Some(inv.as_ref()),
163            _ => None,
164        }
165    }
166}
167
168/// Type tag for an argument. Mirrors [`ArgValue`] one-for-one and is
169/// what an `args_schema` entry declares for each positional argument.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171pub enum ArgKind {
172    /// Free-form text ([`ArgValue::String`]).
173    String,
174    /// A single character ([`ArgValue::Char`]).
175    Char,
176    /// A flag ([`ArgValue::Bool`]).
177    Bool,
178    /// A signed integer ([`ArgValue::Int`]).
179    Int,
180    /// A search pattern ([`ArgValue::Pattern`]).
181    Pattern,
182    /// A keyboard chord. UI surfaces switch the cmdline into
183    /// chord-capture mode while the cursor sits in this slot --
184    /// raw key events are translated to canonical chord notation
185    /// (`<C-c>`, `<Esc>`, ...) and inserted as one token.
186    Chord,
187    /// A parsed sub-invocation (a CommandInvocation in its own right).
188    Body,
189    /// Unparsed text (host re-parses or interprets later).
190    Raw,
191}
192
193/// What the runtime should fall back to when an arg is unsupplied at
194/// invocation time (DESIGN.md §B.1). For interactive entry, the fallback
195/// chain is: caller-supplied value -> `default` -> prompt the user.
196///
197/// What the host acts on today: `Required` on an ex-command's *first* arg
198/// arms the cmdline missing-arg prompt when the command is run bare, and
199/// `Literal` fills unset slots when a transient projects its state into
200/// [`Args`]. The `Use*` variants are declared and rendered by
201/// `:describe-command`, but nothing resolves them yet -- a command that
202/// declares one must still cope with the arg being absent.
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub enum ArgDefault {
205    /// No fallback -- the runtime prompts (or errors, in non-interactive
206    /// callers) when this arg is missing.
207    Required,
208    /// Optional. Missing means "absent"; the apply closure must handle
209    /// the absence gracefully.
210    None,
211    /// A literal default value.
212    Literal(ArgValue),
213    /// Use the current visual selection's text. Useful for `:s` invoked
214    /// over a visual range: the default pattern is "the selected text".
215    UseSelection,
216    /// Use the word under the cursor. Useful for `:grep`-style commands.
217    UseCursorWord,
218    /// Use the most recently entered value of this argument. Useful for
219    /// `:s` to default to the previous pattern + replacement.
220    UseLastResponse,
221}
222
223/// One argument's metadata. A command's `args_schema` is the ordered list
224/// of these. Drives:
225///
226/// 1. The `:` parser front-end's structured-args extraction (when the
227///    syntax permits; delimiter-syntax commands still own their own
228///    `parse_args`).
229/// 2. Keymap binding pre-supply: a binding may set some args ahead of
230///    time and prompt for the rest.
231/// 3. Command palette / interactive form: each missing arg becomes a
232///    prompt with the schema-supplied prompt text + completion.
233/// 4. `:describe-command` enumeration.
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235pub struct ArgSpec {
236    /// Identifier shown in `:describe-command` output and used as the
237    /// minibuffer prompt label.
238    ///
239    /// PL8.F: `Cow<'static, str>` — a builtin passes a zero-cost `Cow::Borrowed`
240    /// literal; a plugin-contributed schema (crossing WIT) passes `Cow::Owned`
241    /// that frees on `unregister_plugin`, replacing the old `Box::leak` intern.
242    pub name: Cow<'static, str>,
243    /// The value type this slot expects; drives the parser, the prompt's
244    /// capture mode ([`ArgKind::Chord`]) and `:describe-command`.
245    pub kind: ArgKind,
246    /// One-line documentation. Surfaced in palette tooltips and
247    /// `:describe-command`.
248    pub doc: Cow<'static, str>,
249    /// Prompt shown when the runtime needs to ask for this arg
250    /// interactively. Empty string means "use `name` as the prompt".
251    pub prompt: Cow<'static, str>,
252    /// Fallback when the caller does not supply this arg; see
253    /// [`ArgDefault`] for which variants the host currently acts on.
254    pub default: ArgDefault,
255    /// Name of the registered completion source (`gen:commands`,
256    /// `gen:files`, etc. -- see `lattice-completion`) that fires
257    /// when the user is typing this arg. `None` = no completion
258    /// (free-form text). Wire-form is the source name (not its
259    /// runtime id) so the schema is constructable as a literal.
260    pub completion: Option<Cow<'static, str>>,
261    /// YR.6: name of a registered **picker** source offered for this
262    /// argument (`magit-revision`, `magit-branch`, ...). `None` = no
263    /// picker.
264    ///
265    /// Deliberately a second field rather than a variant of
266    /// [`Self::completion`], because the two name entries in two
267    /// registries and that split is a decision, not an accident:
268    /// `completion` names a `CandidateGenerator` (engine shape —
269    /// prefix, buffer, command registry), `picker` names a
270    /// `PickerSourceGenerator` (surface shape — needs `PickerContext`).
271    /// `completion-pipeline-unification.md` slice 7d.1 chose to keep
272    /// both rather than bloat one context or type-erase it, so an
273    /// argument can legitimately have both: `<Tab>` completes inline,
274    /// the picker opens the richer surface for the same question.
275    pub picker: Option<Cow<'static, str>>,
276}
277
278impl ArgSpec {
279    /// Sugar for declaring a required arg with no fancy default: empty
280    /// prompt (falls back to `name`), no completion, no picker.
281    ///
282    /// # Examples
283    ///
284    /// ```
285    /// use lattice_grammar::{ArgDefault, ArgKind, ArgSpec};
286    ///
287    /// let spec = ArgSpec::required("path", ArgKind::String, "File to open.")
288    ///     .with_completion("gen:files");
289    /// assert_eq!(spec.default, ArgDefault::Required);
290    /// assert_eq!(spec.completion.as_deref(), Some("gen:files"));
291    /// assert!(spec.picker.is_none());
292    ///
293    /// let opt = ArgSpec::optional("count", ArgKind::Int, "Repeat count.");
294    /// assert_eq!(opt.default, ArgDefault::None);
295    /// ```
296    pub fn required(
297        name: impl Into<Cow<'static, str>>,
298        kind: ArgKind,
299        doc: impl Into<Cow<'static, str>>,
300    ) -> Self {
301        Self {
302            name: name.into(),
303            kind,
304            doc: doc.into(),
305            prompt: Cow::Borrowed(""),
306            default: ArgDefault::Required,
307            completion: None,
308            picker: None,
309        }
310    }
311
312    /// Sugar for declaring an optional arg ([`ArgDefault::None`]); otherwise
313    /// as [`Self::required`].
314    pub fn optional(
315        name: impl Into<Cow<'static, str>>,
316        kind: ArgKind,
317        doc: impl Into<Cow<'static, str>>,
318    ) -> Self {
319        Self {
320            name: name.into(),
321            kind,
322            doc: doc.into(),
323            prompt: Cow::Borrowed(""),
324            default: ArgDefault::None,
325            completion: None,
326            picker: None,
327        }
328    }
329
330    /// Builder helper: attach a completion source by registered name.
331    pub fn with_completion(mut self, source_name: impl Into<Cow<'static, str>>) -> Self {
332        self.completion = Some(source_name.into());
333        self
334    }
335
336    /// Builder helper: attach a **picker** source by registered name.
337    ///
338    /// Composable with [`Self::with_completion`] — an argument may offer
339    /// inline completion on `<Tab>` and a picker on `<C-x><C-o>`, and
340    /// several magit arguments do exactly that.
341    pub fn with_picker(mut self, source_name: impl Into<Cow<'static, str>>) -> Self {
342        self.picker = Some(source_name.into());
343        self
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    #![allow(clippy::unwrap_used, clippy::panic)]
350    use super::*;
351
352    #[test]
353    fn default_is_none() {
354        assert_eq!(Args::default(), Args::None);
355        assert!(Args::None.is_none());
356    }
357
358    #[test]
359    fn char_carries_character() {
360        let a = Args::Char('x');
361        assert!(!a.is_none());
362        match a {
363            Args::Char(c) => assert_eq!(c, 'x'),
364            _ => panic!("expected Char"),
365        }
366    }
367
368    #[test]
369    fn string_args_round_trip() {
370        let a = Args::String("hello".into());
371        let json = serde_json::to_string(&a).unwrap();
372        let back: Args = serde_json::from_str(&json).unwrap();
373        assert_eq!(back, a);
374    }
375
376    #[test]
377    fn list_args_carry_positional_values() {
378        let a = Args::List(vec![
379            ArgValue::Pattern("foo".into()),
380            ArgValue::String("bar".into()),
381            ArgValue::Bool(true),
382        ]);
383        let list = a.as_list().unwrap();
384        assert_eq!(list.len(), 3);
385        assert_eq!(list[0].kind(), ArgKind::Pattern);
386        assert_eq!(list[2].as_bool(), Some(true));
387    }
388
389    #[test]
390    fn arg_value_kind_matches_variant() {
391        assert_eq!(ArgValue::String("x".into()).kind(), ArgKind::String);
392        assert_eq!(ArgValue::Char('x').kind(), ArgKind::Char);
393        assert_eq!(ArgValue::Bool(false).kind(), ArgKind::Bool);
394        assert_eq!(ArgValue::Int(42).kind(), ArgKind::Int);
395        assert_eq!(ArgValue::Pattern("p".into()).kind(), ArgKind::Pattern);
396        assert_eq!(ArgValue::Raw("r".into()).kind(), ArgKind::Raw);
397    }
398
399    #[test]
400    fn arg_value_as_str_covers_string_variants() {
401        assert_eq!(ArgValue::String("a".into()).as_str(), Some("a"));
402        assert_eq!(ArgValue::Pattern("b".into()).as_str(), Some("b"));
403        assert_eq!(ArgValue::Raw("c".into()).as_str(), Some("c"));
404        assert_eq!(ArgValue::Bool(true).as_str(), None);
405    }
406
407    #[test]
408    fn arg_value_round_trips_through_json() {
409        let v = ArgValue::Pattern("hello".into());
410        let s = serde_json::to_string(&v).unwrap();
411        let back: ArgValue = serde_json::from_str(&s).unwrap();
412        assert_eq!(back, v);
413    }
414
415    #[test]
416    fn arg_spec_required_sugar() {
417        let s = ArgSpec::required("path", ArgKind::String, "file path");
418        assert_eq!(s.name, "path");
419        assert_eq!(s.kind, ArgKind::String);
420        assert!(matches!(s.default, ArgDefault::Required));
421    }
422
423    #[test]
424    fn arg_spec_optional_sugar() {
425        let s = ArgSpec::optional("flag", ArgKind::Bool, "doc");
426        assert!(matches!(s.default, ArgDefault::None));
427    }
428
429    #[test]
430    fn args_list_serde_round_trip() {
431        let a = Args::List(vec![
432            ArgValue::Pattern("p".into()),
433            ArgValue::String("r".into()),
434            ArgValue::Bool(true),
435        ]);
436        let s = serde_json::to_string(&a).unwrap();
437        let back: Args = serde_json::from_str(&s).unwrap();
438        assert_eq!(back, a);
439    }
440}