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