clap_complete/aot/shells/
fish.rs

1use std::io::{Error, Write};
2
3use clap::{builder, Arg, Command, ValueHint};
4
5use crate::generator::{utils, Generator};
6
7/// Generate fish completion file
8///
9/// Note: The fish generator currently only supports named options (-o/--option), not positional arguments.
10#[derive(Copy, Clone, PartialEq, Eq, Debug)]
11pub struct Fish;
12
13impl Generator for Fish {
14    fn file_name(&self, name: &str) -> String {
15        format!("{name}.fish")
16    }
17
18    fn generate(&self, cmd: &Command, buf: &mut dyn Write) {
19        self.try_generate(cmd, buf)
20            .expect("failed to write completion file");
21    }
22
23    fn try_generate(&self, cmd: &Command, buf: &mut dyn Write) -> Result<(), Error> {
24        let bin_name = cmd
25            .get_bin_name()
26            .expect("crate::generate should have set the bin_name");
27
28        let name = escape_name(bin_name);
29        let mut needs_fn_name = &format!("__fish_{name}_needs_command")[..];
30        let mut using_fn_name = &format!("__fish_{name}_using_subcommand")[..];
31        // Given `git --git-dir somedir status`, using `__fish_seen_subcommand_from` won't help us
32        // find out `status` is the real subcommand, and not `somedir`. However, when there are no subcommands,
33        // there is no need to use our custom stubs.
34        if cmd.has_subcommands() {
35            gen_subcommand_helpers(&name, cmd, buf, needs_fn_name, using_fn_name);
36        } else {
37            needs_fn_name = "__fish_use_subcommand";
38            using_fn_name = "__fish_seen_subcommand_from";
39        }
40
41        let mut buffer = String::new();
42        gen_fish_inner(
43            bin_name,
44            &[],
45            cmd,
46            &mut buffer,
47            needs_fn_name,
48            using_fn_name,
49        );
50        write!(buf, "{buffer}")
51    }
52}
53
54// Escape string inside single quotes
55fn escape_string(string: &str, escape_comma: bool) -> String {
56    let string = string.replace('\\', "\\\\").replace('\'', "\\'");
57    if escape_comma {
58        string.replace(',', "\\,")
59    } else {
60        string
61    }
62}
63
64fn escape_help(help: &builder::StyledStr) -> String {
65    escape_string(&help.to_string().replace('\n', " "), false)
66}
67
68fn escape_name(name: &str) -> String {
69    name.replace('-', "_")
70}
71
72fn gen_fish_inner(
73    root_command: &str,
74    parent_commands: &[&str],
75    cmd: &Command,
76    buffer: &mut String,
77    needs_fn_name: &str,
78    using_fn_name: &str,
79) {
80    debug!("gen_fish_inner");
81    // example :
82    //
83    // complete
84    //      -c {command}
85    //      -d "{description}"
86    //      -s {short}
87    //      -l {long}
88    //      -a "{possible_arguments}"
89    //      -r # if require parameter
90    //      -f # don't use file completion
91    //      -n "{needs_fn_name}"            # complete for command "myprog"
92    //      -n "{using_fn_name} subcmd1"    # complete for command "myprog subcmd1"
93
94    let mut basic_template = format!("complete -c {root_command}");
95
96    if parent_commands.is_empty() {
97        if cmd.has_subcommands() {
98            basic_template.push_str(&format!(" -n \"{needs_fn_name}\""));
99        }
100    } else {
101        let mut out = String::from(using_fn_name);
102        match parent_commands {
103            [] => unreachable!(),
104            [command] => {
105                out.push_str(&format!(" {command}"));
106                if cmd.has_subcommands() {
107                    out.push_str("; and not __fish_seen_subcommand_from");
108                }
109                let subcommands = cmd
110                    .get_subcommands()
111                    .flat_map(Command::get_name_and_visible_aliases);
112                for name in subcommands {
113                    out.push_str(&format!(" {name}"));
114                }
115            }
116            [command, subcommand] => out.push_str(&format!(
117                " {command}; and __fish_seen_subcommand_from {subcommand}"
118            )),
119            // HACK: Assuming subcommands are only nested less than 3 levels as more than that is
120            // unwieldy and takes more effort to support.
121            // For example, `rustup toolchain help install` is the longest valid command line of `rustup`
122            // that uses nested subcommands, and it cannot receive any flags to it.
123            _ => return,
124        }
125        basic_template.push_str(format!(" -n \"{out}\"").as_str());
126    }
127
128    debug!("gen_fish_inner: parent_commands={parent_commands:?}");
129
130    for option in cmd.get_opts() {
131        let mut template = basic_template.clone();
132
133        if let Some(shorts) = option.get_short_and_visible_aliases() {
134            for short in shorts {
135                template.push_str(format!(" -s {short}").as_str());
136            }
137        }
138
139        if let Some(longs) = option.get_long_and_visible_aliases() {
140            for long in longs {
141                template.push_str(format!(" -l {}", escape_string(long, false)).as_str());
142            }
143        }
144
145        if let Some(data) = option.get_help() {
146            template.push_str(&format!(" -d '{}'", escape_help(data)));
147        }
148
149        template.push_str(value_completion(option).as_str());
150
151        buffer.push_str(template.as_str());
152        buffer.push('\n');
153    }
154
155    for flag in utils::flags(cmd) {
156        let mut template = basic_template.clone();
157
158        if let Some(shorts) = flag.get_short_and_visible_aliases() {
159            for short in shorts {
160                template.push_str(format!(" -s {short}").as_str());
161            }
162        }
163
164        if let Some(longs) = flag.get_long_and_visible_aliases() {
165            for long in longs {
166                template.push_str(format!(" -l {}", escape_string(long, false)).as_str());
167            }
168        }
169
170        if let Some(data) = flag.get_help() {
171            template.push_str(&format!(" -d '{}'", escape_help(data)));
172        }
173
174        buffer.push_str(template.as_str());
175        buffer.push('\n');
176    }
177
178    let has_positionals = cmd.get_positionals().next().is_some();
179    if !has_positionals {
180        basic_template.push_str(" -f");
181    }
182    for subcommand in cmd.get_subcommands() {
183        for subcommand_name in subcommand.get_name_and_visible_aliases() {
184            let mut template = basic_template.clone();
185
186            template.push_str(format!(" -a \"{subcommand_name}\"").as_str());
187
188            if let Some(data) = subcommand.get_about() {
189                template.push_str(format!(" -d '{}'", escape_help(data)).as_str());
190            }
191
192            buffer.push_str(template.as_str());
193            buffer.push('\n');
194        }
195    }
196
197    // generate options of subcommands
198    for subcommand in cmd.get_subcommands() {
199        for subcommand_name in subcommand.get_name_and_visible_aliases() {
200            let mut parent_commands: Vec<_> = parent_commands.into();
201            parent_commands.push(subcommand_name);
202            gen_fish_inner(
203                root_command,
204                &parent_commands,
205                subcommand,
206                buffer,
207                needs_fn_name,
208                using_fn_name,
209            );
210        }
211    }
212}
213
214/// Print fish's helpers for easy handling subcommands.
215fn gen_subcommand_helpers(
216    bin_name: &str,
217    cmd: &Command,
218    buf: &mut dyn Write,
219    needs_fn_name: &str,
220    using_fn_name: &str,
221) {
222    let mut optspecs = String::new();
223    let cmd_opts = cmd.get_arguments().filter(|a| !a.is_positional());
224    for option in cmd_opts {
225        optspecs.push(' ');
226        let mut has_short = false;
227        if let Some(short) = option.get_short() {
228            has_short = true;
229            optspecs.push(short);
230        }
231
232        if let Some(long) = option.get_long() {
233            if has_short {
234                optspecs.push('/');
235            }
236            optspecs.push_str(&escape_string(long, false));
237        }
238
239        let is_an_option = option
240            .get_num_args()
241            .map(|r| r.takes_values())
242            .unwrap_or(true);
243        if is_an_option {
244            optspecs.push('=');
245        }
246    }
247    let optspecs_fn_name = format!("__fish_{bin_name}_global_optspecs");
248    write!(
249        buf,
250        "\
251        # Print an optspec for argparse to handle cmd's options that are independent of any subcommand.\n\
252        function {optspecs_fn_name}\n\
253        \tstring join \\n{optspecs}\n\
254        end\n\n\
255        function {needs_fn_name}\n\
256        \t# Figure out if the current invocation already has a command.\n\
257        \tset -l cmd (commandline -opc)\n\
258        \tset -e cmd[1]\n\
259        \targparse -s ({optspecs_fn_name}) -- $cmd 2>/dev/null\n\
260        \tor return\n\
261        \tif set -q argv[1]\n\
262        \t\t# Also print the command, so this can be used to figure out what it is.\n\
263        \t\techo $argv[1]\n\
264        \t\treturn 1\n\
265        \tend\n\
266        \treturn 0\n\
267        end\n\n\
268        function {using_fn_name}\n\
269        \tset -l cmd ({needs_fn_name})\n\
270        \ttest -z \"$cmd\"\n\
271        \tand return 1\n\
272        \tcontains -- $cmd[1] $argv\n\
273        end\n\n\
274    ").expect("failed to write completion file");
275}
276
277fn value_completion(option: &Arg) -> String {
278    if !option.get_num_args().expect("built").takes_values() {
279        return "".to_string();
280    }
281
282    if let Some(data) = utils::possible_values(option) {
283        // We return the possible values with their own empty description e.g. "a\t''\nb\t''"
284        // this makes sure that a and b don't get the description of the option or argument
285        format!(
286            " -r -f -a \"{}\"",
287            data.iter()
288                .filter_map(|value| if value.is_hide_set() {
289                    None
290                } else {
291                    // The help text after \t is wrapped in '' to make sure that the it is taken literally
292                    // and there is no command substitution or variable expansion resulting in unexpected errors
293                    Some(format!(
294                        "{}\\t'{}'",
295                        escape_string(value.get_name(), true).as_str(),
296                        escape_help(value.get_help().unwrap_or_default())
297                    ))
298                })
299                .collect::<Vec<_>>()
300                .join("\n")
301        )
302    } else {
303        // NB! If you change this, please also update the table in `ValueHint` documentation.
304        match option.get_value_hint() {
305            ValueHint::Unknown => " -r",
306            // fish has no built-in support to distinguish these
307            ValueHint::AnyPath | ValueHint::FilePath | ValueHint::ExecutablePath => " -r -F",
308            ValueHint::DirPath => " -r -f -a \"(__fish_complete_directories)\"",
309            // It seems fish has no built-in support for completing command + arguments as
310            // single string (CommandString). Complete just the command name.
311            ValueHint::CommandString | ValueHint::CommandName => {
312                " -r -f -a \"(__fish_complete_command)\""
313            }
314            ValueHint::Username => " -r -f -a \"(__fish_complete_users)\"",
315            ValueHint::Hostname => " -r -f -a \"(__fish_print_hostnames)\"",
316            // Disable completion for others
317            _ => " -r -f",
318        }
319        .to_string()
320    }
321}