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