Skip to main content

scope_cli/cli/tui/
complete.rs

1//! # Tab Completion
2//!
3//! Completes the last word of the input line from the clap definition and
4//! the [`Vocab`]. Commands, flags and enum values come from `Cli::command()`,
5//! so a new command or flag completes with no extra code.
6
7use super::exec::SESSION_COMMANDS;
8use super::vocab::Vocab;
9use crate::cli::Cli;
10use clap::{Arg, Command, CommandFactory};
11
12/// The candidates for the word that ends the line.
13#[derive(Debug, Default, PartialEq, Eq)]
14pub struct Completion {
15    /// Byte index where the word starts. A candidate replaces `line[start..]`.
16    pub start: usize,
17    /// Sorted, unique candidates that start with the word.
18    pub candidates: Vec<String>,
19}
20
21/// Completes the last word of `line`.
22pub fn complete(line: &str, vocab: &Vocab) -> Completion {
23    let start = line.rfind(' ').map_or(0, |i| i + 1);
24    let word = &line[start..];
25    // Inside an open quote the word boundaries are not known.
26    if line[..start].matches('"').count() % 2 == 1 || word.starts_with('"') {
27        return Completion {
28            start,
29            candidates: Vec::new(),
30        };
31    }
32    let before: Vec<&str> = line[..start].split_whitespace().collect();
33
34    let mut root = Cli::command();
35    // build() copies global flags (--ai, address-book's --format) down to
36    // the subcommands, so they complete there too.
37    root.build();
38    let mut cmd: &Command = &root;
39    let mut positional_seen = false;
40    for w in &before {
41        if w.starts_with('-') {
42            continue;
43        }
44        match (positional_seen, cmd.find_subcommand(w)) {
45            (false, Some(sub)) => cmd = sub,
46            _ => positional_seen = true,
47        }
48    }
49    let at_root = std::ptr::eq(cmd, &root);
50    if at_root && positional_seen {
51        // The first word is not a known command.
52        return Completion {
53            start,
54            candidates: Vec::new(),
55        };
56    }
57
58    let pool: Vec<String> = if let Some(arg) = before
59        .last()
60        .filter(|p| p.starts_with('-'))
61        .and_then(|p| find_arg(cmd, p))
62        .filter(|a| a.get_action().takes_values())
63    {
64        values_for(arg, vocab)
65    } else if word.starts_with('@') {
66        vocab.labels.iter().map(|l| format!("@{}", l)).collect()
67    } else if word.starts_with('-') {
68        cmd.get_arguments()
69            .filter(|a| !a.is_hide_set())
70            .filter_map(|a| a.get_long().map(|l| format!("--{}", l)))
71            .chain(std::iter::once("--help".to_string()))
72            .collect()
73    } else if cmd.has_subcommands() && !positional_seen {
74        let mut names: Vec<String> = cmd
75            .get_subcommands()
76            .filter(|s| !s.is_hide_set())
77            .map(|s| s.get_name().to_string())
78            .collect();
79        if at_root {
80            names.extend(SESSION_COMMANDS.iter().map(|(w, _, _)| w.to_string()));
81        }
82        names
83    } else {
84        vocab.aliases.clone()
85    };
86
87    let mut candidates: Vec<String> = pool.into_iter().filter(|c| c.starts_with(word)).collect();
88    candidates.sort();
89    candidates.dedup();
90    Completion { start, candidates }
91}
92
93/// Finds the argument that a flag word names (`--venue`, `--venue=x`, `-c`).
94fn find_arg<'a>(cmd: &'a Command, flag: &str) -> Option<&'a Arg> {
95    if let Some(long) = flag.strip_prefix("--") {
96        let long = long.split('=').next().unwrap_or(long);
97        cmd.get_arguments().find(|a| a.get_long() == Some(long))
98    } else {
99        let short = flag.strip_prefix('-')?.chars().next()?;
100        cmd.get_arguments().find(|a| a.get_short() == Some(short))
101    }
102}
103
104/// The values a flag accepts: its enum values, or a store from the vocab.
105fn values_for(arg: &Arg, vocab: &Vocab) -> Vec<String> {
106    let enums: Vec<String> = arg
107        .get_possible_values()
108        .iter()
109        .filter(|p| !p.is_hide_set())
110        .map(|p| p.get_name().to_string())
111        .collect();
112    if !enums.is_empty() {
113        return enums;
114    }
115    match arg.get_long() {
116        Some("venue") => vocab.venues.clone(),
117        Some("chain") => vocab.chains.clone(),
118        _ => Vec::new(),
119    }
120}
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn vocab() -> Vocab {
126        Vocab {
127            labels: vec!["cold".into(), "hot".into()],
128            aliases: vec!["USDC".into(), "USDT".into()],
129            venues: vec!["binance".into(), "bitget".into(), "okx".into()],
130            chains: vec!["ethereum".into(), "solana".into()],
131        }
132    }
133
134    fn cands(line: &str) -> Vec<String> {
135        complete(line, &vocab()).candidates
136    }
137
138    #[test]
139    fn test_root_offers_cli_and_session_commands() {
140        let c = cands("ad");
141        assert!(c.contains(&"address".to_string()));
142        assert!(c.contains(&"address-book".to_string()));
143        assert_eq!(cands("cha"), vec!["chain"]);
144    }
145
146    #[test]
147    fn test_subcommands_flags_and_enum_values() {
148        assert_eq!(cands("market su"), vec!["summary"]);
149        assert!(cands("market summary USDC --ve").contains(&"--venue".to_string()));
150        assert_eq!(cands("address 0x1 --format c"), vec!["csv"]);
151    }
152
153    #[test]
154    fn test_dynamic_values() {
155        assert_eq!(
156            cands("market summary USDC --venue bi"),
157            vec!["binance", "bitget"]
158        );
159        assert_eq!(cands("address 0x1 --chain so"), vec!["solana"]);
160        assert_eq!(cands("address @co"), vec!["@cold"]);
161        assert_eq!(cands("crawl US"), vec!["USDC", "USDT"]);
162    }
163
164    #[test]
165    fn test_start_points_at_the_word() {
166        let c = complete("market su", &vocab());
167        assert_eq!(c.start, "market ".len());
168    }
169
170    #[test]
171    fn test_global_flags_complete_under_subcommands() {
172        // Review I2: `--format` on address-book is `global = true`, and the
173        // root flags (--ai, --config) are global too. Spec 5.2's example.
174        assert_eq!(
175            cands("address-book list --format "),
176            vec!["csv", "json", "markdown", "table"]
177        );
178        assert!(cands("market summary USDC --a").contains(&"--ai".to_string()));
179    }
180
181    #[test]
182    fn test_inside_open_quote_offers_nothing() {
183        // Review Focus 2.
184        assert!(cands("address-book add 0x1 --label \"cold wal").is_empty());
185    }
186
187    #[test]
188    fn test_empty_line_offers_commands_and_unknown_command_offers_nothing() {
189        assert!(cands("").contains(&"address".to_string()));
190        assert!(cands("nosuchcommand x").is_empty());
191    }
192
193    #[test]
194    fn test_every_visible_subcommand_is_offered_at_the_root() {
195        // One source of truth: a new CLI command is completable with no
196        // extra code.
197        let all = cands("");
198        for sub in crate::cli::Cli::command().get_subcommands() {
199            if !sub.is_hide_set() {
200                assert!(
201                    all.contains(&sub.get_name().to_string()),
202                    "{}",
203                    sub.get_name()
204                );
205            }
206        }
207    }
208}