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    show_subcommands: bool,
38    show_flag_names: bool,
39}
40
41/// Generates ranked completion suggestions from a completion tree and cursor analysis.
42#[derive(Debug, Clone)]
43pub struct SuggestionEngine {
44    tree: CompletionTree,
45}
46
47impl SuggestionEngine {
48    /// Creates a suggestion engine for the provided completion tree.
49    pub fn new(tree: CompletionTree) -> Self {
50        Self { tree }
51    }
52
53    /// Produces sorted completion outputs for the current cursor analysis.
54    ///
55    /// # Examples
56    ///
57    /// ```
58    /// use osp_cli::completion::{
59    ///     CommandLine, CompletionAnalysis, CompletionContext, CompletionNode,
60    ///     CompletionRequest, CompletionTree, CursorState, ParsedLine,
61    ///     SuggestionEngine, SuggestionOutput,
62    /// };
63    /// use std::collections::BTreeMap;
64    ///
65    /// let tree = CompletionTree {
66    ///     root: CompletionNode::default()
67    ///         .with_child("ldap", CompletionNode::default()),
68    ///     pipe_verbs: BTreeMap::new(),
69    /// };
70    /// let analysis = CompletionAnalysis {
71    ///     parsed: ParsedLine {
72    ///         safe_cursor: 2,
73    ///         full_tokens: vec!["ld".to_string()],
74    ///         cursor_tokens: vec!["ld".to_string()],
75    ///         full_cmd: CommandLine::default(),
76    ///         cursor_cmd: CommandLine::default(),
77    ///     },
78    ///     cursor: CursorState::synthetic("ld"),
79    ///     context: CompletionContext {
80    ///         matched_path: Vec::new(),
81    ///         flag_scope_path: Vec::new(),
82    ///         subcommand_context: true,
83    ///     },
84    ///     request: CompletionRequest::Positionals {
85    ///         context_path: Vec::new(),
86    ///         flag_scope_path: Vec::new(),
87    ///         arg_index: 0,
88    ///         show_subcommands: true,
89    ///         show_flag_names: false,
90    ///     },
91    /// };
92    ///
93    /// let suggestions = SuggestionEngine::new(tree).generate(&analysis);
94    ///
95    /// assert!(matches!(
96    ///     suggestions.first(),
97    ///     Some(SuggestionOutput::Item(item)) if item.text == "ldap"
98    /// ));
99    /// ```
100    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        // Provider completion has two special cases:
322        // - selecting `--provider` may be constrained by the current `--os`
323        // - many flags expose provider-specific value sets once a provider is chosen
324        //
325        // `osp-cli` marks these selector flags as context-only in
326        // `repl/completion.rs`; the suggestion engine still needs a small
327        // amount of flag-name-specific logic until that relationship is fully
328        // expressed in completion metadata.
329        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        // Lower scores are better:
375        // - exact match wins with 0
376        // - 100-range keeps ordinary prefix matches together
377        // - 200-range keeps word-boundary prefixes behind direct prefixes
378        // - 10_000+ is fuzzy fallback, where higher fuzzy scores reduce the
379        //   penalty and therefore sort earlier
380        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        // Prefer deterministic prefix classes before fuzzy fallback so short
391        // tab-completion stubs stay predictable.
392        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        // Fuzzy matching is the rescue path, not the primary ranking model.
401        // Its normalized penalty keeps rescues behind explicit prefix matches.
402        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            // Once provider is selected, hide selector flags.
421            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    // Path sentinels are not ranked suggestions; they are control markers that
495    // tell the caller to ask the shell for filesystem completion after all
496    // normal ranked suggestions have been shown. Keep item ordering text-stable
497    // before reattaching the sentinels so redraws do not jump between ties.
498    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;