Skip to main content

scope_cli/cli/tui/
hint.rs

1//! # Inline Hints
2//!
3//! The dim line under the input: the usage of the command being typed, with
4//! the next expected argument, or clap's suggestion for an unknown command.
5
6use super::exec::SESSION_COMMANDS;
7use crate::cli::Cli;
8use clap::{Command, CommandFactory, Parser};
9
10/// One hint line.
11#[derive(Debug, PartialEq, Eq)]
12pub struct Hint {
13    /// The usage text or the suggestion.
14    pub text: String,
15    /// The next positional argument the command expects, as `<NAME>`.
16    pub next: Option<String>,
17}
18
19/// The hint for `line`, or `None` for an empty line.
20pub 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    // Walk the words after the command (#66): a flag's value is not a
68    // positional, an unknown flag gets its own hint, and a flag that still
69    // waits for its value names it.
70    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            // A flag still being typed is not unknown yet.
96            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}
127/// Most allowed values a hint lists.
128const MAX_LISTED_VALUES: usize = 6;
129
130/// `<NAME>` for a flag's value, and its allowed values when clap knows them.
131fn 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    // #66: a flag that waits for its value names it, with its values.
185    #[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        // `--chain ethereum` before the address: the address is still next.
196        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        // While it is still being typed, it is not unknown yet.
205        assert!(!hint("address 0x1 --ch").unwrap().text.contains("unknown"));
206    }
207}