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}