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]
352 fn standalone_long_flag() {
353 assert!(check(&toks(&["grep", "--recursive", "pattern", "."]), &TEST_POLICY));
354 }
355
356 #[test]
357 fn standalone_short_flag() {
358 assert!(check(&toks(&["grep", "-r", "pattern", "."]), &TEST_POLICY));
359 }
360
361 #[test]
362 fn valued_long_flag_space() {
363 assert!(check(&toks(&["grep", "--max-count", "5", "pattern"]), &TEST_POLICY));
364 }
365
366 #[test]
367 fn valued_long_flag_eq() {
368 assert!(check(&toks(&["grep", "--max-count=5", "pattern"]), &TEST_POLICY));
369 }
370
371 #[test]
372 fn valued_short_flag_space() {
373 assert!(check(&toks(&["grep", "-m", "5", "pattern"]), &TEST_POLICY));
374 }
375
376 #[test]
377 fn combined_standalone_short() {
378 assert!(check(&toks(&["grep", "-rn", "pattern", "."]), &TEST_POLICY));
379 }
380
381 #[test]
382 fn combined_short_with_valued_last() {
383 assert!(check(&toks(&["grep", "-rnm", "5", "pattern"]), &TEST_POLICY));
384 }
385
386 #[test]
387 fn combined_short_valued_mid_consumes_rest() {
388 assert!(check(&toks(&["grep", "-rmn", "pattern"]), &TEST_POLICY));
389 }
390
391 #[test]
392 fn unknown_long_flag_denied() {
393 assert!(!check(&toks(&["grep", "--exec", "cmd"]), &TEST_POLICY));
394 }
395
396 #[test]
397 fn unknown_short_flag_denied() {
398 assert!(!check(&toks(&["grep", "-z", "pattern"]), &TEST_POLICY));
399 }
400
401 #[test]
402 fn unknown_combined_short_denied() {
403 assert!(!check(&toks(&["grep", "-rz", "pattern"]), &TEST_POLICY));
404 }
405
406 #[test]
407 fn unknown_long_eq_denied() {
408 assert!(!check(&toks(&["grep", "--output=file.txt", "pattern"]), &TEST_POLICY));
409 }
410
411 #[test]
412 fn double_dash_stops_checking() {
413 assert!(check(&toks(&["grep", "--", "--not-a-flag", "file"]), &TEST_POLICY));
414 }
415
416 #[test]
417 fn positional_args_allowed() {
418 assert!(check(&toks(&["grep", "pattern", "file.txt", "other.txt"]), &TEST_POLICY));
419 }
420
421 #[test]
422 fn mixed_flags_and_positional() {
423 assert!(check(
424 &toks(&["grep", "-rn", "--color", "--max-count", "10", "pattern", "."]),
425 &TEST_POLICY,
426 ));
427 }
428
429 #[test]
430 fn valued_short_in_explicit_form() {
431 assert!(check(&toks(&["grep", "-A", "3", "-B", "3", "pattern"]), &TEST_POLICY));
432 }
433
434 #[test]
435 fn bare_dash_allowed_as_stdin() {
436 assert!(check(&toks(&["grep", "pattern", "-"]), &TEST_POLICY));
437 }
438
439 #[test]
440 fn valued_flag_at_end_without_value() {
441 assert!(check(&toks(&["grep", "--max-count"]), &TEST_POLICY));
442 }
443
444 #[test]
445 fn single_short_in_wordset_and_byte_array() {
446 assert!(check(&toks(&["grep", "-c", "pattern"]), &TEST_POLICY));
447 }
448
449 static SYNTAX_CHECK_POLICY: FlagPolicy = FlagPolicy {
450 standalone: WordSet::flags(&["--help", "-h"]),
451 valued: WordSet::flags(&["--check", "-c"]),
452 bare: false,
453 max_positional: Some(0),
454 tolerance: FlagTolerance::strict(),
455 };
456
457 #[test]
458 fn valued_flag_consumes_path_value() {
459 assert!(check(&toks(&["node", "--check", "app.js"]), &SYNTAX_CHECK_POLICY));
460 assert!(check(&toks(&["node", "-c", "app.js"]), &SYNTAX_CHECK_POLICY));
461 }
462
463 #[test]
464 fn valued_flag_does_not_swallow_following_long_option() {
465 assert!(!check(
466 &toks(&["node", "--check", "--require=./evil.js"]),
467 &SYNTAX_CHECK_POLICY,
468 ));
469 }
470
471 #[test]
472 fn valued_short_does_not_swallow_following_option() {
473 assert!(!check(&toks(&["node", "-c", "-r./evil.js"]), &SYNTAX_CHECK_POLICY));
474 }
475
476 #[test]
477 fn valued_flag_still_consumes_negative_number() {
478 let policy = FlagPolicy {
479 standalone: WordSet::flags(&[]),
480 valued: WordSet::flags(&["-n"]),
481 bare: false,
482 max_positional: Some(1),
483 tolerance: FlagTolerance::strict(),
484 };
485 assert!(check(&toks(&["head", "-n", "-5", "file"]), &policy));
486 }
487
488 static LIMITED_POLICY: FlagPolicy = FlagPolicy {
489 standalone: WordSet::flags(&["--count", "-c", "-d", "-i", "-u"]),
490 valued: WordSet::flags(&["--skip-fields", "-f", "-s"]),
491 bare: true,
492 max_positional: Some(1),
493 tolerance: FlagTolerance::strict(),
494 };
495
496 #[test]
497 fn max_positional_within_limit() {
498 assert!(check(&toks(&["uniq", "input.txt"]), &LIMITED_POLICY));
499 }
500
501 #[test]
502 fn max_positional_exceeded() {
503 assert!(!check(&toks(&["uniq", "input.txt", "output.txt"]), &LIMITED_POLICY));
504 }
505
506 #[test]
507 fn max_positional_with_flags_within_limit() {
508 assert!(check(&toks(&["uniq", "-c", "-f", "3", "input.txt"]), &LIMITED_POLICY));
509 }
510
511 #[test]
512 fn max_positional_with_flags_exceeded() {
513 assert!(!check(&toks(&["uniq", "-c", "input.txt", "output.txt"]), &LIMITED_POLICY));
514 }
515
516 #[test]
517 fn max_positional_after_double_dash() {
518 assert!(!check(&toks(&["uniq", "--", "input.txt", "output.txt"]), &LIMITED_POLICY));
519 }
520
521 #[test]
522 fn max_positional_bare_allowed() {
523 assert!(check(&toks(&["uniq"]), &LIMITED_POLICY));
524 }
525
526 static BOTH_TOLERANCES_POLICY: FlagPolicy = FlagPolicy {
527 standalone: WordSet::flags(&["-E", "-e", "-n"]),
528 valued: WordSet::flags(&[]),
529 bare: true,
530 max_positional: None,
531 tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
532 };
533
534 #[test]
535 fn both_tolerances_accept_unknown_long() {
536 assert!(check(&toks(&["echo", "--unknown", "hello"]), &BOTH_TOLERANCES_POLICY));
537 }
538
539 #[test]
540 fn both_tolerances_accept_unknown_short() {
541 assert!(check(&toks(&["echo", "-x", "hello"]), &BOTH_TOLERANCES_POLICY));
542 }
543
544 #[test]
545 fn both_tolerances_accept_triple_dash() {
546 assert!(check(&toks(&["echo", "---"]), &BOTH_TOLERANCES_POLICY));
547 }
548
549 #[test]
550 fn both_tolerances_known_flags_still_work() {
551 assert!(check(&toks(&["echo", "-n", "hello"]), &BOTH_TOLERANCES_POLICY));
552 }
553
554 #[test]
555 fn both_tolerances_combo_known_short() {
556 assert!(check(&toks(&["echo", "-ne", "hello"]), &BOTH_TOLERANCES_POLICY));
557 }
558
559 #[test]
560 fn both_tolerances_combo_unknown_short_byte() {
561 assert!(check(&toks(&["echo", "-nx", "hello"]), &BOTH_TOLERANCES_POLICY));
562 }
563
564 #[test]
565 fn both_tolerances_unknown_eq_form() {
566 assert!(check(&toks(&["echo", "--foo=bar"]), &BOTH_TOLERANCES_POLICY));
567 }
568
569 static SHORT_ONLY_POLICY: FlagPolicy = FlagPolicy {
576 standalone: WordSet::flags(&["--help"]),
577 valued: WordSet::flags(&[]),
578 bare: false,
579 max_positional: None,
580 tolerance: FlagTolerance { unknown: UnknownTolerance::Short, numeric_dash: false },
581 };
582
583 #[test]
584 fn short_only_accepts_unknown_dash_letter() {
585 assert!(check(&toks(&["sample", "-mayDie"]), &SHORT_ONLY_POLICY));
586 }
587
588 #[test]
589 fn short_only_accepts_single_dash_long_word() {
590 assert!(check(&toks(&["pdftotext", "-layout"]), &SHORT_ONLY_POLICY));
592 }
593
594 #[test]
595 fn short_only_denies_unknown_double_dash() {
596 assert!(!check(&toks(&["sample", "--evil-flag"]), &SHORT_ONLY_POLICY));
599 }
600
601 #[test]
602 fn short_only_denies_unknown_eq_form() {
603 assert!(!check(&toks(&["sample", "--evil=value"]), &SHORT_ONLY_POLICY));
604 }
605
606 #[test]
607 fn short_only_known_long_flag_still_works() {
608 assert!(check(&toks(&["sample", "--help"]), &SHORT_ONLY_POLICY));
609 }
610
611 static LONG_ONLY_POLICY: FlagPolicy = FlagPolicy {
617 standalone: WordSet::flags(&["--help"]),
618 valued: WordSet::flags(&[]),
619 bare: false,
620 max_positional: None,
621 tolerance: FlagTolerance { unknown: UnknownTolerance::Long, numeric_dash: false },
622 };
623
624 #[test]
625 fn long_only_accepts_unknown_double_dash() {
626 assert!(check(&toks(&["aws", "--some-aws-flag"]), &LONG_ONLY_POLICY));
627 }
628
629 #[test]
630 fn long_only_accepts_unknown_eq_form() {
631 assert!(check(
632 &toks(&["aws", "--filter=Name=tag,Values=foo"]),
633 &LONG_ONLY_POLICY,
634 ));
635 }
636
637 #[test]
638 fn long_only_denies_unknown_short_dash() {
639 assert!(!check(&toks(&["aws", "-x"]), &LONG_ONLY_POLICY));
640 }
641
642 static STRICT_POLICY: FlagPolicy = FlagPolicy {
645 standalone: WordSet::flags(&["--help"]),
646 valued: WordSet::flags(&[]),
647 bare: false,
648 max_positional: None,
649 tolerance: FlagTolerance::strict(),
650 };
651
652 #[test]
653 fn strict_denies_unknown_short() {
654 assert!(!check(&toks(&["foo", "-evil"]), &STRICT_POLICY));
655 }
656
657 #[test]
658 fn strict_denies_unknown_long() {
659 assert!(!check(&toks(&["foo", "--evil"]), &STRICT_POLICY));
660 }
661
662 #[test]
663 fn strict_known_flag_passes() {
664 assert!(check(&toks(&["foo", "--help"]), &STRICT_POLICY));
665 }
666
667 #[test]
668 fn both_tolerances_with_max_positional() {
669 let policy = FlagPolicy {
670 standalone: WordSet::flags(&["-n"]),
671 valued: WordSet::flags(&[]),
672 bare: true,
673 max_positional: Some(2),
674 tolerance: FlagTolerance { unknown: UnknownTolerance::Both, numeric_dash: false },
675 };
676 assert!(check(&toks(&["echo", "--unknown", "hello"]), &policy));
677 assert!(!check(&toks(&["echo", "--a", "--b", "--c"]), &policy));
678 }
679
680 static NUMERIC_DASH_POLICY: FlagPolicy = FlagPolicy {
681 standalone: WordSet::flags(&[
682 "--help", "--quiet", "--verbose", "--version",
683 "-V", "-h", "-q", "-v", "-z",
684 ]),
685 valued: WordSet::flags(&["--bytes", "--lines", "-c", "-n"]),
686 bare: true,
687 max_positional: None,
688 tolerance: FlagTolerance { numeric_dash: true, ..FlagTolerance::strict() },
689 };
690
691 #[test]
692 fn numeric_dash_single_digit() {
693 assert!(check(&toks(&["head", "-5"]), &NUMERIC_DASH_POLICY));
694 }
695
696 #[test]
697 fn numeric_dash_multi_digit() {
698 assert!(check(&toks(&["head", "-20"]), &NUMERIC_DASH_POLICY));
699 }
700
701 #[test]
702 fn numeric_dash_large_number() {
703 assert!(check(&toks(&["head", "-1000"]), &NUMERIC_DASH_POLICY));
704 }
705
706 #[test]
707 fn numeric_dash_with_file_arg() {
708 assert!(check(&toks(&["head", "-20", "file.txt"]), &NUMERIC_DASH_POLICY));
709 }
710
711 #[test]
712 fn numeric_dash_with_other_flags() {
713 assert!(check(&toks(&["head", "-q", "-20", "file.txt"]), &NUMERIC_DASH_POLICY));
714 }
715
716 #[test]
717 fn numeric_dash_zero() {
718 assert!(check(&toks(&["head", "-0"]), &NUMERIC_DASH_POLICY));
719 }
720
721 #[test]
722 fn numeric_dash_still_rejects_unknown_flags() {
723 assert!(!check(&toks(&["head", "-x"]), &NUMERIC_DASH_POLICY));
724 }
725
726 #[test]
727 fn numeric_dash_rejects_mixed_alpha_num() {
728 assert!(!check(&toks(&["head", "-20x"]), &NUMERIC_DASH_POLICY));
729 }
730
731 #[test]
732 fn numeric_dash_disabled_rejects_multi_digit() {
733 assert!(!check(&toks(&["grep", "-20", "pattern"]), &TEST_POLICY));
734 }
735
736 #[test]
737 fn looks_like_path_accepts_relative() {
738 assert!(looks_like_path("./Tiltfile"));
739 assert!(looks_like_path("path/to/file"));
740 }
741
742 #[test]
743 fn looks_like_path_accepts_dotted() {
744 assert!(looks_like_path("Tiltfile.dev"));
745 assert!(looks_like_path("file.rb"));
746 }
747
748 #[test]
749 fn looks_like_path_accepts_stdin_dash() {
750 assert!(looks_like_path("-"));
751 }
752
753 #[test]
754 fn looks_like_path_rejects_flag() {
755 assert!(!looks_like_path("--help"));
756 assert!(!looks_like_path("-x"));
757 }
758
759 #[test]
760 fn looks_like_path_rejects_bare_word() {
761 assert!(!looks_like_path("Tiltfile"));
762 assert!(!looks_like_path("up"));
763 }
764
765 #[test]
766 fn looks_like_path_rejects_empty() {
767 assert!(!looks_like_path(""));
768 }
769
770 #[test]
771 fn positional_shape_path_matches() {
772 assert!(PositionalShape::Path.matches("./file.rb"));
773 assert!(!PositionalShape::Path.matches("--flag"));
774 }
775
776 #[test]
777 fn positional_shape_from_name() {
778 assert_eq!(PositionalShape::from_name("path"), Some(PositionalShape::Path));
779 assert_eq!(PositionalShape::from_name("nope"), None);
780 }
781}