scope_cli/cli/tui/
hint.rs1use super::exec::SESSION_COMMANDS;
7use crate::cli::Cli;
8use clap::{Command, CommandFactory, Parser};
9
10#[derive(Debug, PartialEq, Eq)]
12pub struct Hint {
13 pub text: String,
15 pub next: Option<String>,
17}
18
19pub fn hint(line: &str) -> Option<Hint> {
21 let words: Vec<&str> = line.split_whitespace().collect();
22 let first = *words.first()?;
23 if let Some((_, usage, _)) = SESSION_COMMANDS.iter().find(|(w, _, _)| *w == first) {
24 return Some(Hint {
25 text: usage.to_string(),
26 next: None,
27 });
28 }
29
30 let mut root = Cli::command();
31 root.build();
32 let mut path = vec!["scope".to_string()];
33 let mut cmd: &Command = &root;
34 let mut rest = 0;
35 for (i, w) in words.iter().enumerate() {
36 match cmd.find_subcommand(w) {
37 Some(sub) if !w.starts_with('-') => {
38 path.push(sub.get_name().to_string());
39 cmd = sub;
40 rest = i + 1;
41 }
42 _ => break,
43 }
44 }
45 if path.len() == 1 {
46 let err = Cli::try_parse_from(["scope", first]).err()?;
47 let tip = err
48 .render()
49 .to_string()
50 .lines()
51 .find(|l| l.contains("similar"))
52 .map(|l| l.trim().trim_start_matches("tip:").trim().to_string())
53 .unwrap_or_else(|| format!("unknown command `{}`; type `help`", first));
54 return Some(Hint {
55 text: tip,
56 next: None,
57 });
58 }
59
60 let usage = cmd
61 .clone()
62 .bin_name(path.join(" "))
63 .render_usage()
64 .to_string();
65 let text = usage.trim_start_matches("Usage:").trim().to_string();
66
67 let ends_in_space = line.ends_with(char::is_whitespace);
71 let mut typed = 0;
72 let mut pending: Option<&clap::Arg> = None;
73 for (i, w) in words[rest..].iter().enumerate() {
74 let last = rest + i + 1 == words.len();
75 if pending.take().is_some() {
76 continue;
77 }
78 if *w == "--" || !w.starts_with('-') || w.len() == 1 {
79 typed += 1;
80 continue;
81 }
82 let (flag, has_value) = match w.split_once('=') {
83 Some((f, _)) => (f, true),
84 None => (*w, false),
85 };
86 let arg = cmd.get_arguments().find(|a| {
87 flag.strip_prefix("--")
88 .is_some_and(|l| a.get_long() == Some(l))
89 || flag
90 .strip_prefix('-')
91 .and_then(|s| s.chars().next())
92 .is_some_and(|c| !flag.starts_with("--") && a.get_short() == Some(c))
93 });
94 let Some(arg) = arg else {
95 if last && !ends_in_space {
97 continue;
98 }
99 return Some(Hint {
100 text: format!(
101 "unknown flag `{flag}`; see `{} --help`",
102 path[1..].join(" ")
103 ),
104 next: None,
105 });
106 };
107 if arg.get_action().takes_values() && !has_value {
108 pending = Some(arg);
109 }
110 }
111 if let Some(arg) = pending {
112 return Some(Hint {
113 text,
114 next: Some(value_hint(arg)),
115 });
116 }
117 let next = cmd.get_positionals().nth(typed).map(|a| {
118 let name = a
119 .get_value_names()
120 .and_then(|v| v.first())
121 .map(|n| n.to_string())
122 .unwrap_or_else(|| a.get_id().to_string().to_uppercase());
123 format!("<{}>", name)
124 });
125 Some(Hint { text, next })
126}
127const MAX_LISTED_VALUES: usize = 6;
129
130fn value_hint(arg: &clap::Arg) -> String {
132 let name = arg
133 .get_value_names()
134 .and_then(|v| v.first())
135 .map(|n| n.to_string())
136 .unwrap_or_else(|| arg.get_id().to_string().to_uppercase());
137 let values: Vec<String> = arg
138 .get_possible_values()
139 .iter()
140 .filter(|v| !v.is_hide_set())
141 .map(|v| v.get_name().to_string())
142 .collect();
143 match values.len() {
144 0 => format!("<{name}>"),
145 n if n <= MAX_LISTED_VALUES => format!("<{name}>: {}", values.join(" | ")),
146 _ => format!("<{name}>: {} | …", values[..MAX_LISTED_VALUES].join(" | ")),
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn test_usage_of_the_matched_command() {
156 let h = hint("market summary").unwrap();
157 assert!(h.text.starts_with("scope market summary"), "{}", h.text);
158 assert_eq!(h.next.as_deref(), Some("<SYMBOL>"));
159 }
160
161 #[test]
162 fn test_next_moves_past_typed_positionals() {
163 let h = hint("address 0x1 ").unwrap();
164 assert_eq!(h.next, None, "address has one positional");
165 }
166
167 #[test]
168 fn test_session_command_usage() {
169 assert_eq!(hint("chain").unwrap().text, "chain [name|auto]");
170 }
171
172 #[test]
173 fn test_unknown_command_suggests_like_clap() {
174 let h = hint("adress 0x1").unwrap();
175 assert!(h.text.contains("address"), "{}", h.text);
176 }
177
178 #[test]
179 fn test_empty_line_has_no_hint() {
180 assert!(hint("").is_none());
181 assert!(hint(" ").is_none());
182 }
183
184 #[test]
186 fn test_a_flag_waiting_for_its_value_names_it() {
187 let h = hint("market summary USDC --venue ").unwrap();
188 assert_eq!(h.next.as_deref(), Some("<VENUE>"), "{h:?}");
189 let h = hint("market summary USDC --format ").unwrap();
190 assert!(h.next.as_deref().unwrap().contains("json"), "{h:?}");
191 }
192
193 #[test]
194 fn test_a_flag_value_is_not_a_positional() {
195 let h = hint("address --chain ethereum ").unwrap();
197 assert_eq!(h.next.as_deref(), Some("<ADDRESS>"), "{h:?}");
198 }
199
200 #[test]
201 fn test_an_unknown_flag_is_named() {
202 let h = hint("address 0x1 --nosuch ").unwrap();
203 assert!(h.text.contains("unknown flag `--nosuch`"), "{}", h.text);
204 assert!(!hint("address 0x1 --ch").unwrap().text.contains("unknown"));
206 }
207}