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