Skip to main content

escriba_command/
ex.rs

1//! The ex-command NAME GRAMMAR — vim's abbreviations, resolved in one place.
2//!
3//! `:wq` is not a command name. It is a *spelling* of one, and vim has a
4//! whole grammar of them: every ex command has a full name, a minimum prefix
5//! that selects it, and an optional `!`. `:w`, `:wr`, `:writ`, `:write` are
6//! one command; `:q`, `:qu`, `:quit` are another; `:qa` and `:quita` are a
7//! third, and the reason `:qu` is not the third is that `quitall`'s minimum
8//! is five characters, not one.
9//!
10//! The runtime used to carry that knowledge as three arms —
11//! `"w" => "save", "q" => "quit", "u" => "undo"` — and every other spelling
12//! fell through to a registry lookup that could not possibly hold it. `:wq`
13//! reported "command not found" while both halves of it worked.
14//!
15//! So the grammar is a TABLE, not a chain of ifs, and the table is the only
16//! thing that knows how a typed word becomes a registered command. Two
17//! properties follow, and both are asserted rather than asserted-to:
18//!
19//! - **Every valid abbreviation resolves.** For each verb, every prefix from
20//!   its minimum to its full spelling selects it (`abbreviations_all_resolve`).
21//! - **No abbreviation is ambiguous.** No typed word is a valid abbreviation
22//!   of two verbs (`no_abbreviation_is_ambiguous`) — which is a property of
23//!   the *minimums*, and the reason vim gives `quitall` a five-character one.
24//!
25//! A word the grammar does not know is passed through UNCHANGED to the
26//! registry, so `:noh`, `:picker.files` and every plugin-registered command
27//! still dispatch. The grammar covers the vim vocabulary; it does not fence
28//! the command namespace.
29
30/// One ex command's spelling rule.
31///
32/// `plain` and `forced` are separate registered command names rather than one
33/// name plus a bang argument. A command body receives `&[String]` and nothing
34/// else, so a bang passed as an argument is a convention every body has to
35/// remember to read — and the one that forgets quits without asking. Two
36/// names cannot be misread, and both show up in `--commands` saying what they
37/// do. Verbs for which `!` changes nothing (writing has no force semantics
38/// here — escriba has no read-only flag to override) point both fields at the
39/// same command, which is the honest encoding of "the bang is accepted and
40/// means nothing".
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct ExVerb {
43    /// The full spelling, as `:help ex-cmd-index` writes it.
44    pub full: &'static str,
45    /// The fewest characters that select it. `full[..min]` is what vim
46    /// prints in square-bracket notation: `:q[uit]` is `("quit", 1)`.
47    pub min: usize,
48    /// The registered command the plain form dispatches to.
49    pub plain: &'static str,
50    /// The registered command the `!` form dispatches to.
51    pub forced: &'static str,
52}
53
54impl ExVerb {
55    /// Does `word` spell this verb? Prefix of the full name, at least `min`
56    /// characters long — vim's rule exactly.
57    #[must_use]
58    pub fn spelled_by(&self, word: &str) -> bool {
59        word.len() >= self.min && self.full.len() >= word.len() && self.full.starts_with(word)
60    }
61
62    /// The command name for this verb, banged or not.
63    #[must_use]
64    pub const fn command(&self, bang: bool) -> &'static str {
65        if bang { self.forced } else { self.plain }
66    }
67}
68
69/// The vim write/quit family plus the two verbs the old three-arm table
70/// carried, with vim's own minimum prefixes.
71///
72/// Deliberately NOT the whole ex vocabulary: a verb belongs here once escriba
73/// has something for it to dispatch to. An entry naming a command that is not
74/// registered would report "declared but not implemented yet" — announced,
75/// per [`crate::CommandError::Unhandled`], but still a promise the editor
76/// cannot keep.
77pub const VERBS: &[ExVerb] = &[
78    // ── write ────────────────────────────────────────────────────────
79    ExVerb {
80        full: "write",
81        min: 1,
82        plain: "save",
83        forced: "save",
84    },
85    ExVerb {
86        full: "wall",
87        min: 2,
88        plain: "buffer.write-all",
89        forced: "buffer.write-all",
90    },
91    // ── write-and-quit ───────────────────────────────────────────────
92    ExVerb {
93        full: "wq",
94        min: 2,
95        plain: "write-quit",
96        forced: "write-quit",
97    },
98    ExVerb {
99        full: "wqall",
100        min: 3,
101        plain: "write-quit-all",
102        forced: "write-quit-all",
103    },
104    // `:x` differs from `:wq` by ONE thing and it is the thing that matters
105    // to anything watching the file: it writes only when the buffer is
106    // modified, so `:x` on an untouched file leaves the mtime alone and a
107    // watching build does not rebuild.
108    ExVerb {
109        full: "xit",
110        min: 1,
111        plain: "exit-write",
112        forced: "exit-write",
113    },
114    ExVerb {
115        full: "xall",
116        min: 2,
117        plain: "write-quit-all",
118        forced: "write-quit-all",
119    },
120    ExVerb {
121        full: "exit",
122        min: 3,
123        plain: "exit-write",
124        forced: "exit-write",
125    },
126    // ── quit ─────────────────────────────────────────────────────────
127    ExVerb {
128        full: "quit",
129        min: 1,
130        plain: "quit",
131        forced: "quit!",
132    },
133    ExVerb {
134        full: "qall",
135        min: 2,
136        plain: "quit-all",
137        forced: "quit-all!",
138    },
139    ExVerb {
140        full: "quitall",
141        min: 5,
142        plain: "quit-all",
143        forced: "quit-all!",
144    },
145    // ── the two the old table carried ────────────────────────────────
146    ExVerb {
147        full: "undo",
148        min: 1,
149        plain: "undo",
150        forced: "undo",
151    },
152    ExVerb {
153        full: "redo",
154        min: 3,
155        plain: "redo",
156        forced: "redo",
157    },
158];
159
160/// The verb `word` spells, if any. `word` carries no `!` and no arguments.
161#[must_use]
162pub fn resolve(word: &str) -> Option<&'static ExVerb> {
163    VERBS.iter().find(|v| v.spelled_by(word))
164}
165
166/// A parsed ex line: which command to run, and with what.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct Invocation {
169    /// The registered command name to dispatch.
170    pub command: String,
171    /// Everything after the command word.
172    pub args: Vec<String>,
173}
174
175/// Parse a command line into the command to dispatch and its arguments.
176///
177/// `None` for an empty line — `:` then `<CR>` does nothing, as in vim, rather
178/// than dispatching the empty name and reporting it missing.
179///
180/// A word the grammar knows resolves through [`VERBS`]; anything else is
181/// passed through with its `!` intact, so what the operator typed is what the
182/// "not found" report names. Silently stripping a bang off an unknown word
183/// would turn `:Ghost!` into a report about `:Ghost`, which is a report about
184/// a different thing than the one that failed.
185#[must_use]
186pub fn parse(line: &str) -> Option<Invocation> {
187    let line = line.trim();
188    let line = line.strip_prefix(':').unwrap_or(line);
189    if let Some(source) = line
190        .strip_prefix("blue")
191        .filter(|r| r.starts_with(char::is_whitespace))
192    {
193        return Some(Invocation {
194            command: "blue".to_string(),
195            args: vec![source.trim().to_string()],
196        });
197    }
198    let mut parts = line.split_whitespace();
199    let word = parts.next()?;
200    let args: Vec<String> = parts.map(str::to_string).collect();
201    let (head, bang) = word
202        .strip_suffix('!')
203        .map_or((word, false), |stripped| (stripped, true));
204    let command = resolve(head).map_or_else(|| word.to_string(), |v| v.command(bang).to_string());
205    Some(Invocation { command, args })
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn a_blue_line_keeps_its_spaces_and_a_word_that_starts_with_blue_is_its_own_command() {
214        let inv = parse(":blue message(\"a  b\")").unwrap();
215        assert_eq!(inv.command, "blue");
216        assert_eq!(inv.args, vec!["message(\"a  b\")".to_string()]);
217        assert_eq!(parse(":bluefoo x").unwrap().command, "bluefoo");
218        let bare = parse(":blue").unwrap();
219        assert_eq!(bare.command, "blue");
220        assert!(bare.args.is_empty());
221    }
222
223    /// Every abbreviation vim would accept reaches its verb. This is the
224    /// all-variants proof: it walks the table rather than sampling it, so a
225    /// verb added with a wrong `min` fails here rather than in an operator's
226    /// hands.
227    #[test]
228    fn abbreviations_all_resolve() {
229        for v in VERBS {
230            for len in v.min..=v.full.len() {
231                let word = &v.full[..len];
232                assert_eq!(
233                    resolve(word),
234                    Some(v),
235                    "`:{word}` must resolve to `{}`",
236                    v.full,
237                );
238            }
239        }
240    }
241
242    /// No typed word spells two verbs. Ambiguity here is invisible in normal
243    /// use — [`resolve`] takes the first match, so the SECOND verb simply
244    /// becomes unreachable at that spelling, which nothing else would notice.
245    #[test]
246    fn no_abbreviation_is_ambiguous() {
247        for v in VERBS {
248            for len in v.min..=v.full.len() {
249                let word = &v.full[..len];
250                let hits: Vec<&str> = VERBS
251                    .iter()
252                    .filter(|c| c.spelled_by(word))
253                    .map(|c| c.full)
254                    .collect();
255                assert_eq!(hits.len(), 1, "`:{word}` is ambiguous: {hits:?}");
256            }
257        }
258    }
259
260    /// A shorter-than-minimum prefix resolves to nothing rather than to the
261    /// wrong thing. `:q` must never be `:qall`.
262    #[test]
263    fn below_the_minimum_selects_nothing() {
264        for v in VERBS {
265            for len in 1..v.min {
266                let word = &v.full[..len];
267                let hit = resolve(word);
268                assert!(
269                    hit.is_none_or(|h| h.full != v.full),
270                    "`:{word}` is below `{}`'s minimum and must not select it",
271                    v.full,
272                );
273            }
274        }
275    }
276
277    #[test]
278    fn the_write_quit_family_reaches_its_commands() {
279        for (typed, expect) in [
280            ("w", "save"),
281            ("write", "save"),
282            ("wq", "write-quit"),
283            ("wq!", "write-quit"),
284            ("wqa", "write-quit-all"),
285            ("wqall", "write-quit-all"),
286            ("x", "exit-write"),
287            ("xit", "exit-write"),
288            ("xa", "write-quit-all"),
289            ("exi", "exit-write"),
290            ("exit", "exit-write"),
291            ("wa", "buffer.write-all"),
292            ("q", "quit"),
293            ("q!", "quit!"),
294            ("quit", "quit"),
295            ("qa", "quit-all"),
296            ("qa!", "quit-all!"),
297            ("quita", "quit-all"),
298            ("quitall", "quit-all"),
299            ("u", "undo"),
300            ("red", "redo"),
301        ] {
302            assert_eq!(
303                parse(typed).map(|i| i.command),
304                Some(expect.to_string()),
305                "`:{typed}`",
306            );
307        }
308    }
309
310    #[test]
311    fn a_leading_colon_and_surrounding_space_are_not_part_of_the_name() {
312        assert_eq!(parse(":wq").map(|i| i.command), Some("write-quit".into()));
313        assert_eq!(
314            parse("  wq  ").map(|i| i.command),
315            Some("write-quit".into())
316        );
317    }
318
319    #[test]
320    fn an_empty_line_dispatches_nothing() {
321        assert_eq!(parse(""), None);
322        assert_eq!(parse("   "), None);
323        assert_eq!(parse(":"), None);
324    }
325
326    #[test]
327    fn an_unknown_word_passes_through_with_its_bang() {
328        // Registry names must survive the grammar untouched…
329        assert_eq!(parse("noh").map(|i| i.command), Some("noh".into()));
330        assert_eq!(
331            parse("picker.files").map(|i| i.command),
332            Some("picker.files".into()),
333        );
334        // …and an unknown bang is reported as the operator typed it.
335        assert_eq!(parse("Ghost!").map(|i| i.command), Some("Ghost!".into()));
336    }
337
338    #[test]
339    fn arguments_survive_the_verb() {
340        let i = parse("w  foo.txt  bar").expect("a verb with arguments parses");
341        assert_eq!(i.command, "save");
342        assert_eq!(i.args, vec!["foo.txt".to_string(), "bar".to_string()]);
343    }
344}