scope_cli/cli/tui/
complete.rs1use super::exec::SESSION_COMMANDS;
8use super::vocab::Vocab;
9use crate::cli::Cli;
10use clap::{Arg, Command, CommandFactory};
11
12#[derive(Debug, Default, PartialEq, Eq)]
14pub struct Completion {
15 pub start: usize,
17 pub candidates: Vec<String>,
19}
20
21pub fn complete(line: &str, vocab: &Vocab) -> Completion {
23 let start = line.rfind(' ').map_or(0, |i| i + 1);
24 let word = &line[start..];
25 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 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 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
93fn 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
104fn 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 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 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 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}