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