Skip to main content

appcore_args/
completion.rs

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