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)]
15pub struct CompletionEngine {
17 parser: CommandLineParser,
18 suggester: SuggestionEngine,
19 tree: CompletionTree,
20 global_context_flags: BTreeSet<String>,
21}
22
23impl CompletionEngine {
24 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 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 pub fn suggestions_for_analysis(&self, analysis: &CompletionAnalysis) -> Vec<SuggestionOutput> {
44 self.suggester.generate(analysis)
45 }
46
47 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 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 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 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 pub fn tokenize(&self, line: &str) -> Vec<String> {
112 self.parser.tokenize(line)
113 }
114
115 pub fn matched_command_len_tokens(&self, tokens: &[String]) -> usize {
117 TreeResolver::new(&self.tree).matched_command_len_tokens(tokens)
118 }
119
120 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_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 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 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}