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)]
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 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 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 pub fn suggestions_for_analysis(&self, analysis: &CompletionAnalysis) -> Vec<SuggestionOutput> {
76 self.suggester.generate(analysis)
77 }
78
79 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 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 pub fn tokenize(&self, line: &str) -> Vec<String> {
133 self.parser.tokenize(line)
134 }
135
136 pub fn matched_command_len_tokens(&self, tokens: &[String]) -> usize {
138 TreeResolver::new(&self.tree).matched_command_len_tokens(tokens)
139 }
140
141 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 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 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_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 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 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}