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