1use crate::completion::context::{ProviderSelection, TreeResolver};
14use crate::completion::model::{
15 CommandLine, CompletionAnalysis, CompletionNode, CompletionRequest, CompletionTree, Suggestion,
16 SuggestionEntry, SuggestionOutput, ValueType,
17};
18use crate::core::fuzzy::{completion_fuzzy_matcher, fold_case};
19use skim::fuzzy_matcher::FuzzyMatcher;
20use std::collections::BTreeSet;
21
22const MATCH_SCORE_EXACT: u32 = 0;
23const MATCH_SCORE_EMPTY_STUB: u32 = 1_000;
24const MATCH_SCORE_PREFIX_BASE: u32 = 100;
25const MATCH_SCORE_BOUNDARY_PREFIX_BASE: u32 = 200;
26const MATCH_SCORE_FUZZY_BASE: u32 = 10_000;
27const MATCH_SCORE_FUZZY_NORMALIZED_MAX: u32 = 100_000;
28struct PositionalRequest<'a> {
32 context_node: &'a CompletionNode,
33 flag_scope_node: &'a CompletionNode,
34 arg_index: usize,
35 stub: &'a str,
36 cmd: &'a CommandLine,
37 show_subcommands: bool,
38 show_flag_names: bool,
39}
40
41#[derive(Debug, Clone)]
43pub struct SuggestionEngine {
44 tree: CompletionTree,
45}
46
47impl SuggestionEngine {
48 pub fn new(tree: CompletionTree) -> Self {
50 Self { tree }
51 }
52
53 pub fn generate(&self, analysis: &CompletionAnalysis) -> Vec<SuggestionOutput> {
101 self.emit_suggestions(&analysis.request, analysis)
102 }
103
104 fn emit_suggestions(
105 &self,
106 request: &CompletionRequest,
107 analysis: &CompletionAnalysis,
108 ) -> Vec<SuggestionOutput> {
109 let cmd = &analysis.parsed.cursor_cmd;
110 let stub = analysis.cursor.token_stub.as_str();
111 let resolver = TreeResolver::new(&self.tree);
112
113 let mut out = match request {
114 CompletionRequest::Pipe => self.pipe_suggestions(stub),
115 CompletionRequest::FlagNames { flag_scope_path } => {
116 let flag_scope_node = resolver
117 .resolve_exact(flag_scope_path)
118 .unwrap_or(&self.tree.root);
119 self.flag_name_suggestions(flag_scope_node, stub, cmd)
120 .into_iter()
121 .map(SuggestionOutput::Item)
122 .collect()
123 }
124 CompletionRequest::FlagValues {
125 flag_scope_path,
126 flag,
127 } => {
128 let flag_scope_node = resolver
129 .resolve_exact(flag_scope_path)
130 .unwrap_or(&self.tree.root);
131 self.flag_value_suggestions(flag_scope_node, flag, stub, cmd)
132 }
133 CompletionRequest::Positionals {
134 context_path,
135 flag_scope_path,
136 arg_index,
137 show_subcommands,
138 show_flag_names,
139 } => {
140 let context_node = resolver
141 .resolve_exact(context_path)
142 .unwrap_or(&self.tree.root);
143 let flag_scope_node = resolver
144 .resolve_exact(flag_scope_path)
145 .unwrap_or(&self.tree.root);
146 let request = PositionalRequest {
147 context_node,
148 flag_scope_node,
149 arg_index: *arg_index,
150 stub,
151 cmd,
152 show_subcommands: *show_subcommands,
153 show_flag_names: *show_flag_names,
154 };
155 let mut out = self.positional_suggestions(request);
156 sort_suggestion_outputs(&mut out);
157 return out;
158 }
159 };
160
161 sort_suggestion_outputs(&mut out);
162 out
163 }
164
165 fn positional_suggestions(&self, request: PositionalRequest<'_>) -> Vec<SuggestionOutput> {
166 let mut out = Vec::new();
167
168 if request.show_subcommands {
169 let subcommand_stub = if request.context_node.children.contains_key(request.stub) {
170 ""
171 } else {
172 request.stub
173 };
174 out.extend(
175 self.subcommand_suggestions(request.context_node, subcommand_stub)
176 .into_iter()
177 .map(SuggestionOutput::Item),
178 );
179 } else {
180 out.extend(self.arg_value_suggestions(
181 request.context_node,
182 request.arg_index,
183 request.stub,
184 ));
185 }
186
187 if request.show_flag_names {
188 out.extend(
189 self.flag_name_suggestions(request.flag_scope_node, request.stub, request.cmd)
190 .into_iter()
191 .map(SuggestionOutput::Item),
192 );
193 }
194
195 out
196 }
197
198 fn pipe_suggestions(&self, stub: &str) -> Vec<SuggestionOutput> {
199 self.tree
200 .pipe_verbs
201 .iter()
202 .filter_map(|(verb, tooltip)| {
203 let score = self.match_score(stub, verb)?;
204 Some(SuggestionOutput::Item(Suggestion {
205 text: verb.clone(),
206 meta: Some(tooltip.clone()),
207 display: None,
208 is_exact: score == 0,
209 sort: None,
210 match_score: score,
211 }))
212 })
213 .collect()
214 }
215
216 fn flag_name_suggestions(
217 &self,
218 node: &CompletionNode,
219 stub: &str,
220 cmd: &CommandLine,
221 ) -> Vec<Suggestion> {
222 let allowlist = self.resolved_flag_allowlist(node, cmd);
223 let required = self.required_flags(node, cmd);
224 let flag_stub = if node.flags.contains_key(stub) {
225 ""
226 } else {
227 stub
228 };
229
230 node.flags
231 .iter()
232 .filter_map(|(flag, meta)| {
233 let score = self.match_score(flag_stub, flag)?;
234 Some((flag, meta, score))
235 })
236 .filter(|(flag, _, _)| {
237 allowlist
238 .as_ref()
239 .is_none_or(|allowed| allowed.contains(flag.as_str()))
240 })
241 .filter(|(flag, meta, _)| meta.multi || !cmd.has_flag(flag) || stub == *flag)
242 .map(|(flag, meta, score)| Suggestion {
243 text: flag.clone(),
244 meta: meta.tooltip.clone(),
245 display: required.contains(flag.as_str()).then(|| format!("{flag}*")),
246 is_exact: score == 0,
247 sort: None,
248 match_score: score,
249 })
250 .collect()
251 }
252
253 fn flag_value_suggestions(
254 &self,
255 node: &CompletionNode,
256 flag: &str,
257 stub: &str,
258 cmd: &CommandLine,
259 ) -> Vec<SuggestionOutput> {
260 let Some(flag_node) = node.flags.get(flag) else {
261 return Vec::new();
262 };
263
264 if flag_node.flag_only {
265 return Vec::new();
266 }
267
268 if flag_node.value_type == Some(ValueType::Path) {
269 return vec![SuggestionOutput::PathSentinel];
270 }
271
272 if let Some(output) =
273 self.provider_specific_flag_value_suggestions(flag_node, flag, stub, cmd)
274 {
275 return output;
276 }
277
278 self.entry_suggestions(&flag_node.suggestions, stub)
279 }
280
281 fn arg_value_suggestions(
282 &self,
283 node: &CompletionNode,
284 index: usize,
285 stub: &str,
286 ) -> Vec<SuggestionOutput> {
287 let Some(arg) = node.args.get(index) else {
288 return Vec::new();
289 };
290
291 if arg.value_type == Some(ValueType::Path) {
292 return vec![SuggestionOutput::PathSentinel];
293 }
294
295 self.entry_suggestions(&arg.suggestions, stub)
296 }
297
298 fn subcommand_suggestions(&self, node: &CompletionNode, stub: &str) -> Vec<Suggestion> {
299 node.children
300 .iter()
301 .filter_map(|(name, child)| {
302 let score = self.match_score(stub, name)?;
303 Some(Suggestion {
304 text: name.clone(),
305 meta: child_completion_meta(child),
306 display: None,
307 is_exact: score == 0,
308 sort: child.sort.clone(),
309 match_score: score,
310 })
311 })
312 .collect()
313 }
314 fn provider_specific_flag_value_suggestions(
315 &self,
316 flag_node: &crate::completion::model::FlagNode,
317 flag: &str,
318 stub: &str,
319 cmd: &CommandLine,
320 ) -> Option<Vec<SuggestionOutput>> {
321 let provider = ProviderSelection::from_command(cmd);
330
331 if flag == "--provider" {
332 let os_token = provider.normalized_os();
333 if let Some(os_token) = os_token {
334 let filtered = flag_node
335 .suggestions
336 .iter()
337 .filter(|entry| {
338 flag_node
339 .os_provider_map
340 .get(os_token)
341 .is_none_or(|providers| providers.iter().any(|p| p == &entry.value))
342 })
343 .cloned()
344 .collect::<Vec<_>>();
345 if !filtered.is_empty() {
346 return Some(self.entry_suggestions(&filtered, stub));
347 }
348 }
349 }
350
351 let provider_values = flag_node.suggestions_by_provider.get(provider.name()?)?;
352 Some(self.entry_suggestions(provider_values, stub))
353 }
354
355 fn entry_suggestions(&self, entries: &[SuggestionEntry], stub: &str) -> Vec<SuggestionOutput> {
356 let stub = if entries
357 .iter()
358 .any(|entry| fold_case(&entry.value) == fold_case(stub))
359 {
360 ""
361 } else {
362 stub
363 };
364 entries
365 .iter()
366 .filter_map(|entry| {
367 let score = self.match_score(stub, &entry.value)?;
368 Some(SuggestionOutput::Item(entry_to_suggestion(entry, score)))
369 })
370 .collect()
371 }
372
373 fn match_score(&self, stub: &str, candidate: &str) -> Option<u32> {
374 if stub.is_empty() {
381 return Some(MATCH_SCORE_EMPTY_STUB);
382 }
383
384 let stub_lc = fold_case(stub);
385 let candidate_lc = fold_case(candidate);
386
387 if stub_lc == candidate_lc {
388 return Some(MATCH_SCORE_EXACT);
389 }
390 if candidate_lc.starts_with(&stub_lc) {
393 return Some(MATCH_SCORE_PREFIX_BASE + (candidate_lc.len() - stub_lc.len()) as u32);
394 }
395
396 if let Some(boundary) = boundary_prefix_index(&candidate_lc, &stub_lc) {
397 return Some(MATCH_SCORE_BOUNDARY_PREFIX_BASE + boundary as u32);
398 }
399
400 let fuzzy = completion_fuzzy_matcher().fuzzy_match(&candidate_lc, &stub_lc)?;
403 let normalized = fuzzy.max(0) as u32;
404 let penalty = MATCH_SCORE_FUZZY_NORMALIZED_MAX.saturating_sub(normalized);
405 Some(MATCH_SCORE_FUZZY_BASE + penalty)
406 }
407
408 fn resolved_flag_allowlist(
409 &self,
410 node: &CompletionNode,
411 cmd: &CommandLine,
412 ) -> Option<BTreeSet<String>> {
413 let hints = node.flag_hints.as_ref()?;
414 let mut allowed = hints.common.iter().cloned().collect::<BTreeSet<_>>();
415
416 if let Some(provider) = ProviderSelection::from_command(cmd).name() {
417 if let Some(provider_specific) = hints.by_provider.get(provider) {
418 allowed.extend(provider_specific.iter().cloned());
419 }
420 allowed.remove("--provider");
422 allowed.remove("--nrec");
423 allowed.remove("--vmware");
424 }
425
426 if cmd.has_flag("--linux") {
427 allowed.remove("--windows");
428 }
429 if cmd.has_flag("--windows") {
430 allowed.remove("--linux");
431 }
432
433 Some(allowed)
434 }
435
436 fn required_flags(&self, node: &CompletionNode, cmd: &CommandLine) -> BTreeSet<String> {
437 let mut required = BTreeSet::new();
438 let Some(hints) = node.flag_hints.as_ref() else {
439 return required;
440 };
441
442 required.extend(hints.required_common.iter().cloned());
443 if let Some(provider) = ProviderSelection::from_command(cmd).name()
444 && let Some(provider_required) = hints.required_by_provider.get(provider)
445 {
446 required.extend(provider_required.iter().cloned());
447 }
448 required
449 }
450}
451
452fn child_completion_meta(child: &CompletionNode) -> Option<String> {
453 let summary = child_subcommand_summary(child);
454 match (child.tooltip.as_deref(), summary) {
455 (Some(tooltip), Some(summary)) => Some(format!("{tooltip} ({summary})")),
456 (Some(tooltip), None) => Some(tooltip.to_string()),
457 (None, Some(summary)) => Some(summary),
458 (None, None) => None,
459 }
460}
461
462fn child_subcommand_summary(child: &CompletionNode) -> Option<String> {
463 if child.children.is_empty() {
464 return None;
465 }
466
467 let preview = child.children.keys().take(3).cloned().collect::<Vec<_>>();
468 if preview.is_empty() {
469 return None;
470 }
471
472 let mut summary = format!("subcommands: {}", preview.join(", "));
473 if child.children.len() > preview.len() {
474 summary.push_str(", ...");
475 }
476 Some(summary)
477}
478
479fn sort_suggestion_outputs(outputs: &mut Vec<SuggestionOutput>) {
480 let mut items: Vec<Suggestion> = outputs
481 .iter()
482 .filter_map(|entry| match entry {
483 SuggestionOutput::Item(item) => Some(item.clone()),
484 SuggestionOutput::PathSentinel => None,
485 })
486 .collect();
487 let path_sentinel_count = outputs
488 .iter()
489 .filter(|entry| matches!(entry, SuggestionOutput::PathSentinel))
490 .count();
491
492 items.sort_by(compare_suggestions);
493
494 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(|| fold_case(&left.text).cmp(&fold_case(&right.text)))
511}
512
513fn compare_sort_value(left: Option<&str>, right: Option<&str>) -> std::cmp::Ordering {
514 match (left, right) {
515 (Some(left), Some(right)) => {
516 match (
517 left.trim().parse::<f64>().ok(),
518 right.trim().parse::<f64>().ok(),
519 ) {
520 (Some(left_num), Some(right_num)) => left_num
521 .partial_cmp(&right_num)
522 .unwrap_or(std::cmp::Ordering::Equal),
523 _ => fold_case(left).cmp(&fold_case(right)),
524 }
525 }
526 (Some(_), None) => std::cmp::Ordering::Less,
527 (None, Some(_)) => std::cmp::Ordering::Greater,
528 (None, None) => std::cmp::Ordering::Equal,
529 }
530}
531
532fn not_exact(suggestion: &Suggestion) -> bool {
533 !suggestion.is_exact
534}
535
536fn entry_to_suggestion(entry: &SuggestionEntry, match_score: u32) -> Suggestion {
537 Suggestion {
538 text: entry.value.clone(),
539 meta: entry.meta.clone(),
540 display: entry.display.clone(),
541 is_exact: match_score == 0,
542 sort: entry.sort.clone(),
543 match_score,
544 }
545}
546
547fn boundary_prefix_index(candidate: &str, stub: &str) -> Option<usize> {
548 candidate
549 .match_indices(stub)
550 .find(|(idx, _)| {
551 *idx == 0
552 || candidate
553 .as_bytes()
554 .get(idx.saturating_sub(1))
555 .is_some_and(|byte| matches!(byte, b'-' | b'_' | b'.' | b':' | b'/'))
556 })
557 .map(|(idx, _)| idx)
558}
559
560#[cfg(test)]
561mod tests;