1use heck::ToKebabCase;
7use std::string::String;
8use std::vec::Vec;
9
10use crate::schema::{ArgLevelSchema, ArgSchema, Schema, Subcommand};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, facet::Facet)]
14#[repr(u8)]
15pub enum Shell {
16 Bash,
18 Zsh,
20 Fish,
22}
23
24pub fn generate_completions_for_shape(
29 shape: &'static facet_core::Shape,
30 shell: Shell,
31 program_name: &str,
32) -> String {
33 let schema = match Schema::from_shape(shape) {
34 Ok(s) => s,
35 Err(_) => {
36 return format!("# Could not generate completions for {program_name}\n");
38 }
39 };
40
41 generate_completions_for_schema(&schema, shell, program_name)
42}
43
44pub fn generate_completions_for_schema(
46 schema: &Schema,
47 shell: Shell,
48 program_name: &str,
49) -> String {
50 match shell {
51 Shell::Bash => generate_bash(schema.args(), program_name),
52 Shell::Zsh => generate_zsh(schema.args(), program_name),
53 Shell::Fish => generate_fish(schema.args(), program_name),
54 }
55}
56
57fn generate_bash(args: &ArgLevelSchema, program_name: &str) -> String {
60 let mut out = String::new();
61
62 generate_bash_function(&mut out, args, program_name, &[]);
64
65 generate_bash_subcommand_functions(&mut out, args, program_name, &[]);
67
68 out.push_str(&format!("complete -F _{program_name} {program_name}\n"));
70
71 out
72}
73
74fn generate_bash_function(
75 out: &mut String,
76 args: &ArgLevelSchema,
77 program_name: &str,
78 path: &[&str],
79) {
80 let func_name = if path.is_empty() {
81 program_name.to_string()
82 } else {
83 format!("{}_{}", program_name, path.join("_").replace('-', "_"))
84 };
85
86 let (flags, subcommands) = collect_options(args);
87
88 out.push_str(&format!(
89 r#"_{func_name}() {{
90 local cur prev words cword
91 _init_completion || return
92
93"#
94 ));
95
96 if !flags.is_empty() {
98 out.push_str(" local flags=\"");
99 for (i, flag) in flags.iter().enumerate() {
100 if i > 0 {
101 out.push(' ');
102 }
103 out.push_str(&format!("--{}", flag.long));
104 if let Some(short) = flag.short {
105 out.push_str(&format!(" -{short}"));
106 }
107 }
108 out.push_str("\"\n");
109 } else {
110 out.push_str(" local flags=\"\"\n");
111 }
112
113 if !subcommands.is_empty() {
115 out.push_str(" local commands=\"");
116 for (i, cmd) in subcommands.iter().enumerate() {
117 if i > 0 {
118 out.push(' ');
119 }
120 out.push_str(&cmd.name);
121 }
122 out.push_str("\"\n");
123 } else {
124 out.push_str(" local commands=\"\"\n");
125 }
126
127 let value_flags: Vec<_> = flags.iter().filter(|f| f.takes_value).collect();
129 if !value_flags.is_empty() {
130 out.push_str("\n case \"$prev\" in\n");
131 for flag in &value_flags {
132 let mut cases = vec![format!("--{}", flag.long)];
133 if let Some(short) = flag.short {
134 cases.push(format!("-{short}"));
135 }
136 out.push_str(&format!(
137 " {})\n # Value expected, provide default file completion\n return\n ;;\n",
138 cases.join("|")
139 ));
140 }
141 out.push_str(" esac\n");
142 }
143
144 if !subcommands.is_empty() {
145 out.push_str(&format!(
147 r#"
148 # Find the subcommand
149 local cmd_idx={}
150 local cmd=""
151 for ((i=1; i < cword; i++)); do
152 case "${{words[i]}}" in
153 -*)
154 # Skip flags
155"#,
156 path.len() + 1
157 ));
158
159 if !value_flags.is_empty() {
161 out.push_str(" case \"${words[i]}\" in\n");
162 for flag in &value_flags {
163 let mut cases = vec![format!("--{}", flag.long)];
164 if let Some(short) = flag.short {
165 cases.push(format!("-{short}"));
166 }
167 out.push_str(&format!(
168 " {}) ((i++)) ;;\n",
169 cases.join("|")
170 ));
171 }
172 out.push_str(" esac\n");
173 }
174
175 out.push_str(
176 r#" ;;
177 *)
178 cmd="${words[i]}"
179 cmd_idx=$i
180 break
181 ;;
182 esac
183 done
184
185 # If we're past the subcommand, dispatch to subcommand completer
186 if [[ -n "$cmd" && $cword -gt $cmd_idx ]]; then
187 case "$cmd" in
188"#,
189 );
190
191 for cmd in &subcommands {
192 let sub_func = if path.is_empty() {
193 format!("{}_{}", program_name, cmd.name.replace('-', "_"))
194 } else {
195 format!("{}_{}", func_name, cmd.name.replace('-', "_"))
196 };
197 out.push_str(&format!(
198 " {})\n _{sub_func}\n return\n ;;\n",
199 cmd.name
200 ));
201 }
202
203 out.push_str(
204 r#" esac
205 fi
206
207 # Complete flags or subcommands
208 if [[ "$cur" == -* ]]; then
209 COMPREPLY=($(compgen -W "$flags" -- "$cur"))
210 else
211 COMPREPLY=($(compgen -W "$commands" -- "$cur"))
212 fi
213}
214
215"#,
216 );
217 } else {
218 out.push_str(
220 r#"
221 if [[ "$cur" == -* ]]; then
222 COMPREPLY=($(compgen -W "$flags" -- "$cur"))
223 fi
224}
225
226"#,
227 );
228 }
229}
230
231fn generate_bash_subcommand_functions(
232 out: &mut String,
233 args: &ArgLevelSchema,
234 program_name: &str,
235 path: &[&str],
236) {
237 for (_, sub) in args.subcommands() {
238 let mut new_path = path.to_vec();
239 new_path.push(sub.cli_name());
240 generate_bash_function(out, sub.args(), program_name, &new_path);
241 generate_bash_subcommand_functions(out, sub.args(), program_name, &new_path);
242 }
243}
244
245fn generate_zsh(args: &ArgLevelSchema, program_name: &str) -> String {
248 let mut out = String::new();
249
250 out.push_str(&format!("#compdef {program_name}\n\n"));
252
253 generate_zsh_function(&mut out, args, program_name, program_name);
255
256 generate_zsh_subcommand_helpers(&mut out, args, program_name);
258
259 out.push_str(&format!(
261 r#"
262if [ "$funcstack[1]" = "_{program_name}" ]; then
263 _{program_name} "$@"
264else
265 compdef _{program_name} {program_name}
266fi
267"#
268 ));
269
270 out
271}
272
273fn generate_zsh_function(
274 out: &mut String,
275 args: &ArgLevelSchema,
276 func_name: &str,
277 _program_name: &str,
278) {
279 out.push_str(&format!(
280 r#"_{func_name}() {{
281 local -a options
282 local -a commands
283 local ret=1
284
285"#
286 ));
287
288 let (flags, subcommands) = collect_options(args);
289
290 out.push_str(" options=(\n");
292 for flag in &flags {
293 let desc = flag.doc.as_deref().unwrap_or("");
294 let escaped_desc = escape_zsh_description(desc);
295
296 let value_spec = if flag.takes_value {
298 ":value:_default"
299 } else {
300 ""
301 };
302
303 if let Some(short) = flag.short {
304 out.push_str(&format!(
306 " '(-{short} --{long})'-{short}'[{escaped_desc}]{value_spec}'\n",
307 long = flag.long,
308 ));
309 out.push_str(&format!(
310 " '(-{short} --{long})'--{long}'[{escaped_desc}]{value_spec}'\n",
311 long = flag.long,
312 ));
313 } else {
314 out.push_str(&format!(
315 " '--{long}[{escaped_desc}]{value_spec}'\n",
316 long = flag.long,
317 ));
318 }
319 }
320 out.push_str(" )\n\n");
321
322 if !subcommands.is_empty() {
323 out.push_str(" commands=(\n");
325 for cmd in &subcommands {
326 let desc = cmd.doc.as_deref().unwrap_or("");
327 let escaped_desc = escape_zsh_description(desc);
328 out.push_str(&format!(
329 " '{name}:{escaped_desc}'\n",
330 name = cmd.name
331 ));
332 }
333 out.push_str(" )\n\n");
334
335 out.push_str(&format!(
337 r#" _arguments -C \
338 $options \
339 "1: :->command" \
340 "*::arg:->args" \
341 && ret=0
342
343 case $state in
344 command)
345 _describe -t commands '{func_name} commands' commands && ret=0
346 ;;
347 args)
348 case $words[1] in
349"#
350 ));
351
352 for cmd in &subcommands {
354 let sub_func = format!("{}_{}", func_name, cmd.name.replace('-', "_"));
355 out.push_str(&format!(
356 " {name})\n _{sub_func} && ret=0\n ;;\n",
357 name = cmd.name,
358 ));
359 }
360
361 out.push_str(
362 r#" esac
363 ;;
364 esac
365
366 return ret
367}
368
369"#,
370 );
371 } else {
372 out.push_str(
374 r#" _arguments $options && ret=0
375 return ret
376}
377
378"#,
379 );
380 }
381}
382
383fn generate_zsh_subcommand_helpers(out: &mut String, args: &ArgLevelSchema, parent_func: &str) {
384 for (_, sub) in args.subcommands() {
385 let func_name = format!("{}_{}", parent_func, sub.cli_name().replace('-', "_"));
386 generate_zsh_function(out, sub.args(), &func_name, parent_func);
387 generate_zsh_subcommand_helpers(out, sub.args(), &func_name);
389 }
390}
391
392fn escape_zsh_description(s: &str) -> String {
393 s.replace('\'', "'\\''")
394 .replace('[', "\\[")
395 .replace(']', "\\]")
396 .replace(':', "\\:")
397}
398
399fn generate_fish(args: &ArgLevelSchema, program_name: &str) -> String {
402 let mut out = String::new();
403
404 out.push_str(&format!("# Fish completion for {program_name}\n\n"));
405
406 generate_fish_level(&mut out, args, program_name, &[]);
408
409 generate_fish_subcommands(&mut out, args, program_name, &[]);
411
412 out
413}
414
415fn generate_fish_level(out: &mut String, args: &ArgLevelSchema, program_name: &str, path: &[&str]) {
416 let (flags, subcommands) = collect_options(args);
417
418 let condition = if path.is_empty() {
420 "__fish_use_subcommand".to_string()
421 } else {
422 let seen_checks: Vec<String> = path
424 .iter()
425 .map(|cmd| format!("__fish_seen_subcommand_from {cmd}"))
426 .collect();
427 seen_checks.join("; and ")
428 };
429
430 if !path.is_empty() {
432 out.push_str(&format!("\n# {} subcommand\n", path.join(" ")));
433 }
434
435 for flag in &flags {
437 let desc = flag.doc.as_deref().unwrap_or("");
438 out.push_str(&format!("complete -c {program_name}"));
439
440 if !path.is_empty() {
442 out.push_str(&format!(" -n '{condition}'"));
443 }
444
445 if let Some(short) = flag.short {
446 out.push_str(&format!(" -s {short}"));
447 }
448 out.push_str(&format!(" -l {}", flag.long));
449
450 if flag.takes_value {
452 out.push_str(" -r");
453 }
454
455 if !desc.is_empty() {
456 let escaped_desc = desc.replace('\'', "'\\''");
457 out.push_str(&format!(" -d '{escaped_desc}'"));
458 }
459 out.push('\n');
460 }
461
462 if !subcommands.is_empty() {
464 out.push_str(&format!(
465 "\n# {prefix}subcommands\n",
466 prefix = if path.is_empty() { "" } else { "Nested " }
467 ));
468
469 let sub_names: Vec<&str> = subcommands.iter().map(|s| s.name.as_str()).collect();
471 let no_sub_condition = if path.is_empty() {
472 "__fish_use_subcommand".to_string()
473 } else {
474 format!(
475 "{}; and not __fish_seen_subcommand_from {}",
476 condition,
477 sub_names.join(" ")
478 )
479 };
480
481 for cmd in &subcommands {
482 let desc = cmd.doc.as_deref().unwrap_or("");
483 out.push_str(&format!(
484 "complete -c {program_name} -n '{no_sub_condition}' -f -a {name}",
485 name = cmd.name
486 ));
487 if !desc.is_empty() {
488 let escaped_desc = desc.replace('\'', "'\\''");
489 out.push_str(&format!(" -d '{escaped_desc}'"));
490 }
491 out.push('\n');
492 }
493 }
494}
495
496fn generate_fish_subcommands(
497 out: &mut String,
498 args: &ArgLevelSchema,
499 program_name: &str,
500 path: &[&str],
501) {
502 for (_, sub) in args.subcommands() {
503 let mut new_path = path.to_vec();
504 new_path.push(sub.cli_name());
505 generate_fish_level(out, sub.args(), program_name, &new_path);
506 generate_fish_subcommands(out, sub.args(), program_name, &new_path);
507 }
508}
509
510struct FlagInfo {
513 long: String,
514 short: Option<char>,
515 doc: Option<String>,
516 takes_value: bool,
517}
518
519struct SubcommandInfo {
520 name: String,
521 doc: Option<String>,
522}
523
524fn collect_options(args: &ArgLevelSchema) -> (Vec<FlagInfo>, Vec<SubcommandInfo>) {
530 let mut flags = Vec::new();
531 let mut subcommands = Vec::new();
532
533 for (name, arg) in args.args() {
535 if !arg.kind().is_positional() {
536 flags.push(arg_to_flag(name, arg));
537 }
538 }
539
540 for sub in args.subcommands().values() {
542 subcommands.push(subcommand_to_info(sub));
543 }
544
545 (flags, subcommands)
546}
547
548fn arg_to_flag(name: &str, arg: &ArgSchema) -> FlagInfo {
550 let takes_value = !arg.value().inner_if_option().is_bool();
552
553 FlagInfo {
554 long: name.to_kebab_case(),
556 short: arg.kind().short(),
557 doc: arg.docs().summary().map(|s| s.trim().to_string()),
558 takes_value,
559 }
560}
561
562fn subcommand_to_info(sub: &Subcommand) -> SubcommandInfo {
564 SubcommandInfo {
565 name: sub.cli_name().to_string(),
567 doc: sub.docs().summary().map(|s| s.trim().to_string()),
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use facet::Facet;
575 use figue_attrs as args;
576
577 #[derive(Facet)]
579 struct CommonArgs {
580 #[facet(args::named, crate::short = 'v')]
582 verbose: bool,
583
584 #[facet(args::named, crate::short = 'q')]
586 quiet: bool,
587 }
588
589 #[derive(Facet)]
591 struct ArgsWithFlatten {
592 #[facet(args::positional)]
594 input: String,
595
596 #[facet(flatten)]
598 common: CommonArgs,
599 }
600
601 #[test]
602 fn test_flatten_args_appear_in_completions() {
603 let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
604 let completions = generate_completions_for_schema(&schema, Shell::Bash, "myapp");
605
606 assert!(
608 completions.contains("--verbose"),
609 "completions should contain --verbose from flattened CommonArgs"
610 );
611 assert!(
612 completions.contains("-v"),
613 "completions should contain -v short flag"
614 );
615 assert!(
616 completions.contains("--quiet"),
617 "completions should contain --quiet from flattened CommonArgs"
618 );
619 assert!(
620 completions.contains("-q"),
621 "completions should contain -q short flag"
622 );
623
624 assert!(
626 !completions.contains("--common"),
627 "completions should not show --common as a flag"
628 );
629 }
630
631 #[derive(Facet)]
633 struct ArgsWithRename {
634 #[facet(args::named, rename = "debug-mode")]
636 debug: bool,
637
638 #[facet(args::named, rename = "out")]
640 output_file: String,
641 }
642
643 #[test]
644 fn test_rename_respected_in_completions() {
645 let schema = Schema::from_shape(ArgsWithRename::SHAPE).unwrap();
646 let completions = generate_completions_for_schema(&schema, Shell::Bash, "myapp");
647
648 assert!(
650 completions.contains("--debug-mode"),
651 "completions should contain --debug-mode (renamed from debug)"
652 );
653 assert!(
654 completions.contains("--out"),
655 "completions should contain --out (renamed from output_file)"
656 );
657
658 assert!(
660 !completions.contains("--debug ") && !completions.contains("--debug\n"),
661 "completions should not show --debug (was renamed to --debug-mode)"
662 );
663 assert!(
664 !completions.contains("--output-file"),
665 "completions should not show --output-file (was renamed to --out)"
666 );
667 }
668
669 #[derive(Facet)]
671 #[repr(u8)]
672 #[allow(dead_code)]
673 enum CommandWithRename {
674 List,
676 #[facet(rename = "rm")]
678 Remove,
679 }
680
681 #[derive(Facet)]
683 struct ArgsWithRenamedSubcommand {
684 #[facet(args::subcommand)]
685 command: Option<CommandWithRename>,
686 }
687
688 #[test]
689 fn test_subcommand_rename_respected_in_completions() {
690 let schema = Schema::from_shape(ArgsWithRenamedSubcommand::SHAPE).unwrap();
691 let completions = generate_completions_for_schema(&schema, Shell::Bash, "myapp");
692
693 assert!(
696 completions.contains("list"),
697 "completions should contain 'list' subcommand"
698 );
699 assert!(
700 completions.contains("rm"),
701 "completions should contain 'rm' subcommand (renamed from Remove)"
702 );
703
704 assert!(
707 !completions.contains("remove"),
708 "completions should not show 'remove' (was renamed to 'rm')"
709 );
710 }
711
712 #[test]
713 fn test_zsh_completions_with_docs() {
714 let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
715 let completions = generate_completions_for_schema(&schema, Shell::Zsh, "myapp");
716
717 assert!(
719 completions.contains("verbose output"),
720 "zsh completions should include doc for --verbose"
721 );
722 assert!(
723 completions.contains("quiet mode"),
724 "zsh completions should include doc for --quiet"
725 );
726 }
727
728 #[test]
729 fn test_fish_completions_with_docs() {
730 let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
731 let completions = generate_completions_for_schema(&schema, Shell::Fish, "myapp");
732
733 assert!(
735 completions.contains("verbose output"),
736 "fish completions should include doc for --verbose"
737 );
738 assert!(
739 completions.contains("quiet mode"),
740 "fish completions should include doc for --quiet"
741 );
742 }
743
744 #[derive(Facet)]
746 #[allow(dead_code)]
747 struct GitLikeArgs {
748 #[facet(args::named)]
750 version: bool,
751
752 #[facet(args::subcommand)]
754 command: Option<GitCommand>,
755 }
756
757 #[derive(Facet)]
758 #[repr(u8)]
759 #[allow(dead_code)]
760 enum GitCommand {
761 Clone {
763 #[facet(args::positional)]
765 url: String,
766
767 #[facet(default, args::named, args::short = 'b')]
769 branch: Option<String>,
770 },
771 Remote {
773 #[facet(args::subcommand)]
775 action: RemoteAction,
776 },
777 }
778
779 #[derive(Facet)]
780 #[repr(u8)]
781 #[allow(dead_code)]
782 enum RemoteAction {
783 Add {
785 #[facet(args::positional)]
787 name: String,
788 },
789 #[facet(rename = "rm")]
791 Remove {
792 #[facet(args::positional)]
794 name: String,
795 },
796 }
797
798 #[test]
799 fn test_zsh_inline_sourcing_format() {
800 let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
801 let completions = generate_completions_for_schema(&schema, Shell::Zsh, "myapp");
802
803 assert!(
805 completions.contains("compdef _myapp myapp"),
806 "zsh completions should have compdef for inline sourcing"
807 );
808 assert!(
809 completions.contains(r#"if [ "$funcstack[1]" = "_myapp" ]"#),
810 "zsh completions should detect autoload vs inline sourcing"
811 );
812 }
813
814 #[test]
815 fn test_nested_subcommands_zsh() {
816 let schema = Schema::from_shape(GitLikeArgs::SHAPE).unwrap();
817 let completions = generate_completions_for_schema(&schema, Shell::Zsh, "git");
818
819 assert!(
821 completions.contains("'clone:"),
822 "zsh completions should have clone subcommand"
823 );
824 assert!(
825 completions.contains("'remote:"),
826 "zsh completions should have remote subcommand"
827 );
828
829 assert!(
831 completions.contains("_git_remote()"),
832 "zsh completions should generate function for remote subcommand"
833 );
834
835 assert!(
837 completions.contains("'rm:"),
838 "zsh completions should have 'rm' subcommand (renamed from Remove)"
839 );
840 }
841
842 #[test]
843 fn test_nested_subcommands_bash() {
844 let schema = Schema::from_shape(GitLikeArgs::SHAPE).unwrap();
845 let completions = generate_completions_for_schema(&schema, Shell::Bash, "git");
846
847 assert!(
849 completions.contains("clone"),
850 "bash completions should have clone subcommand"
851 );
852 assert!(
853 completions.contains("remote"),
854 "bash completions should have remote subcommand"
855 );
856
857 assert!(
859 completions.contains("_git_remote()"),
860 "bash completions should generate function for remote subcommand"
861 );
862
863 assert!(
865 completions.contains("rm"),
866 "bash completions should have 'rm' subcommand"
867 );
868 }
869
870 #[test]
871 fn test_nested_subcommands_fish() {
872 let schema = Schema::from_shape(GitLikeArgs::SHAPE).unwrap();
873 let completions = generate_completions_for_schema(&schema, Shell::Fish, "git");
874
875 assert!(
877 completions.contains("-a clone"),
878 "fish completions should have clone subcommand"
879 );
880 assert!(
881 completions.contains("-a remote"),
882 "fish completions should have remote subcommand"
883 );
884
885 assert!(
887 completions.contains("__fish_seen_subcommand_from remote"),
888 "fish completions should handle remote subcommand context"
889 );
890
891 assert!(
893 completions.contains("-a rm"),
894 "fish completions should have 'rm' subcommand"
895 );
896 }
897
898 #[test]
899 fn test_value_flags_distinguished_from_bool_flags() {
900 let schema = Schema::from_shape(ArgsWithRename::SHAPE).unwrap();
901 let completions = generate_completions_for_schema(&schema, Shell::Zsh, "myapp");
902
903 assert!(
906 !completions.contains("--debug-mode[Enable debug mode]:value"),
907 "bool flag should not require value"
908 );
909
910 assert!(
912 completions.contains("--out[Set output file]:value:_default"),
913 "string flag should require value"
914 );
915 }
916}