use std::collections::BTreeSet;
pub const FILE: &str = ".isoloomignore";
#[derive(Debug, Clone, PartialEq, Eq)]
enum Token {
Char(char),
Star,
Any,
Set {
negated: bool,
ranges: Vec<(char, char)>,
},
Folders,
Everything,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Rule {
tokens: Vec<Token>,
folders_only: bool,
keep: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Rules(Vec<Rule>);
impl Rules {
#[must_use]
pub fn parse(text: &str) -> Rules {
Rules(text.split('\n').filter_map(rule).collect())
}
#[must_use]
pub fn ignored(&self, path: &str) -> bool {
let chars: Vec<char> = path.chars().collect();
self.0.iter().fold(false, |out, r| {
let hit = ends(&r.tokens, &chars, 0).into_iter().any(|e| match chars.get(e) {
Some(c) => *c == '/',
None => !r.folders_only,
});
if hit { !r.keep } else { out }
})
}
#[must_use]
pub fn brings_back(&self) -> bool {
self.0.iter().any(|r| r.keep)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
fn rule(line: &str) -> Option<Rule> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let (keep, pat) = line.strip_prefix('!').map_or((false, line), |p| (true, p));
let s = pat.strip_suffix('/').unwrap_or(pat);
let s = s.strip_suffix("/**").unwrap_or(s);
let glob = s.strip_suffix('/').unwrap_or(s);
if glob.is_empty() {
return None;
}
let anchored = if glob.contains('/') {
glob.strip_prefix('/').unwrap_or(glob).to_owned()
} else {
format!("**/{glob}")
};
Some(Rule {
tokens: tokens(&anchored.chars().collect::<Vec<char>>()),
folders_only: glob != pat,
keep,
})
}
fn tokens(glob: &[char]) -> Vec<Token> {
let mut out = Vec::new();
let mut rest = glob;
while let Some((token, after)) = token(rest) {
out.push(token);
rest = after;
}
out
}
fn token(glob: &[char]) -> Option<(Token, &[char])> {
Some(match glob {
[] => return None,
['*', '*', '/', rest @ ..] => (Token::Folders, rest),
['*', '*', rest @ ..] => (Token::Everything, rest),
['*', rest @ ..] => (Token::Star, rest),
['?', rest @ ..] => (Token::Any, rest),
['[', rest @ ..] => set(rest).unwrap_or((Token::Char('['), rest)),
[c, rest @ ..] => (Token::Char(*c), rest),
})
}
fn set(after: &[char]) -> Option<(Token, &[char])> {
let (negated, body) = match after {
['!', rest @ ..] => (true, rest),
_ => (false, after),
};
let from = usize::from(body.first() == Some(&']'));
let close = body.iter().skip(from).position(|c| *c == ']')? + from;
let (members, rest) = body.split_at_checked(close)?;
Some((
Token::Set {
negated,
ranges: ranges(members),
},
rest.get(1..).unwrap_or_default(),
))
}
fn ranges(members: &[char]) -> Vec<(char, char)> {
let mut out = Vec::new();
let mut rest = members;
loop {
rest = match rest {
[a, '-', b, tail @ ..] => {
out.push((*a, *b));
tail
}
[c, tail @ ..] => {
out.push((*c, *c));
tail
}
[] => return out,
};
}
}
fn ends(tokens: &[Token], s: &[char], at: usize) -> BTreeSet<usize> {
let Some((first, rest)) = tokens.split_first() else {
return BTreeSet::from([at]);
};
let next = s.get(at);
let one = |matched: bool| if matched { vec![at + 1] } else { Vec::new() };
let positions: Vec<usize> = match first {
Token::Char(ch) => one(next == Some(ch)),
Token::Any => one(next.is_some_and(|c| *c != '/')),
Token::Set { negated, ranges } => one(next.is_some_and(|c| ranges.iter().any(|(a, b)| a <= c && c <= b) != *negated)),
Token::Star => {
let run = s.get(at..).unwrap_or_default().iter().take_while(|c| **c != '/').count();
(at..=at + run).collect()
}
Token::Folders => std::iter::once(at)
.chain((at..s.len()).filter(|p| s.get(*p) == Some(&'/')).map(|p| p + 1))
.collect(),
Token::Everything => (at..=s.len()).collect(),
};
positions.into_iter().flat_map(|p| ends(rest, s, p)).collect()
}
#[cfg(test)]
mod tests;