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 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 #[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 #[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 #[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 #[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 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}