clap_complete/aot/shells/
fish.rs

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