1use clap::builder::ValueParser;
35use clap::{Arg, ArgAction, Command};
36use std::fmt::Write as _;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub enum Shell {
41 Bash,
43 Zsh,
45 Fish,
47 PowerShell,
49}
50
51impl Shell {
52 pub const ALL: [Self; 4] =
54 [Self::Bash, Self::Zsh, Self::Fish, Self::PowerShell];
55
56 #[must_use]
58 pub const fn name(self) -> &'static str {
59 match self {
60 Self::Bash => "bash",
61 Self::Zsh => "zsh",
62 Self::Fish => "fish",
63 Self::PowerShell => "powershell",
64 }
65 }
66
67 #[must_use]
83 pub fn file_name(self, bin: &str) -> String {
84 match self {
85 Self::Bash => bin.to_owned(),
86 Self::Zsh => format!("_{bin}"),
87 Self::Fish => format!("{bin}.fish"),
88 Self::PowerShell => format!("_{bin}.ps1"),
89 }
90 }
91
92 #[must_use]
94 pub fn parse(s: &str) -> Option<Self> {
95 Self::ALL
96 .into_iter()
97 .find(|sh| sh.name().eq_ignore_ascii_case(s))
98 }
99}
100
101fn takes_path(arg: &Arg) -> bool {
106 arg.get_value_parser().type_id() == ValueParser::path_buf().type_id()
107}
108
109fn takes_value(arg: &Arg) -> bool {
119 matches!(arg.get_action(), ArgAction::Set | ArgAction::Append)
120}
121
122fn spellings(arg: &Arg) -> Vec<String> {
124 let mut out = Vec::new();
125 if let Some(short) = arg.get_short() {
126 out.push(format!("-{short}"));
127 }
128 if let Some(long) = arg.get_long() {
129 out.push(format!("--{long}"));
130 }
131 out
132}
133
134fn help_of(arg: &Arg) -> String {
136 arg.get_help()
137 .map(|h| h.to_string().replace('\n', " "))
138 .unwrap_or_default()
139}
140
141fn visible_args(cmd: &Command) -> impl Iterator<Item = &Arg> {
143 cmd.get_arguments().filter(|a| !a.is_hide_set())
144}
145
146#[must_use]
158pub fn render(app: &Command, shell: Shell) -> String {
159 match shell {
160 Shell::Bash => render_bash(app),
161 Shell::Zsh => render_zsh(app),
162 Shell::Fish => render_fish(app),
163 Shell::PowerShell => render_powershell(app),
164 }
165}
166
167fn render_bash(app: &Command) -> String {
172 let bin = app.get_name();
173 let subs: Vec<&str> =
174 app.get_subcommands().map(Command::get_name).collect();
175
176 let mut out = String::new();
177 let _ =
178 writeln!(out, "# {bin} completion for bash. Generated — do not edit.");
179 let _ = writeln!(out, "_{bin}() {{");
180 let _ = writeln!(out, " local cur prev cmd opts paths i");
181 let _ = writeln!(out, " COMPREPLY=()");
182 let _ = writeln!(out, r#" cur="${{COMP_WORDS[COMP_CWORD]}}""#);
183 let _ = writeln!(out, r#" prev="${{COMP_WORDS[COMP_CWORD-1]}}""#);
184 let _ = writeln!(out, r#" cmd="""#);
185 let _ = writeln!(out, " for ((i = 1; i < COMP_CWORD; i++)); do");
186 let _ = writeln!(out, r#" case "${{COMP_WORDS[i]}}" in"#);
187 let _ = writeln!(out, " -*) ;;");
188 let _ = writeln!(
189 out,
190 r#" {}) cmd="${{COMP_WORDS[i]}}"; break ;;"#,
191 subs.join("|")
192 );
193 let _ = writeln!(out, " esac");
194 let _ = writeln!(out, " done");
195 let _ = writeln!(out);
196 let _ = writeln!(out, r#" paths="""#);
197 let _ = writeln!(out, r#" case "$cmd" in"#);
198
199 for sub in app.get_subcommands() {
200 let opts = bash_word_list(sub, false);
201 let paths = bash_word_list(sub, true);
202 let _ = writeln!(out, r#" {})"#, sub.get_name());
203 let _ = writeln!(out, r#" opts="{opts}""#);
204 if !paths.is_empty() {
205 let _ = writeln!(out, r#" paths="{paths}""#);
206 }
207 let _ = writeln!(out, " ;;");
208 }
209
210 let mut root = subs.join(" ");
212 let root_opts = bash_word_list(app, false);
213 if !root_opts.is_empty() {
214 root.push(' ');
215 root.push_str(&root_opts);
216 }
217 let root_paths = bash_word_list(app, true);
218 let _ = writeln!(out, " *)");
219 let _ = writeln!(out, r#" opts="{root}""#);
220 if !root_paths.is_empty() {
221 let _ = writeln!(out, r#" paths="{root_paths}""#);
222 }
223 let _ = writeln!(out, " ;;");
224 let _ = writeln!(out, " esac");
225 let _ = writeln!(out);
226 let _ = writeln!(
229 out,
230 r#" if [[ -n "$paths" && " $paths " == *" $prev "* ]]; then"#
231 );
232 let _ = writeln!(
233 out,
234 r#" mapfile -t COMPREPLY < <(compgen -f -- "$cur")"#
235 );
236 let _ = writeln!(out, " return 0");
237 let _ = writeln!(out, " fi");
238 let _ = writeln!(
239 out,
240 r#" mapfile -t COMPREPLY < <(compgen -W "$opts" -- "$cur")"#
241 );
242 let _ = writeln!(out, " return 0");
243 let _ = writeln!(out, "}}");
244 let _ = writeln!(out, "complete -F _{bin} {bin}");
245 out
246}
247
248fn bash_word_list(cmd: &Command, paths_only: bool) -> String {
251 let mut words = Vec::new();
252 for arg in visible_args(cmd) {
253 if paths_only && !takes_path(arg) {
254 continue;
255 }
256 words.extend(spellings(arg));
257 }
258 words.join(" ")
259}
260
261fn zsh_escape(s: &str) -> String {
270 s.replace('\\', r"\\")
271 .replace('\'', r"'\''")
272 .replace('[', r"\[")
273 .replace(']', r"\]")
274 .replace(':', r"\:")
275}
276
277fn render_zsh(app: &Command) -> String {
278 let bin = app.get_name();
279 let mut out = String::new();
280 let _ = writeln!(out, "#compdef {bin}");
281 let _ =
282 writeln!(out, "# {bin} completion for zsh. Generated — do not edit.");
283 let _ = writeln!(out);
284 let _ = writeln!(out, "_{bin}() {{");
285 let _ = writeln!(out, " local curcontext=\"$curcontext\" state line");
286 let _ = writeln!(out, " local -a commands");
287 let _ = writeln!(out, " commands=(");
288 for sub in app.get_subcommands() {
289 let about = sub
290 .get_about()
291 .map(|a| a.to_string().replace('\n', " "))
292 .unwrap_or_default();
293 let _ = writeln!(
294 out,
295 " '{}:{}'",
296 sub.get_name(),
297 zsh_escape(&about)
298 );
299 }
300 let _ = writeln!(out, " )");
301 let _ = writeln!(out);
302 let _ = writeln!(out, " _arguments -C \\");
303 for arg in visible_args(app) {
304 let _ = writeln!(out, " {} \\", zsh_arg_spec(arg));
305 }
306 let _ = writeln!(out, " '1: :->command' \\");
307 let _ = writeln!(out, " '*:: :->args' && return 0");
308 let _ = writeln!(out);
309 let _ = writeln!(out, " case $state in");
310 let _ = writeln!(out, " command)");
311 let _ = writeln!(
312 out,
313 " _describe -t commands '{bin} command' commands && return 0"
314 );
315 let _ = writeln!(out, " ;;");
316 let _ = writeln!(out, " args)");
317 let _ = writeln!(out, " case $words[1] in");
318 for sub in app.get_subcommands() {
319 let _ = writeln!(out, " {})", sub.get_name());
320 let _ = writeln!(out, " _arguments \\");
321 for arg in visible_args(sub) {
322 let _ = writeln!(
323 out,
324 " {} \\",
325 zsh_arg_spec(arg)
326 );
327 }
328 let _ = writeln!(out, " && return 0");
329 let _ = writeln!(out, " ;;");
330 }
331 let _ = writeln!(out, " esac");
332 let _ = writeln!(out, " ;;");
333 let _ = writeln!(out, " esac");
334 let _ = writeln!(out, " return 1");
335 let _ = writeln!(out, "}}");
336 let _ = writeln!(out);
337 let _ = writeln!(out, "_{bin} \"$@\"");
338 out
339}
340
341fn zsh_arg_spec(arg: &Arg) -> String {
343 let names = spellings(arg);
344 let help = zsh_escape(&help_of(arg));
345
346 let exclusion = if names.len() > 1 {
348 format!("({})", names.join(" "))
349 } else {
350 String::new()
351 };
352
353 let action = if takes_value(arg) {
354 let value = arg
355 .get_value_names()
356 .and_then(|n| n.first())
357 .map_or_else(|| "VALUE".to_owned(), ToString::to_string);
358 let completer = if takes_path(arg) { "_files" } else { " " };
359 format!(":{}:{completer}", zsh_escape(&value))
360 } else {
361 String::new()
362 };
363
364 if names.len() > 1 {
365 format!("'{exclusion}'{{{}}}'[{help}]{action}'", names.join(","))
367 } else {
368 format!("'{}[{help}]{action}'", names.join(""))
369 }
370}
371
372fn fish_escape(s: &str) -> String {
379 s.replace('\\', r"\\").replace('\'', r"\'")
380}
381
382fn render_fish(app: &Command) -> String {
383 let bin = app.get_name();
384 let mut out = String::new();
385 let _ =
386 writeln!(out, "# {bin} completion for fish. Generated — do not edit.");
387 let _ = writeln!(out, "complete -c {bin} -f");
390 let _ = writeln!(out);
391
392 for arg in visible_args(app) {
393 let _ = writeln!(
394 out,
395 "complete -c {bin} -n '__fish_use_subcommand' {}",
396 fish_arg_spec(arg)
397 );
398 }
399 for sub in app.get_subcommands() {
400 let about = sub
401 .get_about()
402 .map(|a| a.to_string().replace('\n', " "))
403 .unwrap_or_default();
404 let _ = writeln!(
405 out,
406 "complete -c {bin} -n '__fish_use_subcommand' -a '{}' -d '{}'",
407 sub.get_name(),
408 fish_escape(&about)
409 );
410 }
411 let _ = writeln!(out);
412 for sub in app.get_subcommands() {
413 let name = sub.get_name();
414 for arg in visible_args(sub) {
415 let _ = writeln!(
416 out,
417 "complete -c {bin} -n '__fish_seen_subcommand_from {name}' {}",
418 fish_arg_spec(arg)
419 );
420 }
421 }
422 out
423}
424
425fn fish_arg_spec(arg: &Arg) -> String {
426 let mut parts = Vec::new();
427 if let Some(short) = arg.get_short() {
428 parts.push(format!("-s {short}"));
429 }
430 if let Some(long) = arg.get_long() {
431 parts.push(format!("-l {long}"));
432 }
433 if takes_value(arg) {
434 parts.push(if takes_path(arg) {
437 "-r -F".to_owned()
438 } else {
439 "-r".to_owned()
440 });
441 }
442 let help = help_of(arg);
443 if !help.is_empty() {
444 parts.push(format!("-d '{}'", fish_escape(&help)));
445 }
446 parts.join(" ")
447}
448
449fn ps_escape(s: &str) -> String {
456 s.replace('\'', "''")
457}
458
459fn render_powershell(app: &Command) -> String {
460 let bin = app.get_name();
461 let mut out = String::new();
462 let _ = writeln!(
463 out,
464 "# {bin} completion for PowerShell. Generated — do not edit."
465 );
466 let _ = writeln!(out, "using namespace System.Management.Automation");
467 let _ =
468 writeln!(out, "using namespace System.Management.Automation.Language");
469 let _ = writeln!(out);
470 let _ = writeln!(
471 out,
472 "Register-ArgumentCompleter -Native -CommandName '{bin}' -ScriptBlock {{"
473 );
474 let _ = writeln!(
475 out,
476 " param($wordToComplete, $commandAst, $cursorPosition)"
477 );
478 let _ = writeln!(out);
479 let _ = writeln!(out, " $commandElements = $commandAst.CommandElements");
480 let _ = writeln!(out, " $command = @(");
481 let _ = writeln!(out, " '{bin}'");
482 let _ = writeln!(
483 out,
484 " for ($i = 1; $i -lt $commandElements.Count; $i++) {{"
485 );
486 let _ = writeln!(out, " $element = $commandElements[$i]");
487 let _ = writeln!(
488 out,
489 " if ($element -isnot [StringConstantExpressionAst] -or"
490 );
491 let _ = writeln!(out, " $element.StringConstantType -ne [StringConstantType]::BareWord -or");
492 let _ = writeln!(out, " $element.Value.StartsWith('-')) {{");
493 let _ = writeln!(out, " break");
494 let _ = writeln!(out, " }}");
495 let _ = writeln!(out, " $element.Value");
496 let _ = writeln!(out, " }}) -join ';'");
497 let _ = writeln!(out);
498 let _ = writeln!(out, " $completions = @(switch ($command) {{");
499
500 let _ = writeln!(out, " '{bin}' {{");
501 for arg in visible_args(app) {
502 for line in ps_completion_results(arg) {
503 let _ = writeln!(out, " {line}");
504 }
505 }
506 for sub in app.get_subcommands() {
507 let about = sub
508 .get_about()
509 .map(|a| a.to_string().replace('\n', " "))
510 .unwrap_or_default();
511 let name = sub.get_name();
512 let _ = writeln!(
513 out,
514 " [CompletionResult]::new('{name}', '{name}', \
515 [CompletionResultType]::ParameterValue, '{}')",
516 ps_escape(&about)
517 );
518 }
519 let _ = writeln!(out, " break");
520 let _ = writeln!(out, " }}");
521
522 for sub in app.get_subcommands() {
523 let _ = writeln!(out, " '{bin};{}' {{", sub.get_name());
524 for arg in visible_args(sub) {
525 for line in ps_completion_results(arg) {
526 let _ = writeln!(out, " {line}");
527 }
528 }
529 let _ = writeln!(out, " break");
530 let _ = writeln!(out, " }}");
531 }
532
533 let _ = writeln!(out, " }})");
534 let _ = writeln!(out);
535 let _ = writeln!(
536 out,
537 " $completions.Where{{ $_.CompletionText -like \"$wordToComplete*\" }} |"
538 );
539 let _ = writeln!(out, " Sort-Object -Property ListItemText");
540 let _ = writeln!(out, "}}");
541 out
542}
543
544fn ps_completion_results(arg: &Arg) -> Vec<String> {
545 let help = ps_escape(&help_of(arg));
546 spellings(arg)
547 .into_iter()
548 .map(|name| {
549 format!(
550 "[CompletionResult]::new('{name}', '{name}', \
551 [CompletionResultType]::ParameterName, '{help}')"
552 )
553 })
554 .collect()
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560 use crate::cmd::Cli;
561
562 fn app() -> Command {
563 Cli::subcommand_app()
564 }
565
566 #[test]
567 fn shell_names_round_trip() {
568 for sh in Shell::ALL {
569 assert_eq!(Shell::parse(sh.name()), Some(sh));
570 }
571 assert_eq!(Shell::parse("BASH"), Some(Shell::Bash));
572 assert_eq!(Shell::parse("tcsh"), None);
573 }
574
575 #[test]
576 fn file_names_match_what_each_shell_looks_for() {
577 assert_eq!(Shell::Bash.file_name("ssg"), "ssg");
578 assert_eq!(Shell::Zsh.file_name("ssg"), "_ssg");
579 assert_eq!(Shell::Fish.file_name("ssg"), "ssg.fish");
580 assert_eq!(Shell::PowerShell.file_name("ssg"), "_ssg.ps1");
581 }
582
583 #[test]
584 fn every_shell_produces_a_non_empty_script() {
585 for sh in Shell::ALL {
586 let script = render(&app(), sh);
587 assert!(
588 script.len() > 200,
589 "{} produced a suspiciously short script",
590 sh.name()
591 );
592 }
593 }
594
595 #[test]
596 fn rendering_is_deterministic() {
597 for sh in Shell::ALL {
598 assert_eq!(
599 render(&app(), sh),
600 render(&app(), sh),
601 "{} output varies between runs",
602 sh.name()
603 );
604 }
605 }
606
607 #[test]
608 fn zsh_escape_neutralises_every_spec_delimiter() {
609 assert_eq!(zsh_escape("a[b]c:d"), r"a\[b\]c\:d");
610 assert_eq!(zsh_escape(r"back\slash"), r"back\\slash");
611 assert_eq!(zsh_escape("it's"), r"it'\''s");
612 }
613
614 #[test]
615 fn fish_escape_handles_quotes_and_backslashes() {
616 assert_eq!(fish_escape("it's"), r"it\'s");
617 assert_eq!(fish_escape(r"a\b"), r"a\\b");
618 }
619
620 #[test]
621 fn powershell_escape_doubles_quotes() {
622 assert_eq!(ps_escape("it's"), "it''s");
623 }
624
625 #[test]
626 fn path_arguments_are_detected_from_the_value_parser() {
627 let app = app();
628 let build = app
629 .get_subcommands()
630 .find(|c| c.get_name() == "build")
631 .expect("build subcommand");
632 let content = build
633 .get_arguments()
634 .find(|a| a.get_id() == "content")
635 .expect("--content");
636 assert!(
637 takes_path(content),
638 "--content takes a PathBuf and must complete filenames"
639 );
640 }
641
642 #[test]
646 fn value_taking_and_bare_flags_are_told_apart() {
647 let app = app();
648 let build = app
649 .get_subcommands()
650 .find(|c| c.get_name() == "build")
651 .expect("build subcommand");
652 let arg = |id: &str| {
653 build
654 .get_arguments()
655 .find(|a| a.get_id() == id)
656 .unwrap_or_else(|| panic!("--{id}"))
657 };
658 assert!(takes_value(arg("content")), "--content takes a directory");
659 assert!(takes_value(arg("output")), "--output takes a directory");
660 assert!(!takes_value(arg("drafts")), "--drafts is a bare flag");
661 assert!(!takes_value(arg("quiet")), "--quiet is a bare flag");
662 }
663}