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