Skip to main content

ashlar_debug/
words.rs

1//! Words: splitting a script line like a shell does (quotes, escapes, comments) and a small
2//! argument parser (flags, options and positionals in any order).
3
4/// Split a line into words: whitespace separates, `"…"` and `'…'` group (with `\"`, `\\`, `\n`,
5/// `\t` inside double quotes), and an unquoted `#` at the start of a word begins a comment.
6pub fn split(line: &str) -> Result<Vec<String>, String> {
7    let mut words = Vec::new();
8    let mut cur = String::new();
9    let mut in_word = false;
10    let mut chars = line.chars();
11    while let Some(c) = chars.next() {
12        match c {
13            c if c.is_whitespace() => {
14                if in_word {
15                    words.push(std::mem::take(&mut cur));
16                    in_word = false;
17                }
18            }
19            '#' if !in_word => break,
20            '"' => {
21                in_word = true;
22                loop {
23                    match chars.next() {
24                        None => return Err("unterminated \" quote".into()),
25                        Some('"') => break,
26                        Some('\\') => match chars.next() {
27                            Some('n') => cur.push('\n'),
28                            Some('t') => cur.push('\t'),
29                            Some(e) => cur.push(e),
30                            None => return Err("unterminated \" quote".into()),
31                        },
32                        Some(x) => cur.push(x),
33                    }
34                }
35            }
36            '\'' => {
37                in_word = true;
38                loop {
39                    match chars.next() {
40                        None => return Err("unterminated ' quote".into()),
41                        Some('\'') => break,
42                        Some(x) => cur.push(x),
43                    }
44                }
45            }
46            c => {
47                in_word = true;
48                cur.push(c);
49            }
50        }
51    }
52    if in_word {
53        words.push(cur);
54    }
55    Ok(words)
56}
57
58/// Quote a word for display when it needs it (the inverse of [`split`] for one word).
59pub fn quote(w: &str) -> String {
60    if !w.is_empty() && !w.chars().any(|c| c.is_whitespace() || matches!(c, '"' | '\'' | '\\' | '#')) {
61        return w.to_string();
62    }
63    let mut s = String::from("\"");
64    for c in w.chars() {
65        match c {
66            '"' => s.push_str("\\\""),
67            '\\' => s.push_str("\\\\"),
68            '\n' => s.push_str("\\n"),
69            '\t' => s.push_str("\\t"),
70            c => s.push(c),
71        }
72    }
73    s.push('"');
74    s
75}
76
77/// The arguments after the verb. Options are taken by name; what is left are positionals.
78#[derive(Debug, Clone, PartialEq, Default)]
79pub struct Args {
80    items: Vec<String>,
81    /// Items after a bare `--` are positionals even when they start with `-`.
82    rest: Vec<String>,
83}
84
85fn is_option(a: &str) -> bool {
86    // `-5` is a number (scroll by a negative amount), not an option.
87    a.starts_with('-') && a.len() > 1 && !a.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
88}
89
90impl Args {
91    pub fn new(items: impl IntoIterator<Item = impl Into<String>>) -> Self {
92        let mut all: Vec<String> = items.into_iter().map(Into::into).collect();
93        let rest = match all.iter().position(|a| a == "--") {
94            Some(i) => all.split_off(i).into_iter().skip(1).collect(),
95            None => Vec::new(),
96        };
97        Self { items: all, rest }
98    }
99
100    /// Take a flag by any of its names (`-i`, `--interactive`).
101    pub fn flag(&mut self, names: &[&str]) -> bool {
102        let mut found = false;
103        self.items.retain(|a| {
104            let hit = names.contains(&a.as_str());
105            found |= hit;
106            !hit
107        });
108        found
109    }
110
111    /// Take `--name VALUE` or `--name=VALUE` (the last one wins).
112    pub fn value(&mut self, names: &[&str]) -> Result<Option<String>, String> {
113        let mut out = None;
114        let mut i = 0;
115        while i < self.items.len() {
116            let a = self.items.get(i).cloned().unwrap_or_default();
117            if let Some(v) = names.iter().find_map(|n| a.strip_prefix(&format!("{n}="))) {
118                out = Some(v.to_string());
119                self.items.remove(i);
120            } else if names.contains(&a.as_str()) {
121                self.items.remove(i);
122                if i < self.items.len() {
123                    out = Some(self.items.remove(i));
124                } else {
125                    return Err(format!("{a} needs a value"));
126                }
127            } else {
128                i = i.saturating_add(1);
129            }
130        }
131        Ok(out)
132    }
133
134    /// Take `--name N` as a number.
135    pub fn number<T: std::str::FromStr>(&mut self, names: &[&str]) -> Result<Option<T>, String> {
136        match self.value(names)? {
137            Some(v) => v.parse().map(Some).map_err(|_| format!("{}: `{v}` is not a valid number", names.first().copied().unwrap_or("option"))),
138            None => Ok(None),
139        }
140    }
141
142    /// Take the next positional. Take options first, so an option's value is not mistaken for one.
143    pub fn positional(&mut self) -> Option<String> {
144        match self.items.iter().position(|a| !is_option(a)) {
145            Some(i) => Some(self.items.remove(i)),
146            None => (!self.rest.is_empty()).then(|| self.rest.remove(0)),
147        }
148    }
149
150    /// Every remaining positional (including those after `--`).
151    pub fn rest(&mut self) -> Vec<String> {
152        let mut out = Vec::new();
153        while let Some(p) = self.positional() {
154            out.push(p);
155        }
156        out
157    }
158
159    /// Whether anything (option or positional) is left.
160    pub fn is_empty(&self) -> bool {
161        self.items.is_empty() && self.rest.is_empty()
162    }
163
164    /// Everything was taken; otherwise the first leftover is an error.
165    pub fn finish(self) -> Result<(), String> {
166        match self.items.first().or(self.rest.first()) {
167            None => Ok(()),
168            Some(a) if is_option(a) => Err(format!("unknown option {a}")),
169            Some(a) => Err(format!("unexpected argument `{a}`")),
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn splits_like_a_shell() {
180        assert_eq!(split(r#"fill @e3 "hello world""#).unwrap(), ["fill", "@e3", "hello world"]);
181        assert_eq!(split(r#"type 'it''s' "a\"b\\c\n""#).unwrap(), ["type", "its", "a\"b\\c\n"]);
182        assert_eq!(split("  click e3   # a comment").unwrap(), ["click", "e3"]);
183        assert_eq!(split("# only a comment").unwrap(), Vec::<String>::new());
184        assert_eq!(split(r#"fill e1 "" x"#).unwrap(), ["fill", "e1", "", "x"]);
185        assert_eq!(split("text=a#b").unwrap(), ["text=a#b"]);
186        assert!(split(r#"fill e1 "open"#).is_err());
187        assert!(split("fill e1 'open").is_err());
188    }
189
190    #[test]
191    fn quote_round_trips() {
192        for w in ["plain", "two words", "", "a\"b", "back\\slash", "#hash", "tab\there"] {
193            assert_eq!(split(&quote(w)).unwrap(), [w], "{w}");
194        }
195    }
196
197    #[test]
198    fn options_in_any_order() {
199        let mut a = Args::new(["@e3", "--count=2", "-i", "--button", "secondary", "--", "--literal"]);
200        assert!(a.flag(&["-i", "--interactive"]));
201        assert_eq!(a.number::<u32>(&["--count"]), Ok(Some(2)));
202        assert_eq!(a.value(&["--button"]), Ok(Some("secondary".into())));
203        assert_eq!(a.positional().as_deref(), Some("@e3"));
204        assert_eq!(a.positional().as_deref(), Some("--literal"));
205        assert_eq!(a.finish(), Ok(()));
206        let mut a = Args::new(["down", "-300"]);
207        assert_eq!(a.rest(), ["down", "-300"]);
208        assert_eq!(Args::new(["--nope"]).finish(), Err("unknown option --nope".into()));
209        assert_eq!(Args::new(["x"]).finish(), Err("unexpected argument `x`".into()));
210        assert_eq!(Args::new(["--timeout"]).value(&["--timeout"]), Err("--timeout needs a value".into()));
211        assert!(Args::new(["--timeout", "soon"]).number::<u64>(&["--timeout"]).is_err());
212    }
213}