#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct IgnoreRule {
negated: bool,
dir_only: bool,
anchored: bool,
pattern: String,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct IgnoreLayer {
prefix: String,
rules: Vec<IgnoreRule>,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct IgnoreStack {
layers: Vec<IgnoreLayer>,
}
impl IgnoreStack {
pub(crate) fn push_file(&mut self, prefix: &str, text: &str) {
let rules = parse_rules(text);
if !rules.is_empty() {
self.layers.push(IgnoreLayer {
prefix: prefix.trim_end_matches('/').to_owned(),
rules,
});
}
}
pub(crate) fn is_ignored(&self, relative: &str, is_dir: bool) -> bool {
let mut ignored = false;
for layer in &self.layers {
if let Some(local) = strip_prefix(relative, &layer.prefix) {
for rule in &layer.rules {
if rule.dir_only && !is_dir {
continue;
}
if rule_matches(rule, local) {
ignored = !rule.negated;
}
}
}
}
ignored
}
}
fn parse_rules(text: &str) -> Vec<IgnoreRule> {
text.lines().filter_map(parse_line).collect()
}
fn parse_line(raw: &str) -> Option<IgnoreRule> {
let line = raw.trim_end_matches('\r');
if line.is_empty() || line.starts_with('#') {
return None;
}
let mut body = line.to_owned();
let negated = body.starts_with('!');
if negated {
body.remove(0);
}
if body.is_empty() {
return None;
}
let dir_only = body.ends_with('/');
if dir_only {
body.pop();
}
if body.starts_with('/') {
body.remove(0);
return Some(IgnoreRule {
negated,
dir_only,
anchored: true,
pattern: body,
});
}
let anchored = body.contains('/');
Some(IgnoreRule {
negated,
dir_only,
anchored,
pattern: body,
})
}
fn strip_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
if prefix.is_empty() {
return Some(path);
}
if path == prefix {
return Some("");
}
let head = [prefix, "/"].concat();
path.strip_prefix(&head)
}
fn rule_matches(rule: &IgnoreRule, local: &str) -> bool {
if local.is_empty() {
return false;
}
if rule.anchored {
return glob_match(&rule.pattern, local);
}
if glob_match(&rule.pattern, local) {
return true;
}
local
.rsplit('/')
.next()
.is_some_and(|name| glob_match(&rule.pattern, name))
}
fn glob_match(pattern: &str, text: &str) -> bool {
glob_bytes(pattern.as_bytes(), text.as_bytes())
}
fn glob_bytes(pattern: &[u8], text: &[u8]) -> bool {
if pattern.is_empty() {
return text.is_empty();
}
if pattern.starts_with(b"**") {
return glob_starstar(&pattern[2..], text);
}
if pattern[0] == b'*' {
return glob_star(&pattern[1..], text);
}
if pattern[0] == b'?' {
return !text.is_empty() && text[0] != b'/' && glob_bytes(&pattern[1..], &text[1..]);
}
!text.is_empty() && pattern[0] == text[0] && glob_bytes(&pattern[1..], &text[1..])
}
fn glob_starstar(pattern: &[u8], text: &[u8]) -> bool {
let rest = if pattern.first() == Some(&b'/') {
&pattern[1..]
} else {
pattern
};
if rest.is_empty() {
return true;
}
let mut offset = 0;
loop {
if glob_bytes(rest, &text[offset..]) {
return true;
}
if offset >= text.len() {
return false;
}
match text[offset..].iter().position(|byte| *byte == b'/') {
Some(slash) => offset += slash + 1,
None => offset = text.len(),
}
}
}
fn glob_star(pattern: &[u8], text: &[u8]) -> bool {
let mut offset = 0;
loop {
if glob_bytes(pattern, &text[offset..]) {
return true;
}
if offset >= text.len() || text[offset] == b'/' {
return false;
}
offset += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unanchored_extension_and_negation() {
let mut stack = IgnoreStack::default();
stack.push_file("", "*.log\n!keep.log\nbuild/\n");
assert!(stack.is_ignored("debug.log", false));
assert!(stack.is_ignored("src/debug.log", false));
assert!(!stack.is_ignored("keep.log", false));
assert!(stack.is_ignored("build", true));
assert!(!stack.is_ignored("build.txt", false));
}
#[test]
fn nested_gitignore_is_relative() {
let mut stack = IgnoreStack::default();
stack.push_file("pkg", "/dist\n");
assert!(stack.is_ignored("pkg/dist", true));
assert!(!stack.is_ignored("dist", true));
}
}