use std::path::Path;
#[derive(Debug, Default)]
pub struct Ignore {
rules: Vec<Rule>,
}
#[derive(Debug)]
struct Rule {
negated: bool,
dir_only: bool,
anchored: bool,
parts: Vec<Part>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Part {
Literal(String),
Wild(String),
DoubleStar,
}
impl Ignore {
pub fn parse(text: &str) -> Ignore {
let mut rules = Vec::new();
for raw in text.lines() {
let mut line = raw.trim_end();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut negated = false;
if let Some(rest) = line.strip_prefix('!') {
negated = true;
line = rest;
}
if line.is_empty() {
continue;
}
let mut dir_only = false;
if let Some(rest) = line.strip_suffix('/') {
dir_only = true;
line = rest;
}
let mut anchored = false;
if let Some(rest) = line.strip_prefix('/') {
anchored = true;
line = rest;
}
if line.contains('/') {
anchored = true;
}
if let Some(pat) = parse_pattern(line) {
rules.push(Rule {
negated,
dir_only,
anchored,
parts: pat,
});
}
}
Ignore { rules }
}
pub fn from_root(root: &Path) -> Ignore {
match std::fs::read_to_string(root.join(".gitignore")) {
Ok(text) => Ignore::parse(&text),
Err(_) => Ignore::default(),
}
}
pub fn is_ignored(&self, rel_path: &str, is_dir: bool) -> bool {
if rel_path.is_empty() {
return false;
}
let segments: Vec<&str> = rel_path.split('/').collect();
for i in 1..segments.len() {
let prefix = segments[..i].join("/");
if self.decide(&prefix, true) {
return true;
}
}
self.decide(rel_path, is_dir)
}
fn decide(&self, path: &str, is_dir: bool) -> bool {
let segments: Vec<&str> = path.split('/').collect();
let mut ignored = false;
for rule in &self.rules {
if rule.dir_only && !is_dir {
continue;
}
if rule_matches(rule, &segments) {
ignored = !rule.negated;
}
}
ignored
}
}
fn rule_matches(rule: &Rule, segments: &[&str]) -> bool {
if rule.anchored {
parts_match(&rule.parts, segments)
} else {
for start in 0..segments.len() {
if parts_match(&rule.parts, &segments[start..]) {
return true;
}
}
false
}
}
fn parts_match(parts: &[Part], segments: &[&str]) -> bool {
match parts.first() {
None => segments.is_empty(),
Some(Part::DoubleStar) => {
if parts.len() == 1 {
return true;
}
for consumed in 0..=segments.len() {
if parts_match(&parts[1..], &segments[consumed..]) {
return true;
}
}
false
}
Some(Part::Literal(name)) => {
if segments.is_empty() {
return false;
}
segments[0] == name && parts_match(&parts[1..], &segments[1..])
}
Some(Part::Wild(pattern)) => {
if segments.is_empty() {
return false;
}
seg_match(pattern, segments[0]) && parts_match(&parts[1..], &segments[1..])
}
}
}
fn seg_match(pattern: &str, seg: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let s: Vec<char> = seg.chars().collect();
seg_match_rec(&p, &s)
}
fn seg_match_rec(p: &[char], s: &[char]) -> bool {
match p.first() {
None => s.is_empty(),
Some('*') => {
for i in 0..=s.len() {
if seg_match_rec(&p[1..], &s[i..]) {
return true;
}
}
false
}
Some('?') => !s.is_empty() && seg_match_rec(&p[1..], &s[1..]),
Some(c) => !s.is_empty() && s[0] == *c && seg_match_rec(&p[1..], &s[1..]),
}
}
fn parse_pattern(pattern: &str) -> Option<Vec<Part>> {
let mut parts = Vec::new();
for seg in pattern.split('/') {
if seg == "**" {
parts.push(Part::DoubleStar);
} else if seg.contains('*') || seg.contains('?') {
parts.push(Part::Wild(seg.to_string()));
} else {
parts.push(Part::Literal(seg.to_string()));
}
}
Some(parts)
}
#[cfg(test)]
mod tests {
use super::*;
fn ignored(rules: &str, path: &str, is_dir: bool) -> bool {
Ignore::parse(rules).is_ignored(path, is_dir)
}
#[test]
fn simple_rules() {
assert!(ignored("*.log\n", "a.log", false));
assert!(!ignored("*.log\n", "a.txt", false));
assert!(ignored("*.log\n", "deep/nested/a.log", false));
assert!(ignored("build/\n", "build", true));
assert!(!ignored("build/\n", "build", false));
assert!(ignored("build/\n", "a/build", true));
}
#[test]
fn anchored_patterns() {
assert!(ignored("/foo\n", "foo", false));
assert!(!ignored("/foo\n", "a/foo", false));
assert!(ignored("foo\n", "a/foo", false));
assert!(ignored("a/b\n", "a/b", false));
assert!(!ignored("a/b\n", "x/a/b", false));
}
#[test]
fn double_star() {
assert!(ignored("**/foo\n", "a/b/foo", false));
assert!(ignored("**/foo\n", "foo", false));
assert!(ignored("foo/**\n", "foo/x/y", false));
assert!(!ignored("foo/**\n", "x/foo", false));
}
#[test]
fn negation_last_match_wins() {
let rules = "*.log\n!keep.log\n";
assert!(!ignored(rules, "keep.log", false));
assert!(ignored(rules, "drop.log", false));
}
#[test]
fn parent_dir_exclusion_wins() {
let rules = "build/\n!build/keep.txt\n";
assert!(ignored(rules, "build/keep.txt", false));
}
#[test]
fn question_mark_and_comment() {
assert!(ignored("file?.txt\n", "file1.txt", false));
assert!(!ignored("file?.txt\n", "file10.txt", false));
assert!(!ignored("# comment\n", "anything", false));
}
}