Skip to main content

osp_cli/completion/
engine.rs

1use crate::completion::{
2    context::TreeResolver,
3    model::{
4        CommandLine, CompletionAnalysis, CompletionContext, CompletionNode, CompletionRequest,
5        CompletionTree, ContextScope, CursorState, MatchKind, ParsedLine, SuggestionOutput,
6        TailItem,
7    },
8    parse::CommandLineParser,
9    suggest::SuggestionEngine,
10};
11use crate::core::fuzzy::fold_case;
12use std::collections::BTreeSet;
13
14/// High-level entry point for parsing and completing command lines.
15///
16/// Reuse one engine across many completion requests for the same tree. The
17/// constructor precomputes global context-only flags so each request only pays
18/// for parsing, context resolution, and ranking.
19#[derive(Debug, Clone)]
20#[must_use]
21pub struct CompletionEngine {
22    parser: CommandLineParser,
23    suggester: SuggestionEngine,
24    tree: CompletionTree,
25    global_context_flags: BTreeSet<String>,
26}
27
28impl CompletionEngine {
29    /// Creates an engine for a prebuilt completion tree.
30    ///
31    /// The engine keeps a clone of the tree for suggestion ranking and caches
32    /// global context-only flags up front.
33    pub fn new(tree: CompletionTree) -> Self {
34        let global_context_flags = collect_global_context_flags(&tree.root);
35        Self {
36            parser: CommandLineParser,
37            suggester: SuggestionEngine::new(tree.clone()),
38            tree,
39            global_context_flags,
40        }
41    }
42
43    /// Parses `line` at `cursor` and returns the cursor state with suggestions.
44    ///
45    /// This is the canonical one-shot completion entrypoint when callers do
46    /// not need the intermediate [`CompletionAnalysis`].
47    ///
48    /// # Examples
49    ///
50    /// ```
51    /// use osp_cli::completion::{
52    ///     CommandSpec, CompletionEngine, CompletionTreeBuilder, SuggestionOutput,
53    /// };
54    ///
55    /// let tree = CompletionTreeBuilder
56    ///     .build_from_specs(&[CommandSpec::new("ldap")], [])
57    ///     .expect("tree should build");
58    /// let engine = CompletionEngine::new(tree);
59    ///
60    /// let (cursor, suggestions) = engine.complete("ld", 2);
61    ///
62    /// assert_eq!(cursor.token_stub, "ld");
63    /// assert!(matches!(
64    ///     suggestions.first(),
65    ///     Some(SuggestionOutput::Item(item)) if item.text == "ldap"
66    /// ));
67    /// ```
68    pub fn complete(&self, line: &str, cursor: usize) -> (CursorState, Vec<SuggestionOutput>) {
69        let analysis = self.analyze(line, cursor);
70        let suggestions = self.suggestions_for_analysis(&analysis);
71        (analysis.cursor, suggestions)
72    }
73
74    /// Generates suggestions for a previously computed completion analysis.
75    pub fn suggestions_for_analysis(&self, analysis: &CompletionAnalysis) -> Vec<SuggestionOutput> {
76        self.suggester.generate(analysis)
77    }
78
79    /// Parses `line` and resolves completion context at `cursor`.
80    ///
81    /// `cursor` is clamped to the line length and to a valid UTF-8 character
82    /// boundary before parsing.
83    pub fn analyze(&self, line: &str, cursor: usize) -> CompletionAnalysis {
84        let parsed = self.parser.analyze(line, cursor);
85
86        self.analyze_command_parts(parsed.parsed, parsed.cursor)
87    }
88
89    /// Resolves completion state from pre-parsed command representations.
90    ///
91    /// This is mainly useful for tests or callers that already have split
92    /// command state for the full line and the cursor-local prefix.
93    pub fn analyze_command(
94        &self,
95        full_cmd: CommandLine,
96        cursor_cmd: CommandLine,
97        cursor: CursorState,
98    ) -> CompletionAnalysis {
99        self.analyze_command_parts(
100            ParsedLine {
101                safe_cursor: 0,
102                full_tokens: Vec::new(),
103                cursor_tokens: Vec::new(),
104                full_cmd,
105                cursor_cmd,
106            },
107            cursor,
108        )
109    }
110
111    fn analyze_command_parts(
112        &self,
113        mut parsed: ParsedLine,
114        cursor: CursorState,
115    ) -> CompletionAnalysis {
116        // Prefilled alias and shell defaults can change the effective scope, so
117        // resolve once to inject them and then resolve again against the
118        // completed command state.
119        let mut context =
120            self.resolve_completion_context(&parsed.cursor_cmd, cursor.token_stub.as_str());
121        self.merge_prefilled_values(&mut parsed.cursor_cmd, &context.matched_path);
122        context = self.resolve_completion_context(&parsed.cursor_cmd, cursor.token_stub.as_str());
123
124        // Context-only flags can appear later in the line than the cursor.
125        // Merge them before scope resolution so completion reflects the user's
126        // effective command state, not just the prefix before the cursor.
127        if !parsed.cursor_cmd.has_pipe() {
128            self.merge_context_flags(
129                &mut parsed.cursor_cmd,
130                &parsed.full_cmd,
131                cursor.token_stub.as_str(),
132            );
133        }
134
135        let request = self.build_completion_request(&parsed.cursor_cmd, &cursor, &context);
136
137        CompletionAnalysis {
138            parsed,
139            cursor,
140            context,
141            request,
142        }
143    }
144
145    /// Tokenizes a shell-like command line using the parser's permissive rules.
146    pub fn tokenize(&self, line: &str) -> Vec<String> {
147        self.parser.tokenize(line)
148    }
149
150    /// Returns how many leading tokens resolve to a command path in the tree.
151    pub fn matched_command_len_tokens(&self, tokens: &[String]) -> usize {
152        TreeResolver::new(&self.tree).matched_command_len_tokens(tokens)
153    }
154
155    /// Classifies a candidate value relative to the current completion analysis.
156    pub fn classify_match(&self, analysis: &CompletionAnalysis, value: &str) -> MatchKind {
157        if analysis.parsed.cursor_cmd.has_pipe() {
158            return MatchKind::Pipe;
159        }
160        let nodes = TreeResolver::new(&self.tree).resolved_nodes(&analysis.context);
161
162        if value.starts_with("--") || nodes.flag_scope_node.flags.contains_key(value) {
163            return MatchKind::Flag;
164        }
165        if nodes.context_node.children.contains_key(value) {
166            return if analysis.context.matched_path.is_empty() {
167                MatchKind::Command
168            } else {
169                MatchKind::Subcommand
170            };
171        }
172        MatchKind::Value
173    }
174
175    fn merge_context_flags(
176        &self,
177        cursor_cmd: &mut CommandLine,
178        full_cmd: &CommandLine,
179        stub: &str,
180    ) {
181        let context = self.resolve_completion_context(cursor_cmd, stub);
182        let mut scoped_flags = BTreeSet::new();
183        let resolver = TreeResolver::new(&self.tree);
184        for i in (0..=context.matched_path.len()).rev() {
185            let (node, matched) = resolver.resolve_context(&context.matched_path[..i]);
186            if matched.len() == i {
187                scoped_flags.extend(node.flags.keys().cloned());
188            }
189        }
190        scoped_flags.extend(self.global_context_flags.iter().cloned());
191
192        for item in full_cmd.tail().iter().skip(cursor_cmd.tail_len()) {
193            let TailItem::Flag(flag) = item else {
194                continue;
195            };
196            if cursor_cmd.has_flag(&flag.name) {
197                continue;
198            }
199            if !scoped_flags.contains(&flag.name) {
200                continue;
201            }
202            cursor_cmd.merge_flag_values(flag.name.clone(), flag.values.clone());
203        }
204    }
205
206    fn merge_prefilled_values(&self, cursor_cmd: &mut CommandLine, matched_path: &[String]) {
207        let resolver = TreeResolver::new(&self.tree);
208        let mut prefilled_positionals = Vec::new();
209        for i in 0..=matched_path.len() {
210            let Some(node) = resolver.resolve_exact(&matched_path[..i]) else {
211                continue;
212            };
213            // Prefilled values are inherited down the matched command path so
214            // alias targets and shell roots behave like already-typed input.
215            prefilled_positionals.extend(node.prefilled_positionals.iter().cloned());
216            for (flag, values) in &node.prefilled_flags {
217                if cursor_cmd.has_flag(flag) {
218                    continue;
219                }
220                cursor_cmd.merge_flag_values(flag.clone(), values.clone());
221            }
222        }
223        cursor_cmd.prepend_positional_values(prefilled_positionals);
224    }
225
226    fn resolve_completion_context(&self, cmd: &CommandLine, stub: &str) -> CompletionContext {
227        let resolver = TreeResolver::new(&self.tree);
228        let exact_token_commits = if !stub.is_empty() && !stub.starts_with('-') {
229            let parent_path = &cmd.head()[..cmd.head().len().saturating_sub(1)];
230            resolver
231                .resolve_exact(parent_path)
232                .and_then(|node| node.children.get(stub))
233                .is_some_and(|child| child.exact_token_commits)
234        } else {
235            false
236        };
237        // A command token is not committed until the user types a delimiter.
238        // Keep exact and partial head tokens in the parent scope so Tab keeps
239        // cycling sibling commands until a trailing space commits the token,
240        // unless the exact token explicitly commits scope on its own.
241        let head_without_partial_subcommand = if !stub.is_empty()
242            && !stub.starts_with('-')
243            && cmd.head().last().is_some_and(|token| token == stub)
244            && !exact_token_commits
245        {
246            &cmd.head()[..cmd.head().len().saturating_sub(1)]
247        } else {
248            cmd.head()
249        };
250        let (_, matched) = resolver.resolve_context(head_without_partial_subcommand);
251        let flag_scope_path = resolver.resolve_flag_scope_path(&matched);
252
253        // Keep the in-progress stub out of arg accounting so a partial
254        // subcommand or value does not shift completion into the next slot.
255        let arg_tokens: Vec<String> = cmd
256            .head()
257            .iter()
258            .skip(matched.len())
259            .filter(|token| token.as_str() != stub)
260            .cloned()
261            .chain(
262                cmd.positional_args()
263                    .filter(|token| token.as_str() != stub)
264                    .cloned(),
265            )
266            .collect();
267
268        let context_node = resolver.resolve_exact(&matched).unwrap_or(&self.tree.root);
269        let has_subcommands = !context_node.children.is_empty();
270        let subcommand_context =
271            context_node.value_key || (has_subcommands && arg_tokens.is_empty());
272
273        CompletionContext {
274            matched_path: matched,
275            flag_scope_path,
276            subcommand_context,
277        }
278    }
279
280    fn build_completion_request(
281        &self,
282        cmd: &CommandLine,
283        cursor: &CursorState,
284        context: &CompletionContext,
285    ) -> CompletionRequest {
286        let stub = cursor.token_stub.as_str();
287        if cmd.has_pipe() {
288            return CompletionRequest::Pipe;
289        }
290
291        if stub.starts_with('-') {
292            return CompletionRequest::FlagNames {
293                flag_scope_path: context.flag_scope_path.clone(),
294            };
295        }
296
297        let resolver = TreeResolver::new(&self.tree);
298        let flag_scope_node = resolver
299            .resolve_exact(&context.flag_scope_path)
300            .unwrap_or(&self.tree.root);
301        let (needs_flag_value, last_flag) = last_flag_needs_value(flag_scope_node, cmd, stub);
302        if needs_flag_value && let Some(flag) = last_flag {
303            return CompletionRequest::FlagValues {
304                flag_scope_path: context.flag_scope_path.clone(),
305                flag,
306            };
307        }
308
309        CompletionRequest::Positionals {
310            context_path: context.matched_path.clone(),
311            flag_scope_path: context.flag_scope_path.clone(),
312            arg_index: positional_arg_index(cmd, stub, context.matched_path.len()),
313            show_subcommands: context.subcommand_context,
314            show_flag_names: stub.is_empty() && !context.subcommand_context,
315        }
316    }
317}
318
319fn last_flag_needs_value(
320    node: &CompletionNode,
321    cmd: &CommandLine,
322    stub: &str,
323) -> (bool, Option<String>) {
324    let Some(last_occurrence) = cmd.last_flag_occurrence() else {
325        return (false, None);
326    };
327    let last_flag = &last_occurrence.name;
328
329    let Some(flag_node) = node.flags.get(last_flag) else {
330        return (false, None);
331    };
332
333    if flag_node.flag_only {
334        return (false, None);
335    }
336
337    if last_occurrence.values.is_empty() {
338        return (true, Some(last_flag.clone()));
339    }
340
341    if !stub.is_empty()
342        && last_occurrence
343            .values
344            .last()
345            .is_some_and(|value| fold_case(value).starts_with(&fold_case(stub)))
346    {
347        return (true, Some(last_flag.clone()));
348    }
349
350    (flag_node.multi, Some(last_flag.clone()))
351}
352
353fn positional_arg_index(cmd: &CommandLine, stub: &str, matched_head_len: usize) -> usize {
354    cmd.head()
355        .iter()
356        .skip(matched_head_len)
357        .chain(cmd.positional_args())
358        .filter(|token| token.as_str() != stub)
359        .count()
360}
361
362fn collect_global_context_flags(root: &CompletionNode) -> BTreeSet<String> {
363    fn walk(node: &CompletionNode, out: &mut BTreeSet<String>) {
364        for (name, flag) in &node.flags {
365            if flag.context_only && flag.context_scope == ContextScope::Global {
366                out.insert(name.clone());
367            }
368        }
369        for child in node.children.values() {
370            walk(child, out);
371        }
372    }
373
374    let mut out = BTreeSet::new();
375    walk(root, &mut out);
376    out
377}
378
379#[cfg(test)]
380mod tests {
381    use std::collections::BTreeMap;
382
383    use crate::completion::{
384        CompletionEngine,
385        model::{
386            CompletionNode, CompletionTree, FlagNode, QuoteStyle, SuggestionEntry, SuggestionOutput,
387        },
388    };
389
390    fn tree() -> CompletionTree {
391        let mut provision = CompletionNode::default();
392        provision.flags.insert(
393            "--provider".to_string(),
394            FlagNode {
395                suggestions: vec![
396                    SuggestionEntry::from("vmware"),
397                    SuggestionEntry::from("nrec"),
398                ],
399                context_only: true,
400                ..FlagNode::default()
401            },
402        );
403        provision.flags.insert(
404            "--os".to_string(),
405            FlagNode {
406                suggestions_by_provider: BTreeMap::from([
407                    ("vmware".to_string(), vec![SuggestionEntry::from("rhel")]),
408                    ("nrec".to_string(), vec![SuggestionEntry::from("alma")]),
409                ]),
410                suggestions: vec![SuggestionEntry::from("rhel"), SuggestionEntry::from("alma")],
411                context_only: true,
412                ..FlagNode::default()
413            },
414        );
415
416        let mut orch = CompletionNode::default();
417        orch.children.insert("provision".to_string(), provision);
418
419        CompletionTree {
420            root: CompletionNode::default().with_child("orch", orch),
421            pipe_verbs: BTreeMap::from([("F".to_string(), "Filter".to_string())]),
422        }
423    }
424
425    fn suggestion_texts(suggestions: impl IntoIterator<Item = SuggestionOutput>) -> Vec<String> {
426        suggestions
427            .into_iter()
428            .filter_map(|entry| match entry {
429                SuggestionOutput::Item(item) => Some(item.text),
430                SuggestionOutput::PathSentinel => None,
431            })
432            .collect()
433    }
434
435    fn provider_cursor(line: &str) -> usize {
436        line.find("--provider").expect("provider in test line") - 1
437    }
438
439    mod request_contracts {
440        use super::*;
441
442        #[test]
443        fn completion_request_characterization_covers_representative_kinds_and_suggestions() {
444            let engine = CompletionEngine::new(tree());
445            let cases = [
446                ("or", 2usize, "subcommands", "orch"),
447                ("orch pr", "orch pr".len(), "subcommands", "provision"),
448                (
449                    "orch provision --",
450                    "orch provision --".len(),
451                    "flag-names",
452                    "--provider",
453                ),
454                (
455                    "orch provision --provider ",
456                    "orch provision --provider ".len(),
457                    "flag-values",
458                    "vmware",
459                ),
460                (
461                    "orch provision | F",
462                    "orch provision | F".len(),
463                    "pipe",
464                    "F",
465                ),
466            ];
467
468            for (line, cursor, expected_kind, expected_value) in cases {
469                let analysis = engine.analyze(line, cursor);
470                assert_eq!(
471                    analysis.request.kind(),
472                    expected_kind,
473                    "unexpected request kind for `{line}`"
474                );
475                let values = suggestion_texts(engine.complete(line, cursor).1);
476                assert!(
477                    values.iter().any(|value| value == expected_value),
478                    "expected `{expected_value}` in suggestions for `{line}`, got {values:?}"
479                );
480            }
481        }
482    }
483
484    mod context_merge_contracts {
485        use super::*;
486
487        #[test]
488        fn provider_context_merges_across_completion_and_analysis() {
489            let engine = CompletionEngine::new(tree());
490            let line = "orch provision --os  --provider vmware";
491            let cursor = provider_cursor(line);
492
493            let (_, suggestions) = engine.complete(line, cursor);
494            let values = suggestion_texts(suggestions);
495            assert!(values.contains(&"rhel".to_string()));
496
497            let analysis = engine.analyze(line, cursor);
498            assert_eq!(analysis.cursor.token_stub, "");
499            assert_eq!(analysis.context.matched_path, vec!["orch", "provision"]);
500            assert_eq!(analysis.context.flag_scope_path, vec!["orch", "provision"]);
501            assert!(!analysis.context.subcommand_context);
502            assert_eq!(
503                analysis
504                    .parsed
505                    .cursor_cmd
506                    .flag_values("--provider")
507                    .expect("provider should merge into cursor context"),
508                &vec!["vmware".to_string()][..]
509            );
510        }
511
512        #[test]
513        fn value_completion_handles_equals_flags_and_open_quotes() {
514            let engine = CompletionEngine::new(tree());
515
516            let equals_line = "orch provision --os=";
517            let values = suggestion_texts(engine.complete(equals_line, equals_line.len()).1);
518            assert!(values.contains(&"rhel".to_string()));
519            assert!(values.contains(&"alma".to_string()));
520
521            let open_quote_line = "orch provision --os \"rh";
522            let analysis = engine.analyze(open_quote_line, open_quote_line.len());
523            assert_eq!(analysis.cursor.token_stub, "rh");
524            assert_eq!(analysis.cursor.quote_style, Some(QuoteStyle::Double));
525        }
526    }
527
528    mod scope_resolution_contracts {
529        use super::*;
530
531        #[test]
532        fn completion_hides_later_flags_and_does_not_inherit_root_flags() {
533            let engine = CompletionEngine::new(tree());
534            let line = "orch provision  --provider vmware";
535            let cursor = line.find("--provider").expect("provider in test line") - 2;
536
537            let values = suggestion_texts(engine.complete(line, cursor).1);
538            assert!(!values.contains(&"--provider".to_string()));
539
540            let mut root = CompletionNode::default();
541            root.flags
542                .insert("--json".to_string(), FlagNode::default().flag_only());
543            root.children
544                .insert("exit".to_string(), CompletionNode::default());
545            let engine = CompletionEngine::new(CompletionTree {
546                root,
547                ..CompletionTree::default()
548            });
549
550            let analysis = engine.analyze("exit ", 5);
551            assert_eq!(analysis.parsed.cursor_tokens, vec!["exit".to_string()]);
552            assert_eq!(analysis.parsed.cursor_cmd.head(), &["exit".to_string()]);
553            assert_eq!(analysis.context.matched_path, vec!["exit".to_string()]);
554            assert_eq!(analysis.context.flag_scope_path, vec!["exit".to_string()]);
555
556            let suggestions = engine.suggestions_for_analysis(&analysis);
557            assert!(
558                suggestions.is_empty(),
559                "expected no inherited flags, got {suggestions:?}"
560            );
561        }
562
563        #[test]
564        fn analysis_tolerates_non_char_boundary_cursors_and_counts_value_keys() {
565            let engine = CompletionEngine::new(tree());
566            let line = "orch å";
567            let cursor = line.find('å').expect("multibyte char should exist") + 1;
568            let (_cursor, _suggestions) = engine.complete(line, cursor);
569
570            let mut set = CompletionNode::default();
571            set.children.insert(
572                "ui.mode".to_string(),
573                CompletionNode {
574                    value_key: true,
575                    ..CompletionNode::default()
576                },
577            );
578            let mut config = CompletionNode::default();
579            config.children.insert("set".to_string(), set);
580            let engine = CompletionEngine::new(CompletionTree {
581                root: CompletionNode::default().with_child("config", config),
582                ..CompletionTree::default()
583            });
584
585            let tokens = vec![
586                "config".to_string(),
587                "set".to_string(),
588                "ui.mode".to_string(),
589            ];
590            assert_eq!(engine.matched_command_len_tokens(&tokens), 3);
591        }
592    }
593
594    mod metadata_contracts {
595        use super::*;
596
597        #[test]
598        fn subcommand_metadata_includes_tooltip_and_preview() {
599            let mut ldap = CompletionNode {
600                tooltip: Some("Directory lookup".to_string()),
601                ..CompletionNode::default()
602            };
603            ldap.children
604                .insert("user".to_string(), CompletionNode::default());
605            ldap.children
606                .insert("host".to_string(), CompletionNode::default());
607
608            let engine = CompletionEngine::new(CompletionTree {
609                root: CompletionNode::default().with_child("ldap", ldap),
610                ..CompletionTree::default()
611            });
612
613            let meta = engine
614                .complete("ld", 2)
615                .1
616                .into_iter()
617                .find_map(|entry| match entry {
618                    SuggestionOutput::Item(item) if item.text == "ldap" => item.meta,
619                    SuggestionOutput::PathSentinel => None,
620                    _ => None,
621                })
622                .expect("ldap suggestion should have metadata");
623
624            assert!(meta.contains("Directory lookup"));
625            assert!(meta.contains("subcommands:"));
626            assert!(meta.contains("host"));
627            assert!(meta.contains("user"));
628        }
629    }
630}