Skip to main content

osp_cli/completion/
suggest.rs

1//! Suggestion ranking and shaping for completion results.
2//!
3//! This module exists to turn parsed cursor context plus a static completion
4//! tree into ranked suggestion outputs. It is deliberately separate from
5//! tokenization so ranking rules can evolve without changing the parser.
6//!
7//! Contract:
8//!
9//! - suggestion ranking lives here
10//! - this layer may depend on fuzzy matching and provider context helpers
11//! - it should not own shell parsing or terminal rendering
12
13use 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;
28// Lower scores win:
29// exact < prefix < boundary-prefix < fuzzy fallback.
30
31struct 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/// Generates ranked completion suggestions from a completion tree and cursor analysis.
43#[derive(Debug, Clone)]
44pub struct SuggestionEngine {
45    tree: CompletionTree,
46}
47
48impl SuggestionEngine {
49    /// Creates a suggestion engine for the provided completion tree.
50    pub fn new(tree: CompletionTree) -> Self {
51        Self { tree }
52    }
53
54    /// Produces sorted completion outputs for the current cursor analysis.
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// use osp_cli::completion::{
60    ///     CommandLine, CompletionAnalysis, CompletionContext, CompletionNode,
61    ///     CompletionRequest, CompletionTree, CursorState, ParsedLine,
62    ///     SuggestionEngine, SuggestionOutput,
63    /// };
64    /// use std::collections::BTreeMap;
65    ///
66    /// let tree = CompletionTree {
67    ///     root: CompletionNode::default()
68    ///         .with_child("ldap", CompletionNode::default()),
69    ///     pipe_verbs: BTreeMap::new(),
70    /// };
71    /// let analysis = CompletionAnalysis {
72    ///     parsed: ParsedLine {
73    ///         safe_cursor: 2,
74    ///         full_tokens: vec!["ld".to_string()],
75    ///         cursor_tokens: vec!["ld".to_string()],
76    ///         full_cmd: CommandLine::default(),
77    ///         cursor_cmd: CommandLine::default(),
78    ///     },
79    ///     cursor: CursorState::synthetic("ld"),
80    ///     context: CompletionContext {
81    ///         matched_path: Vec::new(),
82    ///         flag_scope_path: Vec::new(),
83    ///         subcommand_context: true,
84    ///     },
85    ///     request: CompletionRequest::Positionals {
86    ///         context_path: Vec::new(),
87    ///         flag_scope_path: Vec::new(),
88    ///         arg_index: 0,
89    ///         show_subcommands: true,
90    ///         show_flag_names: false,
91    ///     },
92    /// };
93    ///
94    /// let suggestions = SuggestionEngine::new(tree).generate(&analysis);
95    ///
96    /// assert!(matches!(
97    ///     suggestions.first(),
98    ///     Some(SuggestionOutput::Item(item)) if item.text == "ldap"
99    /// ));
100    /// ```
101    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        // Provider completion has two special cases:
315        // - selecting `--provider` may be constrained by the current `--os`
316        // - many flags expose provider-specific value sets once a provider is chosen
317        //
318        // `osp-cli` marks these selector flags as context-only in
319        // `repl/completion.rs`; the suggestion engine still needs a small
320        // amount of flag-name-specific logic until that relationship is fully
321        // expressed in completion metadata.
322        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        // Lower scores are better:
366        // - exact match wins with 0
367        // - 100-range keeps ordinary prefix matches together
368        // - 200-range keeps word-boundary prefixes behind direct prefixes
369        // - 10_000+ is fuzzy fallback, where higher fuzzy scores reduce the
370        //   penalty and therefore sort earlier
371        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        // Prefer deterministic prefix classes before fuzzy fallback so short
382        // tab-completion stubs stay predictable.
383        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        // Fuzzy matching is the rescue path, not the primary ranking model.
392        // Its normalized penalty keeps rescues behind explicit prefix matches.
393        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            // Once provider is selected, hide selector flags.
413            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    // Path sentinels are not ranked suggestions; they are control markers that
491    // tell the caller to ask the shell for filesystem completion after all
492    // normal ranked suggestions have been shown. Keep item ordering text-stable
493    // before reattaching the sentinels so redraws do not jump between ties.
494    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;