Skip to main content

osp_cli/completion/
model.rs

1//! Data structures shared across completion parsing, analysis, and ranking.
2//!
3//! This module exists to give the completion engine a stable vocabulary for
4//! cursor state, command-line structure, command-tree metadata, and ranked
5//! suggestions. The parser and suggester can evolve independently as long as
6//! they keep exchanging these values.
7//!
8//! Contract:
9//!
10//! - types here should stay pure data and small helpers
11//! - this layer may depend on shell tokenization details, but not on terminal
12//!   painting or REPL host state
13//! - public builders should describe the stable completion contract, not
14//!   internal parser quirks
15
16pub use crate::core::shell_words::QuoteStyle;
17use std::{collections::BTreeMap, ops::Range};
18
19/// Semantic type for values completed by the engine.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum ValueType {
22    /// Filesystem path value.
23    Path,
24}
25
26/// Replacement details for the token currently being completed.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CursorState {
29    /// Normalized token text used for matching suggestions.
30    pub token_stub: String,
31    /// Raw slice from the input buffer that will be replaced.
32    pub raw_stub: String,
33    /// Byte range in the input buffer that should be replaced.
34    pub replace_range: Range<usize>,
35    /// Quote style active at the cursor, if the token is quoted.
36    pub quote_style: Option<QuoteStyle>,
37}
38
39impl CursorState {
40    /// Creates a cursor state from explicit replacement data.
41    ///
42    /// `raw_stub` keeps the exact buffer slice that will be replaced, while
43    /// `token_stub` keeps the normalized text used for matching.
44    pub fn new(
45        token_stub: impl Into<String>,
46        raw_stub: impl Into<String>,
47        replace_range: Range<usize>,
48        quote_style: Option<QuoteStyle>,
49    ) -> Self {
50        Self {
51            token_stub: token_stub.into(),
52            raw_stub: raw_stub.into(),
53            replace_range,
54            quote_style,
55        }
56    }
57
58    /// Creates a synthetic cursor state for a standalone token stub.
59    ///
60    /// This is useful in tests and non-editor callers that only care about a
61    /// single token rather than a full input buffer.
62    ///
63    /// # Examples
64    ///
65    /// ```
66    /// use osp_cli::completion::CursorState;
67    ///
68    /// let state = CursorState::synthetic("ldap");
69    ///
70    /// assert_eq!(state.raw_stub, "ldap");
71    /// assert_eq!(state.replace_range, 0..4);
72    /// ```
73    pub fn synthetic(token_stub: impl Into<String>) -> Self {
74        let token_stub = token_stub.into();
75        let len = token_stub.len();
76        Self {
77            raw_stub: token_stub.clone(),
78            token_stub,
79            replace_range: 0..len,
80            quote_style: None,
81        }
82    }
83}
84
85impl Default for CursorState {
86    fn default() -> Self {
87        Self::synthetic("")
88    }
89}
90
91/// Scope used when merging context-only flags into the cursor view.
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub enum ContextScope {
94    /// Merge the flag regardless of the matched command path.
95    Global,
96    /// Merge the flag only within the matched subtree.
97    #[default]
98    Subtree,
99}
100
101/// Suggestion payload shown to the user and inserted on accept.
102///
103/// This separates the inserted value from the optional display label so menu
104/// UIs can stay human-friendly without changing what lands in the buffer.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct SuggestionEntry {
107    /// Text inserted into the buffer if this suggestion is accepted.
108    pub value: String,
109    /// Short right-column description in menu-style UIs.
110    pub meta: Option<String>,
111    /// Optional human-friendly label when the inserted value should stay terse.
112    pub display: Option<String>,
113    /// Hidden sort key for cases where display order should differ from labels.
114    pub sort: Option<String>,
115}
116
117impl SuggestionEntry {
118    /// Creates a suggestion that inserts `value`.
119    pub fn value(value: impl Into<String>) -> Self {
120        Self {
121            value: value.into(),
122            meta: None,
123            display: None,
124            sort: None,
125        }
126    }
127
128    /// Sets the right-column metadata text.
129    pub fn meta(mut self, meta: impl Into<String>) -> Self {
130        self.meta = Some(meta.into());
131        self
132    }
133
134    /// Sets the human-friendly label shown in menus.
135    pub fn display(mut self, display: impl Into<String>) -> Self {
136        self.display = Some(display.into());
137        self
138    }
139
140    /// Sets the hidden sort key for this suggestion.
141    pub fn sort(mut self, sort: impl Into<String>) -> Self {
142        self.sort = Some(sort.into());
143        self
144    }
145}
146
147impl From<&str> for SuggestionEntry {
148    fn from(value: &str) -> Self {
149        Self::value(value)
150    }
151}
152
153#[derive(Debug, Clone, Default, PartialEq, Eq)]
154/// OS version suggestions shared globally or scoped by provider.
155pub struct OsVersions {
156    /// Suggestions indexed by OS name across all providers.
157    pub union: BTreeMap<String, Vec<SuggestionEntry>>,
158    /// Suggestions indexed first by provider, then by OS name.
159    pub by_provider: BTreeMap<String, BTreeMap<String, Vec<SuggestionEntry>>>,
160}
161
162#[derive(Debug, Clone, Default, PartialEq, Eq)]
163/// Request-form hints used to derive flag and value suggestions.
164pub struct RequestHints {
165    /// Known request keys.
166    pub keys: Vec<String>,
167    /// Request keys that must be present.
168    pub required: Vec<String>,
169    /// Allowed values grouped by tier.
170    pub tiers: BTreeMap<String, Vec<String>>,
171    /// Default values by request key.
172    pub defaults: BTreeMap<String, String>,
173    /// Explicit value choices by request key.
174    pub choices: BTreeMap<String, Vec<String>>,
175}
176
177#[derive(Debug, Clone, Default, PartialEq, Eq)]
178/// Request hints shared globally and overridden by provider.
179pub struct RequestHintSet {
180    /// Hints available regardless of provider.
181    pub common: RequestHints,
182    /// Provider-specific request hints.
183    pub by_provider: BTreeMap<String, RequestHints>,
184}
185
186#[derive(Debug, Clone, Default, PartialEq, Eq)]
187/// Flag-name hints shared globally and overridden by provider.
188pub struct FlagHints {
189    /// Optional flags available regardless of provider.
190    pub common: Vec<String>,
191    /// Optional flags available for specific providers.
192    pub by_provider: BTreeMap<String, Vec<String>>,
193    /// Required flags available regardless of provider.
194    pub required_common: Vec<String>,
195    /// Required flags available for specific providers.
196    pub required_by_provider: BTreeMap<String, Vec<String>>,
197}
198
199/// Positional argument definition for one command slot.
200///
201/// This is declarative completion metadata, not parser state. One `ArgNode`
202/// says what a command slot expects once command-path resolution has reached the
203/// owning node.
204#[derive(Debug, Clone, Default, PartialEq, Eq)]
205pub struct ArgNode {
206    /// Argument name shown in completion UIs.
207    pub name: Option<String>,
208    /// Optional description shown alongside the argument.
209    pub tooltip: Option<String>,
210    /// Whether the argument may consume multiple values.
211    pub multi: bool,
212    /// Semantic type for the argument value.
213    pub value_type: Option<ValueType>,
214    /// Suggested values for the argument.
215    pub suggestions: Vec<SuggestionEntry>,
216}
217
218impl ArgNode {
219    /// Creates an argument node with a visible argument name.
220    pub fn named(name: impl Into<String>) -> Self {
221        Self {
222            name: Some(name.into()),
223            ..Self::default()
224        }
225    }
226
227    /// Sets the display tooltip for this argument.
228    pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
229        self.tooltip = Some(tooltip.into());
230        self
231    }
232
233    /// Marks this argument as accepting multiple values.
234    pub fn multi(mut self) -> Self {
235        self.multi = true;
236        self
237    }
238
239    /// Sets the semantic value type for this argument.
240    pub fn value_type(mut self, value_type: ValueType) -> Self {
241        self.value_type = Some(value_type);
242        self
243    }
244
245    /// Replaces the suggestion list for this argument.
246    pub fn suggestions(mut self, suggestions: impl IntoIterator<Item = SuggestionEntry>) -> Self {
247        self.suggestions = suggestions.into_iter().collect();
248        self
249    }
250}
251
252/// Completion metadata for a flag spelling.
253///
254/// Flags can contribute both direct value suggestions and context that affects
255/// later completion. `context_only` flags are the bridge for options that shape
256/// suggestion scope even when the cursor is not currently editing that flag.
257#[derive(Debug, Clone, Default, PartialEq, Eq)]
258pub struct FlagNode {
259    /// Optional description shown alongside the flag.
260    pub tooltip: Option<String>,
261    /// Whether the flag does not accept a value.
262    pub flag_only: bool,
263    /// Whether the flag may be repeated.
264    pub multi: bool,
265    // Context-only flags are merged from the full line into the cursor context.
266    // `context_scope` controls whether merge is global or path-scoped.
267    /// Whether the flag should be merged from the full line into cursor context.
268    pub context_only: bool,
269    /// Scope used when merging a context-only flag.
270    pub context_scope: ContextScope,
271    /// Semantic type for the flag value, if any.
272    pub value_type: Option<ValueType>,
273    /// Generic suggestions for the flag value.
274    pub suggestions: Vec<SuggestionEntry>,
275    /// Provider-specific value suggestions.
276    pub suggestions_by_provider: BTreeMap<String, Vec<SuggestionEntry>>,
277    /// Allowed providers by OS name.
278    pub os_provider_map: BTreeMap<String, Vec<String>>,
279    /// OS version suggestions attached to this flag.
280    pub os_versions: Option<OsVersions>,
281    /// Request-form hints attached to this flag.
282    pub request_hints: Option<RequestHintSet>,
283    /// Extra flag-name hints attached to this flag.
284    pub flag_hints: Option<FlagHints>,
285}
286
287impl FlagNode {
288    /// Creates an empty flag node.
289    pub fn new() -> Self {
290        Self::default()
291    }
292
293    /// Sets the display tooltip for this flag.
294    pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
295        self.tooltip = Some(tooltip.into());
296        self
297    }
298
299    /// Marks this flag as taking no value.
300    pub fn flag_only(mut self) -> Self {
301        self.flag_only = true;
302        self
303    }
304
305    /// Marks this flag as repeatable.
306    pub fn multi(mut self) -> Self {
307        self.multi = true;
308        self
309    }
310
311    /// Marks this flag as context-only within the given scope.
312    pub fn context_only(mut self, scope: ContextScope) -> Self {
313        self.context_only = true;
314        self.context_scope = scope;
315        self
316    }
317
318    /// Sets the semantic value type for this flag.
319    pub fn value_type(mut self, value_type: ValueType) -> Self {
320        self.value_type = Some(value_type);
321        self
322    }
323
324    /// Replaces the suggestion list for this flag value.
325    pub fn suggestions(mut self, suggestions: impl IntoIterator<Item = SuggestionEntry>) -> Self {
326        self.suggestions = suggestions.into_iter().collect();
327        self
328    }
329}
330
331/// One node in the immutable completion tree.
332///
333/// A node owns the completion contract for one resolved command scope:
334/// subcommands, flags, positional arguments, and any hidden defaults inherited
335/// through aliases or shell scope.
336#[derive(Debug, Clone, Default, PartialEq, Eq)]
337pub struct CompletionNode {
338    /// Optional description shown alongside the node.
339    pub tooltip: Option<String>,
340    /// Optional suggestion-order hint for command/subcommand completion.
341    pub sort: Option<String>,
342    /// Whether an exact token should commit scope even without a trailing delimiter.
343    pub exact_token_commits: bool,
344    /// This node expects the next token to be a key chosen from `children`.
345    pub value_key: bool,
346    /// This node is itself a terminal value that can be suggested/accepted.
347    pub value_leaf: bool,
348    /// Hidden context flags injected when this node is matched.
349    pub prefilled_flags: BTreeMap<String, Vec<String>>,
350    /// Fixed positional values contributed before user-provided args.
351    pub prefilled_positionals: Vec<String>,
352    /// Nested subcommands or value-like children.
353    pub children: BTreeMap<String, CompletionNode>,
354    /// Flags visible in this command scope.
355    pub flags: BTreeMap<String, FlagNode>,
356    /// Positional arguments accepted in this command scope.
357    pub args: Vec<ArgNode>,
358    /// Extra flag-name hints contributed by this node.
359    pub flag_hints: Option<FlagHints>,
360}
361
362impl CompletionNode {
363    /// Sets the hidden sort key for this node.
364    pub fn sort(mut self, sort: impl Into<String>) -> Self {
365        self.sort = Some(sort.into());
366        self
367    }
368
369    /// Adds a child node keyed by command or value name.
370    pub fn with_child(mut self, name: impl Into<String>, node: CompletionNode) -> Self {
371        self.children.insert(name.into(), node);
372        self
373    }
374
375    /// Adds a flag node keyed by its spelling.
376    pub fn with_flag(mut self, name: impl Into<String>, node: FlagNode) -> Self {
377        self.flags.insert(name.into(), node);
378        self
379    }
380}
381
382#[derive(Debug, Clone, Default, PartialEq, Eq)]
383/// Immutable completion data consumed by the engine.
384pub struct CompletionTree {
385    /// Root completion node for the command hierarchy.
386    pub root: CompletionNode,
387    /// Pipe verbs are kept separate from the command tree because they only
388    /// become visible after the parser has entered DSL mode.
389    pub pipe_verbs: BTreeMap<String, String>,
390}
391
392#[derive(Debug, Clone, Default, PartialEq, Eq)]
393/// Parsed command-line structure before higher-level completion analysis.
394pub struct CommandLine {
395    /// Command path tokens matched before tail parsing starts.
396    pub(crate) head: Vec<String>,
397    /// Parsed flags and positional arguments after the command path.
398    pub(crate) tail: Vec<TailItem>,
399    /// Merged flag values keyed by spelling.
400    pub(crate) flag_values: BTreeMap<String, Vec<String>>,
401    /// Tokens that appear after the first pipe.
402    pub(crate) pipes: Vec<String>,
403    /// Whether the parser entered pipe mode.
404    pub(crate) has_pipe: bool,
405}
406
407#[derive(Debug, Clone, Default, PartialEq, Eq)]
408/// One occurrence of a flag and the values consumed with it.
409pub struct FlagOccurrence {
410    /// Flag spelling as it appeared in the input.
411    pub name: String,
412    /// Values consumed by this flag occurrence.
413    pub values: Vec<String>,
414}
415
416#[derive(Debug, Clone, PartialEq, Eq)]
417/// Item in the parsed tail after the command path.
418pub enum TailItem {
419    /// A flag occurrence with any values it consumed.
420    Flag(FlagOccurrence),
421    /// A positional argument.
422    Positional(String),
423}
424
425impl CommandLine {
426    /// Returns the matched command path tokens.
427    pub fn head(&self) -> &[String] {
428        &self.head
429    }
430
431    /// Returns the parsed tail items after the command path.
432    pub fn tail(&self) -> &[TailItem] {
433        &self.tail
434    }
435
436    /// Returns tokens in the pipe segment, if present.
437    pub fn pipes(&self) -> &[String] {
438        &self.pipes
439    }
440
441    /// Returns whether the line entered pipe mode.
442    pub fn has_pipe(&self) -> bool {
443        self.has_pipe
444    }
445
446    /// Returns all merged flag values keyed by flag spelling.
447    pub fn flag_values_map(&self) -> &BTreeMap<String, Vec<String>> {
448        &self.flag_values
449    }
450
451    /// Returns values collected for one flag spelling.
452    pub fn flag_values(&self, name: &str) -> Option<&[String]> {
453        self.flag_values.get(name).map(Vec::as_slice)
454    }
455
456    /// Returns whether the command line contains the flag spelling.
457    pub fn has_flag(&self, name: &str) -> bool {
458        self.flag_values.contains_key(name)
459    }
460
461    /// Iterates over flag occurrences in input order.
462    pub fn flag_occurrences(&self) -> impl Iterator<Item = &FlagOccurrence> {
463        self.tail.iter().filter_map(|item| match item {
464            TailItem::Flag(flag) => Some(flag),
465            TailItem::Positional(_) => None,
466        })
467    }
468
469    /// Returns the last flag occurrence, if any.
470    pub fn last_flag_occurrence(&self) -> Option<&FlagOccurrence> {
471        self.flag_occurrences().last()
472    }
473
474    /// Iterates over positional arguments in the tail.
475    pub fn positional_args(&self) -> impl Iterator<Item = &String> {
476        self.tail.iter().filter_map(|item| match item {
477            TailItem::Positional(value) => Some(value),
478            TailItem::Flag(_) => None,
479        })
480    }
481
482    /// Returns the number of tail items.
483    pub fn tail_len(&self) -> usize {
484        self.tail.len()
485    }
486
487    /// Appends a flag occurrence and merges its values into the lookup map.
488    #[cfg(test)]
489    pub(crate) fn push_flag_occurrence(&mut self, occurrence: FlagOccurrence) {
490        self.flag_values
491            .entry(occurrence.name.clone())
492            .or_default()
493            .extend(occurrence.values.iter().cloned());
494        self.tail.push(TailItem::Flag(occurrence));
495    }
496
497    /// Appends a positional argument to the tail.
498    #[cfg(test)]
499    pub(crate) fn push_positional(&mut self, value: impl Into<String>) {
500        self.tail.push(TailItem::Positional(value.into()));
501    }
502
503    /// Merges additional values for a flag spelling.
504    pub(crate) fn merge_flag_values(&mut self, name: impl Into<String>, values: Vec<String>) {
505        self.flag_values
506            .entry(name.into())
507            .or_default()
508            .extend(values);
509    }
510
511    /// Inserts positional values ahead of the existing tail.
512    pub(crate) fn prepend_positional_values(&mut self, values: impl IntoIterator<Item = String>) {
513        let mut values = values
514            .into_iter()
515            .filter(|value| !value.trim().is_empty())
516            .map(TailItem::Positional)
517            .collect::<Vec<_>>();
518        if values.is_empty() {
519            return;
520        }
521        values.extend(std::mem::take(&mut self.tail));
522        self.tail = values;
523    }
524
525    /// Marks the command line as piped and stores the pipe tokens.
526    #[cfg(test)]
527    pub(crate) fn set_pipe(&mut self, pipes: Vec<String>) {
528        self.has_pipe = true;
529        self.pipes = pipes;
530    }
531
532    /// Appends one segment to the command path.
533    #[cfg(test)]
534    pub(crate) fn push_head(&mut self, segment: impl Into<String>) {
535        self.head.push(segment.into());
536    }
537}
538
539#[derive(Debug, Clone, Default, PartialEq, Eq)]
540/// Parser output for the full line and the cursor-local prefix.
541pub struct ParsedLine {
542    /// Cursor offset clamped to a valid UTF-8 boundary.
543    pub safe_cursor: usize,
544    /// Tokens parsed from the full line.
545    pub full_tokens: Vec<String>,
546    /// Tokens parsed from the line prefix before the cursor.
547    pub cursor_tokens: Vec<String>,
548    /// Parsed command-line structure for the full line.
549    pub full_cmd: CommandLine,
550    /// Parsed command-line structure for the prefix before the cursor.
551    pub cursor_cmd: CommandLine,
552}
553
554#[derive(Debug, Clone, PartialEq, Eq)]
555/// Explicit request kind for the current cursor position.
556pub enum CompletionRequest {
557    /// Completing a DSL pipe verb.
558    Pipe,
559    /// Completing a flag spelling in the current flag scope.
560    FlagNames {
561        /// Command path that contributes visible flags.
562        flag_scope_path: Vec<String>,
563    },
564    /// Completing values for a specific flag.
565    FlagValues {
566        /// Command path that contributes the flag definition.
567        flag_scope_path: Vec<String>,
568        /// Flag currently requesting values.
569        flag: String,
570    },
571    /// Completing subcommands, positional values, or empty-stub flags.
572    Positionals {
573        /// Command path contributing subcommands or positional args.
574        context_path: Vec<String>,
575        /// Command path that contributes visible flags.
576        flag_scope_path: Vec<String>,
577        /// Positional argument index relative to the resolved command path.
578        arg_index: usize,
579        /// Whether subcommand names should be suggested.
580        show_subcommands: bool,
581        /// Whether empty-stub flag spellings should also be suggested.
582        show_flag_names: bool,
583    },
584}
585
586impl Default for CompletionRequest {
587    fn default() -> Self {
588        Self::Positionals {
589            context_path: Vec::new(),
590            flag_scope_path: Vec::new(),
591            arg_index: 0,
592            show_subcommands: false,
593            show_flag_names: false,
594        }
595    }
596}
597
598impl CompletionRequest {
599    /// Returns the stable request-kind label used by tests and debug surfaces.
600    pub fn kind(&self) -> &'static str {
601        match self {
602            Self::Pipe => "pipe",
603            Self::FlagNames { .. } => "flag-names",
604            Self::FlagValues { .. } => "flag-values",
605            Self::Positionals {
606                show_subcommands: true,
607                ..
608            } => "subcommands",
609            Self::Positionals { .. } => "positionals",
610        }
611    }
612}
613
614#[derive(Debug, Clone, Default, PartialEq, Eq)]
615/// Full completion analysis derived from parsing and context resolution.
616pub struct CompletionAnalysis {
617    /// Full parser output plus the cursor-local context derived from it.
618    pub parsed: ParsedLine,
619    /// Replacement details for the active token.
620    pub cursor: CursorState,
621    /// Resolved command context used for suggestion generation.
622    pub context: CompletionContext,
623    /// Explicit request kind for suggestion generation.
624    pub request: CompletionRequest,
625}
626
627/// Resolved completion state for the cursor position.
628///
629/// The parser only knows about tokens. This structure captures the derived
630/// command context the suggester/debug layers actually care about:
631/// which command path matched, which node contributes visible flags, and
632/// whether the cursor is still in subcommand-selection mode.
633#[derive(Debug, Clone, Default, PartialEq, Eq)]
634pub struct CompletionContext {
635    /// Command path matched before the cursor.
636    pub matched_path: Vec<String>,
637    /// Command path that contributes visible flags.
638    pub flag_scope_path: Vec<String>,
639    /// Whether the cursor is completing a subcommand name.
640    pub subcommand_context: bool,
641}
642
643/// High-level classification for a completion candidate.
644#[derive(Debug, Clone, Copy, PartialEq, Eq)]
645pub enum MatchKind {
646    /// Candidate belongs to pipe-mode completion.
647    Pipe,
648    /// Candidate is a flag spelling.
649    Flag,
650    /// Candidate is a top-level command.
651    Command,
652    /// Candidate is a nested subcommand.
653    Subcommand,
654    /// Candidate is a value or positional suggestion.
655    Value,
656}
657
658impl MatchKind {
659    /// Returns the stable string form used by presentation layers.
660    pub fn as_str(self) -> &'static str {
661        match self {
662            Self::Pipe => "pipe",
663            Self::Flag => "flag",
664            Self::Command => "command",
665            Self::Subcommand => "subcommand",
666            Self::Value => "value",
667        }
668    }
669}
670
671#[derive(Debug, Clone, PartialEq, Eq)]
672/// Ranked suggestion ready for formatting or rendering.
673pub struct Suggestion {
674    /// Text inserted into the buffer if accepted.
675    pub text: String,
676    /// Short metadata shown alongside the suggestion.
677    pub meta: Option<String>,
678    /// Optional human-friendly label.
679    pub display: Option<String>,
680    /// Whether the suggestion exactly matches the current stub.
681    pub is_exact: bool,
682    /// Hidden sort key for ordering.
683    pub sort: Option<String>,
684    /// Numeric score used for ranking.
685    pub match_score: u32,
686}
687
688impl Suggestion {
689    /// Creates a suggestion with default ranking metadata.
690    pub fn new(text: impl Into<String>) -> Self {
691        Self {
692            text: text.into(),
693            meta: None,
694            display: None,
695            is_exact: false,
696            sort: None,
697            match_score: u32::MAX,
698        }
699    }
700}
701
702#[derive(Debug, Clone, PartialEq, Eq)]
703/// Output emitted by the suggestion engine.
704pub enum SuggestionOutput {
705    /// A normal suggestion item.
706    Item(Suggestion),
707    /// Sentinel indicating that filesystem path completion should run next.
708    PathSentinel,
709}
710
711#[cfg(test)]
712mod tests;