1#![cfg_attr(feature = "derive", doc = "```no_run")]
36#![cfg_attr(not(feature = "derive"), doc = "```ignore")]
37use std::ffi::OsString;
60use std::fmt::Write as _;
61
62use winnow::stream::BStr;
63
64use crate::help::{Command, Item};
65
66pub const REQUEST: &str = "__complete_word__";
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
72#[non_exhaustive]
73pub enum Hint {
74 #[default]
79 Nothing,
80 Files,
83 Dirs,
85 Commands,
87}
88
89impl Hint {
90 const fn line(self) -> Option<&'static str> {
92 match self {
93 Hint::Files => Some("\u{1}files"),
94 Hint::Dirs => Some("\u{1}dirs"),
95 Hint::Commands => Some("\u{1}commands"),
96 Hint::Nothing => None,
97 }
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103#[non_exhaustive]
104pub enum Shell {
105 Bash,
107 Zsh,
109 Fish,
111 Elvish,
113 PowerShell,
115}
116
117impl Shell {
118 pub const ALL: &'static [Shell] = &[
120 Shell::Bash,
121 Shell::Zsh,
122 Shell::Fish,
123 Shell::Elvish,
124 Shell::PowerShell,
125 ];
126
127 pub const fn as_str(self) -> &'static str {
129 match self {
130 Shell::Bash => "bash",
131 Shell::Zsh => "zsh",
132 Shell::Fish => "fish",
133 Shell::Elvish => "elvish",
134 Shell::PowerShell => "powershell",
135 }
136 }
137
138 pub fn from_name(name: &str) -> Option<Shell> {
140 Shell::ALL.iter().copied().find(|s| s.as_str() == name)
141 }
142}
143
144impl crate::FromArg for Shell {
146 const SPEC: &'static str = "Shell";
148
149 const CHOICES: &'static [&'static str] = &["bash", "zsh", "fish", "elvish", "powershell"];
150
151 fn from_arg(value: &BStr) -> Result<Self, crate::error::BoxError> {
152 let name = std::str::from_utf8(value).ok();
153 match name.and_then(Shell::from_name) {
154 Some(shell) => Ok(shell),
155 None => Err(Box::new(crate::ChoiceError {
156 choices: Self::CHOICES,
157 })),
158 }
159 }
160}
161
162impl std::fmt::Display for Shell {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 f.write_str(self.as_str())
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Candidate {
171 pub value: String,
173 pub help: &'static str,
175}
176
177#[derive(Debug, Clone, Default, PartialEq, Eq)]
179pub struct Completions {
180 pub candidates: Vec<Candidate>,
182 pub hint: Hint,
184 pub before: String,
187}
188
189pub fn complete(root: &'static Command, words: &[&str]) -> Completions {
193 let (current, before) = match words.split_last() {
194 Some((current, before)) => (*current, before),
195 None => ("", words),
196 };
197 let mut state = Walk {
198 command: root,
199 globals: Vec::new(),
200 owing: None,
201 stopped: false,
202 position: 0,
203 };
204 for word in before {
205 state.step(word);
206 }
207 state.complete(current)
208}
209
210#[derive(Clone, Copy)]
213struct Owing {
214 item: &'static Item,
215 fixed: usize,
216 flexible: bool,
217 once: bool,
218}
219
220struct Walk {
222 command: &'static Command,
223 globals: Vec<&'static Item>,
225 owing: Option<Owing>,
227 stopped: bool,
229 position: usize,
231}
232
233impl Walk {
234 fn flags(&self) -> impl Iterator<Item = &'static Item> + '_ {
235 let own = self.command.items.iter().filter(|i| !i.positional);
236 own.chain(self.globals.iter().copied())
237 }
238
239 fn long(&self, name: &str) -> Option<&'static Item> {
240 self.flags()
241 .find(|i| i.long == Some(name) || i.negate == Some(name) || i.aliases.contains(&name))
242 }
243
244 fn takes_next(item: &Item) -> bool {
246 item.value_name.is_some() && !item.require_equals
247 }
248
249 fn letter(&self, letter: char, plus: bool) -> Option<&'static Item> {
250 self.flags().find(|i| {
251 if plus {
252 i.plus == Some(letter)
253 } else {
254 i.short == Some(letter) || i.more_shorts.contains(&letter)
255 }
256 })
257 }
258
259 fn plus_options(&self) -> bool {
260 self.flags().any(|i| i.plus.is_some())
261 }
262
263 fn plus_letters<'a>(&self, word: &'a str) -> Option<&'a str> {
265 let letters = word.strip_prefix('+').filter(|l| !l.is_empty())?;
266 self.plus_options().then_some(letters)
267 }
268
269 fn owe(&mut self, item: &'static Item, attached: bool) {
271 if item.value_name.is_none() || (!attached && item.require_equals) {
272 return;
273 }
274 if item.more_values {
275 self.owing = Some(Owing {
276 item,
277 fixed: 0,
278 flexible: true,
279 once: false,
280 });
281 return;
282 }
283 if !attached && item.optional_value {
284 self.owing = Some(Owing {
285 item,
286 fixed: 0,
287 flexible: true,
288 once: true,
289 });
290 return;
291 }
292 let left = item.values.max(1) - usize::from(attached);
293 if left > 0 {
294 self.owing = Some(Owing {
295 item,
296 fixed: left,
297 flexible: false,
298 once: false,
299 });
300 }
301 }
302
303 fn consume_owing(&mut self, word: &str) -> bool {
306 let Some(owing) = self.owing else {
307 return false;
308 };
309 if owing.fixed > 0 {
310 let fixed = owing.fixed - 1;
311 self.owing = (fixed > 0).then_some(Owing { fixed, ..owing });
312 return true;
313 }
314 if owing.flexible && !crate::token::is_flag_like(word.as_bytes()) {
315 if owing.once {
316 self.owing = None;
317 }
318 return true;
319 }
320 self.owing = None;
321 false
322 }
323
324 fn bundle(&mut self, letters: &str, plus: bool) {
325 for (at, letter) in letters.char_indices() {
326 let Some(item) = self.letter(letter, plus) else {
327 break;
328 };
329 if item.value_name.is_some() {
330 if at + letter.len_utf8() == letters.len() && Self::takes_next(item) {
332 self.owe(item, false);
333 }
334 break;
335 }
336 }
337 }
338
339 fn step(&mut self, word: &str) {
340 if self.consume_owing(word) {
341 return;
342 }
343 if self.stopped {
344 self.position += 1;
345 return;
346 }
347 if word == "--" {
348 self.stopped = true;
349 } else if let Some(body) = word.strip_prefix("--") {
350 let (name, value) = match body.split_once('=') {
351 Some((name, value)) => (name, Some(value)),
352 None => (body, None),
353 };
354 if let Some(item) = self.long(name) {
355 self.owe(item, value.is_some());
356 }
357 } else if let Some(letters) = word.strip_prefix('-').filter(|l| !l.is_empty()) {
358 self.bundle(letters, false);
359 } else if let Some(letters) = self.plus_letters(word) {
360 self.bundle(letters, true);
361 } else if let Some(sub) = self
362 .command
363 .subcommands
364 .iter()
365 .find(|s| s.names.contains(&word))
366 {
367 let globals = self.command.items.iter().filter(|i| i.global);
368 self.globals.extend(globals);
369 self.command = sub.command;
370 self.position = 0;
371 } else {
372 self.position += 1;
373 }
374 }
375
376 fn complete(&self, current: &str) -> Completions {
377 let mut out = Completions::default();
378 if let Some(owing) = self.owing {
379 let as_value = owing.fixed > 0
380 || (owing.flexible && !crate::token::is_flag_like(current.as_bytes()));
381 if as_value {
382 values(owing.item, "", current, &mut out);
383 return out;
384 }
385 }
386 if !self.stopped && current.starts_with('+') && self.plus_options() {
387 self.plus_names(current, &mut out);
388 return out;
389 }
390 if !self.stopped && current.starts_with('-') {
391 if let Some((name, value)) = current
392 .strip_prefix("--")
393 .and_then(|body| body.split_once('='))
394 {
395 if let Some(item) = self.long(name).filter(|i| i.value_name.is_some()) {
396 values(
397 item,
398 ¤t[..current.len() - value.len()],
399 value,
400 &mut out,
401 );
402 }
403 return out;
404 }
405 self.flag_names(current, &mut out);
406 return out;
407 }
408 if !self.stopped {
409 for sub in self.command.subcommands.iter().filter(|s| !s.hide) {
410 push(&mut out, sub.name, sub.about, current);
411 }
412 }
413 let mut positionals = self.command.items.iter().filter(|i| i.positional);
414 let positional = positionals
415 .clone()
416 .nth(self.position)
417 .or_else(|| positionals.rfind(|i| i.multiple));
418 if let Some(item) = positional {
419 values(item, "", current, &mut out);
420 }
421 out
422 }
423
424 fn flag_names(&self, current: &str, out: &mut Completions) {
425 let shorts = !current.starts_with("--");
427 for item in self.flags().filter(|i| !i.hide) {
428 if let Some(long) = item.long {
429 push(out, &format!("--{long}"), item.help, current);
430 }
431 if let Some(negate) = item.negate {
432 push(out, &format!("--{negate}"), item.help, current);
433 }
434 if shorts {
435 if let Some(short) = item.short {
436 push(out, &format!("-{short}"), item.help, current);
437 }
438 for &letter in item.more_shorts {
439 push(out, &format!("-{letter}"), item.help, current);
440 }
441 }
442 }
443 let command = self.command;
444 if command.help_flag {
445 if command.help_short && shorts {
446 push(out, "-h", "Print help", current);
447 }
448 push(out, "--help", "Print help", current);
449 }
450 if command.version.is_some() {
451 if shorts {
452 push(out, "-V", "Print version", current);
453 }
454 push(out, "--version", "Print version", current);
455 }
456 }
457
458 fn plus_names(&self, current: &str, out: &mut Completions) {
459 for item in self.flags().filter(|i| !i.hide) {
460 if let Some(letter) = item.plus {
461 push(out, &format!("+{letter}"), item.help, current);
462 }
463 }
464 }
465}
466
467fn values(item: &'static Item, prefix: &str, typed: &str, out: &mut Completions) {
470 if let Some(complete) = item.complete {
471 let fitting = complete(typed)
472 .into_iter()
473 .filter(|c| c.value.starts_with(typed));
474 out.candidates.extend(fitting.map(|c| Candidate {
475 value: format!("{prefix}{}", c.value),
476 help: c.help,
477 }));
478 return;
479 }
480 if item.choices.is_empty() {
481 out.hint = item.hint;
482 prefix.clone_into(&mut out.before);
483 return;
484 }
485 for choice in item.choices {
486 if choice.starts_with(typed) {
487 out.candidates.push(Candidate {
488 value: format!("{prefix}{choice}"),
489 help: "",
490 });
491 }
492 }
493}
494
495fn push(out: &mut Completions, value: &str, help: &'static str, typed: &str) {
496 if value.starts_with(typed) {
497 out.candidates.push(Candidate {
498 value: value.to_owned(),
499 help,
500 });
501 }
502}
503
504pub fn answer(root: &'static Command, args: &[OsString]) -> Option<String> {
515 let (first, rest) = args.split_first()?;
516 if first.to_str()? != REQUEST {
517 return None;
518 }
519 let (mut shell, mut line, mut replaced) = (None, String::new(), None);
520 let mut given: Option<Vec<String>> = None;
521 let mut rest = rest.iter().map(|a| a.to_string_lossy().into_owned());
522 while let Some(option) = rest.next() {
523 if option == "--words" {
524 given = Some(rest.by_ref().collect());
526 break;
527 }
528 let value = rest.next().unwrap_or_default();
529 match option.as_str() {
530 "--shell" => shell = Shell::from_name(&value),
531 "--line" => line = value,
532 "--bash-word" => replaced = Some(value),
533 _ => {}
534 }
535 }
536 let shell = shell?;
537 let mut words = given.unwrap_or_else(|| split_line(&line));
538 if !words.is_empty() {
539 words.remove(0);
540 }
541 if words.is_empty() {
542 words.push(String::new());
543 }
544 let words: Vec<&str> = words.iter().map(String::as_str).collect();
545 let completions = complete(root, &words);
546 Some(match (shell, replaced) {
547 (Shell::Bash, Some(replaced)) => {
548 render_bash(&completions, words.last().copied().unwrap_or(""), &replaced)
549 }
550 _ => render(&completions, shell),
551 })
552}
553
554pub fn render(completions: &Completions, shell: Shell) -> String {
559 let mut out = String::new();
560 for c in &completions.candidates {
561 match shell {
562 Shell::Bash => out.push_str(&c.value),
563 Shell::Zsh => {
564 out.push_str(&c.value.replace(':', "\\:"));
565 if !c.help.is_empty() {
566 let _ = write!(out, ":{}", first_line(c.help));
567 }
568 }
569 Shell::Fish | Shell::Elvish | Shell::PowerShell => {
570 out.push_str(&c.value);
571 if !c.help.is_empty() {
572 let _ = write!(out, "\t{}", first_line(c.help));
573 }
574 }
575 }
576 out.push('\n');
577 }
578 ask_the_shell(completions, shell, &mut out);
579 out
580}
581
582fn ask_the_shell(completions: &Completions, shell: Shell, out: &mut String) {
585 let Some(line) = completions.hint.line() else {
586 return;
587 };
588 out.push_str(line);
589 if shell != Shell::Bash {
590 out.push('\t');
591 out.push_str(&completions.before);
592 }
593 out.push('\n');
594}
595
596fn render_bash(completions: &Completions, typed: &str, replaced: &str) -> String {
599 let replaced = if replaced == "=" { "" } else { replaced };
601 let cut = typed
602 .strip_suffix(replaced)
603 .map_or(0, |before| before.len());
604 let mut out = String::new();
605 for c in &completions.candidates {
606 out.push_str(c.value.get(cut..).unwrap_or(&c.value));
607 out.push('\n');
608 }
609 ask_the_shell(completions, Shell::Bash, &mut out);
610 out
611}
612
613fn first_line(text: &str) -> &str {
614 text.lines().next().unwrap_or("")
615}
616
617fn split_line(line: &str) -> Vec<String> {
620 let mut words = Vec::new();
621 let mut word: Option<String> = None;
622 let mut quote = None;
623 let mut chars = line.chars();
624 while let Some(c) = chars.next() {
625 match (quote, c) {
626 (Some(q), c) if c == q => quote = None,
627 (Some('"') | None, '\\') => {
628 if let Some(next) = chars.next() {
629 word.get_or_insert_default().push(next);
630 }
631 }
632 (Some(_), c) => word.get_or_insert_default().push(c),
633 (None, '\'' | '"') => {
634 quote = Some(c);
635 word.get_or_insert_default();
636 }
637 (None, c) if c.is_whitespace() => words.extend(word.take()),
638 (None, c) => word.get_or_insert_default().push(c),
639 }
640 }
641 words.push(word.unwrap_or_default());
642 words
643}
644
645pub fn script(bin: &str, shell: Shell) -> String {
653 assert!(
654 !bin.is_empty()
655 && bin
656 .chars()
657 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+')),
658 "a completion script needs a plain program name, not {bin:?}"
659 );
660 let function: String = bin
661 .chars()
662 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
663 .collect();
664 match shell {
665 Shell::Bash => format!(
666 r#"# Completion for {bin}, answered by `{bin} {REQUEST}`.
667_{function}_complete() {{
668 local line ask= cur="${{COMP_WORDS[COMP_CWORD]}}"
669 # Right after `--name=`, the current word is the `=` itself.
670 [[ $cur == = ]] && cur=
671 COMPREPLY=()
672 while IFS= read -r line; do
673 case $line in
674 $'\001files') ask=f ;;
675 $'\001dirs') ask=d ;;
676 $'\001commands') ask=c ;;
677 ?*) COMPREPLY+=("$line") ;;
678 esac
679 done < <(command {bin} {REQUEST} --shell bash --line "${{COMP_LINE:0:COMP_POINT}}" \
680 --bash-word "${{COMP_WORDS[COMP_CWORD]}}" 2>/dev/null)
681 if [[ -n $ask ]]; then
682 [[ $ask == c ]] || compopt -o filenames 2>/dev/null
683 local IFS=$'\n'
684 COMPREPLY+=($(compgen -$ask -- "$cur"))
685 fi
686}}
687complete -F _{function}_complete {bin}
688"#
689 ),
690 Shell::Zsh => format!(
691 r#"#compdef {bin}
692# Completion for {bin}, answered by `{bin} {REQUEST}`.
693_{function}() {{
694 local -a candidates
695 local line ask= before=
696 while IFS= read -r line; do
697 case $line in
698 $'\001files\t'*) ask=_files ;;
699 $'\001dirs\t'*) ask='_path_files -/' ;;
700 $'\001commands\t'*) ask=_command_names ;;
701 ?*) candidates+=("$line"); continue ;;
702 esac
703 before=${{line#*$'\t'}}
704 done < <(command {bin} {REQUEST} --shell zsh --line "${{BUFFER[1,CURSOR]}}" 2>/dev/null)
705 (( ${{#candidates}} )) && _describe -t values '{bin}' candidates
706 if [[ -n $ask ]]; then
707 # `--name=`, when the value is attached: not a part of what is asked for.
708 if [[ -n $before && $PREFIX == "$before"* ]]; then
709 IPREFIX+=$before
710 PREFIX=${{PREFIX#"$before"}}
711 fi
712 ${{=ask}}
713 fi
714 return 0
715}}
716if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
717 _{function} "$@"
718else
719 compdef _{function} {bin}
720fi
721"#
722 ),
723 Shell::Fish => format!(
724 r#"# Completion for {bin}, answered by `{bin} {REQUEST}`.
725function __{function}_complete
726 set -l token (commandline -ct)
727 for line in (command {bin} {REQUEST} --shell fish --line (commandline -cp) 2>/dev/null)
728 if not string match -q \u0001'*' -- $line
729 test -n "$line"; and printf '%s\n' $line
730 continue
731 end
732 # The mark, then `--name=` when the value is attached to its flag.
733 set -l asked (string split -m 1 \t -- $line)
734 set -l before $asked[2]
735 set -l typed (string sub -s (math (string length -- "$before") + 1) -- $token)
736 switch $asked[1]
737 case \u0001files
738 printf '%s\n' $before(__fish_complete_path "$typed")
739 case \u0001dirs
740 printf '%s\n' $before(__fish_complete_directories "$typed")
741 case \u0001commands
742 printf '%s\n' $before(complete -C "$typed")
743 end
744 end
745end
746complete -c {bin} -f -a '(__{function}_complete)'
747"#
748 ),
749 Shell::Elvish => format!(
750 r#"# Completion for {bin}, answered by `{bin} {REQUEST}`.
751use str
752
753set edit:completion:arg-completer[{bin}] = {{|@words|
754 for answer [(e:{bin} {REQUEST} --shell elvish --words $@words 2>/dev/null | from-lines)] {{
755 if (str:has-prefix $answer "\x01") {{
756 # The mark, then `--name=` when the value is attached to its flag.
757 var asked before = (str:split &max=2 "\t" $answer)
758 var typed = (str:trim-prefix $words[-1] $before)
759 var found = []
760 if (has-value ["\x01files" "\x01dirs"] $asked) {{
761 set found = [(edit:complete-filename $typed)]
762 }} elif (eq $asked "\x01commands") {{
763 set found = [(edit:complete-sudo sudo $typed)]
764 }}
765 for one $found {{
766 if (eq (kind-of $one) string) {{
767 put $before$one
768 }} elif (or (not-eq $asked "\x01dirs") (str:has-suffix $one[stem] /)) {{
769 edit:complex-candidate $before$one[stem] &code-suffix=$one[code-suffix] &display=$one[display]
770 }}
771 }}
772 }} elif (not-eq $answer '') {{
773 var parts = [(str:split "\t" $answer)]
774 var display = $parts[0]
775 if (> (count $parts) 1) {{
776 set display = (str:join ' -- ' $parts)
777 }}
778 edit:complex-candidate $parts[0] &display=$display
779 }}
780 }}
781}}
782"#
783 ),
784 Shell::PowerShell => format!(
785 r#"# Completion for {bin}, answered by `{bin} {REQUEST}`.
786Register-ArgumentCompleter -Native -CommandName '{bin}' -ScriptBlock {{
787 param($wordToComplete, $commandAst, $cursorPosition)
788
789 # The command's text up to the cursor: `$cursorPosition` counts from the
790 # start of the whole input, the extent from its own start.
791 $extent = $commandAst.Extent
792 $offset = [Math]::Min([Math]::Max($cursorPosition - $extent.StartOffset, 0), $extent.Text.Length)
793 $line = $extent.Text.Substring(0, $offset)
794 # The extent ends at the last token: a word not yet typed needs its space.
795 if ($wordToComplete -eq '' -and -not $line.EndsWith(' ')) {{ $line += ' ' }}
796 $answers = @(& '{bin}' {REQUEST} --shell powershell --line $line 2>$null)
797
798 foreach ($answer in $answers) {{
799 if ([string]::IsNullOrEmpty($answer)) {{ continue }}
800 $parts = $answer -split "`t", 2
801 if ($answer[0] -eq [char]1) {{
802 # The mark, then `--name=` when the value is attached to its flag.
803 $before = $parts[1]
804 $typed = $wordToComplete
805 if ($before -and $typed.StartsWith($before)) {{ $typed = $typed.Substring($before.Length) }}
806 $found = switch ($parts[0].Substring(1)) {{
807 'files' {{ [System.Management.Automation.CompletionCompleters]::CompleteFilename($typed) }}
808 'dirs' {{
809 [System.Management.Automation.CompletionCompleters]::CompleteFilename($typed) |
810 Where-Object ResultType -eq 'ProviderContainer'
811 }}
812 'commands' {{ [System.Management.Automation.CompletionCompleters]::CompleteCommand($typed) }}
813 }}
814 foreach ($one in $found) {{
815 [System.Management.Automation.CompletionResult]::new(
816 $before + $one.CompletionText, $one.ListItemText, $one.ResultType, $one.ToolTip)
817 }}
818 continue
819 }}
820 $help = if ($parts.Count -gt 1 -and $parts[1]) {{ $parts[1] }} else {{ $parts[0] }}
821 [System.Management.Automation.CompletionResult]::new(
822 $parts[0], $parts[0], 'ParameterValue', $help)
823 }}
824}}
825"#
826 ),
827 }
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833
834 #[test]
835 fn lines_split_as_a_shell_splits_them() {
836 assert_eq!(
837 split_line("prog -o 'a b' c\\ d"),
838 ["prog", "-o", "a b", "c d"]
839 );
840 assert_eq!(split_line("prog --x="), ["prog", "--x="]);
841 assert_eq!(split_line("prog "), ["prog", ""]);
842 assert_eq!(split_line("prog \"q\\\"x"), ["prog", "q\"x"]);
843 }
844
845 #[test]
846 fn bash_gets_the_part_after_its_word_break() {
847 let c = Completions {
848 candidates: vec![Candidate {
849 value: "--color=always".into(),
850 help: "",
851 }],
852 ..Completions::default()
853 };
854 assert_eq!(render_bash(&c, "--color=al", "al"), "always\n");
855 assert_eq!(render_bash(&c, "--color=", "="), "always\n");
856 assert_eq!(render_bash(&c, "--col", "--col"), "--color=always\n");
857 }
858
859 #[test]
860 fn scripts_call_back_the_program() {
861 for &shell in Shell::ALL {
862 let script = script("my-tool", shell);
863 assert!(script.contains("my-tool __complete_word__"), "{script}");
864 }
865 }
866}