Skip to main content

safe_chains/
parse.rs

1use std::ops::Deref;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct Token(String);
5
6impl Deref for Token {
7    type Target = str;
8    fn deref(&self) -> &str {
9        &self.0
10    }
11}
12
13#[derive(Copy, Clone)]
14pub struct WordSet(&'static [&'static str]);
15
16impl WordSet {
17    pub const fn new(words: &'static [&'static str]) -> Self {
18        let mut i = 1;
19        while i < words.len() {
20            assert!(const_less(words[i - 1].as_bytes(), words[i].as_bytes()), "WordSet: entries must be sorted, no duplicates");
21            i += 1;
22        }
23        Self(words)
24    }
25
26    pub const fn flags(words: &'static [&'static str]) -> Self {
27        let mut i = 0;
28        while i < words.len() {
29            let b = words[i].as_bytes();
30            assert!(b.len() >= 2, "WordSet::flags: flag too short (need at least 2 chars)");
31            assert!(b[0] == b'-', "WordSet::flags: flag must start with '-'");
32            if b[1] == b'-' {
33                assert!(b.len() >= 3, "WordSet::flags: long flag needs at least 3 chars (e.g. --x)");
34            }
35            i += 1;
36        }
37        Self::new(words)
38    }
39
40    pub fn contains(&self, s: &str) -> bool {
41        self.0.binary_search(&s).is_ok()
42    }
43
44    pub fn contains_short(&self, b: u8) -> bool {
45        let target = [b'-', b];
46        std::str::from_utf8(&target).is_ok_and(|s| self.0.binary_search(&s).is_ok())
47    }
48
49    pub fn iter(&self) -> impl Iterator<Item = &'static str> + '_ {
50        self.0.iter().copied()
51    }
52}
53
54const fn const_less(a: &[u8], b: &[u8]) -> bool {
55    let min = if a.len() < b.len() { a.len() } else { b.len() };
56    let mut i = 0;
57    while i < min {
58        if a[i] < b[i] {
59            return true;
60        }
61        if a[i] > b[i] {
62            return false;
63        }
64        i += 1;
65    }
66    a.len() < b.len()
67}
68
69impl Token {
70    pub fn from_raw(s: String) -> Self {
71        Self(s)
72    }
73
74    #[cfg(test)]
75    pub(crate) fn from_test(s: &str) -> Self {
76        Self(s.to_string())
77    }
78
79    pub fn as_str(&self) -> &str {
80        &self.0
81    }
82
83    pub fn command_name(&self) -> &str {
84        let s = self.as_str();
85        if s.starts_with('@') {
86            return s;
87        }
88        s.rsplit('/').next().unwrap_or(s)
89    }
90
91    pub fn is_one_of(&self, options: &[&str]) -> bool {
92        options.contains(&self.as_str())
93    }
94
95    pub fn split_value(&self, sep: &str) -> Option<&str> {
96        self.as_str().split_once(sep).map(|(_, v)| v)
97    }
98
99    /// The token with all DOUBLE-quoted string content replaced by a space — for analyzing code
100    /// whose string literals are INERT (awk: `{print "system"}` is a literal, not a call). NOT
101    /// sound for languages that INTERPOLATE code inside double quotes (perl `"@{[…]}"`), which use
102    /// their own interpolation-aware stripper.
103    pub fn content_outside_double_quotes(&self) -> String {
104        let bytes = self.as_str().as_bytes();
105        let mut result = Vec::with_capacity(bytes.len());
106        let mut i = 0;
107        while i < bytes.len() {
108            if bytes[i] == b'"' {
109                result.push(b' ');
110                i += 1;
111                while i < bytes.len() {
112                    if bytes[i] == b'\\' && i + 1 < bytes.len() {
113                        i += 2;
114                        continue;
115                    }
116                    if bytes[i] == b'"' {
117                        i += 1;
118                        break;
119                    }
120                    i += 1;
121                }
122            } else {
123                result.push(bytes[i]);
124                i += 1;
125            }
126        }
127        String::from_utf8(result).unwrap_or_default()
128    }
129}
130
131impl PartialEq<str> for Token {
132    fn eq(&self, other: &str) -> bool {
133        self.0 == other
134    }
135}
136
137impl PartialEq<&str> for Token {
138    fn eq(&self, other: &&str) -> bool {
139        self.0 == *other
140    }
141}
142
143impl PartialEq<Token> for str {
144    fn eq(&self, other: &Token) -> bool {
145        self == other.as_str()
146    }
147}
148
149impl PartialEq<Token> for &str {
150    fn eq(&self, other: &Token) -> bool {
151        *self == other.as_str()
152    }
153}
154
155impl std::fmt::Display for Token {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        f.write_str(&self.0)
158    }
159}
160
161pub fn has_flag(tokens: &[Token], short: Option<&str>, long: Option<&str>) -> bool {
162    for token in &tokens[1..] {
163        if token == "--" {
164            return false;
165        }
166        if let Some(long_flag) = long
167            && (token == long_flag || token.starts_with(&format!("{long_flag}=")))
168        {
169            return true;
170        }
171        if let Some(short_flag) = short {
172            let short_char = short_flag.trim_start_matches('-');
173            if token.starts_with('-') && !token.starts_with("--") && token[1..].contains(short_char) {
174                return true;
175            }
176        }
177    }
178    false
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use proptest::prelude::*;
185
186    fn tok(s: &str) -> Token {
187        Token(s.to_string())
188    }
189
190    fn toks(words: &[&str]) -> Vec<Token> {
191        words.iter().map(|s| tok(s)).collect()
192    }
193
194    #[test]
195    fn has_flag_short() {
196        let tokens = toks(&["sed", "-i", "s/foo/bar/"]);
197        assert!(has_flag(&tokens, Some("-i"), Some("--in-place")));
198    }
199
200    #[test]
201    fn has_flag_long_with_eq() {
202        let tokens = toks(&["sed", "--in-place=.bak", "s/foo/bar/"]);
203        assert!(has_flag(&tokens, Some("-i"), Some("--in-place")));
204    }
205
206    #[test]
207    fn has_flag_combined_short() {
208        let tokens = toks(&["sed", "-ni", "s/foo/bar/p"]);
209        assert!(has_flag(&tokens, Some("-i"), Some("--in-place")));
210    }
211
212    #[test]
213    fn has_flag_stops_at_double_dash() {
214        let tokens = toks(&["cmd", "--", "-i"]);
215        assert!(!has_flag(&tokens, Some("-i"), Some("--in-place")));
216    }
217
218    #[test]
219    fn has_flag_long_only() {
220        let tokens = toks(&["sort", "--compress-program", "gzip", "file.txt"]);
221        assert!(has_flag(&tokens, None, Some("--compress-program")));
222    }
223
224    #[test]
225    fn has_flag_long_only_eq() {
226        let tokens = toks(&["sort", "--compress-program=gzip", "file.txt"]);
227        assert!(has_flag(&tokens, None, Some("--compress-program")));
228    }
229
230    #[test]
231    fn has_flag_long_only_absent() {
232        let tokens = toks(&["sort", "-r", "file.txt"]);
233        assert!(!has_flag(&tokens, None, Some("--compress-program")));
234    }
235
236    #[test]
237    fn command_name_simple() {
238        assert_eq!(tok("ls").command_name(), "ls");
239    }
240
241    #[test]
242    fn command_name_with_path() {
243        assert_eq!(tok("/usr/bin/ls").command_name(), "ls");
244    }
245
246    #[test]
247    fn command_name_relative_path() {
248        assert_eq!(tok("./scripts/test.sh").command_name(), "test.sh");
249    }
250
251    #[test]
252    fn command_name_scoped_package() {
253        assert_eq!(tok("@herb-tools/linter").command_name(), "@herb-tools/linter");
254    }
255
256    #[test]
257    fn reverse_partial_eq() {
258        let t = tok("hello");
259        assert!("hello" == t);
260        assert!("world" != t);
261    }
262
263    #[test]
264    fn token_deref() {
265        let t = tok("--flag");
266        assert!(t.starts_with("--"));
267        assert!(t.contains("fl"));
268        assert_eq!(t.len(), 6);
269    }
270
271    #[test]
272    fn token_is_one_of() {
273        assert!(tok("-v").is_one_of(&["-v", "--verbose"]));
274        assert!(!tok("-q").is_one_of(&["-v", "--verbose"]));
275    }
276
277    #[test]
278    fn token_split_value() {
279        assert_eq!(tok("--method=GET").split_value("="), Some("GET"));
280        assert_eq!(tok("--flag").split_value("="), None);
281    }
282
283    #[test]
284    fn word_set_contains() {
285        let set = WordSet::new(&["list", "show", "view"]);
286        assert!(set.contains(&tok("list")));
287        assert!(set.contains(&tok("view")));
288        assert!(!set.contains(&tok("delete")));
289    }
290
291    #[test]
292    fn word_set_iter() {
293        let set = WordSet::new(&["a", "b", "c"]);
294        let items: Vec<&str> = set.iter().collect();
295        assert_eq!(items, vec!["a", "b", "c"]);
296    }
297
298    #[test]
299    fn content_outside_double_quotes_strips_string() {
300        assert_eq!(tok(r#""system""#).content_outside_double_quotes(), " ");
301    }
302
303    #[test]
304    fn content_outside_double_quotes_preserves_code() {
305        let result = tok(r#"{print "hello"} END{print NR}"#).content_outside_double_quotes();
306        assert_eq!(result, r#"{print  } END{print NR}"#);
307    }
308
309    #[test]
310    fn content_outside_double_quotes_escaped() {
311        let result = tok(r#"{print "he said \"hi\""}"#).content_outside_double_quotes();
312        assert_eq!(result, "{print  }");
313    }
314
315    #[test]
316    fn content_outside_double_quotes_no_quotes() {
317        assert_eq!(tok("{print $1}").content_outside_double_quotes(), "{print $1}");
318    }
319
320    proptest! {
321        /// `command_name` is the last `/`-separated segment of its input, with one carve-out: any
322        /// string starting with `@` (a scoped npm package name) is returned verbatim. This is the
323        /// contract callers rely on — `git command_name` returns `git`, `./bin/foo` returns `foo`,
324        /// `@scope/pkg` returns `@scope/pkg` — so the property states it exactly.
325        #[test]
326        fn command_name_is_last_slash_segment_or_full_at_prefixed(
327            s in "\\PC{0,128}",
328        ) {
329            let token = Token::from_raw(s.clone());
330            let expected = if s.starts_with('@') {
331                s.as_str()
332            } else {
333                s.rsplit('/').next().unwrap_or(&s)
334            };
335            prop_assert_eq!(token.command_name(), expected);
336        }
337
338        /// `split_value(sep)` returns `Some(suffix)` iff `sep` occurs in the input, and that
339        /// `suffix` is exactly the substring after the FIRST occurrence of `sep`. Empty
340        /// `sep` is meaningless to `split_once`, so it is filtered out. Together this means
341        /// a flag of the form `--key=value` yields `Some("value")` and the prefix is the
342        /// smallest possible one — no caller can be surprised by where the split lands.
343        #[test]
344        fn split_value_is_some_iff_separator_present_and_yields_first_suffix(
345            s in "\\PC{0,128}",
346            sep in "\\PC{1,8}",
347        ) {
348            let token = Token::from_raw(s.clone());
349            let result = token.split_value(&sep);
350            let first_at = s.find(&sep);
351            prop_assert_eq!(result.is_some(), first_at.is_some());
352            if let (Some(suffix), Some(idx)) = (result, first_at) {
353                prop_assert_eq!(suffix, &s[idx + sep.len()..]);
354            }
355        }
356
357        /// The stripper replaces every closed `"…"` region with a single space and copies
358        /// everything else through unchanged. The fact that each `"` either becomes a space
359        /// or is consumed (escapes skip ahead, the closing `"` ends the loop with `i += 1`)
360        /// means a second application never sees a `"` to act on, so the operation is
361        /// idempotent. A regression that, say, copied the closing `"` through would break
362        /// this on the very next iteration.
363        #[test]
364        fn content_outside_double_quotes_is_idempotent(
365            s in "\\PC{0,256}",
366        ) {
367            let once = Token::from_raw(s).content_outside_double_quotes();
368            let twice = Token::from_raw(once.clone()).content_outside_double_quotes();
369            prop_assert_eq!(once, twice);
370        }
371
372        /// The `--` token terminates argument parsing; `has_flag` must respect it on every
373        /// input shape. Whether the flags asked about appear before `--` or after it, in the
374        /// leading tokens, in a later `--key=value`, or nowhere at all, the verdict computed
375        /// on `prefix + ["--"] + tail` is identical to the verdict on `prefix` alone. This
376        /// is the property the implementation's `if token == "--" { return false; }` line
377        /// promises; arbitrary fillers after `--` exercise the loop continuing past the
378        /// terminator.
379        #[test]
380        fn has_flag_verdict_is_invariant_under_appending_a_double_dash_tail(
381            prefix in proptest::collection::vec("\\PC{0,16}", 0..4),
382            tail in proptest::collection::vec("\\PC{0,16}", 0..4),
383            short in proptest::option::of("\\PC{2,16}"),
384            long in proptest::option::of("\\PC{3,32}"),
385        ) {
386            // Force the flag forms into the shapes `has_flag` actually recognises; anything
387            // else would be testing the regex, not the function.
388            let short = short.filter(|s| s.starts_with('-') && !s.starts_with("--") && s.len() >= 2);
389            let long = long.filter(|s| s.starts_with("--") && s.len() >= 3 && !s.contains('='));
390
391            let mut base: Vec<Token> = Vec::with_capacity(prefix.len() + 2);
392            base.push(tok("cmd"));
393            for p in &prefix {
394                base.push(tok(p));
395            }
396            if let Some(s) = &short {
397                base.push(tok(s));
398            }
399            if let Some(l) = &long {
400                base.push(tok(l));
401            }
402
403            let mut with_tail = base.clone();
404            with_tail.push(tok("--"));
405            for t in &tail {
406                with_tail.push(tok(t));
407            }
408
409            prop_assert_eq!(
410                has_flag(&base, short.as_deref(), long.as_deref()),
411                has_flag(&with_tail, short.as_deref(), long.as_deref()),
412            );
413        }
414    }
415}