1use crate::completion::context::{ProviderSelection, TreeResolver};
2use crate::completion::model::{
3 CommandLine, CompletionAnalysis, CompletionNode, CompletionTree, Suggestion, SuggestionEntry,
4 SuggestionOutput, ValueType,
5};
6use fuzzy_matcher::FuzzyMatcher;
7use fuzzy_matcher::skim::SkimMatcherV2;
8use std::collections::BTreeSet;
9use std::sync::OnceLock;
10
11const MATCH_SCORE_EXACT: u32 = 0;
12const MATCH_SCORE_EMPTY_STUB: u32 = 1_000;
13const MATCH_SCORE_PREFIX_BASE: u32 = 100;
14const MATCH_SCORE_BOUNDARY_PREFIX_BASE: u32 = 200;
15const MATCH_SCORE_FUZZY_BASE: u32 = 10_000;
16const MATCH_SCORE_FUZZY_NORMALIZED_MAX: u32 = 100_000;
17struct PositionalRequest<'a> {
21 context_node: &'a CompletionNode,
22 flag_scope_node: &'a CompletionNode,
23 arg_index: usize,
24 stub: &'a str,
25 cmd: &'a CommandLine,
26 show_subcommands: bool,
27 show_flag_names: bool,
28}
29
30enum SuggestionMode<'a> {
31 Pipe,
32 FlagNames {
33 flag_scope_node: &'a CompletionNode,
34 },
35 FlagValues {
36 flag_scope_node: &'a CompletionNode,
37 flag: String,
38 },
39 Positionals {
40 context_node: &'a CompletionNode,
41 flag_scope_node: &'a CompletionNode,
42 arg_index: usize,
43 show_subcommands: bool,
44 show_flag_names: bool,
45 },
46}
47
48#[derive(Debug, Clone)]
49pub struct SuggestionEngine {
50 tree: CompletionTree,
51}
52
53impl SuggestionEngine {
54 pub fn new(tree: CompletionTree) -> Self {
55 Self { tree }
56 }
57
58 pub fn generate(&self, analysis: &CompletionAnalysis) -> Vec<SuggestionOutput> {
59 let mode = self.suggestion_mode(analysis);
60 self.emit_suggestions(mode, analysis)
61 }
62
63 fn suggestion_mode<'a>(&'a self, analysis: &'a CompletionAnalysis) -> SuggestionMode<'a> {
64 let cmd = &analysis.parsed.cursor_cmd;
65 let stub = analysis.cursor.token_stub.as_str();
66
67 if cmd.has_pipe() {
68 return SuggestionMode::Pipe;
69 }
70
71 let nodes = TreeResolver::new(&self.tree).resolved_nodes(&analysis.context);
72 if stub.starts_with('-') {
73 return SuggestionMode::FlagNames {
74 flag_scope_node: nodes.flag_scope_node,
75 };
76 }
77
78 let (needs_flag_value, last_flag) =
79 self.last_flag_needs_value(nodes.flag_scope_node, cmd, stub);
80 if needs_flag_value && let Some(flag) = last_flag {
81 return SuggestionMode::FlagValues {
82 flag_scope_node: nodes.flag_scope_node,
83 flag,
84 };
85 }
86
87 SuggestionMode::Positionals {
88 context_node: nodes.context_node,
89 flag_scope_node: nodes.flag_scope_node,
90 arg_index: self.arg_index(cmd, stub, analysis.context.matched_path.len()),
91 show_subcommands: analysis.context.subcommand_context,
92 show_flag_names: stub.is_empty() && !analysis.context.subcommand_context,
93 }
94 }
95
96 fn emit_suggestions(
97 &self,
98 mode: SuggestionMode<'_>,
99 analysis: &CompletionAnalysis,
100 ) -> Vec<SuggestionOutput> {
101 let cmd = &analysis.parsed.cursor_cmd;
102 let stub = analysis.cursor.token_stub.as_str();
103
104 let mut out = match mode {
105 SuggestionMode::Pipe => self.pipe_suggestions(stub),
106 SuggestionMode::FlagNames { flag_scope_node } => self
107 .flag_name_suggestions(flag_scope_node, stub, cmd)
108 .into_iter()
109 .map(SuggestionOutput::Item)
110 .collect(),
111 SuggestionMode::FlagValues {
112 flag_scope_node,
113 flag,
114 } => self.flag_value_suggestions(flag_scope_node, &flag, stub, cmd),
115 SuggestionMode::Positionals {
116 context_node,
117 flag_scope_node,
118 arg_index,
119 show_subcommands,
120 show_flag_names,
121 } => {
122 let request = PositionalRequest {
123 context_node,
124 flag_scope_node,
125 arg_index,
126 stub,
127 cmd,
128 show_subcommands,
129 show_flag_names,
130 };
131 let mut out = self.positional_suggestions(request);
132 sort_suggestion_outputs(&mut out);
133 return out;
134 }
135 };
136
137 sort_suggestion_outputs(&mut out);
138 out
139 }
140
141 fn positional_suggestions(&self, request: PositionalRequest<'_>) -> Vec<SuggestionOutput> {
142 let mut out = Vec::new();
143
144 if request.show_subcommands {
145 out.extend(
146 self.subcommand_suggestions(request.context_node, request.stub)
147 .into_iter()
148 .map(SuggestionOutput::Item),
149 );
150 } else {
151 out.extend(self.arg_value_suggestions(
152 request.context_node,
153 request.arg_index,
154 request.stub,
155 ));
156 }
157
158 if request.show_flag_names {
159 out.extend(
160 self.flag_name_suggestions(request.flag_scope_node, request.stub, request.cmd)
161 .into_iter()
162 .filter(|suggestion| !request.cmd.has_flag(&suggestion.text))
163 .map(SuggestionOutput::Item),
164 );
165 }
166
167 out
168 }
169
170 fn pipe_suggestions(&self, stub: &str) -> Vec<SuggestionOutput> {
171 self.tree
172 .pipe_verbs
173 .iter()
174 .filter_map(|(verb, tooltip)| {
175 let score = self.match_score(stub, verb)?;
176 Some(SuggestionOutput::Item(Suggestion {
177 text: verb.clone(),
178 meta: Some(tooltip.clone()),
179 display: None,
180 is_exact: score == 0,
181 sort: None,
182 match_score: score,
183 }))
184 })
185 .collect()
186 }
187
188 fn flag_name_suggestions(
189 &self,
190 node: &CompletionNode,
191 stub: &str,
192 cmd: &CommandLine,
193 ) -> Vec<Suggestion> {
194 let allowlist = self.resolved_flag_allowlist(node, cmd);
195 let required = self.required_flags(node, cmd);
196
197 node.flags
198 .iter()
199 .filter_map(|(flag, meta)| {
200 let score = self.match_score(stub, flag)?;
201 Some((flag, meta, score))
202 })
203 .filter(|(flag, _, _)| {
204 allowlist
205 .as_ref()
206 .is_none_or(|allowed| allowed.contains(flag.as_str()))
207 })
208 .filter(|(flag, _, _)| !cmd.has_flag(flag) || stub == *flag)
209 .map(|(flag, meta, score)| Suggestion {
210 text: flag.clone(),
211 meta: meta.tooltip.clone(),
212 display: required.contains(flag.as_str()).then(|| format!("{flag}*")),
213 is_exact: score == 0,
214 sort: None,
215 match_score: score,
216 })
217 .collect()
218 }
219
220 fn flag_value_suggestions(
221 &self,
222 node: &CompletionNode,
223 flag: &str,
224 stub: &str,
225 cmd: &CommandLine,
226 ) -> Vec<SuggestionOutput> {
227 let Some(flag_node) = node.flags.get(flag) else {
228 return Vec::new();
229 };
230
231 if flag_node.flag_only {
232 return Vec::new();
233 }
234
235 if flag_node.value_type == Some(ValueType::Path) {
236 return vec![SuggestionOutput::PathSentinel];
237 }
238
239 if let Some(output) =
240 self.provider_specific_flag_value_suggestions(flag_node, flag, stub, cmd)
241 {
242 return output;
243 }
244
245 self.entry_suggestions(&flag_node.suggestions, stub)
246 }
247
248 fn arg_value_suggestions(
249 &self,
250 node: &CompletionNode,
251 index: usize,
252 stub: &str,
253 ) -> Vec<SuggestionOutput> {
254 let Some(arg) = node.args.get(index) else {
255 return Vec::new();
256 };
257
258 if arg.value_type == Some(ValueType::Path) {
259 return vec![SuggestionOutput::PathSentinel];
260 }
261
262 self.entry_suggestions(&arg.suggestions, stub)
263 }
264
265 fn subcommand_suggestions(&self, node: &CompletionNode, stub: &str) -> Vec<Suggestion> {
266 node.children
267 .iter()
268 .filter_map(|(name, child)| {
269 let score = self.match_score(stub, name)?;
270 Some(Suggestion {
271 text: name.clone(),
272 meta: child_completion_meta(child),
273 display: None,
274 is_exact: score == 0,
275 sort: child.sort.clone(),
276 match_score: score,
277 })
278 })
279 .collect()
280 }
281
282 fn last_flag_needs_value(
283 &self,
284 node: &CompletionNode,
285 cmd: &CommandLine,
286 stub: &str,
287 ) -> (bool, Option<String>) {
288 let Some(last_occurrence) = cmd.last_flag_occurrence() else {
289 return (false, None);
290 };
291 let last_flag = &last_occurrence.name;
292
293 let Some(flag_node) = node.flags.get(last_flag) else {
294 return (false, None);
295 };
296
297 if flag_node.flag_only {
298 return (false, None);
299 }
300
301 if last_occurrence.values.is_empty() {
302 return (true, Some(last_flag.clone()));
303 }
304
305 if !stub.is_empty()
306 && last_occurrence.values.last().is_some_and(|value| {
307 value
308 .to_ascii_lowercase()
309 .starts_with(&stub.to_ascii_lowercase())
310 })
311 {
312 return (true, Some(last_flag.clone()));
313 }
314
315 (flag_node.multi, Some(last_flag.clone()))
316 }
317
318 fn arg_index(&self, 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
327 fn provider_specific_flag_value_suggestions(
328 &self,
329 flag_node: &crate::completion::model::FlagNode,
330 flag: &str,
331 stub: &str,
332 cmd: &CommandLine,
333 ) -> Option<Vec<SuggestionOutput>> {
334 let provider = ProviderSelection::from_command(cmd);
343
344 if flag == "--provider" {
345 let os_token = provider.normalized_os();
346 if let Some(os_token) = os_token {
347 let filtered = flag_node
348 .suggestions
349 .iter()
350 .filter(|entry| {
351 flag_node
352 .os_provider_map
353 .get(os_token)
354 .is_none_or(|providers| providers.iter().any(|p| p == &entry.value))
355 })
356 .cloned()
357 .collect::<Vec<_>>();
358 if !filtered.is_empty() {
359 return Some(self.entry_suggestions(&filtered, stub));
360 }
361 }
362 }
363
364 let provider_values = flag_node.suggestions_by_provider.get(provider.name()?)?;
365 Some(self.entry_suggestions(provider_values, stub))
366 }
367
368 fn entry_suggestions(&self, entries: &[SuggestionEntry], stub: &str) -> Vec<SuggestionOutput> {
369 entries
370 .iter()
371 .filter_map(|entry| {
372 let score = self.match_score(stub, &entry.value)?;
373 Some(SuggestionOutput::Item(entry_to_suggestion(entry, score)))
374 })
375 .collect()
376 }
377
378 fn match_score(&self, stub: &str, candidate: &str) -> Option<u32> {
379 if stub.is_empty() {
386 return Some(MATCH_SCORE_EMPTY_STUB);
387 }
388
389 let stub_lc = stub.to_ascii_lowercase();
390 let candidate_lc = candidate.to_ascii_lowercase();
391
392 if stub_lc == candidate_lc {
393 return Some(MATCH_SCORE_EXACT);
394 }
395 if candidate_lc.starts_with(&stub_lc) {
396 return Some(MATCH_SCORE_PREFIX_BASE + (candidate_lc.len() - stub_lc.len()) as u32);
397 }
398
399 if let Some(boundary) = boundary_prefix_index(&candidate_lc, &stub_lc) {
400 return Some(MATCH_SCORE_BOUNDARY_PREFIX_BASE + boundary as u32);
401 }
402
403 let fuzzy = fuzzy_matcher().fuzzy_match(&candidate_lc, &stub_lc)?;
404 let normalized = fuzzy.max(0) as u32;
405 let penalty = MATCH_SCORE_FUZZY_NORMALIZED_MAX.saturating_sub(normalized);
406 Some(MATCH_SCORE_FUZZY_BASE + penalty)
407 }
408
409 fn resolved_flag_allowlist(
410 &self,
411 node: &CompletionNode,
412 cmd: &CommandLine,
413 ) -> Option<BTreeSet<String>> {
414 let hints = node.flag_hints.as_ref()?;
415 let mut allowed = hints.common.iter().cloned().collect::<BTreeSet<_>>();
416
417 if let Some(provider) = ProviderSelection::from_command(cmd).name() {
418 if let Some(provider_specific) = hints.by_provider.get(provider) {
419 allowed.extend(provider_specific.iter().cloned());
420 }
421 allowed.remove("--provider");
423 allowed.remove("--nrec");
424 allowed.remove("--vmware");
425 }
426
427 if cmd.has_flag("--linux") {
428 allowed.remove("--windows");
429 }
430 if cmd.has_flag("--windows") {
431 allowed.remove("--linux");
432 }
433
434 Some(allowed)
435 }
436
437 fn required_flags(&self, node: &CompletionNode, cmd: &CommandLine) -> BTreeSet<String> {
438 let mut required = BTreeSet::new();
439 let Some(hints) = node.flag_hints.as_ref() else {
440 return required;
441 };
442
443 required.extend(hints.required_common.iter().cloned());
444 if let Some(provider) = ProviderSelection::from_command(cmd).name()
445 && let Some(provider_required) = hints.required_by_provider.get(provider)
446 {
447 required.extend(provider_required.iter().cloned());
448 }
449 required
450 }
451}
452
453fn child_completion_meta(child: &CompletionNode) -> Option<String> {
454 let summary = child_subcommand_summary(child);
455 match (child.tooltip.as_deref(), summary) {
456 (Some(tooltip), Some(summary)) => Some(format!("{tooltip} ({summary})")),
457 (Some(tooltip), None) => Some(tooltip.to_string()),
458 (None, Some(summary)) => Some(summary),
459 (None, None) => None,
460 }
461}
462
463fn child_subcommand_summary(child: &CompletionNode) -> Option<String> {
464 if child.children.is_empty() {
465 return None;
466 }
467
468 let preview = child.children.keys().take(3).cloned().collect::<Vec<_>>();
469 if preview.is_empty() {
470 return None;
471 }
472
473 let mut summary = format!("subcommands: {}", preview.join(", "));
474 if child.children.len() > preview.len() {
475 summary.push_str(", ...");
476 }
477 Some(summary)
478}
479
480fn sort_suggestion_outputs(outputs: &mut Vec<SuggestionOutput>) {
481 let mut items: Vec<Suggestion> = outputs
482 .iter()
483 .filter_map(|entry| match entry {
484 SuggestionOutput::Item(item) => Some(item.clone()),
485 SuggestionOutput::PathSentinel => None,
486 })
487 .collect();
488 let path_sentinel_count = outputs
489 .iter()
490 .filter(|entry| matches!(entry, SuggestionOutput::PathSentinel))
491 .count();
492
493 items.sort_by(compare_suggestions);
494
495 outputs.clear();
499 outputs.extend(items.into_iter().map(SuggestionOutput::Item));
500 outputs.extend(std::iter::repeat_n(
501 SuggestionOutput::PathSentinel,
502 path_sentinel_count,
503 ));
504}
505
506fn compare_suggestions(left: &Suggestion, right: &Suggestion) -> std::cmp::Ordering {
507 (not_exact(left), left.match_score)
508 .cmp(&(not_exact(right), right.match_score))
509 .then_with(|| compare_sort_value(left.sort.as_deref(), right.sort.as_deref()))
510 .then_with(|| {
511 left.text
512 .to_ascii_lowercase()
513 .cmp(&right.text.to_ascii_lowercase())
514 })
515}
516
517fn compare_sort_value(left: Option<&str>, right: Option<&str>) -> std::cmp::Ordering {
518 match (left, right) {
519 (Some(left), Some(right)) => {
520 match (
521 left.trim().parse::<f64>().ok(),
522 right.trim().parse::<f64>().ok(),
523 ) {
524 (Some(left_num), Some(right_num)) => left_num
525 .partial_cmp(&right_num)
526 .unwrap_or(std::cmp::Ordering::Equal),
527 _ => left.to_ascii_lowercase().cmp(&right.to_ascii_lowercase()),
528 }
529 }
530 (Some(_), None) => std::cmp::Ordering::Less,
531 (None, Some(_)) => std::cmp::Ordering::Greater,
532 (None, None) => std::cmp::Ordering::Equal,
533 }
534}
535
536fn not_exact(suggestion: &Suggestion) -> bool {
537 !suggestion.is_exact
538}
539
540fn entry_to_suggestion(entry: &SuggestionEntry, match_score: u32) -> Suggestion {
541 Suggestion {
542 text: entry.value.clone(),
543 meta: entry.meta.clone(),
544 display: entry.display.clone(),
545 is_exact: match_score == 0,
546 sort: entry.sort.clone(),
547 match_score,
548 }
549}
550
551fn boundary_prefix_index(candidate: &str, stub: &str) -> Option<usize> {
552 candidate
553 .match_indices(stub)
554 .find(|(idx, _)| {
555 *idx == 0
556 || candidate
557 .as_bytes()
558 .get(idx.saturating_sub(1))
559 .is_some_and(|byte| matches!(byte, b'-' | b'_' | b'.' | b':' | b'/'))
560 })
561 .map(|(idx, _)| idx)
562}
563
564fn fuzzy_matcher() -> &'static SkimMatcherV2 {
565 static MATCHER: OnceLock<SkimMatcherV2> = OnceLock::new();
566 MATCHER.get_or_init(SkimMatcherV2::default)
567}
568
569#[cfg(test)]
570mod tests {
571 use std::collections::BTreeMap;
572
573 use crate::completion::model::{
574 ArgNode, CommandLine, CompletionNode, CompletionTree, CursorState, FlagHints, FlagNode,
575 FlagOccurrence, SuggestionEntry, SuggestionOutput, ValueType,
576 };
577
578 use crate::completion::CompletionEngine;
579
580 fn tree() -> CompletionTree {
581 let mut provision = CompletionNode::default();
582 provision.flags.insert(
583 "--provider".to_string(),
584 FlagNode {
585 suggestions: vec![
586 SuggestionEntry::from("nrec"),
587 SuggestionEntry::from("vmware"),
588 ],
589 os_provider_map: BTreeMap::from([
590 ("alma".to_string(), vec!["nrec".to_string()]),
591 ("rhel".to_string(), vec!["vmware".to_string()]),
592 ]),
593 ..FlagNode::default()
594 },
595 );
596 provision.flags.insert(
597 "--os".to_string(),
598 FlagNode {
599 suggestions: vec![SuggestionEntry::from("alma"), SuggestionEntry::from("rhel")],
600 ..FlagNode::default()
601 },
602 );
603
604 let mut orch = CompletionNode::default();
605 orch.children.insert("provision".to_string(), provision);
606
607 CompletionTree {
608 root: CompletionNode::default().with_child("orch", orch),
609 pipe_verbs: BTreeMap::from([("F".to_string(), "Filter".to_string())]),
610 }
611 }
612
613 fn values(output: Vec<SuggestionOutput>) -> Vec<String> {
614 output
615 .into_iter()
616 .filter_map(|entry| match entry {
617 SuggestionOutput::Item(item) => Some(item.text),
618 SuggestionOutput::PathSentinel => None,
619 })
620 .collect()
621 }
622
623 fn generate(engine: &CompletionEngine, cmd: CommandLine, stub: &str) -> Vec<SuggestionOutput> {
624 let analysis = engine.analyze_command(cmd.clone(), cmd, CursorState::synthetic(stub));
625 engine.suggestions_for_analysis(&analysis)
626 }
627
628 fn values_for_line(engine: &CompletionEngine, line: &str) -> Vec<String> {
629 let (_, output) = engine.complete(line, line.len());
630 values(output)
631 }
632
633 fn command(head: &[&str]) -> CommandLine {
634 let mut cmd = CommandLine::default();
635 for segment in head {
636 cmd.push_head(*segment);
637 }
638 cmd
639 }
640
641 fn with_flag(mut cmd: CommandLine, name: &str, values: &[&str]) -> CommandLine {
642 cmd.push_flag_occurrence(FlagOccurrence {
643 name: name.to_string(),
644 values: values.iter().map(|value| (*value).to_string()).collect(),
645 });
646 cmd
647 }
648
649 #[test]
650 fn suggests_flags_in_scope() {
651 let engine = CompletionEngine::new(tree());
652 let cmd = command(&["orch", "provision"]);
653
654 let values = values(generate(&engine, cmd, "--"));
655 assert!(values.contains(&"--provider".to_string()));
656 assert!(values.contains(&"--os".to_string()));
657 }
658
659 #[test]
660 fn fuzzy_matches_flag_names() {
661 let engine = CompletionEngine::new(tree());
662 let cmd = command(&["orch", "provision"]);
663
664 let values = values(generate(&engine, cmd, "--prv"));
665 assert!(values.contains(&"--provider".to_string()));
666 }
667
668 #[test]
669 fn suggests_flag_values() {
670 let engine = CompletionEngine::new(tree());
671 let cmd = with_flag(command(&["orch", "provision"]), "--provider", &[]);
672
673 let values = values(generate(&engine, cmd, ""));
674
675 assert!(values.contains(&"nrec".to_string()));
676 assert!(values.contains(&"vmware".to_string()));
677 }
678
679 #[test]
680 fn filters_provider_values_by_os() {
681 let engine = CompletionEngine::new(tree());
682 let cmd = with_flag(
683 with_flag(command(&["orch", "provision"]), "--os", &["alma"]),
684 "--provider",
685 &[],
686 );
687
688 let values = values(generate(&engine, cmd, ""));
689
690 assert!(values.contains(&"nrec".to_string()));
691 assert!(!values.contains(&"vmware".to_string()));
692 }
693
694 #[test]
695 fn suggests_pipe_verbs_after_pipe() {
696 let engine = CompletionEngine::new(tree());
697 let mut cmd = CommandLine::default();
698 cmd.set_pipe(Vec::new());
699
700 let output = generate(&engine, cmd, "F");
701 assert!(
702 output
703 .iter()
704 .any(|entry| matches!(entry, SuggestionOutput::Item(item) if item.text == "F"))
705 );
706 }
707
708 #[test]
709 fn fuzzy_matches_long_pipe_verbs() {
710 let mut tree = tree();
711 tree.pipe_verbs
712 .insert("VALUE".to_string(), "Extract values".to_string());
713 tree.pipe_verbs
714 .insert("VAL".to_string(), "Extract".to_string());
715 let engine = CompletionEngine::new(tree);
716 let mut cmd = CommandLine::default();
717 cmd.set_pipe(Vec::new());
718
719 let output = generate(&engine, cmd, "vlu");
720 assert!(
721 output
722 .iter()
723 .any(|entry| matches!(entry, SuggestionOutput::Item(item) if item.text == "VALUE"))
724 );
725 let values = values(output);
726 assert_eq!(values.first().map(String::as_str), Some("VALUE"));
727 }
728
729 #[test]
730 fn single_value_flag_switches_to_other_flags_after_value() {
731 let mut cmd_node = CompletionNode::default();
732 cmd_node.flags.insert(
733 "--context".to_string(),
734 FlagNode {
735 suggestions: vec![
736 SuggestionEntry::from("uio"),
737 SuggestionEntry::from("tsd"),
738 SuggestionEntry::from("edu"),
739 ],
740 ..FlagNode::default()
741 },
742 );
743 cmd_node.flags.insert(
744 "--terminal".to_string(),
745 FlagNode {
746 suggestions: vec![SuggestionEntry::from("cli"), SuggestionEntry::from("repl")],
747 ..FlagNode::default()
748 },
749 );
750
751 let tree = CompletionTree {
752 root: CompletionNode::default().with_child("alias", cmd_node),
753 ..CompletionTree::default()
754 };
755 let engine = CompletionEngine::new(tree);
756
757 let cmd = with_flag(command(&["alias"]), "--context", &["uio"]);
758 let values = values(generate(&engine, cmd, ""));
759 assert!(!values.contains(&"uio".to_string()));
760 assert!(values.contains(&"--terminal".to_string()));
761 }
762
763 #[test]
764 fn multi_value_flag_stays_in_value_mode_until_dash() {
765 let mut cmd_node = CompletionNode::default();
766 cmd_node.flags.insert(
767 "--tags".to_string(),
768 FlagNode {
769 multi: true,
770 suggestions: vec![
771 SuggestionEntry::from("red"),
772 SuggestionEntry::from("green"),
773 SuggestionEntry::from("blue"),
774 ],
775 ..FlagNode::default()
776 },
777 );
778 cmd_node.flags.insert(
779 "--mode".to_string(),
780 FlagNode {
781 suggestions: vec![SuggestionEntry::from("fast"), SuggestionEntry::from("full")],
782 ..FlagNode::default()
783 },
784 );
785
786 let tree = CompletionTree {
787 root: CompletionNode::default().with_child("tag", cmd_node),
788 ..CompletionTree::default()
789 };
790 let engine = CompletionEngine::new(tree);
791
792 let cmd = with_flag(command(&["tag"]), "--tags", &["red"]);
793 let values_for_space = values(generate(&engine, cmd.clone(), ""));
794 assert!(values_for_space.contains(&"red".to_string()));
795 assert!(!values_for_space.contains(&"--mode".to_string()));
796
797 let values_for_dash = values(generate(&engine, cmd, "-"));
798 assert!(values_for_dash.contains(&"--mode".to_string()));
799 }
800
801 #[test]
802 fn repeated_flag_without_value_stays_in_value_mode_for_last_occurrence() {
803 let mut cmd_node = CompletionNode::default();
804 cmd_node.flags.insert(
805 "--tags".to_string(),
806 FlagNode {
807 multi: true,
808 suggestions: vec![
809 SuggestionEntry::from("red"),
810 SuggestionEntry::from("green"),
811 SuggestionEntry::from("blue"),
812 ],
813 ..FlagNode::default()
814 },
815 );
816 cmd_node.flags.insert(
817 "--mode".to_string(),
818 FlagNode {
819 suggestions: vec![SuggestionEntry::from("fast"), SuggestionEntry::from("full")],
820 ..FlagNode::default()
821 },
822 );
823
824 let tree = CompletionTree {
825 root: CompletionNode::default().with_child("tag", cmd_node),
826 ..CompletionTree::default()
827 };
828 let engine = CompletionEngine::new(tree);
829
830 let values = values_for_line(&engine, "tag --tags red --mode fast --tags ");
831 assert!(values.contains(&"red".to_string()));
832 assert!(values.contains(&"green".to_string()));
833 assert!(!values.contains(&"--mode".to_string()));
834 }
835
836 #[test]
837 fn repeated_flag_partial_value_uses_last_occurrence_context() {
838 let mut cmd_node = CompletionNode::default();
839 cmd_node.flags.insert(
840 "--tags".to_string(),
841 FlagNode {
842 multi: true,
843 suggestions: vec![
844 SuggestionEntry::from("red"),
845 SuggestionEntry::from("green"),
846 SuggestionEntry::from("blue"),
847 ],
848 ..FlagNode::default()
849 },
850 );
851
852 let tree = CompletionTree {
853 root: CompletionNode::default().with_child("tag", cmd_node),
854 ..CompletionTree::default()
855 };
856 let engine = CompletionEngine::new(tree);
857
858 let values = values_for_line(&engine, "tag --tags red --tags bl");
859 assert!(values.contains(&"blue".to_string()));
860 }
861
862 #[test]
863 fn args_after_double_dash_advance_index() {
864 let cmd_node = CompletionNode {
865 args: vec![
866 ArgNode {
867 suggestions: vec![SuggestionEntry::from("one")],
868 ..ArgNode::default()
869 },
870 ArgNode {
871 suggestions: vec![SuggestionEntry::from("two"), SuggestionEntry::from("three")],
872 ..ArgNode::default()
873 },
874 ],
875 ..CompletionNode::default()
876 };
877 let tree = CompletionTree {
878 root: CompletionNode::default().with_child("cmd", cmd_node),
879 ..CompletionTree::default()
880 };
881 let engine = CompletionEngine::new(tree);
882 let mut cmd = command(&["cmd"]);
883 cmd.push_positional("one");
884
885 let values = values(generate(&engine, cmd, ""));
886 assert!(values.contains(&"two".to_string()));
887 assert!(values.contains(&"three".to_string()));
888 assert!(!values.contains(&"one".to_string()));
889 }
890
891 #[test]
892 fn path_arg_emits_path_sentinel() {
893 let cmd_node = CompletionNode {
894 args: vec![ArgNode {
895 value_type: Some(ValueType::Path),
896 ..ArgNode::default()
897 }],
898 ..CompletionNode::default()
899 };
900 let tree = CompletionTree {
901 root: CompletionNode::default().with_child("cmd", cmd_node),
902 ..CompletionTree::default()
903 };
904 let engine = CompletionEngine::new(tree);
905 let cmd = command(&["cmd"]);
906
907 let output = generate(&engine, cmd, "");
908 assert!(
909 output
910 .iter()
911 .any(|entry| matches!(entry, SuggestionOutput::PathSentinel))
912 );
913 }
914
915 #[test]
916 fn flag_hints_filter_provider_specific_flags_and_hide_selectors() {
917 let mut node = CompletionNode::default();
918 node.flags
919 .insert("--provider".to_string(), FlagNode::default());
920 node.flags.insert(
921 "--nrec".to_string(),
922 FlagNode {
923 flag_only: true,
924 ..FlagNode::default()
925 },
926 );
927 node.flags.insert(
928 "--vmware".to_string(),
929 FlagNode {
930 flag_only: true,
931 ..FlagNode::default()
932 },
933 );
934 node.flags
935 .insert("--comment".to_string(), FlagNode::default());
936 node.flags
937 .insert("--flavor".to_string(), FlagNode::default());
938 node.flags
939 .insert("--vcenter".to_string(), FlagNode::default());
940 node.flag_hints = Some(FlagHints {
941 common: vec![
942 "--provider".to_string(),
943 "--nrec".to_string(),
944 "--vmware".to_string(),
945 "--comment".to_string(),
946 ],
947 by_provider: BTreeMap::from([
948 ("nrec".to_string(), vec!["--flavor".to_string()]),
949 ("vmware".to_string(), vec!["--vcenter".to_string()]),
950 ]),
951 required_common: vec!["--comment".to_string()],
952 required_by_provider: BTreeMap::from([(
953 "nrec".to_string(),
954 vec!["--flavor".to_string()],
955 )]),
956 });
957
958 let tree = CompletionTree {
959 root: CompletionNode::default().with_child("provision", node),
960 ..CompletionTree::default()
961 };
962 let engine = CompletionEngine::new(tree);
963
964 let cmd = with_flag(command(&["provision"]), "--provider", &["nrec"]);
965 let output = generate(&engine, cmd, "--");
966 let values = values(output.clone());
967 assert!(values.contains(&"--comment".to_string()));
968 assert!(values.contains(&"--flavor".to_string()));
969 assert!(!values.contains(&"--provider".to_string()));
970 assert!(!values.contains(&"--nrec".to_string()));
971 assert!(!values.contains(&"--vmware".to_string()));
972 assert!(!values.contains(&"--vcenter".to_string()));
973
974 let items = output
975 .into_iter()
976 .filter_map(|entry| match entry {
977 SuggestionOutput::Item(item) => Some(item),
978 SuggestionOutput::PathSentinel => None,
979 })
980 .collect::<Vec<_>>();
981 let by_text = items
982 .into_iter()
983 .map(|item| (item.text.clone(), item))
984 .collect::<BTreeMap<_, _>>();
985 assert_eq!(
986 by_text
987 .get("--comment")
988 .and_then(|item| item.display.as_deref()),
989 Some("--comment*")
990 );
991 assert_eq!(
992 by_text
993 .get("--flavor")
994 .and_then(|item| item.display.as_deref()),
995 Some("--flavor*")
996 );
997 }
998
999 #[test]
1000 fn provider_alias_flag_enables_provider_specific_allowlist() {
1001 let mut node = CompletionNode::default();
1002 node.flags
1003 .insert("--provider".to_string(), FlagNode::default());
1004 node.flags.insert(
1005 "--nrec".to_string(),
1006 FlagNode {
1007 flag_only: true,
1008 ..FlagNode::default()
1009 },
1010 );
1011 node.flags
1012 .insert("--flavor".to_string(), FlagNode::default());
1013 node.flag_hints = Some(FlagHints {
1014 common: vec!["--provider".to_string(), "--nrec".to_string()],
1015 by_provider: BTreeMap::from([("nrec".to_string(), vec!["--flavor".to_string()])]),
1016 ..FlagHints::default()
1017 });
1018
1019 let tree = CompletionTree {
1020 root: CompletionNode::default().with_child("provision", node),
1021 ..CompletionTree::default()
1022 };
1023 let engine = CompletionEngine::new(tree);
1024 let cmd = with_flag(command(&["provision"]), "--nrec", &[]);
1025
1026 let values = values(generate(&engine, cmd, "--"));
1027 assert!(values.contains(&"--flavor".to_string()));
1028 assert!(!values.contains(&"--provider".to_string()));
1029 }
1030
1031 #[test]
1032 fn path_flag_emits_path_sentinel() {
1033 let mut node = CompletionNode::default();
1034 node.flags.insert(
1035 "--file".to_string(),
1036 FlagNode {
1037 value_type: Some(ValueType::Path),
1038 ..FlagNode::default()
1039 },
1040 );
1041
1042 let tree = CompletionTree {
1043 root: CompletionNode::default().with_child("cmd", node),
1044 ..CompletionTree::default()
1045 };
1046 let engine = CompletionEngine::new(tree);
1047 let cmd = with_flag(command(&["cmd"]), "--file", &[]);
1048
1049 let output = generate(&engine, cmd, "");
1050 assert!(
1051 output
1052 .iter()
1053 .any(|entry| matches!(entry, SuggestionOutput::PathSentinel))
1054 );
1055 }
1056
1057 #[test]
1058 fn flag_suggestions_preserve_meta_and_display_fields() {
1059 let mut node = CompletionNode::default();
1060 node.flags.insert(
1061 "--flavor".to_string(),
1062 FlagNode {
1063 suggestions: vec![
1064 SuggestionEntry {
1065 value: "m1.small".to_string(),
1066 meta: Some("1 vCPU".to_string()),
1067 display: Some("small".to_string()),
1068 sort: Some("10".to_string()),
1069 },
1070 SuggestionEntry::from("m1.medium"),
1071 ],
1072 ..FlagNode::default()
1073 },
1074 );
1075 let tree = CompletionTree {
1076 root: CompletionNode::default().with_child("orch", node),
1077 ..CompletionTree::default()
1078 };
1079 let engine = CompletionEngine::new(tree);
1080 let cmd = with_flag(command(&["orch"]), "--flavor", &[]);
1081
1082 let output = generate(&engine, cmd, "");
1083 let items = output
1084 .into_iter()
1085 .filter_map(|entry| match entry {
1086 SuggestionOutput::Item(item) => Some(item),
1087 SuggestionOutput::PathSentinel => None,
1088 })
1089 .collect::<Vec<_>>();
1090
1091 let rich = items
1092 .iter()
1093 .find(|item| item.text == "m1.small")
1094 .expect("m1.small suggestion should exist");
1095 assert_eq!(rich.meta.as_deref(), Some("1 vCPU"));
1096 assert_eq!(rich.display.as_deref(), Some("small"));
1097 assert_eq!(rich.sort.as_deref(), Some("10"));
1098 }
1099
1100 #[test]
1101 fn arg_suggestions_honor_numeric_sort_after_match_score() {
1102 let cmd_node = CompletionNode {
1103 args: vec![ArgNode {
1104 suggestions: vec![
1105 SuggestionEntry {
1106 value: "v10".to_string(),
1107 meta: None,
1108 display: None,
1109 sort: Some("10".to_string()),
1110 },
1111 SuggestionEntry {
1112 value: "v2".to_string(),
1113 meta: None,
1114 display: None,
1115 sort: Some("2".to_string()),
1116 },
1117 ],
1118 ..ArgNode::default()
1119 }],
1120 ..CompletionNode::default()
1121 };
1122 let tree = CompletionTree {
1123 root: CompletionNode::default().with_child("cmd", cmd_node),
1124 ..CompletionTree::default()
1125 };
1126 let engine = CompletionEngine::new(tree);
1127 let cmd = command(&["cmd"]);
1128
1129 let values = values(generate(&engine, cmd, ""));
1130 assert_eq!(values, vec!["v2".to_string(), "v10".to_string()]);
1131 }
1132
1133 #[test]
1134 fn subcommand_suggestions_honor_child_sort_after_match_score() {
1135 let tree = CompletionTree {
1136 root: CompletionNode::default()
1137 .with_child("orch", CompletionNode::default().sort("20"))
1138 .with_child("config", CompletionNode::default().sort("10")),
1139 ..CompletionTree::default()
1140 };
1141 let engine = CompletionEngine::new(tree);
1142
1143 let output = values(generate(&engine, CommandLine::default(), ""));
1144
1145 assert_eq!(output[..2], ["config", "orch"]);
1146 }
1147}