use std::path::Path;
use ferralk_glob::{Pattern, PatternOptions};
use memchr::memmem;
use super::glob_path_bytes;
fn component_options() -> PatternOptions {
PatternOptions::default()
.braces(false)
.extglob(false)
.recursive_double_star(false)
.match_hidden(true)
}
#[derive(Debug)]
pub(crate) struct RuleSet {
root_len: usize,
rules: Vec<Rule>,
}
impl RuleSet {
pub(crate) fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub(crate) fn matched(&self, path: &[u8], is_dir: bool) -> Option<bool> {
let candidate = path.get(self.root_len..).unwrap_or(path);
self.rules
.iter()
.rev()
.find(|rule| rule.matches(candidate, is_dir))
.map(|rule| !rule.negated)
}
}
#[derive(Debug)]
struct Rule {
components: Vec<Component>,
gate: Option<memmem::Finder<'static>>,
negated: bool,
directory_only: bool,
}
impl Rule {
fn matches(&self, candidate: &[u8], is_dir: bool) -> bool {
if self.directory_only && !is_dir {
return false;
}
self.gate
.as_ref()
.is_none_or(|gate| gate.find(candidate).is_some())
&& matches_components(&self.components, candidate)
}
}
#[derive(Debug)]
enum Component {
AnyDirs,
Pattern(Pattern),
}
fn matches_components(components: &[Component], candidate: &[u8]) -> bool {
let mut index = 0;
let mut rest = candidate;
let mut run: Option<(usize, &[u8])> = None;
while !rest.is_empty() {
if let Some(Component::AnyDirs) = components.get(index) {
run = Some((index, rest));
index += 1;
continue;
}
if let Some(Component::Pattern(pattern)) = components.get(index) {
let (head, tail) = split_component(rest);
if pattern.is_match_glob_path(head) {
index += 1;
rest = tail;
continue;
}
}
let Some((run_index, unswallowed)) = run else {
return false;
};
if unswallowed.is_empty() {
return false;
}
let (_, tail) = split_component(unswallowed);
run = Some((run_index, tail));
index = run_index + 1;
rest = tail;
}
components[index..]
.iter()
.all(|component| matches!(component, Component::AnyDirs))
}
fn split_component(candidate: &[u8]) -> (&[u8], &[u8]) {
match candidate.iter().position(|byte| *byte == b'/') {
Some(separator) => (&candidate[..separator], &candidate[separator + 1..]),
None => (candidate, &[]),
}
}
pub(crate) struct RuleSetBuilder {
root_len: usize,
rules: Vec<Rule>,
}
impl RuleSetBuilder {
pub(crate) fn new(root: &Path) -> Self {
let root = glob_path_bytes(root);
let root_len = root.len() + usize::from(!root.ends_with(b"/"));
Self {
root_len,
rules: Vec::new(),
}
}
pub(crate) fn add_line(&mut self, line: &str) {
if let Some(rule) = parse_rule(line) {
self.rules.push(rule);
}
}
pub(crate) fn build(self) -> RuleSet {
RuleSet {
root_len: self.root_len,
rules: self.rules,
}
}
}
pub fn fuzz_rule(line: &str, candidate: &[u8], is_dir: bool) -> Option<bool> {
let mut builder = RuleSetBuilder::new(Path::new(""));
builder.add_line(line);
builder.build().matched(candidate, is_dir)
}
fn parse_rule(line: &str) -> Option<Rule> {
if line.is_empty() || line.starts_with('#') {
return None;
}
let body = strip_trailing_spaces(line);
if body.is_empty() {
return None;
}
let (negated, body) = match body.strip_prefix('!') {
Some(rest) => (true, rest),
None => (false, body),
};
if trailing_backslashes(body) % 2 == 1 {
return None;
}
let (directory_only, body) = match body.strip_suffix('/') {
Some(rest) => (true, rest),
None => (false, body),
};
let anchored = body.contains('/');
let body = body.strip_prefix('/').unwrap_or(body);
if body.is_empty() {
return None;
}
let mut components = Vec::new();
let mut gate: Option<Vec<u8>> = None;
if !anchored {
components.push(Component::AnyDirs);
}
for part in body.split('/').filter(|part| !part.is_empty()) {
if let Some(literal) = longest_literal_run(part.as_bytes())
&& gate.as_ref().is_none_or(|best| best.len() < literal.len())
{
gate = Some(literal);
}
let component = compile_component(part)?;
if matches!(component, Component::AnyDirs)
&& matches!(components.last(), Some(Component::AnyDirs))
{
continue;
}
components.push(component);
}
if matches!(components.last(), Some(Component::AnyDirs)) {
components.push(compile_component("*")?);
}
if components.len() == usize::from(!anchored) {
return None;
}
Some(Rule {
components,
gate: gate.map(|literal| memmem::Finder::new(&literal).into_owned()),
negated,
directory_only,
})
}
fn compile_component(part: &str) -> Option<Component> {
if part == "**" {
return Some(Component::AnyDirs);
}
Pattern::compile(collapse_partial_stars(part.as_bytes()), component_options())
.ok()
.map(Component::Pattern)
}
fn strip_trailing_spaces(line: &str) -> &str {
let mut end = line.len();
while end > 0 && line.as_bytes()[end - 1] == b' ' {
if trailing_backslashes(&line[..end - 1]) % 2 == 1 {
break;
}
end -= 1;
}
&line[..end]
}
fn trailing_backslashes(text: &str) -> usize {
text.bytes().rev().take_while(|byte| *byte == b'\\').count()
}
fn longest_literal_run(part: &[u8]) -> Option<Vec<u8>> {
let mut longest: Vec<u8> = Vec::new();
let mut current: Vec<u8> = Vec::new();
let mut index = 0;
while index < part.len() {
match part[index] {
b'\\' if index + 1 < part.len() => {
current.push(part[index + 1]);
index += 2;
}
b'*' | b'?' => {
index += 1;
if current.len() > longest.len() {
longest = std::mem::take(&mut current);
}
current.clear();
}
b'[' => {
let mut scan = index + 1;
if matches!(part.get(scan), Some(b'!' | b'^')) {
scan += 1;
}
if part.get(scan) == Some(&b']') {
scan += 1;
}
while scan < part.len() && part[scan] != b']' {
if part[scan] == b'[' && part.get(scan + 1) == Some(&b':') {
match part[scan + 2..].windows(2).position(|pair| pair == b":]") {
Some(end) => scan += 2 + end + 2,
None => scan = part.len(),
}
} else {
scan += 1;
}
}
index = if scan < part.len() {
scan + 1
} else {
part.len()
};
if current.len() > longest.len() {
longest = std::mem::take(&mut current);
}
current.clear();
}
byte => {
current.push(byte);
index += 1;
}
}
}
if current.len() > longest.len() {
longest = current;
}
(longest.len() >= 2).then_some(longest)
}
fn collapse_partial_stars(part: &[u8]) -> Vec<u8> {
let mut collapsed = Vec::with_capacity(part.len());
let mut index = 0;
while index < part.len() {
match part[index] {
b'\\' if index + 1 < part.len() => {
collapsed.extend_from_slice(&part[index..index + 2]);
index += 2;
}
b'*' => {
let run = part[index..]
.iter()
.take_while(|byte| **byte == b'*')
.count();
collapsed.push(b'*');
index += run;
}
byte => {
collapsed.push(byte);
index += 1;
}
}
}
collapsed
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::{RuleSetBuilder, parse_rule};
const RULES: &[&str] = &[
"debug.log",
"*.log",
"*.o",
"build/",
"logs/",
"/root.txt",
"src/temp.o",
"**/foo",
"a/**/b",
"abc/**",
"abc/*",
"a**b",
"doc/frotz",
"!keep.log",
"file[a-c].txt",
"file[!a-c].txt",
"*.[oa]",
"*.[[:digit:]]",
"file[[:upper:]].txt",
"*.{ts,tsx}",
"\\!literal",
"\\#literal",
"trailing ",
"trailing\\ ",
"#comment",
"",
"sp ace.txt",
"**",
"*",
"node_modules/",
"!node_modules/keep/",
"deep/nested/*.rs",
".env",
".*",
];
const PATHS: &[&str] = &[
"debug.log",
"keep.log",
"sub/debug.log",
"build",
"build/main.o",
"logs",
"root.txt",
"sub/root.txt",
"src/temp.o",
"foo",
"a/b/foo",
"a/b",
"a/x/y/b",
"abc",
"abc/x.txt",
"abc/deep/x.txt",
"axxb",
"a/x/b",
"doc/frotz",
"sub/doc/frotz",
"filea.txt",
"filez.txt",
"fileA.txt",
"main.o",
"a.7",
"a.ts",
"a.{ts,tsx}",
"!literal",
"#literal",
"trailing",
"trailing ",
"sp ace.txt",
"node_modules",
"node_modules/keep",
"deep/nested/main.rs",
".env",
".hidden/x",
"xfoo",
"a/xfoo",
"subdebug.log",
"a/xb",
"ax/b",
"abcx/y",
"xbuild",
"build2",
"x.tsx",
"keep.log.bak",
"node_modules2/x",
];
const DELIBERATE: &[&str] = &["*.[[:digit:]]", "*.{ts,tsx}", "file[[:upper:]].txt"];
fn candidate(root: &Path, path: &str) -> Vec<u8> {
root.join(path).to_string_lossy().into_owned().into_bytes()
}
fn ours(rule: &str, path: &str, is_dir: bool) -> Option<bool> {
let root = PathBuf::from("/fixture");
let mut builder = RuleSetBuilder::new(&root);
builder.add_line(rule);
builder.build().matched(&candidate(&root, path), is_dir)
}
fn theirs(rule: &str, path: &str, is_dir: bool) -> Option<bool> {
let root = PathBuf::from("/fixture");
let mut builder = ignore::gitignore::GitignoreBuilder::new(&root);
let _ = builder.add_line(None, rule);
let Ok(matcher) = builder.build() else {
return None;
};
let matched = matcher.matched(root.join(path), is_dir);
if matched.is_none() {
None
} else {
Some(matched.is_ignore())
}
}
#[test]
fn the_rule_layer_agrees_with_the_engine_it_replaces() {
let mut disagreed = Vec::new();
let mut compared = 0_usize;
for rule in RULES {
for path in PATHS {
for is_dir in [false, true] {
compared += 1;
if ours(rule, path, is_dir) != theirs(rule, path, is_dir) {
disagreed.push(*rule);
}
}
}
}
disagreed.sort_unstable();
disagreed.dedup();
assert!(
compared > 1000,
"the matrix has to be broad, was {compared}"
);
assert_eq!(
disagreed, DELIBERATE,
"unexpected disagreement with the previous engine"
);
}
#[test]
fn rule_lines_are_read_the_way_gitignore_describes_them() {
assert!(parse_rule("").is_none(), "a blank line is not a rule");
assert!(parse_rule("# comment").is_none());
assert!(parse_rule(" ").is_none(), "spaces alone are not a rule");
assert!(parse_rule("/").is_none(), "a lone separator is not a rule");
assert!(
parse_rule("foo\\").is_none(),
"a dangling escape matches nothing"
);
assert!(
parse_rule("\\#literal").is_some(),
"an escaped hash is a rule"
);
let negated = parse_rule("!keep.log").expect("negation parses");
assert!(negated.negated);
assert!(!negated.directory_only);
let directory = parse_rule("build/").expect("directory rule parses");
assert!(directory.directory_only);
assert!(!directory.negated);
}
#[test]
fn deeply_nested_unclosed_posix_openers_are_rejected() {
let mut rule = String::from("[");
rule.push_str("[:".repeat(32_768).as_str());
assert!(parse_rule(&rule).is_none());
}
#[test]
fn trailing_spaces_are_dropped_unless_escaped() {
let root = Path::new("/fixture");
let mut builder = RuleSetBuilder::new(root);
builder.add_line("trailing ");
assert_eq!(
builder.build().matched(&candidate(root, "trailing"), false),
Some(true)
);
let mut builder = RuleSetBuilder::new(root);
builder.add_line("trailing\\ ");
let rules = builder.build();
assert_eq!(
rules.matched(&candidate(root, "trailing "), false),
Some(true)
);
assert_eq!(rules.matched(&candidate(root, "trailing"), false), None);
}
#[test]
fn the_last_matching_rule_decides() {
let root = Path::new("/fixture");
let mut builder = RuleSetBuilder::new(root);
builder.add_line("*.log");
builder.add_line("!keep.log");
builder.add_line("keep.log");
assert_eq!(
builder.build().matched(&candidate(root, "keep.log"), false),
Some(true),
"the later rule wins over the negation before it"
);
}
#[test]
fn many_runs_do_not_explode() {
let root = PathBuf::from("/fixture");
let mut builder = RuleSetBuilder::new(&root);
builder.add_line("**/a/**/a/**/a/**/a/**/a/**/a/**/a/**/x");
let rules = builder.build();
let deep = "a/".repeat(24) + "b";
let start = std::time::Instant::now();
assert_eq!(rules.matched(&candidate(&root, &deep), false), None);
assert!(
start.elapsed() < std::time::Duration::from_secs(1),
"matching took {:?}",
start.elapsed()
);
}
#[test]
#[ignore = "measurement, not a verdict"]
fn rule_engine_cost() {
use std::time::Instant;
let root = PathBuf::from("/fixture");
let candidates = (0..64)
.map(|index| candidate(&root, &format!("src/area-{index}/module/file-{index}.txt")))
.collect::<Vec<_>>();
for count in [1_usize, 10, 120] {
let lines = (0..count)
.map(|index| match index % 3 {
0 => format!("build-{index}/"),
1 => format!("*.tmp{index}"),
_ => format!("**/cache-{index}/**"),
})
.collect::<Vec<_>>();
let mut ours = RuleSetBuilder::new(&root);
for line in &lines {
ours.add_line(line);
}
let ours = ours.build();
let mut theirs = ignore::gitignore::GitignoreBuilder::new(&root);
for line in &lines {
let _ = theirs.add_line(None, line);
}
let theirs = theirs.build().expect("the previous engine builds");
let rounds = 200;
let start = Instant::now();
for _ in 0..rounds {
for path in &candidates {
std::hint::black_box(ours.matched(path, false));
}
}
let ours_ns = start.elapsed().as_nanos() as f64 / (rounds * candidates.len()) as f64;
let start = Instant::now();
for _ in 0..rounds {
for path in &candidates {
std::hint::black_box(theirs.matched(
std::str::from_utf8(path).expect("ASCII fixture path"),
false,
));
}
}
let theirs_ns = start.elapsed().as_nanos() as f64 / (rounds * candidates.len()) as f64;
println!(
"{count:>3} rules: ours {ours_ns:6.1} ns/verdict previous engine {theirs_ns:6.1} ns/verdict"
);
}
}
}