use ferralk_glob::PatternError;
use crate::first_metacharacter;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Rewrite {
Relative,
Rooted(Vec<u8>),
Outside,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Syntax {
Posix,
Windows,
}
impl Syntax {
pub(crate) const NATIVE: Self = if cfg!(windows) {
Self::Windows
} else {
Self::Posix
};
}
const FORBIDDEN_IN_A_WINDOWS_NAME: [u8; 8] = *b"\\:*?\"<>|";
pub(crate) fn reject_path_shaped(pattern: &[u8], syntax: Syntax) -> Result<(), PatternError> {
if syntax != Syntax::Windows {
return Ok(());
}
if let Some(offset) = drive_prefix_with_backslash(pattern) {
return Err(PatternError::new(offset, PATH_SHAPED));
}
if let Some(offset) = escape_before_forbidden_byte(pattern) {
return Err(PatternError::new(offset, PATH_SHAPED));
}
Ok(())
}
fn escape_before_forbidden_byte(pattern: &[u8]) -> Option<usize> {
let mut index = 0;
while index < pattern.len() {
if pattern[index] == b'\\' {
let escaped = *pattern.get(index + 1)?;
if FORBIDDEN_IN_A_WINDOWS_NAME.contains(&escaped) {
return Some(index);
}
index += 2;
continue;
}
if opens_a_group(pattern, index) {
return None;
}
index += 1;
}
None
}
fn opens_a_group(pattern: &[u8], index: usize) -> bool {
match pattern[index] {
b'[' | b'{' => true,
b'@' | b'+' | b'!' | b'*' | b'?' => pattern.get(index + 1) == Some(&b'('),
_ => false,
}
}
const PATH_SHAPED: &str = "this looks like a Windows path rather than a pattern; write patterns with `/` separators, \
because `\\` is the escape character on every platform";
fn drive_prefix_with_backslash(pattern: &[u8]) -> Option<usize> {
(pattern.len() >= 3
&& pattern[0].is_ascii_alphabetic()
&& pattern[1] == b':'
&& pattern[2] == b'\\')
.then_some(0)
}
pub(crate) fn rewrite_in(
pattern: &[u8],
root: &[u8],
syntax: Syntax,
) -> Result<Rewrite, PatternError> {
let Some(pattern_prefix) = absolute_prefix(pattern, syntax) else {
return Ok(Rewrite::Relative);
};
let Some(root_prefix) = absolute_prefix(root, syntax) else {
return Err(PatternError::new(
0,
"an absolute pattern needs an absolute walk root",
));
};
if !roots_agree(&pattern[..pattern_prefix], &root[..root_prefix]) {
return Ok(Rewrite::Outside);
}
if let Some(at) = dot_dot_component(pattern) {
return Err(PatternError::new(
at,
"`..` in an absolute pattern is not resolved, because resolving it lexically would be wrong across a symlink",
));
}
let magic = first_metacharacter(pattern);
let mut offset = pattern_prefix;
for root_component in components(&root[root_prefix..]) {
if root_component == b".." {
return Err(PatternError::new(
0,
"an absolute pattern needs a walk root without a `..` component",
));
}
loop {
let Some((component, next)) = next_component(pattern, offset) else {
return Ok(Rewrite::Outside);
};
if component.is_empty() || component == b"." {
offset = next;
continue;
}
if magic.is_some_and(|at| at < offset + component.len()) {
return Err(PatternError::new(
offset,
"a wildcard at or above the walk root cannot be made relative to it",
));
}
if component != root_component {
return Ok(Rewrite::Outside);
}
offset = next;
break;
}
}
while let Some((component, next)) = next_component(pattern, offset) {
if !component.is_empty() && component != b"." {
break;
}
offset = next;
}
let remainder = &pattern[offset..];
if remainder.is_empty() {
return Err(PatternError::new(
offset,
"an absolute pattern that names the walk root itself selects nothing; add `/**` to select what is inside it",
));
}
Ok(Rewrite::Rooted(remainder.to_vec()))
}
fn absolute_prefix(bytes: &[u8], syntax: Syntax) -> Option<usize> {
match syntax {
Syntax::Posix => bytes.starts_with(b"/").then_some(1),
Syntax::Windows => {
if bytes.starts_with(b"//") {
return Some(2);
}
let drive = bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& bytes[2] == b'/';
drive.then_some(3)
}
}
}
fn roots_agree(pattern_prefix: &[u8], root_prefix: &[u8]) -> bool {
pattern_prefix.eq_ignore_ascii_case(root_prefix)
}
fn components(bytes: &[u8]) -> impl Iterator<Item = &[u8]> {
bytes
.split(|byte| *byte == b'/')
.filter(|component| !component.is_empty() && *component != b".")
}
fn next_component(pattern: &[u8], offset: usize) -> Option<(&[u8], usize)> {
if offset >= pattern.len() {
return None;
}
let rest = &pattern[offset..];
match rest.iter().position(|byte| *byte == b'/') {
Some(separator) => Some((&rest[..separator], offset + separator + 1)),
None => Some((rest, pattern.len())),
}
}
fn dot_dot_component(pattern: &[u8]) -> Option<usize> {
let mut offset = 0;
while let Some((component, next)) = next_component(pattern, offset) {
if component == b".." {
return Some(offset);
}
offset = next;
}
None
}
#[cfg(test)]
mod tests {
use super::{Rewrite, Syntax, rewrite_in};
fn rooted(pattern: &str, root: &str, syntax: Syntax) -> String {
match rewrite_in(pattern.as_bytes(), root.as_bytes(), syntax) {
Ok(Rewrite::Rooted(bytes)) => String::from_utf8(bytes).expect("ASCII fixture"),
other => panic!("expected a rewrite, got {other:?}"),
}
}
fn verdict(pattern: &str, root: &str, syntax: Syntax) -> Rewrite {
rewrite_in(pattern.as_bytes(), root.as_bytes(), syntax).expect("no error expected")
}
fn message(pattern: &str, root: &str, syntax: Syntax) -> &'static str {
rewrite_in(pattern.as_bytes(), root.as_bytes(), syntax)
.expect_err("an error was expected")
.message()
}
fn rejection(pattern: &str, syntax: Syntax) -> Option<&'static str> {
super::reject_path_shaped(pattern.as_bytes(), syntax)
.err()
.map(|error| error.message())
}
#[test]
fn a_windows_path_spelled_as_a_pattern_is_refused() {
for pattern in [
r"C:\repo\src\**\*.ts",
r"C:\repo\node_modules",
r"src\*.ts",
r"src\**\*.ts",
r"\\server\share\src\*.ts",
r"\\?\C:\repo\*.ts",
] {
assert!(
rejection(pattern, Syntax::Windows)
.is_some_and(|message| message.starts_with("this looks like a Windows path")),
"{pattern} must be refused on Windows"
);
assert_eq!(
rejection(pattern, Syntax::Posix),
None,
"{pattern} on POSIX"
);
}
}
#[test]
fn a_pattern_that_could_match_on_windows_is_kept() {
for pattern in [
"src/**/*.ts",
"C:/repo/src/**/*.ts",
"//server/share/src/*.ts",
"{src,lib}/**/*.ts",
r"a\b\c",
r"notes\[1\].txt",
r"literal\{braces\}.txt",
"C:/repo",
] {
assert_eq!(rejection(pattern, Syntax::Windows), None, "{pattern}");
assert_eq!(rejection(pattern, Syntax::Posix), None, "{pattern}");
}
}
#[test]
fn an_escape_inside_a_group_is_not_a_path_separator() {
for pattern in [
r"[a\*]",
r"src/[a\*].ts",
r"{a,\*}",
r"src/{a,\*}.ts",
r"@(a|\*)",
r"+(a|\*)",
r"!(a|\*)",
r"*(a|\*)",
r"?(a|\*)",
r"{a\*b}",
] {
assert_eq!(
rejection(pattern, Syntax::Windows),
None,
"{pattern} can match, so it must not be refused"
);
}
}
#[test]
fn narrowing_the_scan_keeps_the_shapes_it_was_built_for() {
for pattern in [
r"C:\repo\*.ts",
r"src\*.ts",
r"C:\repo\src\**\*.ts",
r"\\server\share\src\*.ts",
] {
assert!(
rejection(pattern, Syntax::Windows).is_some(),
"{pattern} can never match on Windows and must be refused"
);
}
}
#[test]
fn a_trailing_backslash_is_left_to_the_compiler() {
assert_eq!(rejection(r"src\", Syntax::Windows), None);
}
#[test]
fn a_pattern_under_the_root_loses_exactly_the_root() {
assert_eq!(rooted("/a/b/src/*.ts", "/a/b", Syntax::Posix), "src/*.ts");
assert_eq!(rooted("/a/b/**/*.ts", "/a/b", Syntax::Posix), "**/*.ts");
assert_eq!(rooted("/a/b/**", "/a/b", Syntax::Posix), "**");
assert_eq!(
rooted("/a/b/{src,lib}/**/*.ts", "/a/b", Syntax::Posix),
"{src,lib}/**/*.ts"
);
assert_eq!(rooted("/a/b/build/", "/a/b", Syntax::Posix), "build/");
}
#[test]
fn separator_noise_on_either_side_is_ignored() {
assert_eq!(rooted("/a//b/src/*.ts", "/a/b", Syntax::Posix), "src/*.ts");
assert_eq!(rooted("/a/./b/src/*.ts", "/a/b", Syntax::Posix), "src/*.ts");
assert_eq!(rooted("/a/b/src/*.ts", "/a/b/", Syntax::Posix), "src/*.ts");
assert_eq!(
rooted("/a/b/src/*.ts", "/a//b//", Syntax::Posix),
"src/*.ts"
);
assert_eq!(rooted("/a/b//src/*.ts", "/a/b", Syntax::Posix), "src/*.ts");
assert_eq!(rooted("/a/b/./src/*.ts", "/a/b", Syntax::Posix), "src/*.ts");
assert_eq!(rooted("//a/b/src/*.ts", "/a/b", Syntax::Posix), "src/*.ts");
}
#[test]
fn a_pattern_that_cannot_reach_the_root_selects_nothing() {
assert_eq!(verdict("/a/c/**", "/a/b", Syntax::Posix), Rewrite::Outside);
assert_eq!(
verdict("/other/**", "/a/b", Syntax::Posix),
Rewrite::Outside
);
assert_eq!(verdict("/a", "/a/b", Syntax::Posix), Rewrite::Outside);
assert_eq!(verdict("/a/bb/**", "/a/b", Syntax::Posix), Rewrite::Outside);
}
#[test]
fn a_relative_pattern_is_left_alone() {
assert_eq!(
verdict("src/*.ts", "/a/b", Syntax::Posix),
Rewrite::Relative
);
assert_eq!(verdict("**/*.ts", "/a/b", Syntax::Posix), Rewrite::Relative);
assert_eq!(
verdict("/src/*.ts", "C:/a/b", Syntax::Windows),
Rewrite::Relative
);
}
#[test]
fn a_wildcard_at_or_above_the_root_is_rejected() {
assert_eq!(
message("/*/x.ts", "/a/b", Syntax::Posix),
"a wildcard at or above the walk root cannot be made relative to it"
);
assert_eq!(
message("/**/*.ts", "/a/b", Syntax::Posix),
"a wildcard at or above the walk root cannot be made relative to it"
);
assert_eq!(
message("/a/b*/x.ts", "/a/b", Syntax::Posix),
"a wildcard at or above the walk root cannot be made relative to it"
);
assert_eq!(rooted("/a/b/*/x.ts", "/a/b", Syntax::Posix), "*/x.ts");
}
#[test]
fn dot_dot_is_rejected_on_both_sides() {
assert!(message("/a/b/../b/x.ts", "/a/b", Syntax::Posix).starts_with("`..`"));
assert_eq!(
message("/a/b/x.ts", "/a/b/../b", Syntax::Posix),
"an absolute pattern needs a walk root without a `..` component"
);
assert!(message("/a/b/../c/*.ts", "/a", Syntax::Posix).starts_with("`..`"));
}
#[test]
fn naming_the_root_itself_is_rejected() {
for pattern in ["/a/b", "/a/b/", "/a/b/.", "/a//b//"] {
assert!(
message(pattern, "/a/b", Syntax::Posix)
.starts_with("an absolute pattern that names"),
"{pattern} names the root"
);
}
}
#[test]
fn an_absolute_pattern_needs_an_absolute_root() {
assert_eq!(
message("/a/b/*.ts", "relative/dir", Syntax::Posix),
"an absolute pattern needs an absolute walk root"
);
assert_eq!(
message("C:/a/*.ts", "a/b", Syntax::Windows),
"an absolute pattern needs an absolute walk root"
);
}
#[test]
fn windows_roots_are_read_the_way_windows_spells_them() {
assert_eq!(
rooted("C:/a/b/src/*.ts", "C:/a/b", Syntax::Windows),
"src/*.ts"
);
assert_eq!(
rooted("c:/a/b/src/*.ts", "C:/a/b", Syntax::Windows),
"src/*.ts"
);
assert_eq!(
verdict("C:/a/B/src/*.ts", "C:/a/b", Syntax::Windows),
Rewrite::Outside
);
assert_eq!(
verdict("D:/a/b/**", "C:/a/b", Syntax::Windows),
Rewrite::Outside
);
assert_eq!(
rooted("//host/share/a/**/*.ts", "//host/share/a", Syntax::Windows),
"**/*.ts"
);
assert_eq!(
verdict("//host/other/a/**", "//host/share/a", Syntax::Windows),
Rewrite::Outside
);
assert_eq!(
message("C:/a\\b\\src\\*.ts", "C:/a/b", Syntax::Windows),
"a wildcard at or above the walk root cannot be made relative to it"
);
}
}