use std::fmt;
use snafu::{ResultExt, Snafu, ensure};
use crate::path::segment::{ForbiddenCategory, forbidden_category};
use crate::path::{HazPath, PathError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PathAnchor {
WorkspaceAbsolute,
ProjectRelative,
}
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum GlobSegmentError {
#[snafu(display("glob segment is empty"))]
Empty,
#[snafu(display("glob segment is the literal `.`"))]
Dot,
#[snafu(display("glob segment is the literal `..`"))]
DotDot,
#[snafu(display(
"glob segment contains forbidden {category} codepoint U+{:04X}",
u32::from(*c)
))]
ContainsForbidden {
c: char,
category: ForbiddenCategory,
},
#[snafu(display("glob segment contains `**` adjacent to other characters"))]
AdjacentDoubleStar,
}
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum PathPatternError {
#[snafu(display("path pattern is empty"))]
EmptyInput,
#[snafu(display("path pattern contains the empty segment `//`"))]
EmptySegment,
#[snafu(display("workspace-absolute glob has no segments"))]
OnlySlash,
#[snafu(display("path pattern ends with trailing `/`"))]
TrailingSlash,
#[snafu(display("invalid literal path: {source}"))]
LiteralPath {
source: PathError,
},
#[snafu(display("invalid glob segment {segment:?} at position {position}: {source}"))]
InvalidGlobSegment {
segment: String,
position: usize,
source: GlobSegmentError,
},
#[snafu(display("nested alternation is forbidden"))]
NestedAlternation,
#[snafu(display("globset rejected pattern: {message}"))]
InvalidGlob {
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GlobPattern {
anchor: PathAnchor,
body: String,
}
impl GlobPattern {
#[must_use]
pub fn anchor(&self) -> PathAnchor {
self.anchor
}
#[must_use]
pub fn body(&self) -> &str {
&self.body
}
#[must_use]
pub fn compile(&self) -> globset::Glob {
let canonical = self.to_string();
globset::GlobBuilder::new(&canonical)
.literal_separator(true)
.case_insensitive(false)
.build()
.expect("validated at construction")
}
}
impl fmt::Display for GlobPattern {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if matches!(self.anchor, PathAnchor::WorkspaceAbsolute) {
f.write_str("/")?;
}
f.write_str(&self.body)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PathPattern {
Literal(HazPath),
Glob(GlobPattern),
}
impl PathPattern {
pub fn parse(s: &str) -> Result<Self, PathPatternError> {
ensure!(!s.is_empty(), EmptyInputSnafu);
if !has_glob_metacharacter(s) {
return HazPath::parse(s)
.context(LiteralPathSnafu)
.map(PathPattern::Literal);
}
let (anchor, body) = if let Some(rest) = s.strip_prefix('/') {
(PathAnchor::WorkspaceAbsolute, rest)
} else {
(PathAnchor::ProjectRelative, s)
};
ensure!(!body.is_empty(), OnlySlashSnafu);
ensure!(!body.contains("//"), EmptySegmentSnafu);
ensure!(!body.ends_with('/'), TrailingSlashSnafu);
for (position, segment) in body.split('/').enumerate() {
validate_glob_segment(segment).context(InvalidGlobSegmentSnafu {
segment: segment.to_owned(),
position,
})?;
}
ensure!(!has_nested_alternation(body), NestedAlternationSnafu);
let canonical = match anchor {
PathAnchor::WorkspaceAbsolute => format!("/{body}"),
PathAnchor::ProjectRelative => body.to_owned(),
};
globset::GlobBuilder::new(&canonical)
.literal_separator(true)
.case_insensitive(false)
.build()
.map_err(|e| PathPatternError::InvalidGlob {
message: e.to_string(),
})?;
Ok(PathPattern::Glob(GlobPattern {
anchor,
body: body.to_owned(),
}))
}
#[must_use]
pub fn is_literal(&self) -> bool {
matches!(self, PathPattern::Literal(_))
}
#[must_use]
pub fn is_glob(&self) -> bool {
matches!(self, PathPattern::Glob(_))
}
#[must_use]
pub fn anchor(&self) -> PathAnchor {
match self {
PathPattern::Literal(p) => {
if p.is_workspace_absolute() {
PathAnchor::WorkspaceAbsolute
} else {
PathAnchor::ProjectRelative
}
}
PathPattern::Glob(g) => g.anchor(),
}
}
}
impl fmt::Display for PathPattern {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PathPattern::Literal(p) => p.fmt(f),
PathPattern::Glob(g) => g.fmt(f),
}
}
}
fn has_glob_metacharacter(s: &str) -> bool {
s.chars().any(|c| matches!(c, '*' | '?' | '[' | '{' | '\\'))
}
fn has_nested_alternation(s: &str) -> bool {
let mut chars = s.chars();
let mut brace_depth: u32 = 0;
let mut in_class = false;
while let Some(c) = chars.next() {
match c {
'\\' => {
chars.next();
}
'[' if !in_class => in_class = true,
']' if in_class => in_class = false,
'{' if !in_class => {
if brace_depth > 0 {
return true;
}
brace_depth += 1;
}
'}' if !in_class => {
brace_depth = brace_depth.saturating_sub(1);
}
_ => {}
}
}
false
}
fn validate_glob_segment(s: &str) -> Result<(), GlobSegmentError> {
if s == "**" {
return Ok(());
}
ensure!(!s.is_empty(), EmptySnafu);
ensure!(s != ".", DotSnafu);
ensure!(s != "..", DotDotSnafu);
ensure!(!s.contains("**"), AdjacentDoubleStarSnafu);
for c in s.chars() {
if let Some(category) = forbidden_category(c) {
return ContainsForbiddenSnafu { c, category }.fail();
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::path::pattern::{
GlobSegmentError, PathAnchor, PathPattern, PathPatternError, has_glob_metacharacter,
};
use crate::path::segment::ForbiddenCategory;
use crate::path::{HazPath, PathError, SegmentError};
#[test]
fn path_008_classifies_literal_no_metacharacters() {
let p = PathPattern::parse("src/lib.rs").unwrap();
assert!(p.is_literal());
assert!(matches!(
p,
PathPattern::Literal(HazPath::ProjectRelative(_))
));
}
#[test]
fn path_008_classifies_workspace_absolute_literal() {
let p = PathPattern::parse("/lib_core/src/main.rs").unwrap();
assert!(p.is_literal());
assert_eq!(p.anchor(), PathAnchor::WorkspaceAbsolute);
}
#[test]
fn path_008_classifies_glob_with_star() {
let p = PathPattern::parse("src/*.rs").unwrap();
assert!(p.is_glob());
}
#[test]
fn path_008_classifies_glob_with_question() {
let p = PathPattern::parse("src/lib?.rs").unwrap();
assert!(p.is_glob());
}
#[test]
fn path_008_classifies_glob_with_charclass() {
let p = PathPattern::parse("dist/[!_]a/index.html").unwrap();
assert!(p.is_glob());
}
#[test]
fn path_008_classifies_glob_with_alternation() {
let p = PathPattern::parse("assets/x.{png,jpg}").unwrap();
assert!(p.is_glob());
}
#[test]
fn path_008_classifies_escape_as_glob() {
let p = PathPattern::parse("with\\*literal.txt").unwrap();
assert!(p.is_glob());
}
#[test]
fn detection_recognises_all_metacharacters() {
assert!(!has_glob_metacharacter("src/lib.rs"));
assert!(has_glob_metacharacter("src/*.rs"));
assert!(has_glob_metacharacter("src/lib?.rs"));
assert!(has_glob_metacharacter("src/[a].rs"));
assert!(has_glob_metacharacter("src/{a,b}.rs"));
assert!(has_glob_metacharacter("with\\*.rs"));
}
#[test]
fn path_009_rejects_unclosed_charclass() {
assert!(matches!(
PathPattern::parse("src/[abc.rs"),
Err(PathPatternError::InvalidGlob { .. })
));
}
#[test]
fn path_010_accepts_single_star_in_segment() {
assert!(PathPattern::parse("src/*.rs").is_ok());
}
#[test]
fn path_011_accepts_doublestar_as_complete_segment() {
assert!(PathPattern::parse("src/**/main.rs").is_ok());
assert!(PathPattern::parse("tests/**").is_ok());
assert!(PathPattern::parse("**/foo.rs").is_ok());
}
#[test]
fn path_011_rejects_doublestar_adjacent_to_other_chars() {
assert!(matches!(
PathPattern::parse("a**b/foo.rs"),
Err(PathPatternError::InvalidGlobSegment {
source: GlobSegmentError::AdjacentDoubleStar,
..
})
));
}
#[test]
fn path_011_rejects_triple_star_segment() {
assert!(matches!(
PathPattern::parse("foo/***/bar.rs"),
Err(PathPatternError::InvalidGlobSegment {
source: GlobSegmentError::AdjacentDoubleStar,
..
})
));
}
#[test]
fn path_012_accepts_question_mark() {
assert!(PathPattern::parse("file?.rs").is_ok());
}
#[test]
fn path_013_accepts_charclass() {
assert!(PathPattern::parse("dist/[abc]/index.html").is_ok());
}
#[test]
fn path_013_accepts_negated_charclass() {
assert!(PathPattern::parse("dist/[!_]a/index.html").is_ok());
}
#[test]
fn path_013_accepts_range_charclass() {
assert!(PathPattern::parse("v[0-9].txt").is_ok());
}
#[test]
fn path_014_accepts_flat_alternation() {
assert!(PathPattern::parse("assets/x.{png,jpg}").is_ok());
}
#[test]
fn path_014_rejects_nested_alternation() {
assert!(matches!(
PathPattern::parse("src/{a,{b,c}}/foo.rs"),
Err(PathPatternError::NestedAlternation)
));
}
#[test]
fn path_014_rejects_deeper_nesting() {
assert!(matches!(
PathPattern::parse("a/{x,{y,{z,w}}}/b"),
Err(PathPatternError::NestedAlternation)
));
}
#[test]
fn path_014_accepts_brace_in_charclass() {
assert!(PathPattern::parse("foo/[{abc]/bar").is_ok());
}
#[test]
fn path_014_accepts_escaped_brace_inside_alternation() {
assert!(PathPattern::parse("foo/{a,\\{lit\\}}.txt").is_ok());
}
#[test]
fn path_015_accepts_escaped_star() {
let p = PathPattern::parse("with\\*literal.txt").unwrap();
assert!(p.is_glob());
assert_eq!(p.to_string(), "with\\*literal.txt");
}
#[test]
fn path_015_accepts_escaped_brace() {
assert!(PathPattern::parse("opt\\{a\\}.txt").is_ok());
}
#[test]
fn path_002_glob_rejects_dot_segment() {
assert!(matches!(
PathPattern::parse("./foo*.rs"),
Err(PathPatternError::InvalidGlobSegment {
source: GlobSegmentError::Dot,
..
})
));
}
#[test]
fn path_002_glob_rejects_dotdot_segment() {
assert!(matches!(
PathPattern::parse("../foo*.rs"),
Err(PathPatternError::InvalidGlobSegment {
source: GlobSegmentError::DotDot,
..
})
));
}
#[test]
fn path_002_glob_rejects_zero_width_space_in_segment() {
assert!(matches!(
PathPattern::parse("src/foo\u{200B}*.rs"),
Err(PathPatternError::InvalidGlobSegment {
source: GlobSegmentError::ContainsForbidden {
category: ForbiddenCategory::Format,
..
},
..
})
));
}
#[test]
fn path_002_glob_rejects_tab_in_segment() {
assert!(matches!(
PathPattern::parse("src/foo\t*.rs"),
Err(PathPatternError::InvalidGlobSegment {
source: GlobSegmentError::ContainsForbidden {
c: '\t',
category: ForbiddenCategory::Control,
},
..
})
));
}
#[test]
fn path_003_glob_rejects_double_slash() {
assert!(matches!(
PathPattern::parse("src//*.rs"),
Err(PathPatternError::EmptySegment)
));
}
#[test]
fn path_003_glob_rejects_trailing_slash() {
assert!(matches!(
PathPattern::parse("src/*/"),
Err(PathPatternError::TrailingSlash)
));
}
#[test]
fn path_003_glob_rejects_only_slash_workspace_absolute() {
assert!(matches!(
PathPattern::parse("/"),
Err(PathPatternError::LiteralPath {
source: PathError::OnlySlash,
})
));
}
#[test]
fn pathpattern_rejects_empty_string() {
assert!(matches!(
PathPattern::parse(""),
Err(PathPatternError::EmptyInput)
));
}
#[test]
fn literal_propagates_hazpath_dot_segment() {
assert!(matches!(
PathPattern::parse("./foo.rs"),
Err(PathPatternError::LiteralPath {
source: PathError::InvalidSegment {
source: SegmentError::Dot,
..
},
})
));
}
#[test]
fn path_016_case_sensitive_glob_equality() {
let upper = PathPattern::parse("Src/*.rs").unwrap();
let lower = PathPattern::parse("src/*.rs").unwrap();
assert_ne!(upper, lower);
}
#[test]
fn round_trip_literal_relative() {
let original = "src/lib.rs";
let p = PathPattern::parse(original).unwrap();
assert_eq!(p.to_string(), original);
assert_eq!(PathPattern::parse(&p.to_string()).unwrap(), p);
}
#[test]
fn round_trip_literal_absolute() {
let original = "/lib_core/src/main.rs";
let p = PathPattern::parse(original).unwrap();
assert_eq!(p.to_string(), original);
assert_eq!(PathPattern::parse(&p.to_string()).unwrap(), p);
}
#[test]
fn round_trip_glob_relative() {
let original = "src/**/*.rs";
let p = PathPattern::parse(original).unwrap();
assert_eq!(p.to_string(), original);
assert_eq!(PathPattern::parse(&p.to_string()).unwrap(), p);
}
#[test]
fn round_trip_glob_absolute() {
let original = "/lib_core/src/**/*.rs";
let p = PathPattern::parse(original).unwrap();
assert_eq!(p.to_string(), original);
assert_eq!(PathPattern::parse(&p.to_string()).unwrap(), p);
}
#[test]
fn glob_compile_matches_expected_paths() {
let p = PathPattern::parse("src/**/*.rs").unwrap();
let glob = match p {
PathPattern::Glob(ref g) => g.compile(),
PathPattern::Literal(_) => unreachable!(),
};
let matcher = glob.compile_matcher();
assert!(matcher.is_match("src/lib.rs"));
assert!(matcher.is_match("src/foo/bar.rs"));
assert!(!matcher.is_match("src/lib.txt"));
assert!(!matcher.is_match("other/lib.rs"));
}
#[test]
fn glob_star_does_not_cross_separator() {
let p = PathPattern::parse("src/*.rs").unwrap();
let glob = match p {
PathPattern::Glob(ref g) => g.compile(),
PathPattern::Literal(_) => unreachable!(),
};
let matcher = glob.compile_matcher();
assert!(matcher.is_match("src/lib.rs"));
assert!(!matcher.is_match("src/foo/bar.rs"));
}
use proptest::prelude::*;
fn segment_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9][a-zA-Z0-9._-]{0,15}"
}
proptest! {
#[test]
fn prop_literal_relative_round_trips(
segs in proptest::collection::vec(segment_strategy(), 1..=5)
) {
let s = segs.join("/");
let parsed = PathPattern::parse(&s).unwrap();
prop_assert!(parsed.is_literal());
prop_assert_eq!(parsed.to_string(), s);
}
#[test]
fn prop_literal_absolute_round_trips(
segs in proptest::collection::vec(segment_strategy(), 1..=5)
) {
let s = format!("/{}", segs.join("/"));
let parsed = PathPattern::parse(&s).unwrap();
prop_assert!(parsed.is_literal());
prop_assert_eq!(parsed.to_string(), s);
}
#[test]
fn prop_simple_star_glob_round_trips(
segs in proptest::collection::vec(segment_strategy(), 1..=4)
) {
let s = format!("{}/*.rs", segs.join("/"));
let parsed = PathPattern::parse(&s).unwrap();
prop_assert!(parsed.is_glob());
prop_assert_eq!(parsed.to_string(), s);
}
}
}