Skip to main content

appcore_args/
completion.rs

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