Skip to main content

appcore_args/
completion.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: completion.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/19 12:52:57 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/19 13:34:54 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11use crate::spec::{ArgumentSpec, CliSpec, CommandSpec, OptionSpec, ValueMode, ValueType};
12use std::collections::HashSet;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct CompletionRequest {
16    words: Vec<String>,
17    cursor_word: usize,
18}
19
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct CompletionCandidate {
22    value: String,
23    description: String,
24    kind: CompletionKind,
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum CompletionKind {
29    Command,
30    Option,
31    Value,
32}
33
34pub struct CompletionEngine<'a> {
35    spec: &'a CliSpec,
36}
37
38impl CompletionRequest {
39    pub fn new(words: Vec<String>, cursor_word: usize) -> Self {
40        Self { words, cursor_word }
41    }
42    pub fn words(&self) -> &[String] {
43        &self.words
44    }
45    pub fn cursor_word(&self) -> usize {
46        self.cursor_word
47    }
48}
49
50impl CompletionCandidate {
51    pub fn new(
52        value: impl Into<String>,
53        description: impl Into<String>,
54        kind: CompletionKind,
55    ) -> Self {
56        Self {
57            value: value.into(),
58            description: description.into(),
59            kind,
60        }
61    }
62    pub fn value(&self) -> &str {
63        &self.value
64    }
65    pub fn description(&self) -> &str {
66        &self.description
67    }
68    pub fn kind(&self) -> CompletionKind {
69        self.kind
70    }
71}
72
73impl<'a> CompletionEngine<'a> {
74    pub fn new(spec: &'a CliSpec) -> Self {
75        Self { spec }
76    }
77
78    pub fn complete(&self, request: &CompletionRequest) -> Vec<CompletionCandidate> {
79        if self.spec.validate().is_err() {
80            return Vec::new();
81        }
82        let (words, cursor) = normalize_words(self.spec.name(), request);
83        let prefix = words.get(cursor).map(String::as_str).unwrap_or("");
84        let context = CompletionContext::analyze(self.spec, &words[..cursor.min(words.len())]);
85        if context.passthrough {
86            return Vec::new();
87        }
88        if let Some((name, value_prefix)) = long_inline_value(prefix) {
89            return context
90                .find_long_option(name)
91                .map(|option| value_candidates(option.possible_values(), value_prefix, Some(name)))
92                .unwrap_or_default();
93        }
94        if let Some(option) = context.pending_value {
95            return value_candidates(option.possible_values(), prefix, None);
96        }
97        let mut candidates = Vec::new();
98        if prefix.starts_with('-') {
99            context.push_options(prefix, &mut candidates);
100            context.push_argument_values(prefix, &mut candidates);
101            return candidates;
102        }
103        context.push_commands(prefix, &mut candidates);
104        context.push_argument_values(prefix, &mut candidates);
105        candidates
106    }
107}
108
109struct CompletionContext<'a> {
110    spec: &'a CliSpec,
111    commands: Vec<&'a CommandSpec>,
112    used_options: HashSet<&'a str>,
113    positionals: usize,
114    pending_value: Option<&'a OptionSpec>,
115    passthrough: bool,
116}
117
118impl<'a> CompletionContext<'a> {
119    fn analyze(spec: &'a CliSpec, words: &[String]) -> Self {
120        let mut context = Self {
121            spec,
122            commands: Vec::new(),
123            used_options: HashSet::new(),
124            positionals: 0,
125            pending_value: None,
126            passthrough: false,
127        };
128        for word in words {
129            if context.consume(word) {
130                break;
131            }
132        }
133        context
134    }
135
136    fn consume(&mut self, word: &str) -> bool {
137        if self.pending_value.take().is_some() {
138            return false;
139        }
140        if word == "--" {
141            self.passthrough = true;
142            return true;
143        }
144        if let Some(raw) = word.strip_prefix("--") {
145            self.consume_long(raw);
146            return false;
147        }
148        if word.starts_with('-') && word.len() > 1 && !self.accepts_negative_positional(word) {
149            self.consume_short(word);
150            return false;
151        }
152        if self.positionals == 0 {
153            if let Some(command) = self
154                .available_commands()
155                .iter()
156                .find(|command| command.matches(word))
157            {
158                self.commands.push(command);
159                return false;
160            }
161        }
162        self.positionals += 1;
163        false
164    }
165
166    fn consume_long(&mut self, raw: &str) {
167        let (name, has_value) = raw
168            .split_once('=')
169            .map_or((raw, false), |(name, _)| (name, true));
170        if let Some(option) = self.find_long_option(name) {
171            self.used_options.insert(option.long());
172            if option.value_mode() == ValueMode::Required && !has_value {
173                self.pending_value = Some(option);
174            }
175        }
176    }
177
178    fn consume_short(&mut self, word: &str) {
179        let raw = word.trim_start_matches('-');
180        let mut chars = raw.char_indices().peekable();
181        while let Some((_, short)) = chars.next() {
182            let Some(option) = self.find_short_option(short) else {
183                return;
184            };
185            self.used_options.insert(option.long());
186            if option.value_mode() != ValueMode::Forbidden {
187                if chars.peek().is_none() && option.value_mode() == ValueMode::Required {
188                    self.pending_value = Some(option);
189                }
190                return;
191            }
192        }
193    }
194
195    fn available_commands(&self) -> &'a [CommandSpec] {
196        self.commands
197            .last()
198            .map(|command| command.commands())
199            .unwrap_or_else(|| self.spec.commands())
200    }
201
202    fn active_arguments(&self) -> &'a [ArgumentSpec] {
203        self.commands
204            .last()
205            .map(|command| command.arguments())
206            .unwrap_or_else(|| self.spec.arguments())
207    }
208
209    fn next_argument(&self) -> Option<&'a ArgumentSpec> {
210        let arguments = self.active_arguments();
211        arguments
212            .get(self.positionals)
213            .or_else(|| arguments.last().filter(|argument| argument.is_multiple()))
214    }
215
216    fn accepts_negative_positional(&self, value: &str) -> bool {
217        self.next_argument().is_some_and(|argument| {
218            argument.value_type_kind() == ValueType::I64
219                && value.parse::<i64>().is_ok()
220                && value
221                    .chars()
222                    .nth(1)
223                    .is_none_or(|short| self.find_short_option(short).is_none())
224        })
225    }
226
227    fn visible_options(&self) -> Vec<&'a OptionSpec> {
228        let mut options = self.spec.options().iter().collect::<Vec<_>>();
229        for command in &self.commands {
230            options.extend(command.options());
231        }
232        options
233    }
234
235    fn find_long_option(&self, name: &str) -> Option<&'a OptionSpec> {
236        self.visible_options()
237            .into_iter()
238            .rev()
239            .find(|option| option.long() == name)
240    }
241
242    fn find_short_option(&self, short: char) -> Option<&'a OptionSpec> {
243        self.visible_options()
244            .into_iter()
245            .rev()
246            .find(|option| option.short_name() == Some(short))
247    }
248
249    fn push_commands(&self, prefix: &str, candidates: &mut Vec<CompletionCandidate>) {
250        if self.positionals > 0 {
251            return;
252        }
253        for command in self
254            .available_commands()
255            .iter()
256            .filter(|command| !command.is_hidden() && command.name().starts_with(prefix))
257        {
258            candidates.push(CompletionCandidate::new(
259                command.name(),
260                command.about_text(),
261                CompletionKind::Command,
262            ));
263        }
264    }
265
266    fn push_options(&self, prefix: &str, candidates: &mut Vec<CompletionCandidate>) {
267        for option in self
268            .visible_options()
269            .into_iter()
270            .filter(|option| !option.is_hidden())
271        {
272            if !option.is_repeatable() && self.used_options.contains(option.long()) {
273                continue;
274            }
275            let long = format!("--{}", option.long());
276            if long.starts_with(prefix) {
277                candidates.push(CompletionCandidate::new(
278                    long,
279                    option.about_text(),
280                    CompletionKind::Option,
281                ));
282            }
283            if let Some(short) = option.short_name() {
284                let short = format!("-{short}");
285                if short.starts_with(prefix) {
286                    candidates.push(CompletionCandidate::new(
287                        short,
288                        option.about_text(),
289                        CompletionKind::Option,
290                    ));
291                }
292            }
293        }
294    }
295
296    fn push_argument_values(&self, prefix: &str, candidates: &mut Vec<CompletionCandidate>) {
297        if let Some(argument) = self.next_argument() {
298            candidates.extend(value_candidates(argument.possible_values(), prefix, None));
299        }
300    }
301}
302
303fn normalize_words(binary: &str, request: &CompletionRequest) -> (Vec<String>, usize) {
304    if request.words().first().is_some_and(|word| word == binary) {
305        (
306            request.words()[1..].to_vec(),
307            request.cursor_word().saturating_sub(1),
308        )
309    } else {
310        (request.words().to_vec(), request.cursor_word())
311    }
312}
313
314fn long_inline_value(prefix: &str) -> Option<(&str, &str)> {
315    prefix.strip_prefix("--")?.split_once('=')
316}
317
318fn value_candidates(
319    values: &[String],
320    prefix: &str,
321    long_option: Option<&str>,
322) -> Vec<CompletionCandidate> {
323    values
324        .iter()
325        .filter(|value| value.starts_with(prefix))
326        .map(|value| {
327            let rendered = long_option
328                .map(|name| format!("--{name}={value}"))
329                .unwrap_or_else(|| value.clone());
330            CompletionCandidate::new(rendered, "", CompletionKind::Value)
331        })
332        .collect()
333}
334
335#[cfg(test)]
336mod tests {
337    use super::{CompletionEngine, CompletionKind, CompletionRequest};
338    use crate::{ArgumentSpec, CliSpec, CommandSpec, OptionSpec};
339
340    #[test]
341    fn completes_nested_commands_and_inherited_options() {
342        let spec = CliSpec::new("demo")
343            .option(OptionSpec::flag("verbose"))
344            .command(CommandSpec::new("publish").command(CommandSpec::new("status")));
345        let nested = CompletionRequest::new(vec!["demo".into(), "publish".into(), "s".into()], 2);
346        let options =
347            CompletionRequest::new(vec!["demo".into(), "publish".into(), "--v".into()], 2);
348        assert_eq!(
349            CompletionEngine::new(&spec).complete(&nested)[0].value(),
350            "status"
351        );
352        assert_eq!(
353            CompletionEngine::new(&spec).complete(&options)[0].value(),
354            "--verbose"
355        );
356    }
357
358    #[test]
359    fn completes_option_and_argument_values() {
360        let spec = CliSpec::new("demo")
361            .option(
362                OptionSpec::value("color")
363                    .possible_value("red")
364                    .possible_value("green"),
365            )
366            .argument(ArgumentSpec::new("mode").possible_value("fast"));
367        let option = CompletionRequest::new(vec!["demo".into(), "--color".into(), "g".into()], 2);
368        let inline = CompletionRequest::new(vec!["demo".into(), "--color=r".into()], 1);
369        let argument = CompletionRequest::new(vec!["demo".into(), "f".into()], 1);
370        assert_eq!(
371            CompletionEngine::new(&spec).complete(&option)[0].value(),
372            "green"
373        );
374        assert_eq!(
375            CompletionEngine::new(&spec).complete(&inline)[0].value(),
376            "--color=red"
377        );
378        assert_eq!(
379            CompletionEngine::new(&spec).complete(&argument)[0].kind(),
380            CompletionKind::Value
381        );
382    }
383
384    #[test]
385    fn hides_hidden_and_consumed_non_repeatable_options() {
386        let spec = CliSpec::new("demo")
387            .option(OptionSpec::flag("visible"))
388            .option(OptionSpec::flag("internal").hidden(true));
389        let request =
390            CompletionRequest::new(vec!["demo".into(), "--visible".into(), "--".into()], 2);
391        assert!(CompletionEngine::new(&spec).complete(&request).is_empty());
392    }
393
394    #[test]
395    fn invalid_specs_do_not_produce_candidates() {
396        let spec = CliSpec::new("demo")
397            .option(OptionSpec::flag("verbose"))
398            .option(OptionSpec::flag("verbose"));
399        let request = CompletionRequest::new(vec!["demo".into(), "--v".into()], 1);
400
401        assert!(CompletionEngine::new(&spec).complete(&request).is_empty());
402    }
403
404    #[test]
405    fn completes_declared_negative_positional_values() {
406        let spec = CliSpec::new("demo").argument(
407            ArgumentSpec::new("offset")
408                .value_type(crate::ValueType::I64)
409                .possible_value("-10"),
410        );
411        let request = CompletionRequest::new(vec!["demo".into(), "-1".into()], 1);
412
413        let candidates = CompletionEngine::new(&spec).complete(&request);
414        assert!(candidates
415            .iter()
416            .any(|candidate| candidate.value() == "-10"));
417    }
418}