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