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) if crate::cst::opaque::is_flag_probe(t.as_str()) => true,
191 Some(t) => {
192 let b = t.as_bytes();
193 !(b.len() > 1 && b[0] == b'-' && !b[1].is_ascii_digit())
200 }
201 }
202}
203
204pub fn check_flags<S: FlagSet + ?Sized, V: FlagSet + ?Sized>(
205 tokens: &[Token],
206 standalone: &S,
207 valued: &V,
208 bare: bool,
209 max_positional: Option<usize>,
210 tolerance: FlagTolerance,
211) -> bool {
212 if tokens.len() == 1 {
213 return bare;
214 }
215
216 let mut i = 1;
217 let mut positionals: usize = 0;
218 while i < tokens.len() {
219 let t = &tokens[i];
220
221 if *t == "--" {
222 positionals += tokens.len() - i - 1;
223 break;
224 }
225
226 if !t.starts_with('-') {
227 positionals += 1;
228 i += 1;
229 continue;
230 }
231
232 if tolerance.numeric_dash && t.len() > 1 && t[1..].bytes().all(|b| b.is_ascii_digit()) {
233 i += 1;
234 continue;
235 }
236
237 if standalone.contains_flag(t) {
238 i += 1;
239 continue;
240 }
241
242 if valued.contains_flag(t) {
243 if consumes_next_value(tokens.get(i + 1)) {
244 i += 2;
245 } else {
246 i += 1;
247 }
248 continue;
249 }
250
251 if let Some(flag) = t.as_str().split_once('=').map(|(f, _)| f) {
252 if valued.contains_flag(flag) {
253 i += 1;
254 continue;
255 }
256 if tolerance.unknown.allows_long() {
258 positionals += 1;
259 i += 1;
260 continue;
261 }
262 return false;
263 }
264
265 if t.starts_with("--") {
266 if tolerance.unknown.allows_long() {
267 positionals += 1;
268 i += 1;
269 continue;
270 }
271 return false;
272 }
273
274 let bytes = t.as_bytes();
275 let mut j = 1;
276 while j < bytes.len() {
277 let b = bytes[j];
278 let is_last = j == bytes.len() - 1;
279 if standalone.contains_short(b) {
280 j += 1;
281 continue;
282 }
283 if valued.contains_short(b) {
284 if is_last && consumes_next_value(tokens.get(i + 1)) {
285 i += 1;
286 }
287 break;
288 }
289 if tolerance.unknown.allows_short() {
290 positionals += 1;
291 break;
292 }
293 return false;
294 }
295 i += 1;
296 }
297 max_positional.is_none_or(|max| positionals <= max)
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 static TEST_POLICY: FlagPolicy = FlagPolicy {
305 standalone: WordSet::flags(&[
306 "--color", "--count", "--help", "--recursive", "--version", "-H", "-c", "-i", "-l", "-n", "-o", "-r", "-s", "-v", "-w",
307 ]),
308 valued: WordSet::flags(&["--after-context", "--before-context", "--max-count", "-A", "-B", "-m"]),
309 bare: false,
310 max_positional: None,
311 tolerance: FlagTolerance::strict(),
312 };
313
314 fn toks(words: &[&str]) -> Vec<Token> {
315 words.iter().map(|s| Token::from_test(s)).collect()
316 }
317
318 #[test]
319 fn bare_denied_when_bare_false() {
320 assert!(!check(&toks(&["grep"]), &TEST_POLICY));
321 }
322
323 #[test]
324 fn bare_allowed_when_bare_true() {
325 let policy = FlagPolicy {
326 standalone: WordSet::flags(&[]),
327 valued: WordSet::flags(&[]),
328 bare: true,
329 max_positional: None,
330 tolerance: FlagTolerance::strict(),
331 };
332 assert!(check(&toks(&["uname"]), &policy));
333 }
334
335 #[test]
348 fn a_flag_declared_in_both_lists_already_takes_an_optional_value() {
349 static BOTH: FlagPolicy = FlagPolicy {
350 standalone: WordSet::flags(&["--long", "-r"]),
351 valued: WordSet::flags(&["--long", "-r"]),
352 bare: true,
353 max_positional: Some(0),
354 tolerance: FlagTolerance::strict(),
355 };
356 assert!(check(&toks(&["zstd", "--long"]), &BOTH), "bare long form");
357 assert!(check(&toks(&["zstd", "--long=27"]), &BOTH), "glued long form");
358 assert!(check(&toks(&["zstd", "-r"]), &BOTH), "bare short form");
359
360 assert!(!check(&toks(&["zstd", "--long", "somefile"]), &BOTH), "the bare form must not consume the following token as its value");
363
364 assert!(!check(&toks(&["7z", "-r0"]), &BOTH), "short-glued is NOT handled today");
366 }
367
368 #[test]
369 fn standalone_long_flag() {
370 assert!(check(&toks(&["grep", "--recursive", "pattern", "."]), &TEST_POLICY));
371 }
372
373 #[test]
374 fn standalone_short_flag() {
375 assert!(check(&toks(&["grep", "-r", "pattern", "."]), &TEST_POLICY));
376 }
377
378 #[test]
379 fn valued_long_flag_space() {
380 assert!(check(&toks(&["grep", "--max-count", "5", "pattern"]), &TEST_POLICY));
381 }
382
383 #[test]
384 fn valued_long_flag_eq() {
385 assert!(check(&toks(&["grep", "--max-count=5", "pattern"]), &TEST_POLICY));
386 }
387
388 #[test]
389 fn valued_short_flag_space() {
390 assert!(check(&toks(&["grep", "-m", "5", "pattern"]), &TEST_POLICY));
391 }
392
393 #[test]
394 fn combined_standalone_short() {
395 assert!(check(&toks(&["grep", "-rn", "pattern", "."]), &TEST_POLICY));
396 }
397
398 #[test]
399 fn combined_short_with_valued_last() {
400 assert!(check(&toks(&["grep", "-rnm", "5", "pattern"]), &TEST_POLICY));
401 }
402
403 #[test]
404 fn combined_short_valued_mid_consumes_rest() {
405 assert!(check(&toks(&["grep", "-rmn", "pattern"]), &TEST_POLICY));
406 }
407
408 #[test]
409 fn unknown_long_flag_denied() {
410 assert!(!check(&toks(&["grep", "--exec", "cmd"]), &TEST_POLICY));
411 }
412
413 #[test]
414 fn unknown_short_flag_denied() {
415 assert!(!check(&toks(&["grep", "-z", "pattern"]), &TEST_POLICY));
416 }
417
418 #[test]
419 fn unknown_combined_short_denied() {
420 assert!(!check(&toks(&["grep", "-rz", "pattern"]), &TEST_POLICY));
421 }
422
423 #[test]
424 fn unknown_long_eq_denied() {
425 assert!(!check(&toks(&["grep", "--output=file.txt", "pattern"]), &TEST_POLICY));
426 }
427
428 #[test]
429 fn double_dash_stops_checking() {
430 assert!(check(&toks(&["grep", "--", "--not-a-flag", "file"]), &TEST_POLICY));
431 }
432
433 #[test]
434 fn positional_args_allowed() {
435 assert!(check(&toks(&["grep", "pattern", "file.txt", "other.txt"]), &TEST_POLICY));
436 }
437
438 #[test]
439 fn mixed_flags_and_positional() {
440 assert!(check(&toks(&["grep", "-rn", "--color", "--max-count", "10", "pattern", "."]), &TEST_POLICY,));
441 }
442
443 #[test]
444 fn valued_short_in_explicit_form() {
445 assert!(check(&toks(&["grep", "-A", "3", "-B", "3", "pattern"]), &TEST_POLICY));
446 }
447
448 #[test]
449 fn bare_dash_allowed_as_stdin() {
450 assert!(check(&toks(&["grep", "pattern", "-"]), &TEST_POLICY));
451 }
452
453 #[test]
454 fn valued_flag_at_end_without_value() {
455 assert!(check(&toks(&["grep", "--max-count"]), &TEST_POLICY));
456 }
457
458 #[test]
459 fn single_short_in_wordset_and_byte_array() {
460 assert!(check(&toks(&["grep", "-c", "pattern"]), &TEST_POLICY));
461 }
462
463 static SYNTAX_CHECK_POLICY: FlagPolicy = FlagPolicy {
464 standalone: WordSet::flags(&["--help", "-h"]),
465 valued: WordSet::flags(&["--check", "-c"]),
466 bare: false,
467 max_positional: Some(0),
468 tolerance: FlagTolerance::strict(),
469 };
470
471 #[test]
472 fn valued_flag_consumes_path_value() {
473 assert!(check(&toks(&["node", "--check", "app.js"]), &SYNTAX_CHECK_POLICY));
474 assert!(check(&toks(&["node", "-c", "app.js"]), &SYNTAX_CHECK_POLICY));
475 }
476
477 #[test]
478 fn valued_flag_does_not_swallow_following_long_option() {
479 assert!(!check(&toks(&["node", "--check", "--require=./evil.js"]), &SYNTAX_CHECK_POLICY,));
480 }
481
482 #[test]
483 fn valued_short_does_not_swallow_following_option() {
484 assert!(!check(&toks(&["node", "-c", "-r./evil.js"]), &SYNTAX_CHECK_POLICY));
485 }
486
487 #[test]
488 fn valued_flag_still_consumes_negative_number() {
489 let policy = FlagPolicy {
490 standalone: WordSet::flags(&[]),
491 valued: WordSet::flags(&["-n"]),
492 bare: false,
493 max_positional: Some(1),
494 tolerance: FlagTolerance::strict(),
495 };
496 assert!(check(&toks(&["head", "-n", "-5", "file"]), &policy));
497 }
498
499 static LIMITED_POLICY: FlagPolicy = FlagPolicy {
500 standalone: WordSet::flags(&["--count", "-c", "-d", "-i", "-u"]),
501 valued: WordSet::flags(&["--skip-fields", "-f", "-s"]),
502 bare: true,
503 max_positional: Some(1),
504 tolerance: FlagTolerance::strict(),
505 };
506
507 #[test]
508 fn max_positional_within_limit() {
509 assert!(check(&toks(&["uniq", "input.txt"]), &LIMITED_POLICY));
510 }
511
512 #[test]
513 fn max_positional_exceeded() {
514 assert!(!check(&toks(&["uniq", "input.txt", "output.txt"]), &LIMITED_POLICY));
515 }
516
517 #[test]
518 fn max_positional_with_flags_within_limit() {
519 assert!(check(&toks(&["uniq", "-c", "-f", "3", "input.txt"]), &LIMITED_POLICY));
520 }
521
522 #[test]
523 fn max_positional_with_flags_exceeded() {
524 assert!(!check(&toks(&["uniq", "-c", "input.txt", "output.txt"]), &LIMITED_POLICY));
525 }
526
527 #[test]
528 fn max_positional_after_double_dash() {
529 assert!(!check(&toks(&["uniq", "--", "input.txt", "output.txt"]), &LIMITED_POLICY));
530 }
531
532 #[test]
533 fn max_positional_bare_allowed() {
534 assert!(check(&toks(&["uniq"]), &LIMITED_POLICY));
535 }
536
537 static BOTH_TOLERANCES_POLICY: FlagPolicy = FlagPolicy {
538 standalone: WordSet::flags(&["-E", "-e", "-n"]),
539 valued: WordSet::flags(&[]),
540 bare: true,
541 max_positional: None,
542 tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
543 };
544
545 #[test]
546 fn both_tolerances_accept_unknown_long() {
547 assert!(check(&toks(&["echo", "--unknown", "hello"]), &BOTH_TOLERANCES_POLICY));
548 }
549
550 #[test]
551 fn both_tolerances_accept_unknown_short() {
552 assert!(check(&toks(&["echo", "-x", "hello"]), &BOTH_TOLERANCES_POLICY));
553 }
554
555 #[test]
556 fn both_tolerances_accept_triple_dash() {
557 assert!(check(&toks(&["echo", "---"]), &BOTH_TOLERANCES_POLICY));
558 }
559
560 #[test]
561 fn both_tolerances_known_flags_still_work() {
562 assert!(check(&toks(&["echo", "-n", "hello"]), &BOTH_TOLERANCES_POLICY));
563 }
564
565 #[test]
566 fn both_tolerances_combo_known_short() {
567 assert!(check(&toks(&["echo", "-ne", "hello"]), &BOTH_TOLERANCES_POLICY));
568 }
569
570 #[test]
571 fn both_tolerances_combo_unknown_short_byte() {
572 assert!(check(&toks(&["echo", "-nx", "hello"]), &BOTH_TOLERANCES_POLICY));
573 }
574
575 #[test]
576 fn both_tolerances_unknown_eq_form() {
577 assert!(check(&toks(&["echo", "--foo=bar"]), &BOTH_TOLERANCES_POLICY));
578 }
579
580 static SHORT_ONLY_POLICY: FlagPolicy = FlagPolicy {
587 standalone: WordSet::flags(&["--help"]),
588 valued: WordSet::flags(&[]),
589 bare: false,
590 max_positional: None,
591 tolerance: FlagTolerance { unknown: UnknownTolerance::Short, numeric_dash: false },
592 };
593
594 #[test]
595 fn short_only_accepts_unknown_dash_letter() {
596 assert!(check(&toks(&["sample", "-mayDie"]), &SHORT_ONLY_POLICY));
597 }
598
599 #[test]
600 fn short_only_accepts_single_dash_long_word() {
601 assert!(check(&toks(&["pdftotext", "-layout"]), &SHORT_ONLY_POLICY));
603 }
604
605 #[test]
606 fn short_only_denies_unknown_double_dash() {
607 assert!(!check(&toks(&["sample", "--evil-flag"]), &SHORT_ONLY_POLICY));
610 }
611
612 #[test]
613 fn short_only_denies_unknown_eq_form() {
614 assert!(!check(&toks(&["sample", "--evil=value"]), &SHORT_ONLY_POLICY));
615 }
616
617 #[test]
618 fn short_only_known_long_flag_still_works() {
619 assert!(check(&toks(&["sample", "--help"]), &SHORT_ONLY_POLICY));
620 }
621
622 static LONG_ONLY_POLICY: FlagPolicy = FlagPolicy {
628 standalone: WordSet::flags(&["--help"]),
629 valued: WordSet::flags(&[]),
630 bare: false,
631 max_positional: None,
632 tolerance: FlagTolerance { unknown: UnknownTolerance::Long, numeric_dash: false },
633 };
634
635 #[test]
636 fn long_only_accepts_unknown_double_dash() {
637 assert!(check(&toks(&["aws", "--some-aws-flag"]), &LONG_ONLY_POLICY));
638 }
639
640 #[test]
641 fn long_only_accepts_unknown_eq_form() {
642 assert!(check(&toks(&["aws", "--filter=Name=tag,Values=foo"]), &LONG_ONLY_POLICY,));
643 }
644
645 #[test]
646 fn long_only_denies_unknown_short_dash() {
647 assert!(!check(&toks(&["aws", "-x"]), &LONG_ONLY_POLICY));
648 }
649
650 static STRICT_POLICY: FlagPolicy = FlagPolicy {
653 standalone: WordSet::flags(&["--help"]),
654 valued: WordSet::flags(&[]),
655 bare: false,
656 max_positional: None,
657 tolerance: FlagTolerance::strict(),
658 };
659
660 #[test]
661 fn strict_denies_unknown_short() {
662 assert!(!check(&toks(&["foo", "-evil"]), &STRICT_POLICY));
663 }
664
665 #[test]
666 fn strict_denies_unknown_long() {
667 assert!(!check(&toks(&["foo", "--evil"]), &STRICT_POLICY));
668 }
669
670 #[test]
671 fn strict_known_flag_passes() {
672 assert!(check(&toks(&["foo", "--help"]), &STRICT_POLICY));
673 }
674
675 #[test]
676 fn both_tolerances_with_max_positional() {
677 let policy = FlagPolicy {
678 standalone: WordSet::flags(&["-n"]),
679 valued: WordSet::flags(&[]),
680 bare: true,
681 max_positional: Some(2),
682 tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
683 };
684 assert!(check(&toks(&["echo", "--unknown", "hello"]), &policy));
685 assert!(!check(&toks(&["echo", "--a", "--b", "--c"]), &policy));
686 }
687
688 static NUMERIC_DASH_POLICY: FlagPolicy = FlagPolicy {
689 standalone: WordSet::flags(&["--help", "--quiet", "--verbose", "--version", "-V", "-h", "-q", "-v", "-z"]),
690 valued: WordSet::flags(&["--bytes", "--lines", "-c", "-n"]),
691 bare: true,
692 max_positional: None,
693 tolerance: FlagTolerance { numeric_dash: true, ..FlagTolerance::strict() },
694 };
695
696 #[test]
697 fn numeric_dash_single_digit() {
698 assert!(check(&toks(&["head", "-5"]), &NUMERIC_DASH_POLICY));
699 }
700
701 #[test]
702 fn numeric_dash_multi_digit() {
703 assert!(check(&toks(&["head", "-20"]), &NUMERIC_DASH_POLICY));
704 }
705
706 #[test]
707 fn numeric_dash_large_number() {
708 assert!(check(&toks(&["head", "-1000"]), &NUMERIC_DASH_POLICY));
709 }
710
711 #[test]
712 fn numeric_dash_with_file_arg() {
713 assert!(check(&toks(&["head", "-20", "file.txt"]), &NUMERIC_DASH_POLICY));
714 }
715
716 #[test]
717 fn numeric_dash_with_other_flags() {
718 assert!(check(&toks(&["head", "-q", "-20", "file.txt"]), &NUMERIC_DASH_POLICY));
719 }
720
721 #[test]
722 fn numeric_dash_zero() {
723 assert!(check(&toks(&["head", "-0"]), &NUMERIC_DASH_POLICY));
724 }
725
726 #[test]
727 fn numeric_dash_still_rejects_unknown_flags() {
728 assert!(!check(&toks(&["head", "-x"]), &NUMERIC_DASH_POLICY));
729 }
730
731 #[test]
732 fn numeric_dash_rejects_mixed_alpha_num() {
733 assert!(!check(&toks(&["head", "-20x"]), &NUMERIC_DASH_POLICY));
734 }
735
736 #[test]
737 fn numeric_dash_disabled_rejects_multi_digit() {
738 assert!(!check(&toks(&["grep", "-20", "pattern"]), &TEST_POLICY));
739 }
740
741 #[test]
742 fn looks_like_path_accepts_relative() {
743 assert!(looks_like_path("./Tiltfile"));
744 assert!(looks_like_path("path/to/file"));
745 }
746
747 #[test]
748 fn looks_like_path_accepts_dotted() {
749 assert!(looks_like_path("Tiltfile.dev"));
750 assert!(looks_like_path("file.rb"));
751 }
752
753 #[test]
754 fn looks_like_path_accepts_stdin_dash() {
755 assert!(looks_like_path("-"));
756 }
757
758 #[test]
759 fn looks_like_path_rejects_flag() {
760 assert!(!looks_like_path("--help"));
761 assert!(!looks_like_path("-x"));
762 }
763
764 #[test]
765 fn looks_like_path_rejects_bare_word() {
766 assert!(!looks_like_path("Tiltfile"));
767 assert!(!looks_like_path("up"));
768 }
769
770 #[test]
771 fn looks_like_path_rejects_empty() {
772 assert!(!looks_like_path(""));
773 }
774
775 #[test]
776 fn positional_shape_path_matches() {
777 assert!(PositionalShape::Path.matches("./file.rb"));
778 assert!(!PositionalShape::Path.matches("--flag"));
779 }
780
781 #[test]
782 fn positional_shape_from_name() {
783 assert_eq!(PositionalShape::from_name("path"), Some(PositionalShape::Path));
784 assert_eq!(PositionalShape::from_name("nope"), None);
785 }
786}