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