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 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 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 pub fn tokenize(&self, line: &str) -> Vec<String> {
147 self.parser.tokenize(line)
148 }
149
150 pub fn matched_command_len_tokens(&self, tokens: &[String]) -> usize {
152 TreeResolver::new(&self.tree).matched_command_len_tokens(tokens)
153 }
154
155 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_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 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 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}