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;