1use std::fmt;
26
27use snafu::{ResultExt, Snafu, ensure};
28
29use crate::path::segment::{ForbiddenCategory, forbidden_category};
30use crate::path::{HazPath, PathError};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum PathAnchor {
40 WorkspaceAbsolute,
42 ProjectRelative,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
53pub enum GlobSegmentError {
54 #[snafu(display("glob segment is empty"))]
56 Empty,
57
58 #[snafu(display("glob segment is the literal `.`"))]
60 Dot,
61
62 #[snafu(display("glob segment is the literal `..`"))]
64 DotDot,
65
66 #[snafu(display(
69 "glob segment contains forbidden {category} codepoint U+{:04X}",
70 u32::from(*c)
71 ))]
72 ContainsForbidden {
73 c: char,
75 category: ForbiddenCategory,
77 },
78
79 #[snafu(display("glob segment contains `**` adjacent to other characters"))]
83 AdjacentDoubleStar,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
88pub enum PathPatternError {
89 #[snafu(display("path pattern is empty"))]
91 EmptyInput,
92
93 #[snafu(display("path pattern contains the empty segment `//`"))]
96 EmptySegment,
97
98 #[snafu(display("workspace-absolute glob has no segments"))]
101 OnlySlash,
102
103 #[snafu(display("path pattern ends with trailing `/`"))]
105 TrailingSlash,
106
107 #[snafu(display("invalid literal path: {source}"))]
109 LiteralPath {
110 source: PathError,
112 },
113
114 #[snafu(display("invalid glob segment {segment:?} at position {position}: {source}"))]
116 InvalidGlobSegment {
117 segment: String,
119 position: usize,
121 source: GlobSegmentError,
123 },
124
125 #[snafu(display("nested alternation is forbidden"))]
131 NestedAlternation,
132
133 #[snafu(display("globset rejected pattern: {message}"))]
136 InvalidGlob {
137 message: String,
139 },
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Hash)]
149pub struct GlobPattern {
150 anchor: PathAnchor,
151 body: String,
152}
153
154impl GlobPattern {
155 #[must_use]
157 pub fn anchor(&self) -> PathAnchor {
158 self.anchor
159 }
160
161 #[must_use]
164 pub fn body(&self) -> &str {
165 &self.body
166 }
167
168 #[must_use]
181 pub fn compile(&self) -> globset::Glob {
182 let canonical = self.to_string();
183 globset::GlobBuilder::new(&canonical)
184 .literal_separator(true)
185 .case_insensitive(false)
186 .build()
187 .expect("validated at construction")
188 }
189}
190
191impl fmt::Display for GlobPattern {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 if matches!(self.anchor, PathAnchor::WorkspaceAbsolute) {
194 f.write_str("/")?;
195 }
196 f.write_str(&self.body)
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Hash)]
219pub enum PathPattern {
220 Literal(HazPath),
222 Glob(GlobPattern),
224}
225
226impl PathPattern {
227 pub fn parse(s: &str) -> Result<Self, PathPatternError> {
267 ensure!(!s.is_empty(), EmptyInputSnafu);
268
269 if !has_glob_metacharacter(s) {
270 return HazPath::parse(s)
271 .context(LiteralPathSnafu)
272 .map(PathPattern::Literal);
273 }
274
275 let (anchor, body) = if let Some(rest) = s.strip_prefix('/') {
276 (PathAnchor::WorkspaceAbsolute, rest)
277 } else {
278 (PathAnchor::ProjectRelative, s)
279 };
280
281 ensure!(!body.is_empty(), OnlySlashSnafu);
282 ensure!(!body.contains("//"), EmptySegmentSnafu);
283 ensure!(!body.ends_with('/'), TrailingSlashSnafu);
284
285 for (position, segment) in body.split('/').enumerate() {
286 validate_glob_segment(segment).context(InvalidGlobSegmentSnafu {
287 segment: segment.to_owned(),
288 position,
289 })?;
290 }
291
292 ensure!(!has_nested_alternation(body), NestedAlternationSnafu);
293
294 let canonical = match anchor {
295 PathAnchor::WorkspaceAbsolute => format!("/{body}"),
296 PathAnchor::ProjectRelative => body.to_owned(),
297 };
298 globset::GlobBuilder::new(&canonical)
299 .literal_separator(true)
300 .case_insensitive(false)
301 .build()
302 .map_err(|e| PathPatternError::InvalidGlob {
303 message: e.to_string(),
304 })?;
305
306 Ok(PathPattern::Glob(GlobPattern {
307 anchor,
308 body: body.to_owned(),
309 }))
310 }
311
312 #[must_use]
314 pub fn is_literal(&self) -> bool {
315 matches!(self, PathPattern::Literal(_))
316 }
317
318 #[must_use]
320 pub fn is_glob(&self) -> bool {
321 matches!(self, PathPattern::Glob(_))
322 }
323
324 #[must_use]
326 pub fn anchor(&self) -> PathAnchor {
327 match self {
328 PathPattern::Literal(p) => {
329 if p.is_workspace_absolute() {
330 PathAnchor::WorkspaceAbsolute
331 } else {
332 PathAnchor::ProjectRelative
333 }
334 }
335 PathPattern::Glob(g) => g.anchor(),
336 }
337 }
338}
339
340impl fmt::Display for PathPattern {
341 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342 match self {
343 PathPattern::Literal(p) => p.fmt(f),
344 PathPattern::Glob(g) => g.fmt(f),
345 }
346 }
347}
348
349fn has_glob_metacharacter(s: &str) -> bool {
350 s.chars().any(|c| matches!(c, '*' | '?' | '[' | '{' | '\\'))
351}
352
353fn has_nested_alternation(s: &str) -> bool {
357 let mut chars = s.chars();
358 let mut brace_depth: u32 = 0;
359 let mut in_class = false;
360 while let Some(c) = chars.next() {
361 match c {
362 '\\' => {
363 chars.next();
364 }
365 '[' if !in_class => in_class = true,
366 ']' if in_class => in_class = false,
367 '{' if !in_class => {
368 if brace_depth > 0 {
369 return true;
370 }
371 brace_depth += 1;
372 }
373 '}' if !in_class => {
374 brace_depth = brace_depth.saturating_sub(1);
375 }
376 _ => {}
377 }
378 }
379 false
380}
381
382fn validate_glob_segment(s: &str) -> Result<(), GlobSegmentError> {
383 if s == "**" {
384 return Ok(());
385 }
386 ensure!(!s.is_empty(), EmptySnafu);
387 ensure!(s != ".", DotSnafu);
388 ensure!(s != "..", DotDotSnafu);
389 ensure!(!s.contains("**"), AdjacentDoubleStarSnafu);
390
391 for c in s.chars() {
392 if let Some(category) = forbidden_category(c) {
393 return ContainsForbiddenSnafu { c, category }.fail();
394 }
395 }
396 Ok(())
397}
398
399#[cfg(test)]
400mod tests {
401 use crate::path::pattern::{
402 GlobSegmentError, PathAnchor, PathPattern, PathPatternError, has_glob_metacharacter,
403 };
404 use crate::path::segment::ForbiddenCategory;
405 use crate::path::{HazPath, PathError, SegmentError};
406
407 #[test]
410 fn path_008_classifies_literal_no_metacharacters() {
411 let p = PathPattern::parse("src/lib.rs").unwrap();
412 assert!(p.is_literal());
413 assert!(matches!(
414 p,
415 PathPattern::Literal(HazPath::ProjectRelative(_))
416 ));
417 }
418
419 #[test]
420 fn path_008_classifies_workspace_absolute_literal() {
421 let p = PathPattern::parse("/lib_core/src/main.rs").unwrap();
422 assert!(p.is_literal());
423 assert_eq!(p.anchor(), PathAnchor::WorkspaceAbsolute);
424 }
425
426 #[test]
427 fn path_008_classifies_glob_with_star() {
428 let p = PathPattern::parse("src/*.rs").unwrap();
429 assert!(p.is_glob());
430 }
431
432 #[test]
433 fn path_008_classifies_glob_with_question() {
434 let p = PathPattern::parse("src/lib?.rs").unwrap();
435 assert!(p.is_glob());
436 }
437
438 #[test]
439 fn path_008_classifies_glob_with_charclass() {
440 let p = PathPattern::parse("dist/[!_]a/index.html").unwrap();
441 assert!(p.is_glob());
442 }
443
444 #[test]
445 fn path_008_classifies_glob_with_alternation() {
446 let p = PathPattern::parse("assets/x.{png,jpg}").unwrap();
447 assert!(p.is_glob());
448 }
449
450 #[test]
451 fn path_008_classifies_escape_as_glob() {
452 let p = PathPattern::parse("with\\*literal.txt").unwrap();
456 assert!(p.is_glob());
457 }
458
459 #[test]
462 fn detection_recognises_all_metacharacters() {
463 assert!(!has_glob_metacharacter("src/lib.rs"));
464 assert!(has_glob_metacharacter("src/*.rs"));
465 assert!(has_glob_metacharacter("src/lib?.rs"));
466 assert!(has_glob_metacharacter("src/[a].rs"));
467 assert!(has_glob_metacharacter("src/{a,b}.rs"));
468 assert!(has_glob_metacharacter("with\\*.rs"));
469 }
470
471 #[test]
476 fn path_009_rejects_unclosed_charclass() {
477 assert!(matches!(
478 PathPattern::parse("src/[abc.rs"),
479 Err(PathPatternError::InvalidGlob { .. })
480 ));
481 }
482
483 #[test]
487 fn path_010_accepts_single_star_in_segment() {
488 assert!(PathPattern::parse("src/*.rs").is_ok());
489 }
490
491 #[test]
494 fn path_011_accepts_doublestar_as_complete_segment() {
495 assert!(PathPattern::parse("src/**/main.rs").is_ok());
496 assert!(PathPattern::parse("tests/**").is_ok());
497 assert!(PathPattern::parse("**/foo.rs").is_ok());
498 }
499
500 #[test]
501 fn path_011_rejects_doublestar_adjacent_to_other_chars() {
502 assert!(matches!(
503 PathPattern::parse("a**b/foo.rs"),
504 Err(PathPatternError::InvalidGlobSegment {
505 source: GlobSegmentError::AdjacentDoubleStar,
506 ..
507 })
508 ));
509 }
510
511 #[test]
512 fn path_011_rejects_triple_star_segment() {
513 assert!(matches!(
515 PathPattern::parse("foo/***/bar.rs"),
516 Err(PathPatternError::InvalidGlobSegment {
517 source: GlobSegmentError::AdjacentDoubleStar,
518 ..
519 })
520 ));
521 }
522
523 #[test]
527 fn path_012_accepts_question_mark() {
528 assert!(PathPattern::parse("file?.rs").is_ok());
529 }
530
531 #[test]
534 fn path_013_accepts_charclass() {
535 assert!(PathPattern::parse("dist/[abc]/index.html").is_ok());
536 }
537
538 #[test]
539 fn path_013_accepts_negated_charclass() {
540 assert!(PathPattern::parse("dist/[!_]a/index.html").is_ok());
541 }
542
543 #[test]
544 fn path_013_accepts_range_charclass() {
545 assert!(PathPattern::parse("v[0-9].txt").is_ok());
546 }
547
548 #[test]
551 fn path_014_accepts_flat_alternation() {
552 assert!(PathPattern::parse("assets/x.{png,jpg}").is_ok());
553 }
554
555 #[test]
556 fn path_014_rejects_nested_alternation() {
557 assert!(matches!(
558 PathPattern::parse("src/{a,{b,c}}/foo.rs"),
559 Err(PathPatternError::NestedAlternation)
560 ));
561 }
562
563 #[test]
564 fn path_014_rejects_deeper_nesting() {
565 assert!(matches!(
566 PathPattern::parse("a/{x,{y,{z,w}}}/b"),
567 Err(PathPatternError::NestedAlternation)
568 ));
569 }
570
571 #[test]
572 fn path_014_accepts_brace_in_charclass() {
573 assert!(PathPattern::parse("foo/[{abc]/bar").is_ok());
577 }
578
579 #[test]
580 fn path_014_accepts_escaped_brace_inside_alternation() {
581 assert!(PathPattern::parse("foo/{a,\\{lit\\}}.txt").is_ok());
583 }
584
585 #[test]
588 fn path_015_accepts_escaped_star() {
589 let p = PathPattern::parse("with\\*literal.txt").unwrap();
590 assert!(p.is_glob());
591 assert_eq!(p.to_string(), "with\\*literal.txt");
592 }
593
594 #[test]
595 fn path_015_accepts_escaped_brace() {
596 assert!(PathPattern::parse("opt\\{a\\}.txt").is_ok());
597 }
598
599 #[test]
602 fn path_002_glob_rejects_dot_segment() {
603 assert!(matches!(
604 PathPattern::parse("./foo*.rs"),
605 Err(PathPatternError::InvalidGlobSegment {
606 source: GlobSegmentError::Dot,
607 ..
608 })
609 ));
610 }
611
612 #[test]
613 fn path_002_glob_rejects_dotdot_segment() {
614 assert!(matches!(
615 PathPattern::parse("../foo*.rs"),
616 Err(PathPatternError::InvalidGlobSegment {
617 source: GlobSegmentError::DotDot,
618 ..
619 })
620 ));
621 }
622
623 #[test]
624 fn path_002_glob_rejects_zero_width_space_in_segment() {
625 assert!(matches!(
627 PathPattern::parse("src/foo\u{200B}*.rs"),
628 Err(PathPatternError::InvalidGlobSegment {
629 source: GlobSegmentError::ContainsForbidden {
630 category: ForbiddenCategory::Format,
631 ..
632 },
633 ..
634 })
635 ));
636 }
637
638 #[test]
639 fn path_002_glob_rejects_tab_in_segment() {
640 assert!(matches!(
641 PathPattern::parse("src/foo\t*.rs"),
642 Err(PathPatternError::InvalidGlobSegment {
643 source: GlobSegmentError::ContainsForbidden {
644 c: '\t',
645 category: ForbiddenCategory::Control,
646 },
647 ..
648 })
649 ));
650 }
651
652 #[test]
655 fn path_003_glob_rejects_double_slash() {
656 assert!(matches!(
657 PathPattern::parse("src//*.rs"),
658 Err(PathPatternError::EmptySegment)
659 ));
660 }
661
662 #[test]
663 fn path_003_glob_rejects_trailing_slash() {
664 assert!(matches!(
665 PathPattern::parse("src/*/"),
666 Err(PathPatternError::TrailingSlash)
667 ));
668 }
669
670 #[test]
671 fn path_003_glob_rejects_only_slash_workspace_absolute() {
672 assert!(matches!(
675 PathPattern::parse("/"),
676 Err(PathPatternError::LiteralPath {
677 source: PathError::OnlySlash,
678 })
679 ));
680 }
681
682 #[test]
683 fn pathpattern_rejects_empty_string() {
684 assert!(matches!(
685 PathPattern::parse(""),
686 Err(PathPatternError::EmptyInput)
687 ));
688 }
689
690 #[test]
693 fn literal_propagates_hazpath_dot_segment() {
694 assert!(matches!(
695 PathPattern::parse("./foo.rs"),
696 Err(PathPatternError::LiteralPath {
697 source: PathError::InvalidSegment {
698 source: SegmentError::Dot,
699 ..
700 },
701 })
702 ));
703 }
704
705 #[test]
710 fn path_016_case_sensitive_glob_equality() {
711 let upper = PathPattern::parse("Src/*.rs").unwrap();
712 let lower = PathPattern::parse("src/*.rs").unwrap();
713 assert_ne!(upper, lower);
714 }
715
716 #[test]
719 fn round_trip_literal_relative() {
720 let original = "src/lib.rs";
721 let p = PathPattern::parse(original).unwrap();
722 assert_eq!(p.to_string(), original);
723 assert_eq!(PathPattern::parse(&p.to_string()).unwrap(), p);
724 }
725
726 #[test]
727 fn round_trip_literal_absolute() {
728 let original = "/lib_core/src/main.rs";
729 let p = PathPattern::parse(original).unwrap();
730 assert_eq!(p.to_string(), original);
731 assert_eq!(PathPattern::parse(&p.to_string()).unwrap(), p);
732 }
733
734 #[test]
735 fn round_trip_glob_relative() {
736 let original = "src/**/*.rs";
737 let p = PathPattern::parse(original).unwrap();
738 assert_eq!(p.to_string(), original);
739 assert_eq!(PathPattern::parse(&p.to_string()).unwrap(), p);
740 }
741
742 #[test]
743 fn round_trip_glob_absolute() {
744 let original = "/lib_core/src/**/*.rs";
745 let p = PathPattern::parse(original).unwrap();
746 assert_eq!(p.to_string(), original);
747 assert_eq!(PathPattern::parse(&p.to_string()).unwrap(), p);
748 }
749
750 #[test]
753 fn glob_compile_matches_expected_paths() {
754 let p = PathPattern::parse("src/**/*.rs").unwrap();
755 let glob = match p {
756 PathPattern::Glob(ref g) => g.compile(),
757 PathPattern::Literal(_) => unreachable!(),
758 };
759 let matcher = glob.compile_matcher();
760 assert!(matcher.is_match("src/lib.rs"));
761 assert!(matcher.is_match("src/foo/bar.rs"));
762 assert!(!matcher.is_match("src/lib.txt"));
763 assert!(!matcher.is_match("other/lib.rs"));
764 }
765
766 #[test]
767 fn glob_star_does_not_cross_separator() {
768 let p = PathPattern::parse("src/*.rs").unwrap();
770 let glob = match p {
771 PathPattern::Glob(ref g) => g.compile(),
772 PathPattern::Literal(_) => unreachable!(),
773 };
774 let matcher = glob.compile_matcher();
775 assert!(matcher.is_match("src/lib.rs"));
776 assert!(!matcher.is_match("src/foo/bar.rs"));
777 }
778
779 use proptest::prelude::*;
782
783 fn segment_strategy() -> impl Strategy<Value = String> {
784 "[a-zA-Z0-9][a-zA-Z0-9._-]{0,15}"
785 }
786
787 proptest! {
788 #[test]
789 fn prop_literal_relative_round_trips(
790 segs in proptest::collection::vec(segment_strategy(), 1..=5)
791 ) {
792 let s = segs.join("/");
793 let parsed = PathPattern::parse(&s).unwrap();
794 prop_assert!(parsed.is_literal());
795 prop_assert_eq!(parsed.to_string(), s);
796 }
797
798 #[test]
799 fn prop_literal_absolute_round_trips(
800 segs in proptest::collection::vec(segment_strategy(), 1..=5)
801 ) {
802 let s = format!("/{}", segs.join("/"));
803 let parsed = PathPattern::parse(&s).unwrap();
804 prop_assert!(parsed.is_literal());
805 prop_assert_eq!(parsed.to_string(), s);
806 }
807
808 #[test]
809 fn prop_simple_star_glob_round_trips(
810 segs in proptest::collection::vec(segment_strategy(), 1..=4)
811 ) {
812 let s = format!("{}/*.rs", segs.join("/"));
813 let parsed = PathPattern::parse(&s).unwrap();
814 prop_assert!(parsed.is_glob());
815 prop_assert_eq!(parsed.to_string(), s);
816 }
817 }
818}