use std::path::Path;
pub struct Ignore {
rules: Vec<Rule>,
}
struct Rule {
segs: Vec<String>,
dir_only: bool,
anchored: bool,
}
impl Ignore {
pub fn load(root: &Path) -> Ignore {
let text = std::fs::read_to_string(root.join(".stoweignore")).unwrap_or_default();
Ignore::parse(&text)
}
pub fn parse(text: &str) -> Ignore {
let mut rules = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let dir_only = line.ends_with('/');
let body = line.trim_matches('/');
if body.is_empty() {
continue;
}
rules.push(Rule {
anchored: body.contains('/') || line.starts_with('/'),
segs: body.split('/').map(str::to_string).collect(),
dir_only,
});
}
Ignore { rules }
}
pub fn is_ignored(&self, rel: &str, is_dir: bool) -> bool {
if self.rules.is_empty() {
return false;
}
if self.matches(rel, is_dir) {
return true;
}
let mut start = 0;
while let Some(pos) = rel[start..].find('/') {
let end = start + pos;
if self.matches(&rel[..end], true) {
return true;
}
start = end + 1;
}
false
}
fn matches(&self, path: &str, is_dir: bool) -> bool {
self.rules.iter().any(|r| r.matches(path, is_dir))
}
}
impl Rule {
fn matches(&self, path: &str, is_dir: bool) -> bool {
if self.dir_only && !is_dir {
return false;
}
if self.anchored {
let segs: Vec<&str> = path.split('/').collect();
segs.len() == self.segs.len()
&& segs.iter().zip(&self.segs).all(|(s, p)| wildcard(p, s))
} else {
wildcard(&self.segs[0], path.rsplit('/').next().unwrap_or(path))
}
}
}
fn wildcard(pat: &str, text: &str) -> bool {
let p: Vec<char> = pat.chars().collect();
let t: Vec<char> = text.chars().collect();
let (mut pi, mut ti) = (0usize, 0usize);
let (mut star, mut mark) = (usize::MAX, 0usize);
while ti < t.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
pi += 1;
ti += 1;
} else if pi < p.len() && p[pi] == '*' {
star = pi;
mark = ti;
pi += 1;
} else if star != usize::MAX {
pi = star + 1;
mark += 1;
ti = mark;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nothing_is_ignored_without_a_file() {
let ig = Ignore::parse("");
assert!(!ig.is_ignored("Photos/a.NEF", false));
}
#[test]
fn a_bare_name_matches_at_any_depth() {
let ig = Ignore::parse(".DS_Store");
assert!(ig.is_ignored(".DS_Store", false));
assert!(ig.is_ignored("Photos/2019/.DS_Store", false));
assert!(!ig.is_ignored("Photos/DS_Store.NEF", false));
}
#[test]
fn wildcards_stay_inside_one_segment() {
let ig = Ignore::parse("*.tmp");
assert!(ig.is_ignored("Renders/scratch.tmp", false));
assert!(!ig.is_ignored("scratch.tmp.exr", false));
assert!(wildcard("a?c", "abc"));
assert!(!wildcard("a?c", "ac"));
assert!(wildcard("*", "anything"));
}
#[test]
fn a_trailing_slash_matches_directories_only() {
let ig = Ignore::parse("cache/");
assert!(ig.is_ignored("Photos/cache", true));
assert!(!ig.is_ignored("Photos/cache", false));
assert!(ig.is_ignored("Photos/cache/thumb.jpg", false));
}
#[test]
fn a_slash_anchors_the_pattern_to_the_root() {
let ig = Ignore::parse("Renders/proxies/");
assert!(ig.is_ignored("Renders/proxies/shot.mov", false));
assert!(!ig.is_ignored("Video/Renders/proxies/shot.mov", false));
}
#[test]
fn comments_and_blank_lines_are_skipped() {
let ig = Ignore::parse("# junk\n\n *.tmp \n");
assert!(ig.is_ignored("a.tmp", false));
assert!(!ig.is_ignored("# junk", false));
}
}