Skip to main content

osp_cli/completion/
model.rs

1use std::{collections::BTreeMap, ops::Range};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub enum ValueType {
5    Path,
6}
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum QuoteStyle {
10    Single,
11    Double,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct CursorState {
16    pub token_stub: String,
17    pub raw_stub: String,
18    pub replace_range: Range<usize>,
19    pub quote_style: Option<QuoteStyle>,
20}
21
22impl CursorState {
23    pub fn new(
24        token_stub: impl Into<String>,
25        raw_stub: impl Into<String>,
26        replace_range: Range<usize>,
27        quote_style: Option<QuoteStyle>,
28    ) -> Self {
29        Self {
30            token_stub: token_stub.into(),
31            raw_stub: raw_stub.into(),
32            replace_range,
33            quote_style,
34        }
35    }
36
37    pub fn synthetic(token_stub: impl Into<String>) -> Self {
38        let token_stub = token_stub.into();
39        let len = token_stub.len();
40        Self {
41            raw_stub: token_stub.clone(),
42            token_stub,
43            replace_range: 0..len,
44            quote_style: None,
45        }
46    }
47}
48
49impl Default for CursorState {
50    fn default() -> Self {
51        Self::synthetic("")
52    }
53}
54
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub enum ContextScope {
57    Global,
58    #[default]
59    Subtree,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct SuggestionEntry {
64    /// Text inserted into the buffer if this suggestion is accepted.
65    pub value: String,
66    /// Short right-column description in menu-style UIs.
67    pub meta: Option<String>,
68    /// Optional human-friendly label when the inserted value should stay terse.
69    pub display: Option<String>,
70    /// Hidden sort key for cases where display order should differ from labels.
71    pub sort: Option<String>,
72}
73
74impl SuggestionEntry {
75    pub fn value(value: impl Into<String>) -> Self {
76        Self {
77            value: value.into(),
78            meta: None,
79            display: None,
80            sort: None,
81        }
82    }
83
84    pub fn meta(mut self, meta: impl Into<String>) -> Self {
85        self.meta = Some(meta.into());
86        self
87    }
88
89    pub fn display(mut self, display: impl Into<String>) -> Self {
90        self.display = Some(display.into());
91        self
92    }
93
94    pub fn sort(mut self, sort: impl Into<String>) -> Self {
95        self.sort = Some(sort.into());
96        self
97    }
98}
99
100impl From<&str> for SuggestionEntry {
101    fn from(value: &str) -> Self {
102        Self::value(value)
103    }
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Eq)]
107pub struct OsVersions {
108    pub union: BTreeMap<String, Vec<SuggestionEntry>>,
109    pub by_provider: BTreeMap<String, BTreeMap<String, Vec<SuggestionEntry>>>,
110}
111
112#[derive(Debug, Clone, Default, PartialEq, Eq)]
113pub struct RequestHints {
114    pub keys: Vec<String>,
115    pub required: Vec<String>,
116    pub tiers: BTreeMap<String, Vec<String>>,
117    pub defaults: BTreeMap<String, String>,
118    pub choices: BTreeMap<String, Vec<String>>,
119}
120
121#[derive(Debug, Clone, Default, PartialEq, Eq)]
122pub struct RequestHintSet {
123    pub common: RequestHints,
124    pub by_provider: BTreeMap<String, RequestHints>,
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Eq)]
128pub struct FlagHints {
129    pub common: Vec<String>,
130    pub by_provider: BTreeMap<String, Vec<String>>,
131    pub required_common: Vec<String>,
132    pub required_by_provider: BTreeMap<String, Vec<String>>,
133}
134
135#[derive(Debug, Clone, Default, PartialEq, Eq)]
136pub struct ArgNode {
137    /// Positional-argument metadata for one command slot.
138    pub name: Option<String>,
139    pub tooltip: Option<String>,
140    pub multi: bool,
141    pub value_type: Option<ValueType>,
142    pub suggestions: Vec<SuggestionEntry>,
143}
144
145impl ArgNode {
146    pub fn named(name: impl Into<String>) -> Self {
147        Self {
148            name: Some(name.into()),
149            ..Self::default()
150        }
151    }
152
153    pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
154        self.tooltip = Some(tooltip.into());
155        self
156    }
157
158    pub fn multi(mut self) -> Self {
159        self.multi = true;
160        self
161    }
162
163    pub fn value_type(mut self, value_type: ValueType) -> Self {
164        self.value_type = Some(value_type);
165        self
166    }
167
168    pub fn suggestions(mut self, suggestions: impl IntoIterator<Item = SuggestionEntry>) -> Self {
169        self.suggestions = suggestions.into_iter().collect();
170        self
171    }
172}
173
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub struct FlagNode {
176    pub tooltip: Option<String>,
177    pub flag_only: bool,
178    pub multi: bool,
179    // Context-only flags are merged from the full line into the cursor context.
180    // `context_scope` controls whether merge is global or path-scoped.
181    pub context_only: bool,
182    pub context_scope: ContextScope,
183    pub value_type: Option<ValueType>,
184    pub suggestions: Vec<SuggestionEntry>,
185    pub suggestions_by_provider: BTreeMap<String, Vec<SuggestionEntry>>,
186    pub os_provider_map: BTreeMap<String, Vec<String>>,
187    pub os_versions: Option<OsVersions>,
188    pub request_hints: Option<RequestHintSet>,
189    pub flag_hints: Option<FlagHints>,
190}
191
192impl FlagNode {
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    pub fn tooltip(mut self, tooltip: impl Into<String>) -> Self {
198        self.tooltip = Some(tooltip.into());
199        self
200    }
201
202    pub fn flag_only(mut self) -> Self {
203        self.flag_only = true;
204        self
205    }
206
207    pub fn multi(mut self) -> Self {
208        self.multi = true;
209        self
210    }
211
212    pub fn context_only(mut self, scope: ContextScope) -> Self {
213        self.context_only = true;
214        self.context_scope = scope;
215        self
216    }
217
218    pub fn value_type(mut self, value_type: ValueType) -> Self {
219        self.value_type = Some(value_type);
220        self
221    }
222
223    pub fn suggestions(mut self, suggestions: impl IntoIterator<Item = SuggestionEntry>) -> Self {
224        self.suggestions = suggestions.into_iter().collect();
225        self
226    }
227}
228
229#[derive(Debug, Clone, Default, PartialEq, Eq)]
230pub struct CompletionNode {
231    /// One command/subcommand scope in the completion tree.
232    ///
233    /// A node can expose child commands, flags, positional arguments, or
234    /// value-like leaves for config-style key completion.
235    pub tooltip: Option<String>,
236    /// Optional suggestion-order hint for command/subcommand completion.
237    pub sort: Option<String>,
238    /// This node expects the next token to be a key chosen from `children`.
239    pub value_key: bool,
240    /// This node is itself a terminal value that can be suggested/accepted.
241    pub value_leaf: bool,
242    /// Hidden context flags injected when this node is matched.
243    pub prefilled_flags: BTreeMap<String, Vec<String>>,
244    /// Fixed positional values contributed before user-provided args.
245    pub prefilled_positionals: Vec<String>,
246    pub children: BTreeMap<String, CompletionNode>,
247    pub flags: BTreeMap<String, FlagNode>,
248    pub args: Vec<ArgNode>,
249    pub flag_hints: Option<FlagHints>,
250}
251
252impl CompletionNode {
253    pub fn sort(mut self, sort: impl Into<String>) -> Self {
254        self.sort = Some(sort.into());
255        self
256    }
257
258    pub fn with_child(mut self, name: impl Into<String>, node: CompletionNode) -> Self {
259        self.children.insert(name.into(), node);
260        self
261    }
262
263    pub fn with_flag(mut self, name: impl Into<String>, node: FlagNode) -> Self {
264        self.flags.insert(name.into(), node);
265        self
266    }
267}
268
269#[derive(Debug, Clone, Default, PartialEq, Eq)]
270pub struct CompletionTree {
271    pub root: CompletionNode,
272    /// Pipe verbs are kept separate from the command tree because they only
273    /// become visible after the parser has entered DSL mode.
274    pub pipe_verbs: BTreeMap<String, String>,
275}
276
277#[derive(Debug, Clone, Default, PartialEq, Eq)]
278pub struct CommandLine {
279    /// Parsed command-line shape before completion-specific analysis.
280    ///
281    /// `head` is the command path, `flags` and `args` are the option/positional
282    /// tail, and `pipes` contains the first pipeline segment onward.
283    pub(crate) head: Vec<String>,
284    pub(crate) tail: Vec<TailItem>,
285    pub(crate) flag_values: BTreeMap<String, Vec<String>>,
286    pub(crate) pipes: Vec<String>,
287    pub(crate) has_pipe: bool,
288}
289
290#[derive(Debug, Clone, Default, PartialEq, Eq)]
291pub struct FlagOccurrence {
292    pub name: String,
293    pub values: Vec<String>,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub enum TailItem {
298    Flag(FlagOccurrence),
299    Positional(String),
300}
301
302impl CommandLine {
303    pub fn head(&self) -> &[String] {
304        &self.head
305    }
306
307    pub fn tail(&self) -> &[TailItem] {
308        &self.tail
309    }
310
311    pub fn pipes(&self) -> &[String] {
312        &self.pipes
313    }
314
315    pub fn has_pipe(&self) -> bool {
316        self.has_pipe
317    }
318
319    pub fn flag_values_map(&self) -> &BTreeMap<String, Vec<String>> {
320        &self.flag_values
321    }
322
323    pub fn flag_values(&self, name: &str) -> Option<&[String]> {
324        self.flag_values.get(name).map(Vec::as_slice)
325    }
326
327    pub fn has_flag(&self, name: &str) -> bool {
328        self.flag_values.contains_key(name)
329    }
330
331    pub fn flag_occurrences(&self) -> impl Iterator<Item = &FlagOccurrence> {
332        self.tail.iter().filter_map(|item| match item {
333            TailItem::Flag(flag) => Some(flag),
334            TailItem::Positional(_) => None,
335        })
336    }
337
338    pub fn last_flag_occurrence(&self) -> Option<&FlagOccurrence> {
339        self.flag_occurrences().last()
340    }
341
342    pub fn positional_args(&self) -> impl Iterator<Item = &String> {
343        self.tail.iter().filter_map(|item| match item {
344            TailItem::Positional(value) => Some(value),
345            TailItem::Flag(_) => None,
346        })
347    }
348
349    pub fn tail_len(&self) -> usize {
350        self.tail.len()
351    }
352
353    pub fn push_flag_occurrence(&mut self, occurrence: FlagOccurrence) {
354        self.flag_values
355            .entry(occurrence.name.clone())
356            .or_default()
357            .extend(occurrence.values.iter().cloned());
358        self.tail.push(TailItem::Flag(occurrence));
359    }
360
361    pub fn push_positional(&mut self, value: impl Into<String>) {
362        self.tail.push(TailItem::Positional(value.into()));
363    }
364
365    pub fn merge_flag_values(&mut self, name: impl Into<String>, values: Vec<String>) {
366        self.flag_values
367            .entry(name.into())
368            .or_default()
369            .extend(values);
370    }
371
372    pub fn prepend_positional_values(&mut self, values: impl IntoIterator<Item = String>) {
373        let mut values = values
374            .into_iter()
375            .filter(|value| !value.trim().is_empty())
376            .map(TailItem::Positional)
377            .collect::<Vec<_>>();
378        if values.is_empty() {
379            return;
380        }
381        values.extend(std::mem::take(&mut self.tail));
382        self.tail = values;
383    }
384
385    pub fn set_pipe(&mut self, pipes: Vec<String>) {
386        self.has_pipe = true;
387        self.pipes = pipes;
388    }
389
390    pub fn push_head(&mut self, segment: impl Into<String>) {
391        self.head.push(segment.into());
392    }
393}
394
395#[derive(Debug, Clone, Default, PartialEq, Eq)]
396pub struct ParsedLine {
397    pub safe_cursor: usize,
398    pub full_tokens: Vec<String>,
399    pub cursor_tokens: Vec<String>,
400    pub full_cmd: CommandLine,
401    pub cursor_cmd: CommandLine,
402}
403
404#[derive(Debug, Clone, Default, PartialEq, Eq)]
405pub struct CompletionAnalysis {
406    /// Full parser output plus the cursor-local context derived from it.
407    pub parsed: ParsedLine,
408    pub cursor: CursorState,
409    pub context: CompletionContext,
410}
411
412/// Resolved completion state for the cursor position.
413///
414/// The parser only knows about tokens. This structure captures the derived
415/// command context the suggester/debug layers actually care about:
416/// which command path matched, which node contributes visible flags, and
417/// whether the cursor is still in subcommand-selection mode.
418#[derive(Debug, Clone, Default, PartialEq, Eq)]
419pub struct CompletionContext {
420    pub matched_path: Vec<String>,
421    pub flag_scope_path: Vec<String>,
422    pub subcommand_context: bool,
423}
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub enum MatchKind {
427    Pipe,
428    Flag,
429    Command,
430    Subcommand,
431    Value,
432}
433
434impl MatchKind {
435    pub fn as_str(self) -> &'static str {
436        match self {
437            Self::Pipe => "pipe",
438            Self::Flag => "flag",
439            Self::Command => "command",
440            Self::Subcommand => "subcommand",
441            Self::Value => "value",
442        }
443    }
444}
445
446#[derive(Debug, Clone, PartialEq, Eq)]
447pub struct Suggestion {
448    pub text: String,
449    pub meta: Option<String>,
450    pub display: Option<String>,
451    pub is_exact: bool,
452    pub sort: Option<String>,
453    pub match_score: u32,
454}
455
456impl Suggestion {
457    pub fn new(text: impl Into<String>) -> Self {
458        Self {
459            text: text.into(),
460            meta: None,
461            display: None,
462            is_exact: false,
463            sort: None,
464            match_score: u32::MAX,
465        }
466    }
467}
468
469#[derive(Debug, Clone, PartialEq, Eq)]
470pub enum SuggestionOutput {
471    Item(Suggestion),
472    PathSentinel,
473}
474
475#[cfg(test)]
476mod tests;