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}