facet_args/
completions.rs1use alloc::string::String;
7use alloc::vec::Vec;
8use facet_core::{Def, Facet, Field, Shape, Type, UserType, Variant};
9use heck::ToKebabCase;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Shell {
14 Bash,
16 Zsh,
18 Fish,
20}
21
22pub fn generate_completions<T: Facet<'static>>(shell: Shell, program_name: &str) -> String {
24 generate_completions_for_shape(T::SHAPE, shell, program_name)
25}
26
27pub fn generate_completions_for_shape(
29 shape: &'static Shape,
30 shell: Shell,
31 program_name: &str,
32) -> String {
33 match shell {
34 Shell::Bash => generate_bash(shape, program_name),
35 Shell::Zsh => generate_zsh(shape, program_name),
36 Shell::Fish => generate_fish(shape, program_name),
37 }
38}
39
40fn generate_bash(shape: &'static Shape, program_name: &str) -> String {
43 let mut out = String::new();
44
45 out.push_str(&format!(
46 r#"_{program_name}() {{
47 local cur prev words cword
48 _init_completion || return
49
50 local commands=""
51 local flags=""
52
53"#
54 ));
55
56 let (flags, subcommands) = collect_options(shape);
58
59 if !flags.is_empty() {
61 out.push_str(" flags=\"");
62 for (i, flag) in flags.iter().enumerate() {
63 if i > 0 {
64 out.push(' ');
65 }
66 out.push_str(&format!("--{}", flag.long));
67 if let Some(short) = flag.short {
68 out.push_str(&format!(" -{short}"));
69 }
70 }
71 out.push_str("\"\n");
72 }
73
74 if !subcommands.is_empty() {
76 out.push_str(" commands=\"");
77 for (i, cmd) in subcommands.iter().enumerate() {
78 if i > 0 {
79 out.push(' ');
80 }
81 out.push_str(&cmd.name);
82 }
83 out.push_str("\"\n");
84 }
85
86 out.push_str(
87 r#"
88 case "$prev" in
89 # Add cases for flags that take values
90 *)
91 ;;
92 esac
93
94 if [[ "$cur" == -* ]]; then
95 COMPREPLY=($(compgen -W "$flags" -- "$cur"))
96 elif [[ -n "$commands" ]]; then
97 COMPREPLY=($(compgen -W "$commands" -- "$cur"))
98 fi
99}
100
101"#,
102 );
103
104 out.push_str(&format!("complete -F _{program_name} {program_name}\n"));
105
106 out
107}
108
109fn generate_zsh(shape: &'static Shape, program_name: &str) -> String {
112 let mut out = String::new();
113
114 out.push_str(&format!(
115 r#"#compdef {program_name}
116
117_{program_name}() {{
118 local -a commands
119 local -a options
120
121"#
122 ));
123
124 let (flags, subcommands) = collect_options(shape);
125
126 out.push_str(" options=(\n");
128 for flag in &flags {
129 let desc = flag.doc.as_deref().unwrap_or("");
130 let escaped_desc = desc.replace('\'', "'\\''");
131 if let Some(short) = flag.short {
132 out.push_str(&format!(" '-{short}[{escaped_desc}]'\n"));
133 }
134 out.push_str(&format!(" '--{}[{escaped_desc}]'\n", flag.long));
135 }
136 out.push_str(" )\n\n");
137
138 if !subcommands.is_empty() {
140 out.push_str(" commands=(\n");
141 for cmd in &subcommands {
142 let desc = cmd.doc.as_deref().unwrap_or("");
143 let escaped_desc = desc.replace('\'', "'\\''");
144 out.push_str(&format!(" '{}:{}'\n", cmd.name, escaped_desc));
145 }
146 out.push_str(" )\n\n");
147
148 out.push_str(
149 r#" _arguments -C \
150 $options \
151 "1: :->command" \
152 "*::arg:->args"
153
154 case $state in
155 command)
156 _describe -t commands 'commands' commands
157 ;;
158 args)
159 case $words[1] in
160"#,
161 );
162
163 for cmd in &subcommands {
165 out.push_str(&format!(
166 " {})\n ;;\n",
167 cmd.name
168 ));
169 }
170
171 out.push_str(
172 r#" esac
173 ;;
174 esac
175"#,
176 );
177 } else {
178 out.push_str(" _arguments $options\n");
179 }
180
181 out.push_str("}\n\n");
182 out.push_str(&format!("_{program_name} \"$@\"\n"));
183
184 out
185}
186
187fn generate_fish(shape: &'static Shape, program_name: &str) -> String {
190 let mut out = String::new();
191
192 out.push_str(&format!("# Fish completion for {program_name}\n\n"));
193
194 let (flags, subcommands) = collect_options(shape);
195
196 for flag in &flags {
198 let desc = flag.doc.as_deref().unwrap_or("");
199 out.push_str(&format!("complete -c {program_name}"));
200 if let Some(short) = flag.short {
201 out.push_str(&format!(" -s {short}"));
202 }
203 out.push_str(&format!(" -l {}", flag.long));
204 if !desc.is_empty() {
205 let escaped_desc = desc.replace('\'', "'\\''");
206 out.push_str(&format!(" -d '{escaped_desc}'"));
207 }
208 out.push('\n');
209 }
210
211 if !subcommands.is_empty() {
213 out.push('\n');
214 out.push_str("# Subcommands\n");
215
216 out.push_str(&format!("complete -c {program_name} -f\n"));
218
219 for cmd in &subcommands {
220 let desc = cmd.doc.as_deref().unwrap_or("");
221 out.push_str(&format!(
222 "complete -c {program_name} -n '__fish_use_subcommand' -a {}",
223 cmd.name
224 ));
225 if !desc.is_empty() {
226 let escaped_desc = desc.replace('\'', "'\\''");
227 out.push_str(&format!(" -d '{escaped_desc}'"));
228 }
229 out.push('\n');
230 }
231 }
232
233 out
234}
235
236struct FlagInfo {
239 long: String,
240 short: Option<char>,
241 doc: Option<String>,
242}
243
244struct SubcommandInfo {
245 name: String,
246 doc: Option<String>,
247}
248
249fn collect_options(shape: &'static Shape) -> (Vec<FlagInfo>, Vec<SubcommandInfo>) {
250 let mut flags = Vec::new();
251 let mut subcommands = Vec::new();
252
253 match &shape.ty {
254 Type::User(UserType::Struct(struct_type)) => {
255 for field in struct_type.fields {
256 if field.has_attr(Some("args"), "subcommand") {
257 let field_shape = field.shape();
259 let enum_shape = if let Def::Option(opt) = field_shape.def {
260 opt.t
261 } else {
262 field_shape
263 };
264
265 if let Type::User(UserType::Enum(enum_type)) = enum_shape.ty {
266 for variant in enum_type.variants {
267 subcommands.push(variant_to_subcommand(variant));
268 }
269 }
270 } else if !field.has_attr(Some("args"), "positional") {
271 flags.push(field_to_flag(field));
272 }
273 }
274 }
275 Type::User(UserType::Enum(enum_type)) => {
276 for variant in enum_type.variants {
278 subcommands.push(variant_to_subcommand(variant));
279 }
280 }
281 _ => {}
282 }
283
284 (flags, subcommands)
285}
286
287fn field_to_flag(field: &Field) -> FlagInfo {
288 let short = field
289 .get_attr(Some("args"), "short")
290 .and_then(|attr| attr.get_as::<crate::Attr>())
291 .and_then(|attr| {
292 if let crate::Attr::Short(c) = attr {
293 c.or_else(|| field.name.chars().next())
294 } else {
295 None
296 }
297 });
298
299 FlagInfo {
300 long: field.name.to_kebab_case(),
301 short,
302 doc: field.doc.first().map(|s| s.trim().to_string()),
303 }
304}
305
306fn variant_to_subcommand(variant: &Variant) -> SubcommandInfo {
307 let name = variant
308 .get_builtin_attr("rename")
309 .and_then(|attr| attr.get_as::<&str>())
310 .map(|s| (*s).to_string())
311 .unwrap_or_else(|| variant.name.to_kebab_case());
312
313 SubcommandInfo {
314 name,
315 doc: variant.doc.first().map(|s| s.trim().to_string()),
316 }
317}