1use 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}