Skip to main content

lattice_completion/
slot.rs

1//! Command-line slot detection (DESIGN.md ยง5.11.3).
2//!
3//! Given the current text on the `:` line and a cursor position,
4//! produce a [`CommandLineSlot`] describing what the cursor is
5//! pointing at -- the command name, the N-th positional arg, etc. --
6//! plus the prefix (text the user has already typed in this slot)
7//! and the byte range to replace when a candidate is accepted.
8//!
9//! The host (cmdline parser in `lattice-ui-tui`) calls
10//! [`current_slot`] to figure out *which* completion source to
11//! invoke. The `ArgSpec.completion` field on each command's
12//! arg-schema declares the source per-arg, so the slot resolution
13//! pulls the right generator id straight from the registry.
14
15use lattice_grammar::{ArgSpec, CommandKind, CommandRegistry};
16
17/// What the cursor on the `:` line is pointing at.
18#[derive(Debug, Clone, PartialEq)]
19pub enum CommandLineSlot {
20    /// Cursor is in (or right after) the command name. The
21    /// completion source is conventionally `gen:commands`.
22    CommandName {
23        /// Partial text typed so far for the command name.
24        prefix: String,
25        /// Byte offset within the cmdline where the prefix begins
26        /// (so the host can replace `[replace_start, cursor)` when
27        /// the user accepts a candidate).
28        replace_start: usize,
29    },
30    /// Cursor is in the N-th positional arg of the resolved
31    /// command. `arg_spec` is the schema entry; `command_name` is
32    /// the canonical registry name (so the host can re-look-up if
33    /// it needs to).
34    Arg {
35        command_name: String,
36        arg_index: usize,
37        arg_spec: ArgSpec,
38        prefix: String,
39        replace_start: usize,
40    },
41    /// Cursor is past the schema's declared args (e.g. extra
42    /// trailing whitespace on a no-arg command). No completion.
43    BeyondSchema { command_name: String },
44    /// Cursor is on a delimiter-syntax command body
45    /// (`:s/pattern/replacement/flags`, `:g/pattern/body`). v1
46    /// returns this without slot specifics; the cmdline parser may
47    /// special-case if it wants to (e.g. complete commands inside
48    /// `:g` body). Default: no completion.
49    DelimiterBody { command_name: String, body: String },
50    /// Couldn't resolve the command word to a registered command.
51    /// No completion possible (we don't know the schema).
52    UnknownCommand { word: String, replace_start: usize },
53    /// Empty cmdline. Treated as command-name slot with empty
54    /// prefix.
55    Empty,
56}
57
58impl CommandLineSlot {
59    /// Convenience: get the prefix the matcher should run against.
60    pub fn prefix(&self) -> &str {
61        match self {
62            Self::CommandName { prefix, .. } => prefix,
63            Self::Arg { prefix, .. } => prefix,
64            Self::UnknownCommand { word, .. } => word,
65            Self::DelimiterBody { body, .. } => body,
66            Self::BeyondSchema { .. } | Self::Empty => "",
67        }
68    }
69
70    pub fn replace_start(&self) -> Option<usize> {
71        match self {
72            Self::CommandName { replace_start, .. } => Some(*replace_start),
73            Self::Arg { replace_start, .. } => Some(*replace_start),
74            Self::UnknownCommand { replace_start, .. } => Some(*replace_start),
75            Self::Empty => Some(0),
76            Self::BeyondSchema { .. } | Self::DelimiterBody { .. } => None,
77        }
78    }
79}
80
81/// Detect what slot the cursor is on. `line` is the text on the
82/// command line (without the leading `:`); `cursor` is a byte
83/// offset within `line` (`0..=line.len()`).
84///
85/// `alias_resolve` is a callback the host supplies to translate
86/// short forms (`w` -> `ex:write`) before looking up the command
87/// in the registry. Living in the host means
88/// `lattice-completion` doesn't bake in TUI-specific aliases.
89pub fn current_slot(
90    line: &str,
91    cursor: usize,
92    registry: &CommandRegistry,
93    alias_resolve: &dyn Fn(&str) -> Option<String>,
94) -> CommandLineSlot {
95    let line = &line[..cursor.min(line.len())];
96
97    // Empty / all-whitespace -> command-name slot, empty prefix.
98    if line.trim().is_empty() {
99        return CommandLineSlot::Empty;
100    }
101
102    // Delimiter-syntax detection. The :s/.../.../ and :g/.../body
103    // forms are flagged but not slot-typed deeply in v1 (Q9 from
104    // the design discussion).
105    if let Some(rest) = line.strip_prefix("s/") {
106        return CommandLineSlot::DelimiterBody {
107            command_name: "ex:substitute".into(),
108            body: rest.to_string(),
109        };
110    }
111    if let Some(rest) = line.strip_prefix("%s/") {
112        return CommandLineSlot::DelimiterBody {
113            command_name: "ex:substitute".into(),
114            body: rest.to_string(),
115        };
116    }
117    if let Some(rest) = line.strip_prefix("g/") {
118        return CommandLineSlot::DelimiterBody {
119            command_name: "ex:global".into(),
120            body: rest.to_string(),
121        };
122    }
123    if let Some(rest) = line.strip_prefix("v/") {
124        return CommandLineSlot::DelimiterBody {
125            command_name: "ex:global".into(),
126            body: rest.to_string(),
127        };
128    }
129
130    // Tokenize on whitespace. The first whitespace splits the
131    // command word from the rest.
132    let leading_ws = line.bytes().take_while(|b| b.is_ascii_whitespace()).count();
133    let after_ws = &line[leading_ws..];
134    let cmd_end = after_ws.find(char::is_whitespace).unwrap_or(after_ws.len());
135
136    if cmd_end == after_ws.len() {
137        // Cursor is still in the command word (no whitespace yet).
138        return CommandLineSlot::CommandName {
139            prefix: after_ws.to_string(),
140            replace_start: leading_ws,
141        };
142    }
143
144    // We have a command word + trailing text.
145    let cmd_word_with_bang = &after_ws[..cmd_end];
146    // Strip a trailing `!` for alias resolution but keep the bang
147    // accounted for in offsets.
148    let cmd_word = cmd_word_with_bang.trim_end_matches('!');
149    // Resolution order matches `excommand::parse_invocation`:
150    // try the typed text as a canonical registry name first
151    // (so `ex:describe-key` resolves), then alias-expand if that
152    // misses (so `describe-key` resolves too). Both forms reach
153    // the same spec; this is what lets the slot detector agree
154    // with the parser regardless of which form the user typed.
155    let id = if let Some(id) = registry.id_by_name(cmd_word) {
156        id
157    } else if let Some(canonical) = alias_resolve(cmd_word) {
158        match registry.id_by_name(&canonical) {
159            Some(id) => id,
160            None => {
161                return CommandLineSlot::UnknownCommand {
162                    word: cmd_word.to_string(),
163                    replace_start: leading_ws,
164                };
165            }
166        }
167    } else {
168        return CommandLineSlot::UnknownCommand {
169            word: cmd_word.to_string(),
170            replace_start: leading_ws,
171        };
172    };
173    let spec = match registry.lookup(id) {
174        Some(s) => s,
175        None => {
176            return CommandLineSlot::UnknownCommand {
177                word: cmd_word.to_string(),
178                replace_start: leading_ws,
179            };
180        }
181    };
182    if spec.kind != CommandKind::ExCommand {
183        return CommandLineSlot::UnknownCommand {
184            word: cmd_word.to_string(),
185            replace_start: leading_ws,
186        };
187    }
188
189    // Walk past the command word + the separating whitespace.
190    let after_cmd = &after_ws[cmd_end..];
191    let post_cmd_ws = after_cmd
192        .bytes()
193        .take_while(|b| b.is_ascii_whitespace())
194        .count();
195    let arg_text = &after_cmd[post_cmd_ws..];
196
197    // Identify which positional arg the cursor is on: count
198    // whitespace-separated arg-tokens before the cursor.
199    let arg_index = arg_text.split_ascii_whitespace().count();
200    // The "current arg" is the last whitespace-delimited token.
201    // If `arg_text` ends with whitespace, the user hasn't typed
202    // anything yet for the next arg; that's still a valid slot
203    // (with empty prefix).
204    let (current_prefix, prefix_offset) =
205        if arg_text.ends_with(char::is_whitespace) || arg_text.is_empty() {
206            ("", arg_text.len())
207        } else {
208            // Find the last whitespace boundary before cursor.
209            let last_ws = arg_text
210                .rfind(char::is_whitespace)
211                .map(|i| i + 1)
212                .unwrap_or(0);
213            (&arg_text[last_ws..], last_ws)
214        };
215    let arg_index = if arg_text.ends_with(char::is_whitespace) || arg_text.is_empty() {
216        arg_index // pointing at the next, untyped slot
217    } else {
218        arg_index - 1 // pointing at the last typed token
219    };
220
221    let replace_start = leading_ws + cmd_end + post_cmd_ws + prefix_offset;
222
223    if arg_index >= spec.args_schema.len() {
224        return CommandLineSlot::BeyondSchema {
225            command_name: spec.name.clone(),
226        };
227    }
228    CommandLineSlot::Arg {
229        command_name: spec.name.clone(),
230        arg_index,
231        arg_spec: spec.args_schema[arg_index].clone(),
232        prefix: current_prefix.to_string(),
233        replace_start,
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    #![allow(clippy::unwrap_used, clippy::panic)]
240    use super::*;
241    use lattice_grammar::ex_commands;
242
243    fn fixture() -> CommandRegistry {
244        let mut r = CommandRegistry::new();
245        let _ = lattice_grammar::builtins::populate(&mut r);
246        let _ = ex_commands::populate(&mut r);
247        r
248    }
249
250    fn aliases(short: &str) -> Option<String> {
251        let table: &[(&str, &str)] = &[
252            ("w", "ex:write"),
253            ("write", "ex:write"),
254            ("q", "ex:quit"),
255            ("quit", "ex:quit"),
256            ("e", "ex:edit"),
257            ("edit", "ex:edit"),
258            ("set", "ex:set"),
259            ("describe-command", "ex:describe-command"),
260            ("describe-key", "ex:describe-key"),
261            ("apropos", "ex:apropos"),
262        ];
263        table
264            .iter()
265            .find_map(|(s, c)| (*s == short).then(|| (*c).to_string()))
266    }
267
268    #[test]
269    fn empty_line_is_empty_slot() {
270        let r = fixture();
271        let slot = current_slot("", 0, &r, &aliases);
272        assert!(matches!(slot, CommandLineSlot::Empty));
273    }
274
275    #[test]
276    fn whitespace_only_line_is_empty_slot() {
277        let r = fixture();
278        let slot = current_slot("   ", 3, &r, &aliases);
279        assert!(matches!(slot, CommandLineSlot::Empty));
280    }
281
282    #[test]
283    fn partial_command_word_is_command_name_slot() {
284        let r = fixture();
285        let slot = current_slot("descri", 6, &r, &aliases);
286        match slot {
287            CommandLineSlot::CommandName {
288                prefix,
289                replace_start,
290            } => {
291                assert_eq!(prefix, "descri");
292                assert_eq!(replace_start, 0);
293            }
294            other => panic!("expected CommandName, got {other:?}"),
295        }
296    }
297
298    #[test]
299    fn unknown_command_is_unknown_slot() {
300        let r = fixture();
301        let slot = current_slot("xyzzy ", 6, &r, &aliases);
302        assert!(matches!(slot, CommandLineSlot::UnknownCommand { .. }));
303    }
304
305    #[test]
306    fn first_arg_after_known_command_is_arg_slot_index_zero() {
307        let r = fixture();
308        // ":describe-command moti" -- cursor at end. arg 0 of
309        // describe-command is "name" with String kind.
310        let line = "describe-command moti";
311        let slot = current_slot(line, line.len(), &r, &aliases);
312        match slot {
313            CommandLineSlot::Arg {
314                command_name,
315                arg_index,
316                arg_spec,
317                prefix,
318                ..
319            } => {
320                assert_eq!(command_name, "ex:describe-command");
321                assert_eq!(arg_index, 0);
322                assert_eq!(arg_spec.name, "name");
323                assert_eq!(prefix, "moti");
324            }
325            other => panic!("expected Arg, got {other:?}"),
326        }
327    }
328
329    #[test]
330    fn replace_start_points_at_prefix_start() {
331        let r = fixture();
332        let line = "describe-command moti";
333        // "moti" begins at byte 17.
334        let slot = current_slot(line, line.len(), &r, &aliases);
335        match slot {
336            CommandLineSlot::Arg { replace_start, .. } => {
337                assert_eq!(replace_start, 17);
338            }
339            other => panic!("expected Arg, got {other:?}"),
340        }
341    }
342
343    #[test]
344    fn cursor_on_trailing_whitespace_after_command_is_next_arg_with_empty_prefix() {
345        let r = fixture();
346        let line = "describe-command ";
347        let slot = current_slot(line, line.len(), &r, &aliases);
348        match slot {
349            CommandLineSlot::Arg {
350                arg_index, prefix, ..
351            } => {
352                assert_eq!(arg_index, 0);
353                assert_eq!(prefix, "");
354            }
355            other => panic!("expected Arg with empty prefix, got {other:?}"),
356        }
357    }
358
359    #[test]
360    fn cursor_past_schema_args_is_beyond_schema() {
361        let r = fixture();
362        // ex:quit has no args. Trailing text is beyond schema.
363        let line = "quit foo";
364        let slot = current_slot(line, line.len(), &r, &aliases);
365        assert!(matches!(slot, CommandLineSlot::BeyondSchema { .. }));
366    }
367
368    #[test]
369    fn substitute_form_is_delimiter_body() {
370        let r = fixture();
371        let line = "s/foo/bar/";
372        let slot = current_slot(line, line.len(), &r, &aliases);
373        match slot {
374            CommandLineSlot::DelimiterBody { command_name, body } => {
375                assert_eq!(command_name, "ex:substitute");
376                assert_eq!(body, "foo/bar/");
377            }
378            other => panic!("expected DelimiterBody, got {other:?}"),
379        }
380    }
381
382    #[test]
383    fn percent_substitute_form_is_delimiter_body() {
384        let r = fixture();
385        let line = "%s/foo/bar/g";
386        let slot = current_slot(line, line.len(), &r, &aliases);
387        match slot {
388            CommandLineSlot::DelimiterBody { command_name, .. } => {
389                assert_eq!(command_name, "ex:substitute");
390            }
391            other => panic!("expected DelimiterBody, got {other:?}"),
392        }
393    }
394
395    #[test]
396    fn global_form_is_delimiter_body() {
397        let r = fixture();
398        let line = "g/pattern/d";
399        let slot = current_slot(line, line.len(), &r, &aliases);
400        match slot {
401            CommandLineSlot::DelimiterBody { command_name, .. } => {
402                assert_eq!(command_name, "ex:global");
403            }
404            other => panic!("expected DelimiterBody, got {other:?}"),
405        }
406    }
407
408    #[test]
409    fn vglobal_form_is_delimiter_body() {
410        let r = fixture();
411        let line = "v/pattern/d";
412        let slot = current_slot(line, line.len(), &r, &aliases);
413        assert!(matches!(slot, CommandLineSlot::DelimiterBody { .. }));
414    }
415
416    #[test]
417    fn nested_path_arg_keeps_full_prefix_across_slash() {
418        // Repro for `:e crates/latt<Tab>` -- the slot detector
419        // must return prefix = "crates/latt" (NOT just "latt")
420        // so `gen:files` sees the directory part it needs to
421        // know which directory to read.
422        let r = fixture();
423        let line = "e crates/latt";
424        let slot = current_slot(line, line.len(), &r, &aliases);
425        match slot {
426            CommandLineSlot::Arg {
427                prefix,
428                command_name,
429                ..
430            } => {
431                assert_eq!(command_name, "ex:edit");
432                assert_eq!(prefix, "crates/latt", "prefix should retain dir part");
433            }
434            other => panic!("expected Arg(ex:edit, prefix=crates/latt), got {other:?}"),
435        }
436    }
437
438    #[test]
439    fn cursor_in_middle_of_arg_truncates_prefix_at_cursor() {
440        let r = fixture();
441        // "describe-command motion" with cursor at byte 21 (just
442        // after "moti"). Prefix should be "moti".
443        let line = "describe-command motion";
444        let slot = current_slot(line, 21, &r, &aliases);
445        match slot {
446            CommandLineSlot::Arg { prefix, .. } => assert_eq!(prefix, "moti"),
447            other => panic!("expected Arg, got {other:?}"),
448        }
449    }
450
451    #[test]
452    fn bang_after_command_does_not_break_resolution() {
453        let r = fixture();
454        let line = "q!";
455        let slot = current_slot(line, line.len(), &r, &aliases);
456        // q! with no args is still command name slot (cursor
457        // hasn't moved past the bang).
458        assert!(matches!(slot, CommandLineSlot::CommandName { .. }));
459    }
460
461    #[test]
462    fn slot_prefix_helper_returns_correct_text_for_each_variant() {
463        assert_eq!(
464            CommandLineSlot::CommandName {
465                prefix: "abc".into(),
466                replace_start: 0,
467            }
468            .prefix(),
469            "abc"
470        );
471        assert_eq!(CommandLineSlot::Empty.prefix(), "");
472        assert_eq!(
473            CommandLineSlot::BeyondSchema {
474                command_name: "x".into()
475            }
476            .prefix(),
477            ""
478        );
479    }
480}