clap_dyn_complete/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Dynamic, Rust-driven shell completions for binaries that use `clap`.
5//!
6//! `clap_dyn_complete` differs from `clap_complete` in two ways:
7//!
8//! - Supports dynamically generating completion sets at runtime
9//!     - e.g: `cargo build -p <TAB>` completions that are generated by parsing
10//!       `Cargo.toml` at completion-time to return a list of valid crate names
11//!     - vs. `clap_complete`, which can only emit completions for values known
12//!       ahead-of-time
13//! - _Far_ easier to add support for new shells
14//!     - Logic is driven entirely from Rust, so all that's needed is a small
15//!       "shell adapter" script to adapt `clap_dyn_complete` output to a
16//!       particular shell completion engine.
17//!     - vs. `clap_complete`, which requires writing a bespoke code-gen backend
18//!       for every kind of shell!
19//!
20//! That said, `clap_dyn_complete` has one major downside vs. `clap_complete`:
21//! increased binary size.
22//!
23//! `clap_complete` completions are entirely separate from the binary, whereas
24//! `clap_dyn_complete` completions call back into binary to drive completions,
25//! requiring the binary to include its own completion engine.
26
27#![forbid(unsafe_code)]
28
29use clap::Parser;
30use futures::future::BoxFuture;
31use futures::future::FutureExt;
32
33/// A `clap`-compatible struct that can be used to generate completions for the
34/// current CLI invocation.
35#[derive(Parser)]
36pub struct Complete {
37    /// A single string corresponding to the raw CLI invocation.
38    ///
39    /// e.g: `$ my-command $((1 + 2))   b<TAB>ar baz` would pass `--raw
40    /// "my-command $((1 + 2))   bar baz"`.
41    ///
42    /// Note the significant whitespace!
43    ///
44    /// May not always be available, depending on the shell.
45    #[clap(long, requires = "position")]
46    pub raw: Option<String>,
47
48    /// The cursor's position within the raw CLI invocation.
49    ///
50    /// e.g: `$ my-command $((1 + 2))   b<TAB>ar baz` would pass `--position 25`
51    ///
52    /// May not always be available, depending on the shell.
53    #[clap(long, requires = "raw")]
54    pub position: Option<usize>,
55
56    /// A list of strings corresponding to how the shell has interpreted the
57    /// current command.
58    ///
59    /// e.g: `$ my-command foo $((1 + 2)) bar` would pass `-- my-command foo 3
60    /// bar`.
61    #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
62    pub cmd: Vec<String>,
63}
64
65impl Complete {
66    /// Generate completions for the given `clap` command, and prints them to
67    /// stdout in the format the built-in stub scripts expect.
68    ///
69    /// See [`Complete::generate_completions`] for more info.
70    pub async fn println_to_stub_script<Cli: clap::CommandFactory>(
71        self,
72        maybe_subcommand_of: Option<&str>,
73        custom_completer_factory: impl CustomCompleterFactory,
74    ) {
75        let completions = self
76            .generate_completions::<Cli>(maybe_subcommand_of, custom_completer_factory)
77            .await;
78
79        for completion in completions {
80            log::debug!("suggesting: {}", completion);
81            println!("{}", completion);
82        }
83    }
84
85    /// Generate completions for the given `clap` command.
86    ///
87    /// Set `maybe_subcommand_of` to the root command's value if the binary may
88    /// be invoked as subcommand. e.g: if the binary is invoked as `cargo
89    /// xtask`, pass `Some("cargo")`.
90    pub async fn generate_completions<Cli: clap::CommandFactory>(
91        self,
92        maybe_subcommand_of: Option<&str>,
93        custom_completer_factory: impl CustomCompleterFactory,
94    ) -> Vec<String> {
95        let Self { position, raw, cmd } = self;
96
97        // check if invoked as subcommand (e.g: `cargo foobar`), and if so, we
98        // should skip "cargo" before continuing
99        let cmd = {
100            let mut cmd = cmd;
101            if let Some(maybe_subcommand_of) = maybe_subcommand_of {
102                if cmd.first().map(|s| s.as_str()) == Some(maybe_subcommand_of) {
103                    cmd.remove(0);
104                }
105            }
106
107            cmd
108        };
109
110        log::debug!("");
111        log::debug!("cmd: [{}]", cmd.clone().join(" , "));
112        log::debug!("raw: '{}'", raw.as_deref().unwrap_or_default());
113        log::debug!("position: {:?}", position);
114
115        let (prev_arg, to_complete) = match (position, &raw) {
116            (Some(position), Some(raw)) => {
117                if position <= raw.len() {
118                    let (before, _) = raw.split_at(position);
119                    log::debug!("completing from: '{}'", before);
120                    let mut before_toks = before.split_whitespace().collect::<Vec<_>>();
121                    if before.ends_with(|c: char| c.is_whitespace()) {
122                        before_toks.push("")
123                    }
124                    match before_toks.as_slice() {
125                        [] => ("", ""),
126                        [a] => ("", *a),
127                        [.., a, b] => (*a, *b),
128                    }
129                } else {
130                    (cmd.last().unwrap().as_str(), "")
131                }
132            }
133            _ => match cmd.as_slice() {
134                [] => ("", ""),
135                [a] => ("", a.as_ref()),
136                [.., a, b] => (a.as_ref(), b.as_ref()),
137            },
138        };
139
140        let base_command = Cli::command();
141        // "massage" the command to make it more amenable to completions
142        let command = {
143            command_visitor(
144                base_command.clone(),
145                &mut |arg| {
146                    // do any arg-level tweaks
147                    loosen_value_parser(arg)
148                },
149                &mut |mut command| {
150                    // avoid treating the help flag as something special. make
151                    // it just another arg for the purposes of shell completion
152                    if !command.is_disable_help_flag_set() {
153                        command = command.disable_help_flag(true).arg(
154                            clap::Arg::new("my_help")
155                                .short('h')
156                                .long("help")
157                                .action(clap::ArgAction::SetTrue),
158                        )
159                    }
160
161                    command
162                },
163            )
164        };
165
166        let matches = (command.clone())
167            .ignore_errors(true)
168            .try_get_matches_from(&cmd)
169            .unwrap();
170
171        let ctx = RootCtx {
172            command: base_command,
173            matches: matches.clone(),
174            to_complete,
175            prev_arg,
176        };
177
178        log::debug!("to_complete: {to_complete}");
179        log::debug!("prev_arg: {prev_arg}");
180
181        let mut completions = recurse_completions(
182            &ctx,
183            Vec::new(),
184            &command,
185            &matches,
186            Box::new(custom_completer_factory.build(&ctx).await),
187        )
188        .await;
189
190        // only suggest words that match what the user has already entered
191        completions.retain(|x| x.starts_with(to_complete));
192
193        // we want "whole words" to appear before flags
194        completions.sort_by(|a, b| match (a.starts_with('-'), b.starts_with('-')) {
195            (true, true) => a.cmp(b),
196            (true, false) => std::cmp::Ordering::Greater,
197            (false, true) => std::cmp::Ordering::Less,
198            (false, false) => a.cmp(b),
199        });
200
201        completions
202    }
203}
204
205/// Tweaks an Arg to "loosen" how strict its value parsers is, while leaving
206/// existing lists of `possible_values` intact.
207///
208/// Without this pass, providing mid-word completions to args that have a value
209/// parser is impossible, as clap will eagerly parse the half-complete input as
210/// though it was a full input, fail, and then omit the result entirely from
211/// `ArgMatches` (which the subsequent completion code relies on to check if the
212/// arg was present or not).
213fn loosen_value_parser(arg: clap::Arg) -> clap::Arg {
214    use clap::builder::TypedValueParser;
215
216    #[derive(Clone)]
217    struct PossibleValueString(Vec<clap::builder::PossibleValue>);
218
219    impl TypedValueParser for PossibleValueString {
220        type Value = <clap::builder::StringValueParser as TypedValueParser>::Value;
221
222        fn parse_ref(
223            &self,
224            cmd: &clap::Command,
225            arg: Option<&clap::Arg>,
226            value: &std::ffi::OsStr,
227        ) -> Result<Self::Value, clap::Error> {
228            clap::builder::StringValueParser::new().parse_ref(cmd, arg, value)
229        }
230
231        fn possible_values(
232            &self,
233        ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
234            Some(Box::new(self.0.clone().into_iter()))
235        }
236    }
237
238    let possible_vals = arg.get_possible_values();
239    arg.value_parser(PossibleValueString(possible_vals))
240}
241
242/// Do a recursive depth-first visit to all arguments and subcommands of the
243/// given command.
244///
245/// `on_subcommand` is invoked _before_ recursing into the subcommand.
246fn command_visitor(
247    mut command: clap::Command,
248    mut on_arg: &mut dyn FnMut(clap::Arg) -> clap::Arg,
249    mut on_subcommand: &mut dyn FnMut(clap::Command) -> clap::Command,
250) -> clap::Command {
251    for subcommand in command.clone().get_subcommands() {
252        command = command
253            .mut_subcommand(subcommand.get_name(), &mut *on_subcommand)
254            .mut_subcommand(subcommand.get_name(), |command| {
255                command_visitor(command, &mut on_arg, &mut on_subcommand)
256            })
257    }
258
259    for arg in command.clone().get_arguments() {
260        command = command.mut_arg(arg.get_id(), &mut *on_arg);
261    }
262
263    command
264}
265
266/// Context for the current CLI invocation.
267#[derive(Debug)]
268pub struct RootCtx<'a> {
269    /// The command being completed
270    pub command: clap::Command,
271    /// The current set of matches
272    pub matches: clap::ArgMatches,
273    /// The current word being completed
274    pub to_complete: &'a str,
275    /// The previous argument (useful when completing flag values)
276    pub prev_arg: &'a str,
277}
278
279/// A factory for [`CustomCompleter`]s.
280///
281/// Having a two-step construction flow is useful to avoid constantly
282/// re-initializing "expensive" objects during custom completion (e.g:
283/// re-parsing a TOML file on every invocation to `CustomComplete::complete`).
284pub trait CustomCompleterFactory: Send + Sync {
285    /// The concrete [`CustomCompleter`].
286    type CustomCompleter: CustomCompleter + 'static;
287
288    /// Build a new [`CustomCompleter`].
289    fn build(&self, ctx: &RootCtx<'_>) -> impl std::future::Future<Output = Self::CustomCompleter>;
290}
291
292/// A custom completer for a particular argument.
293pub trait CustomCompleter: Send + Sync {
294    /// Generates a list of completions for the given argument.
295    fn complete(
296        &self,
297        ctx: &RootCtx<'_>,
298        subcommand_path: &[&str],
299        arg_id: &str,
300    ) -> impl Send + std::future::Future<Output = Vec<String>>;
301}
302
303#[async_trait::async_trait]
304trait DynCustomCompleter: Send + Sync {
305    async fn complete(
306        &self,
307        ctx: &RootCtx<'_>,
308        subcommand_path: &[&str],
309        arg_id: &str,
310    ) -> Vec<String>;
311}
312
313#[async_trait::async_trait]
314impl<T: CustomCompleter> DynCustomCompleter for T {
315    async fn complete(
316        &self,
317        ctx: &RootCtx<'_>,
318        subcommand_path: &[&str],
319        arg_id: &str,
320    ) -> Vec<String> {
321        self.complete(ctx, subcommand_path, arg_id).await
322    }
323}
324
325impl CustomCompleterFactory for () {
326    type CustomCompleter = ();
327    async fn build(&self, _ctx: &RootCtx<'_>) -> Self::CustomCompleter {}
328}
329
330impl CustomCompleter for () {
331    async fn complete(
332        &self,
333        _ctx: &RootCtx<'_>,
334        _subcommand_path: &[&str],
335        _arg_id: &str,
336    ) -> Vec<String> {
337        Vec::new()
338    }
339}
340
341// drills-down through subcommands to generate the right completion set
342fn recurse_completions<'a>(
343    ctx: &'a RootCtx<'_>,
344    subcommand_path: Vec<&'a str>,
345    command: &'a clap::Command,
346    matches: &'a clap::ArgMatches,
347    custom_complete_fn: Box<dyn DynCustomCompleter>,
348) -> BoxFuture<'a, Vec<String>> {
349    async move {
350        let mut subcommand_path = subcommand_path;
351        subcommand_path.push(command.get_name());
352
353        let mut completions = Vec::new();
354
355        // begin by recursing down into the subcommands
356        // TODO: before recursing, add suppose for inherited args
357        if let Some((name, matches)) = matches.subcommand() {
358            let subcommand = command
359                .get_subcommands()
360                .find(|s| s.get_name() == name)
361                .unwrap();
362            let mut new_completions = recurse_completions(
363                ctx,
364                subcommand_path,
365                subcommand,
366                matches,
367                custom_complete_fn,
368            )
369            .await;
370            new_completions.extend_from_slice(&completions);
371            return new_completions;
372        }
373
374        // check if `prev_arg` was a `-` arg or a `--` arg that accepts a
375        // free-form completion value.
376        //
377        // do this first, since if it turns out we are completing a flag value,
378        // we want to limit our suggestions to just the things that arg expects
379        for arg in command.get_arguments() {
380            let long = arg.get_long().map(|x| format!("--{x}")).unwrap_or_default();
381            let short = arg.get_short().map(|x| format!("-{x}")).unwrap_or_default();
382
383            if ctx.prev_arg != long && ctx.prev_arg != short {
384                continue;
385            }
386
387            if !matches!(
388                arg.get_action(),
389                clap::ArgAction::Append | clap::ArgAction::Set
390            ) {
391                continue;
392            }
393
394            // ah, ok, the current completion corresponds to the value of the
395            // prev_arg!
396
397            for val in arg.get_possible_values() {
398                completions.push(val.get_name().into())
399            }
400
401            completions.extend(
402                custom_complete_fn
403                    .complete(ctx, &subcommand_path, arg.get_id().as_str())
404                    .await,
405            );
406
407            // immediately stop suggesting
408            return completions;
409        }
410
411        // check positional args
412        //
413        // TODO: think about how to handle multiple-invoked conditionals
414        let mut is_completing_positional = false;
415        for positional in command.get_positionals() {
416            // check if the arg has already been set, and if so: skip its
417            // corresponding suggestions
418            if matches
419                .try_contains_id(positional.get_id().as_str())
420                .unwrap_or(true)
421            {
422                // ...but if the user is actively overriding the already-set
423                // arg, then _don't_ skip it!
424                let val = matches
425                    .get_raw(positional.get_id().as_str())
426                    .unwrap_or_default()
427                    .next_back()
428                    .unwrap_or_default()
429                    .to_str()
430                    .unwrap_or_default();
431
432                if ctx.to_complete.is_empty() || ctx.to_complete.starts_with('-') {
433                    continue;
434                }
435
436                if !val.starts_with(ctx.to_complete) {
437                    continue;
438                }
439            }
440
441            is_completing_positional = true;
442
443            let possible_vals = positional.get_possible_values();
444            if !possible_vals.is_empty() {
445                for val in possible_vals {
446                    completions.push(val.get_name().into())
447                }
448            }
449
450            completions.extend(
451                custom_complete_fn
452                    .complete(ctx, &subcommand_path, positional.get_id().as_str())
453                    .await,
454            );
455
456            // don't want to suggest values for subsequent positionals
457            break;
458        }
459
460        // suggest all `-` and `--` args
461        for arg in command.get_arguments() {
462            if matches!(
463                matches.value_source(arg.get_id().as_str()),
464                Some(clap::parser::ValueSource::CommandLine)
465            ) {
466                // check if the arg was already set, and if so, don't suggest it again
467                //
468                // FIXME: handle args that can be set multiple times
469                if let Some(x) = matches.get_raw_occurrences(arg.get_id().as_str()) {
470                    if x.flatten().count() != 0 {
471                        continue;
472                    }
473                }
474            }
475
476            if let Some(long) = arg.get_long() {
477                completions.push(format!("--{long}"))
478            }
479
480            if let Some(short) = arg.get_short() {
481                completions.push(format!("-{short}"))
482            }
483        }
484
485        // suggest all subcommands
486        //
487        // ...unless we're completing a positional, since we don't want to
488        // suggest subcommand names as valid options for the positional
489        if command.has_subcommands() && !is_completing_positional {
490            if !command.is_disable_help_subcommand_set() {
491                completions.push("help".into());
492            }
493
494            for subcommand in command.get_subcommands() {
495                if let Some((name, _)) = matches.subcommand() {
496                    if !ctx.to_complete.is_empty() && name.starts_with(ctx.to_complete) {
497                        completions.push(subcommand.get_name().into())
498                    }
499                } else {
500                    completions.push(subcommand.get_name().into())
501                }
502            }
503        }
504
505        completions
506    }
507    .boxed()
508}
509
510/// Shell with in-tree completion stub scripts available
511#[derive(Clone, clap::ValueEnum)]
512pub enum Shell {
513    /// [Fish](https://fishshell.com/)
514    Fish,
515    /// [Powershell](https://docs.microsoft.com/en-us/powershell/)
516    Powershell,
517    /// [Zsh](https://www.zsh.org/)
518    Zsh,
519}
520
521/// Emits a minimal "shell adapter" script, tailored to the particular bin.
522///
523/// `completion_subcommand` should be a string corresponding to the name of the
524/// subcommand that invokes [`Complete`].
525///
526/// - e.g: a bin that has `my-bin complete ...` should pass `"complete"`
527/// - e.g: a bin that has `my-bin dev complete ...` should pass `"dev complete"`
528///
529/// **NOTE:** Feel free to ignore / modify these stubs to suit your particular
530/// use-case! e.g: These stubs will not work in cases where the binary is
531/// invoked as a subcommand (e.g: `cargo xtask`). In those cases, you may need
532/// to write additional shell-specific logic!
533pub fn emit_completion_stub(
534    shell: Shell,
535    bin_name: &str,
536    completion_subcommand: &str,
537    buf: &mut dyn std::io::Write,
538) -> std::io::Result<()> {
539    let stub = match shell {
540        Shell::Fish => include_str!("./templates/complete.fish"),
541        Shell::Powershell => include_str!("./templates/complete.ps1"),
542        Shell::Zsh => include_str!("./templates/complete.zsh"),
543    };
544
545    let stub = stub
546        .replace("__COMMAND_NAME__", bin_name)
547        .replace("__COMMAND_NAME_NODASH__", &bin_name.replace('-', "_"))
548        .replace("__COMPLETION_SUBCOMMAND__", completion_subcommand);
549
550    buf.write_all(stub.as_bytes())
551}