facet_args/
completions.rs

1//! Shell completion script generation for command-line interfaces.
2//!
3//! This module generates completion scripts for various shells (bash, zsh, fish)
4//! based on Facet type metadata.
5
6use alloc::string::String;
7use alloc::vec::Vec;
8use facet_core::{Def, Facet, Field, Shape, Type, UserType, Variant};
9use heck::ToKebabCase;
10
11/// Supported shells for completion generation.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Shell {
14    /// Bash shell
15    Bash,
16    /// Zsh shell
17    Zsh,
18    /// Fish shell
19    Fish,
20}
21
22/// Generate shell completion script for a Facet type.
23pub fn generate_completions<T: Facet<'static>>(shell: Shell, program_name: &str) -> String {
24    generate_completions_for_shape(T::SHAPE, shell, program_name)
25}
26
27/// Generate shell completion script for a shape.
28pub 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
40// === Bash Completion ===
41
42fn 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    // Collect flags and subcommands
57    let (flags, subcommands) = collect_options(shape);
58
59    // Add flags
60    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    // Add subcommands
75    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
109// === Zsh Completion ===
110
111fn 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    // Add options
127    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    // Add subcommands if any
139    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        // Add cases for each subcommand
164        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
187// === Fish Completion ===
188
189fn 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    // Add flag completions
197    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    // Add subcommand completions
212    if !subcommands.is_empty() {
213        out.push('\n');
214        out.push_str("# Subcommands\n");
215
216        // Disable file completion when expecting a subcommand
217        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
236// === Helper types and functions ===
237
238struct 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                    // Collect subcommands from the enum
258                    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            // Top-level enum = subcommands
277            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}