1use crate::parse::{Token, WordSet};
2
3#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7pub enum UnknownTolerance {
8 #[default]
10 Strict,
11 Short,
15 Long,
21 Both,
24}
25
26impl UnknownTolerance {
27 pub const fn allows_short(self) -> bool {
28 matches!(self, Self::Short | Self::Both)
29 }
30 pub const fn allows_long(self) -> bool {
31 matches!(self, Self::Long | Self::Both)
32 }
33}
34
35#[derive(Clone, Copy, Debug, Default)]
39pub struct FlagTolerance {
40 pub unknown: UnknownTolerance,
41 pub numeric_dash: bool,
42}
43
44impl FlagTolerance {
45 pub const fn strict() -> Self {
48 Self { unknown: UnknownTolerance::Strict, numeric_dash: false }
49 }
50}
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum PositionalShape {
57 Path,
60 GoPackage,
67}
68
69impl PositionalShape {
70 pub fn matches(self, token: &str) -> bool {
71 match self {
72 Self::Path => looks_like_path(token),
73 Self::GoPackage => is_go_local_package(token),
74 }
75 }
76
77 pub fn from_name(name: &str) -> Option<Self> {
78 match name {
79 "path" => Some(Self::Path),
80 "go-package" => Some(Self::GoPackage),
81 _ => None,
82 }
83 }
84}
85
86pub fn is_go_local_package(token: &str) -> bool {
89 token == "." || token == ".." || token.starts_with("./") || token.starts_with("../") || token.starts_with('/') || token.ends_with(".go")
90}
91
92pub fn looks_like_path(token: &str) -> bool {
106 if token.is_empty() {
107 return false;
108 }
109 if token.starts_with('-') {
110 return token == "-";
111 }
112 token.starts_with('~') || token.contains('/') || token.contains('.')
113}
114
115pub trait FlagSet {
116 fn contains_flag(&self, token: &str) -> bool;
117 fn contains_short(&self, byte: u8) -> bool;
118}
119
120impl FlagSet for WordSet {
121 fn contains_flag(&self, token: &str) -> bool {
122 self.contains(token)
123 }
124 fn contains_short(&self, byte: u8) -> bool {
125 self.contains_short(byte)
126 }
127}
128
129impl FlagSet for [String] {
130 fn contains_flag(&self, token: &str) -> bool {
131 self.iter().any(|f| f.as_str() == token)
132 }
133 fn contains_short(&self, byte: u8) -> bool {
134 self.iter().any(|f| f.len() == 2 && f.as_bytes()[1] == byte)
135 }
136}
137
138impl FlagSet for Vec<String> {
139 fn contains_flag(&self, token: &str) -> bool {
140 self.as_slice().contains_flag(token)
141 }
142 fn contains_short(&self, byte: u8) -> bool {
143 self.as_slice().contains_short(byte)
144 }
145}
146
147pub struct FlagPolicy {
148 pub standalone: WordSet,
149 pub valued: WordSet,
150 pub bare: bool,
151 pub max_positional: Option<usize>,
152 pub tolerance: FlagTolerance,
153}
154
155impl FlagPolicy {
156 pub fn describe(&self) -> String {
157 use crate::docs::wordset_items;
158 let mut lines = Vec::new();
159 let standalone = wordset_items(&self.standalone);
160 if !standalone.is_empty() {
161 lines.push(format!("- Allowed standalone flags: {standalone}"));
162 }
163 let valued = wordset_items(&self.valued);
164 if !valued.is_empty() {
165 lines.push(format!("- Allowed valued flags: {valued}"));
166 }
167 if self.bare {
168 lines.push("- Bare invocation allowed".to_string());
169 }
170 if self.tolerance.unknown != UnknownTolerance::Strict {
171 lines.push("- Hyphen-prefixed positional arguments accepted".to_string());
172 }
173 if self.tolerance.numeric_dash {
174 lines.push("- Numeric shorthand accepted (e.g. -20 for -n 20)".to_string());
175 }
176 if lines.is_empty() && !self.bare {
177 return "- Positional arguments only".to_string();
178 }
179 lines.join("\n")
180 }
181}
182
183pub fn check(tokens: &[Token], policy: &FlagPolicy) -> bool {
184 check_flags(tokens, &policy.standalone, &policy.valued, policy.bare, policy.max_positional, policy.tolerance)
185}
186
187pub(crate) fn consumes_next_value(next: Option<&Token>) -> bool {
188 match next {
189 None => false,
190 Some(t) => {
191 let b = t.as_bytes();
192 !(b.len() > 1 && b[0] == b'-' && !b[1].is_ascii_digit())
199 }
200 }
201}
202
203pub fn check_flags<S: FlagSet + ?Sized, V: FlagSet + ?Sized>(
204 tokens: &[Token],
205 standalone: &S,
206 valued: &V,
207 bare: bool,
208 max_positional: Option<usize>,
209 tolerance: FlagTolerance,
210) -> bool {
211 if tokens.len() == 1 {
212 return bare;
213 }
214
215 let mut i = 1;
216 let mut positionals: usize = 0;
217 while i < tokens.len() {
218 let t = &tokens[i];
219
220 if *t == "--" {
221 positionals += tokens.len() - i - 1;
222 break;
223 }
224
225 if !t.starts_with('-') {
226 positionals += 1;
227 i += 1;
228 continue;
229 }
230
231 if tolerance.numeric_dash && t.len() > 1 && t[1..].bytes().all(|b| b.is_ascii_digit()) {
232 i += 1;
233 continue;
234 }
235
236 if standalone.contains_flag(t) {
237 i += 1;
238 continue;
239 }
240
241 if valued.contains_flag(t) {
242 if consumes_next_value(tokens.get(i + 1)) {
243 i += 2;
244 } else {
245 i += 1;
246 }
247 continue;
248 }
249
250 if let Some(flag) = t.as_str().split_once('=').map(|(f, _)| f) {
251 if valued.contains_flag(flag) {
252 i += 1;
253 continue;
254 }
255 if tolerance.unknown.allows_long() {
257 positionals += 1;
258 i += 1;
259 continue;
260 }
261 return false;
262 }
263
264 if t.starts_with("--") {
265 if tolerance.unknown.allows_long() {
266 positionals += 1;
267 i += 1;
268 continue;
269 }
270 return false;
271 }
272
273 let bytes = t.as_bytes();
274 let mut j = 1;
275 while j < bytes.len() {
276 let b = bytes[j];
277 let is_last = j == bytes.len() - 1;
278 if standalone.contains_short(b) {
279 j += 1;
280 continue;
281 }
282 if valued.contains_short(b) {
283 if is_last && consumes_next_value(tokens.get(i + 1)) {
284 i += 1;
285 }
286 break;
287 }
288 if tolerance.unknown.allows_short() {
289 positionals += 1;
290 break;
291 }
292 return false;
293 }
294 i += 1;
295 }
296 max_positional.is_none_or(|max| positionals <= max)
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 static TEST_POLICY: FlagPolicy = FlagPolicy {
304 standalone: WordSet::flags(&[
305 "--color", "--count", "--help", "--recursive", "--version", "-H", "-c", "-i", "-l", "-n", "-o", "-r", "-s", "-v", "-w",
306 ]),
307 valued: WordSet::flags(&["--after-context", "--before-context", "--max-count", "-A", "-B", "-m"]),
308 bare: false,
309 max_positional: None,
310 tolerance: FlagTolerance::strict(),
311 };
312
313 fn toks(words: &[&str]) -> Vec<Token> {
314 words.iter().map(|s| Token::from_test(s)).collect()
315 }
316
317 #[test]
318 fn bare_denied_when_bare_false() {
319 assert!(!check(&toks(&["grep"]), &TEST_POLICY));
320 }
321
322 #[test]
323 fn bare_allowed_when_bare_true() {
324 let policy = FlagPolicy {
325 standalone: WordSet::flags(&[]),
326 valued: WordSet::flags(&[]),
327 bare: true,
328 max_positional: None,
329 tolerance: FlagTolerance::strict(),
330 };
331 assert!(check(&toks(&["uname"]), &policy));
332 }
333
334 #[test]
347 fn a_flag_declared_in_both_lists_already_takes_an_optional_value() {
348 static BOTH: FlagPolicy = FlagPolicy {
349 standalone: WordSet::flags(&["--long", "-r"]),
350 valued: WordSet::flags(&["--long", "-r"]),
351 bare: true,
352 max_positional: Some(0),
353 tolerance: FlagTolerance::strict(),
354 };
355 assert!(check(&toks(&["zstd", "--long"]), &BOTH), "bare long form");
356 assert!(check(&toks(&["zstd", "--long=27"]), &BOTH), "glued long form");
357 assert!(check(&toks(&["zstd", "-r"]), &BOTH), "bare short form");
358
359 assert!(!check(&toks(&["zstd", "--long", "somefile"]), &BOTH), "the bare form must not consume the following token as its value");
362
363 assert!(!check(&toks(&["7z", "-r0"]), &BOTH), "short-glued is NOT handled today");
365 }
366
367 #[test]
368 fn standalone_long_flag() {
369 assert!(check(&toks(&["grep", "--recursive", "pattern", "."]), &TEST_POLICY));
370 }
371
372 #[test]
373 fn standalone_short_flag() {
374 assert!(check(&toks(&["grep", "-r", "pattern", "."]), &TEST_POLICY));
375 }
376
377 #[test]
378 fn valued_long_flag_space() {
379 assert!(check(&toks(&["grep", "--max-count", "5", "pattern"]), &TEST_POLICY));
380 }
381
382 #[test]
383 fn valued_long_flag_eq() {
384 assert!(check(&toks(&["grep", "--max-count=5", "pattern"]), &TEST_POLICY));
385 }
386
387 #[test]
388 fn valued_short_flag_space() {
389 assert!(check(&toks(&["grep", "-m", "5", "pattern"]), &TEST_POLICY));
390 }
391
392 #[test]
393 fn combined_standalone_short() {
394 assert!(check(&toks(&["grep", "-rn", "pattern", "."]), &TEST_POLICY));
395 }
396
397 #[test]
398 fn combined_short_with_valued_last() {
399 assert!(check(&toks(&["grep", "-rnm", "5", "pattern"]), &TEST_POLICY));
400 }
401
402 #[test]
403 fn combined_short_valued_mid_consumes_rest() {
404 assert!(check(&toks(&["grep", "-rmn", "pattern"]), &TEST_POLICY));
405 }
406
407 #[test]
408 fn unknown_long_flag_denied() {
409 assert!(!check(&toks(&["grep", "--exec", "cmd"]), &TEST_POLICY));
410 }
411
412 #[test]
413 fn unknown_short_flag_denied() {
414 assert!(!check(&toks(&["grep", "-z", "pattern"]), &TEST_POLICY));
415 }
416
417 #[test]
418 fn unknown_combined_short_denied() {
419 assert!(!check(&toks(&["grep", "-rz", "pattern"]), &TEST_POLICY));
420 }
421
422 #[test]
423 fn unknown_long_eq_denied() {
424 assert!(!check(&toks(&["grep", "--output=file.txt", "pattern"]), &TEST_POLICY));
425 }
426
427 #[test]
428 fn double_dash_stops_checking() {
429 assert!(check(&toks(&["grep", "--", "--not-a-flag", "file"]), &TEST_POLICY));
430 }
431
432 #[test]
433 fn positional_args_allowed() {
434 assert!(check(&toks(&["grep", "pattern", "file.txt", "other.txt"]), &TEST_POLICY));
435 }
436
437 #[test]
438 fn mixed_flags_and_positional() {
439 assert!(check(&toks(&["grep", "-rn", "--color", "--max-count", "10", "pattern", "."]), &TEST_POLICY,));
440 }
441
442 #[test]
443 fn valued_short_in_explicit_form() {
444 assert!(check(&toks(&["grep", "-A", "3", "-B", "3", "pattern"]), &TEST_POLICY));
445 }
446
447 #[test]
448 fn bare_dash_allowed_as_stdin() {
449 assert!(check(&toks(&["grep", "pattern", "-"]), &TEST_POLICY));
450 }
451
452 #[test]
453 fn valued_flag_at_end_without_value() {
454 assert!(check(&toks(&["grep", "--max-count"]), &TEST_POLICY));
455 }
456
457 #[test]
458 fn single_short_in_wordset_and_byte_array() {
459 assert!(check(&toks(&["grep", "-c", "pattern"]), &TEST_POLICY));
460 }
461
462 static SYNTAX_CHECK_POLICY: FlagPolicy = FlagPolicy {
463 standalone: WordSet::flags(&["--help", "-h"]),
464 valued: WordSet::flags(&["--check", "-c"]),
465 bare: false,
466 max_positional: Some(0),
467 tolerance: FlagTolerance::strict(),
468 };
469
470 #[test]
471 fn valued_flag_consumes_path_value() {
472 assert!(check(&toks(&["node", "--check", "app.js"]), &SYNTAX_CHECK_POLICY));
473 assert!(check(&toks(&["node", "-c", "app.js"]), &SYNTAX_CHECK_POLICY));
474 }
475
476 #[test]
477 fn valued_flag_does_not_swallow_following_long_option() {
478 assert!(!check(&toks(&["node", "--check", "--require=./evil.js"]), &SYNTAX_CHECK_POLICY,));
479 }
480
481 #[test]
482 fn valued_short_does_not_swallow_following_option() {
483 assert!(!check(&toks(&["node", "-c", "-r./evil.js"]), &SYNTAX_CHECK_POLICY));
484 }
485
486 #[test]
487 fn valued_flag_still_consumes_negative_number() {
488 let policy = FlagPolicy {
489 standalone: WordSet::flags(&[]),
490 valued: WordSet::flags(&["-n"]),
491 bare: false,
492 max_positional: Some(1),
493 tolerance: FlagTolerance::strict(),
494 };
495 assert!(check(&toks(&["head", "-n", "-5", "file"]), &policy));
496 }
497
498 static LIMITED_POLICY: FlagPolicy = FlagPolicy {
499 standalone: WordSet::flags(&["--count", "-c", "-d", "-i", "-u"]),
500 valued: WordSet::flags(&["--skip-fields", "-f", "-s"]),
501 bare: true,
502 max_positional: Some(1),
503 tolerance: FlagTolerance::strict(),
504 };
505
506 #[test]
507 fn max_positional_within_limit() {
508 assert!(check(&toks(&["uniq", "input.txt"]), &LIMITED_POLICY));
509 }
510
511 #[test]
512 fn max_positional_exceeded() {
513 assert!(!check(&toks(&["uniq", "input.txt", "output.txt"]), &LIMITED_POLICY));
514 }
515
516 #[test]
517 fn max_positional_with_flags_within_limit() {
518 assert!(check(&toks(&["uniq", "-c", "-f", "3", "input.txt"]), &LIMITED_POLICY));
519 }
520
521 #[test]
522 fn max_positional_with_flags_exceeded() {
523 assert!(!check(&toks(&["uniq", "-c", "input.txt", "output.txt"]), &LIMITED_POLICY));
524 }
525
526 #[test]
527 fn max_positional_after_double_dash() {
528 assert!(!check(&toks(&["uniq", "--", "input.txt", "output.txt"]), &LIMITED_POLICY));
529 }
530
531 #[test]
532 fn max_positional_bare_allowed() {
533 assert!(check(&toks(&["uniq"]), &LIMITED_POLICY));
534 }
535
536 static BOTH_TOLERANCES_POLICY: FlagPolicy = FlagPolicy {
537 standalone: WordSet::flags(&["-E", "-e", "-n"]),
538 valued: WordSet::flags(&[]),
539 bare: true,
540 max_positional: None,
541 tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
542 };
543
544 #[test]
545 fn both_tolerances_accept_unknown_long() {
546 assert!(check(&toks(&["echo", "--unknown", "hello"]), &BOTH_TOLERANCES_POLICY));
547 }
548
549 #[test]
550 fn both_tolerances_accept_unknown_short() {
551 assert!(check(&toks(&["echo", "-x", "hello"]), &BOTH_TOLERANCES_POLICY));
552 }
553
554 #[test]
555 fn both_tolerances_accept_triple_dash() {
556 assert!(check(&toks(&["echo", "---"]), &BOTH_TOLERANCES_POLICY));
557 }
558
559 #[test]
560 fn both_tolerances_known_flags_still_work() {
561 assert!(check(&toks(&["echo", "-n", "hello"]), &BOTH_TOLERANCES_POLICY));
562 }
563
564 #[test]
565 fn both_tolerances_combo_known_short() {
566 assert!(check(&toks(&["echo", "-ne", "hello"]), &BOTH_TOLERANCES_POLICY));
567 }
568
569 #[test]
570 fn both_tolerances_combo_unknown_short_byte() {
571 assert!(check(&toks(&["echo", "-nx", "hello"]), &BOTH_TOLERANCES_POLICY));
572 }
573
574 #[test]
575 fn both_tolerances_unknown_eq_form() {
576 assert!(check(&toks(&["echo", "--foo=bar"]), &BOTH_TOLERANCES_POLICY));
577 }
578
579 static SHORT_ONLY_POLICY: FlagPolicy = FlagPolicy {
586 standalone: WordSet::flags(&["--help"]),
587 valued: WordSet::flags(&[]),
588 bare: false,
589 max_positional: None,
590 tolerance: FlagTolerance { unknown: UnknownTolerance::Short, numeric_dash: false },
591 };
592
593 #[test]
594 fn short_only_accepts_unknown_dash_letter() {
595 assert!(check(&toks(&["sample", "-mayDie"]), &SHORT_ONLY_POLICY));
596 }
597
598 #[test]
599 fn short_only_accepts_single_dash_long_word() {
600 assert!(check(&toks(&["pdftotext", "-layout"]), &SHORT_ONLY_POLICY));
602 }
603
604 #[test]
605 fn short_only_denies_unknown_double_dash() {
606 assert!(!check(&toks(&["sample", "--evil-flag"]), &SHORT_ONLY_POLICY));
609 }
610
611 #[test]
612 fn short_only_denies_unknown_eq_form() {
613 assert!(!check(&toks(&["sample", "--evil=value"]), &SHORT_ONLY_POLICY));
614 }
615
616 #[test]
617 fn short_only_known_long_flag_still_works() {
618 assert!(check(&toks(&["sample", "--help"]), &SHORT_ONLY_POLICY));
619 }
620
621 static LONG_ONLY_POLICY: FlagPolicy = FlagPolicy {
627 standalone: WordSet::flags(&["--help"]),
628 valued: WordSet::flags(&[]),
629 bare: false,
630 max_positional: None,
631 tolerance: FlagTolerance { unknown: UnknownTolerance::Long, numeric_dash: false },
632 };
633
634 #[test]
635 fn long_only_accepts_unknown_double_dash() {
636 assert!(check(&toks(&["aws", "--some-aws-flag"]), &LONG_ONLY_POLICY));
637 }
638
639 #[test]
640 fn long_only_accepts_unknown_eq_form() {
641 assert!(check(&toks(&["aws", "--filter=Name=tag,Values=foo"]), &LONG_ONLY_POLICY,));
642 }
643
644 #[test]
645 fn long_only_denies_unknown_short_dash() {
646 assert!(!check(&toks(&["aws", "-x"]), &LONG_ONLY_POLICY));
647 }
648
649 static STRICT_POLICY: FlagPolicy = FlagPolicy {
652 standalone: WordSet::flags(&["--help"]),
653 valued: WordSet::flags(&[]),
654 bare: false,
655 max_positional: None,
656 tolerance: FlagTolerance::strict(),
657 };
658
659 #[test]
660 fn strict_denies_unknown_short() {
661 assert!(!check(&toks(&["foo", "-evil"]), &STRICT_POLICY));
662 }
663
664 #[test]
665 fn strict_denies_unknown_long() {
666 assert!(!check(&toks(&["foo", "--evil"]), &STRICT_POLICY));
667 }
668
669 #[test]
670 fn strict_known_flag_passes() {
671 assert!(check(&toks(&["foo", "--help"]), &STRICT_POLICY));
672 }
673
674 #[test]
675 fn both_tolerances_with_max_positional() {
676 let policy = FlagPolicy {
677 standalone: WordSet::flags(&["-n"]),
678 valued: WordSet::flags(&[]),
679 bare: true,
680 max_positional: Some(2),
681 tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
682 };
683 assert!(check(&toks(&["echo", "--unknown", "hello"]), &policy));
684 assert!(!check(&toks(&["echo", "--a", "--b", "--c"]), &policy));
685 }
686
687 static NUMERIC_DASH_POLICY: FlagPolicy = FlagPolicy {
688 standalone: WordSet::flags(&["--help", "--quiet", "--verbose", "--version", "-V", "-h", "-q", "-v", "-z"]),
689 valued: WordSet::flags(&["--bytes", "--lines", "-c", "-n"]),
690 bare: true,
691 max_positional: None,
692 tolerance: FlagTolerance { numeric_dash: true, ..FlagTolerance::strict() },
693 };
694
695 #[test]
696 fn numeric_dash_single_digit() {
697 assert!(check(&toks(&["head", "-5"]), &NUMERIC_DASH_POLICY));
698 }
699
700 #[test]
701 fn numeric_dash_multi_digit() {
702 assert!(check(&toks(&["head", "-20"]), &NUMERIC_DASH_POLICY));
703 }
704
705 #[test]
706 fn numeric_dash_large_number() {
707 assert!(check(&toks(&["head", "-1000"]), &NUMERIC_DASH_POLICY));
708 }
709
710 #[test]
711 fn numeric_dash_with_file_arg() {
712 assert!(check(&toks(&["head", "-20", "file.txt"]), &NUMERIC_DASH_POLICY));
713 }
714
715 #[test]
716 fn numeric_dash_with_other_flags() {
717 assert!(check(&toks(&["head", "-q", "-20", "file.txt"]), &NUMERIC_DASH_POLICY));
718 }
719
720 #[test]
721 fn numeric_dash_zero() {
722 assert!(check(&toks(&["head", "-0"]), &NUMERIC_DASH_POLICY));
723 }
724
725 #[test]
726 fn numeric_dash_still_rejects_unknown_flags() {
727 assert!(!check(&toks(&["head", "-x"]), &NUMERIC_DASH_POLICY));
728 }
729
730 #[test]
731 fn numeric_dash_rejects_mixed_alpha_num() {
732 assert!(!check(&toks(&["head", "-20x"]), &NUMERIC_DASH_POLICY));
733 }
734
735 #[test]
736 fn numeric_dash_disabled_rejects_multi_digit() {
737 assert!(!check(&toks(&["grep", "-20", "pattern"]), &TEST_POLICY));
738 }
739
740 #[test]
741 fn looks_like_path_accepts_relative() {
742 assert!(looks_like_path("./Tiltfile"));
743 assert!(looks_like_path("path/to/file"));
744 }
745
746 #[test]
747 fn looks_like_path_accepts_dotted() {
748 assert!(looks_like_path("Tiltfile.dev"));
749 assert!(looks_like_path("file.rb"));
750 }
751
752 #[test]
753 fn looks_like_path_accepts_stdin_dash() {
754 assert!(looks_like_path("-"));
755 }
756
757 #[test]
758 fn looks_like_path_rejects_flag() {
759 assert!(!looks_like_path("--help"));
760 assert!(!looks_like_path("-x"));
761 }
762
763 #[test]
764 fn looks_like_path_rejects_bare_word() {
765 assert!(!looks_like_path("Tiltfile"));
766 assert!(!looks_like_path("up"));
767 }
768
769 #[test]
770 fn looks_like_path_rejects_empty() {
771 assert!(!looks_like_path(""));
772 }
773
774 #[test]
775 fn positional_shape_path_matches() {
776 assert!(PositionalShape::Path.matches("./file.rb"));
777 assert!(!PositionalShape::Path.matches("--flag"));
778 }
779
780 #[test]
781 fn positional_shape_from_name() {
782 assert_eq!(PositionalShape::from_name("path"), Some(PositionalShape::Path));
783 assert_eq!(PositionalShape::from_name("nope"), None);
784 }
785}