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}