#![warn(missing_docs)]
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
mod glob;
use glob::{PathSeg, Seg, Tok, match_segments, parse_segment};
#[derive(Debug, Default, Clone)]
pub(crate) struct IgnoreRules {
rules: Vec<Rule>,
}
#[derive(Debug, Clone)]
struct Rule {
segs: Vec<Seg>,
negated: bool,
spelling: Option<Box<[SpelledSeg]>>,
dir_only: bool,
}
#[derive(Debug, Clone)]
struct SpelledSeg {
text: Box<str>,
escaped_separator: bool,
}
impl Rule {
fn matches(&self, path: &[PathSeg<'_>], is_dir: bool) -> bool {
if self.dir_only && !is_dir {
return false;
}
match_segments(&self.segs, path)
}
fn negation_leading_path(&self) -> Option<(Vec<String>, NegationTail)> {
self.negated.then(|| {
let leading: Vec<String> = self.segs.iter().map_while(Seg::literal).collect();
let tail = match &self.segs[leading.len()..] {
[] => NegationTail::None,
[Seg::Glob(_)] => NegationTail::Children,
_ => NegationTail::Descendants,
};
(leading, tail)
})
}
fn spelling_from(&self, skip: usize) -> String {
let Some(segs) = self.spelling.as_deref() else {
return String::new();
};
let mut pattern = String::new();
for (i, seg) in segs.iter().enumerate().skip(skip) {
if i > skip {
pattern.push_str(if segs[i - 1].escaped_separator {
"\\/"
} else {
"/"
});
}
pattern.push_str(&seg.text);
}
if self.dir_only {
pattern.push('/');
}
pattern
}
}
impl IgnoreRules {
pub(crate) fn parse(content: &str) -> Self {
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
let rules = content
.split('\n')
.map(|line| line.strip_suffix('\r').unwrap_or(line))
.map(|line| line.split_once('\0').map_or(line, |(pattern, _)| pattern))
.filter_map(parse_line)
.collect();
Self { rules }
}
#[cfg(test)]
pub(crate) fn is_empty(&self) -> bool {
self.rules.is_empty()
}
#[cfg(test)]
pub(crate) fn is_ignored(&self, path: &str, is_dir: bool) -> bool {
first_excluded_prefix(path, is_dir, |prefix, dir| {
self.last_match(prefix, dir).map(|ignored| (ignored, ()))
})
.is_some()
}
fn last_match(&self, path: &[PathSeg<'_>], is_dir: bool) -> Option<bool> {
self.rules
.iter()
.rev()
.find(|rule| rule.matches(path, is_dir))
.map(|rule| !rule.negated)
}
}
#[derive(Debug, Default, Clone)]
pub struct IgnoreStack {
gitignore: Vec<Layer>,
overrides: Vec<Layer>,
}
const _: () = {
const fn assert_auto_traits<
T: Send + Sync + std::panic::UnwindSafe + std::panic::RefUnwindSafe,
>() {
}
assert_auto_traits::<IgnoreStack>();
};
#[derive(Debug, Clone)]
struct Layer {
anchor: Vec<String>,
rules: IgnoreRules,
source: IgnoreSource,
}
impl Layer {
fn new(anchor: &str, content: &str, source: IgnoreSource) -> Self {
Self {
anchor: split_path(anchor),
rules: IgnoreRules::parse(content),
source,
}
}
fn relativize<'a, 'p>(&self, prefix: &'p [PathSeg<'a>]) -> Option<&'p [PathSeg<'a>]> {
if self.anchor.len() >= prefix.len() {
return None;
}
self.anchor
.iter()
.zip(prefix)
.all(|(a, p)| a.as_str() == p.text)
.then(|| &prefix[self.anchor.len()..])
}
}
fn debug_assert_pushed_in_order(stack: &[Layer], layer: &Layer) {
debug_assert!(
!stack.iter().any(|pushed| {
pushed.anchor.len() > layer.anchor.len()
&& pushed.anchor.iter().zip(&layer.anchor).all(|(p, a)| p == a)
}),
"ignore layer {:?} pushed after a layer below it; push shallowest-first",
layer.anchor.join("/")
);
}
impl IgnoreStack {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn push_gitignore(&mut self, anchor: &str, content: &str) {
let layer = Layer::new(anchor, content, IgnoreSource::Gitignore);
debug_assert_pushed_in_order(&self.gitignore, &layer);
self.gitignore.push(layer);
}
pub fn pop_gitignore(&mut self) {
self.gitignore.pop();
}
pub fn push_formatignore(&mut self, anchor: &str, content: &str) {
self.push_override(anchor, content, IgnoreSource::Formatignore);
}
pub fn push_prettierignore(&mut self, anchor: &str, content: &str) {
self.push_override(anchor, content, IgnoreSource::Prettierignore);
}
fn push_override(&mut self, anchor: &str, content: &str, source: IgnoreSource) {
let layer = Layer::new(anchor, content, source);
debug_assert_pushed_in_order(&self.overrides, &layer);
self.overrides.push(layer);
}
pub fn pop_override(&mut self) {
self.overrides.pop();
}
#[doc(hidden)]
pub fn has_gitignore_layers(&self) -> bool {
!self.gitignore.is_empty()
}
#[doc(hidden)]
#[must_use]
pub fn has_gitignore_above(&self, dir: &str) -> bool {
let dir = split_segments(dir);
self.gitignore.iter().any(|layer| {
layer.anchor.len() < dir.len()
&& layer.anchor.iter().zip(&dir).all(|(a, d)| a.as_str() == *d)
})
}
#[must_use]
pub fn is_ignored(&self, path: &str, kind: PathKind) -> bool {
self.exclusion(path, kind).is_some()
}
#[must_use]
pub fn is_ignored_leaf(&self, path: &str, kind: PathKind) -> bool {
self.leaf_polarity(path, kind.is_dir()) == Some(true)
}
#[doc(hidden)]
pub fn is_reincluded(&self, path: &str, kind: PathKind) -> bool {
self.leaf_polarity(path, kind.is_dir()) == Some(false)
}
fn leaf_polarity(&self, path: &str, is_dir: bool) -> Option<bool> {
let segments = path_segments(path);
if segments.is_empty() {
return None;
}
self.last_match_at(&segments, is_dir)
}
#[doc(hidden)]
pub fn negation_under(&self, prefix: &str) -> Option<Negation> {
let prefix_segs = split_segments(prefix);
let mut found = Vec::new();
for layer in &self.overrides {
if layer.anchor.len() >= prefix_segs.len() {
continue;
}
for rule in &layer.rules.rules {
let Some((leading, tail)) = rule.negation_leading_path() else {
continue;
};
let mut concrete = layer.anchor.iter().chain(&leading);
let reaches_under = prefix_segs
.iter()
.all(|seg| concrete.next().is_some_and(|c| c.as_str() == *seg))
&& (concrete.next().is_some() || tail != NegationTail::None);
if reaches_under {
found.push((layer.anchor.len(), layer.source, leading, tail, rule));
}
}
}
let &(anchor_depth, source, ..) = found.iter().max_by_key(|f| f.0)?;
let rules = found
.into_iter()
.map(|(depth, _, leading, tail, rule)| {
let skip = anchor_depth - depth;
NegationRule {
leading: leading[skip..].to_vec(),
tail,
pattern: rule.spelling_from(skip),
}
})
.collect();
Some(Negation {
anchor_depth,
source,
rules,
})
}
fn last_match_at(&self, prefix: &[PathSeg<'_>], is_dir: bool) -> Option<bool> {
last_match_layer(self.gitignore.iter().chain(&self.overrides), prefix, is_dir)
.map(|(ignored, _)| ignored)
}
#[must_use]
pub fn exclusion(&self, path: &str, kind: PathKind) -> Option<Exclusion> {
first_exclusion(path, kind.is_dir(), |prefix, dir| {
last_match_layer(self.gitignore.iter().chain(&self.overrides), prefix, dir)
})
}
#[doc(hidden)]
pub fn gitignore_exclusion(&self, path: &str, kind: PathKind) -> Option<Exclusion> {
first_exclusion(path, kind.is_dir(), |prefix, dir| {
last_match_layer(self.gitignore.iter(), prefix, dir)
})
}
#[doc(hidden)]
pub fn override_exclusion(&self, path: &str, kind: PathKind) -> Option<Exclusion> {
first_exclusion(path, kind.is_dir(), |prefix, dir| {
last_match_layer(self.overrides.iter(), prefix, dir)
})
}
#[doc(hidden)]
pub fn override_layer_source(&self, anchor: &str) -> Option<IgnoreSource> {
let anchor = split_segments(anchor);
self.overrides
.iter()
.rev()
.find(|layer| {
layer
.anchor
.iter()
.map(String::as_str)
.eq(anchor.iter().copied())
})
.map(|layer| layer.source)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PathKind {
File,
Directory,
}
impl PathKind {
#[must_use]
pub const fn from_is_dir(is_dir: bool) -> Self {
if is_dir { Self::Directory } else { Self::File }
}
#[must_use]
pub const fn is_dir(self) -> bool {
matches!(self, Self::Directory)
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum IgnoreSource {
Gitignore,
Formatignore,
Prettierignore,
}
impl IgnoreSource {
#[must_use]
pub const fn file_name(self) -> &'static str {
match self {
Self::Gitignore => ".gitignore",
Self::Formatignore => ".formatignore",
Self::Prettierignore => ".prettierignore",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Exclusion {
pub depth: usize,
pub anchor_depth: usize,
pub source: IgnoreSource,
}
#[doc(hidden)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Negation {
pub anchor_depth: usize,
pub source: IgnoreSource,
pub rules: Vec<NegationRule>,
}
#[doc(hidden)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NegationRule {
pub leading: Vec<String>,
pub tail: NegationTail,
pub pattern: String,
}
#[doc(hidden)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NegationTail {
None,
Children,
Descendants,
}
fn is_segment(component: &str) -> bool {
!component.is_empty() && component != "."
}
#[must_use]
pub fn split_segments(path: &str) -> Vec<&str> {
path.split('/').filter(|s| is_segment(s)).collect()
}
fn split_path(path: &str) -> Vec<String> {
split_segments(path).into_iter().map(String::from).collect()
}
fn path_segments(path: &str) -> Vec<PathSeg<'_>> {
let mut segments = Vec::with_capacity(path.bytes().filter(|&b| b == b'/').count() + 1);
segments.extend(path.split('/').filter(|s| is_segment(s)).map(PathSeg::new));
segments
}
fn first_excluded_prefix<T>(
path: &str,
is_dir: bool,
polarity: impl Fn(&[PathSeg<'_>], bool) -> Option<(bool, T)>,
) -> Option<(usize, T)> {
let segments = path_segments(path);
let last = segments.len().checked_sub(1)?;
(0..segments.len()).find_map(|k| match polarity(&segments[..=k], k < last || is_dir) {
Some((true, witness)) => Some((k + 1, witness)),
_ => None,
})
}
fn first_exclusion<'a>(
path: &str,
is_dir: bool,
polarity: impl Fn(&[PathSeg<'_>], bool) -> Option<(bool, &'a Layer)>,
) -> Option<Exclusion> {
first_excluded_prefix(path, is_dir, polarity).map(|(depth, layer)| Exclusion {
depth,
anchor_depth: layer.anchor.len(),
source: layer.source,
})
}
fn last_match_layer<'a>(
layers: impl DoubleEndedIterator<Item = &'a Layer>,
prefix: &[PathSeg<'_>],
is_dir: bool,
) -> Option<(bool, &'a Layer)> {
layers.rev().find_map(|layer| {
let ignored = layer.rules.last_match(layer.relativize(prefix)?, is_dir)?;
Some((ignored, layer))
})
}
fn parse_line(raw: &str) -> Option<Rule> {
let mut s = strip_trailing_spaces(raw);
if s.is_empty() || s.starts_with('#') {
return None;
}
let negated = s.starts_with('!');
if negated {
s = &s[1..];
}
let dir_only = s.ends_with('/');
if dir_only {
s = &s[..s.len() - 1];
}
let leading_slash = s.starts_with('/');
if leading_slash {
s = &s[1..];
}
if s.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1 {
return None;
}
let anchored = leading_slash || s.contains('/');
let mut raw_segs: Vec<(String, bool)> = Vec::new(); let mut seg = String::new();
let mut chars = s.chars().peekable();
let mut class_members: Option<usize> = None;
while let Some(c) = chars.next() {
match c {
'/' if class_members.is_none() => raw_segs.push((std::mem::take(&mut seg), false)),
'\\' => match chars.next() {
Some('/') if class_members.is_none() => {
raw_segs.push((std::mem::take(&mut seg), true));
}
Some(next) => {
seg.push('\\');
seg.push(next);
if let Some(members) = &mut class_members {
*members += 1;
}
}
None => seg.push('\\'),
},
'[' if class_members.is_none() => {
seg.push('[');
class_members = Some(0);
if let Some(&negation) = chars.peek()
&& (negation == '!' || negation == '^')
{
seg.push(negation);
chars.next();
}
}
']' if class_members.is_some_and(|members| members > 0) => {
seg.push(']');
class_members = None;
}
c => {
seg.push(c);
if let Some(members) = &mut class_members {
*members += 1;
}
}
}
}
raw_segs.push((seg, false));
if raw_segs.iter().any(|(text, _)| text.is_empty()) {
return None;
}
let mut segs = Vec::with_capacity(raw_segs.len() + 1);
for (text, escaped_separator) in &raw_segs {
let parsed = parse_segment(text)?;
if *escaped_separator && matches!(parsed, Seg::DoubleStar) {
segs.push(Seg::Glob(vec![Tok::Star]));
}
segs.push(parsed);
}
if !anchored {
segs.insert(0, Seg::DoubleStar);
}
let spelling = negated.then(|| {
raw_segs
.into_iter()
.map(|(text, escaped_separator)| SpelledSeg {
text: Box::from(text),
escaped_separator,
})
.collect()
});
Some(Rule {
segs,
negated,
spelling,
dir_only,
})
}
fn strip_trailing_spaces(line: &str) -> &str {
let bytes = line.as_bytes();
let mut end = bytes.len();
while end > 0 && bytes[end - 1] == b' ' {
let backslashes = bytes[..end - 1]
.iter()
.rev()
.take_while(|&&b| b == b'\\')
.count();
if backslashes % 2 == 1 {
break;
}
end -= 1;
}
&line[..end]
}
#[cfg(test)]
mod tests {
use super::{
Exclusion, IgnoreRules, IgnoreSource, IgnoreStack, NegationRule, NegationTail, PathKind,
};
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "pushed after a layer below it")]
fn pushing_an_ancestor_layer_after_a_descendants_asserts() {
let mut stack = IgnoreStack::new();
stack.push_gitignore("a", "!x.log\n");
stack.push_gitignore("", "*.log\n");
}
#[test]
fn a_siblings_layer_on_the_stack_is_no_order_violation() {
let mut stack = IgnoreStack::new();
stack.push_formatignore("a", "x\n");
stack.push_formatignore("b", "y\n");
assert!(stack.is_ignored("b/y", PathKind::File));
assert!(!stack.is_ignored("b/x", PathKind::File));
}
fn ig(content: &str) -> IgnoreRules {
IgnoreRules::parse(content)
}
fn excluded(depth: usize, anchor_depth: usize, source: IgnoreSource) -> Option<Exclusion> {
Some(Exclusion {
depth,
anchor_depth,
source,
})
}
#[test]
fn empty_rules_ignore_nothing() {
let rules = ig("");
assert!(rules.is_empty());
assert!(!rules.is_ignored("anything.ts", false));
assert!(!rules.is_ignored("a/b/c.ts", false));
}
#[test]
fn comments_and_blanks_are_skipped() {
let rules = ig("# a comment\n\n \n# another\n");
assert!(rules.is_empty());
}
#[test]
fn floating_name_matches_at_any_depth() {
let rules = ig("foo.ts\n");
assert!(rules.is_ignored("foo.ts", false));
assert!(rules.is_ignored("a/foo.ts", false));
assert!(rules.is_ignored("a/b/foo.ts", false));
assert!(!rules.is_ignored("bar.ts", false));
assert!(!rules.is_ignored("a/foobar.ts", false));
}
#[test]
fn anchored_pattern_only_matches_from_root() {
let rules = ig("/foo.ts\n");
assert!(rules.is_ignored("foo.ts", false));
assert!(!rules.is_ignored("a/foo.ts", false));
}
#[test]
fn interior_slash_anchors_to_root() {
let rules = ig("src/gen.ts\n");
assert!(rules.is_ignored("src/gen.ts", false));
assert!(!rules.is_ignored("a/src/gen.ts", false));
assert!(!rules.is_ignored("src/sub/gen.ts", false));
}
#[test]
fn star_matches_within_a_segment_only() {
let rules = ig("*.log\n");
assert!(rules.is_ignored("debug.log", false));
assert!(rules.is_ignored("a/b/debug.log", false));
assert!(!rules.is_ignored("debug.log.txt", false));
let rules = ig("src/*.ts\n");
assert!(rules.is_ignored("src/a.ts", false));
assert!(!rules.is_ignored("src/sub/a.ts", false));
}
#[test]
fn question_matches_one_char() {
let rules = ig("file?.ts\n");
assert!(rules.is_ignored("file1.ts", false));
assert!(rules.is_ignored("fileA.ts", false));
assert!(!rules.is_ignored("file.ts", false));
assert!(!rules.is_ignored("file12.ts", false));
}
#[test]
fn glob_is_code_point_granular() {
let rules = ig("file?.ts\n");
assert!(rules.is_ignored("fileX.ts", false));
assert!(rules.is_ignored("fileé.ts", false)); assert!(!rules.is_ignored("fileéé.ts", false));
let rules = ig("a?.ts\n");
assert!(rules.is_ignored("a😀.ts", false));
let rules = ig("x[a-z].ts\n");
assert!(rules.is_ignored("xa.ts", false));
assert!(!rules.is_ignored("xé.ts", false));
let rules = ig("*.ts\n");
assert!(rules.is_ignored("é.ts", false));
let rules = ig("x*é.ts\n");
assert!(rules.is_ignored("xéé.ts", false));
assert!(!rules.is_ignored("xéc.ts", false));
}
#[test]
fn char_class_and_range_and_negation() {
let rules = ig("file[0-9].ts\n");
assert!(rules.is_ignored("file3.ts", false));
assert!(!rules.is_ignored("fileA.ts", false));
let rules = ig("file[!0-9].ts\n");
assert!(rules.is_ignored("fileA.ts", false));
assert!(!rules.is_ignored("file3.ts", false));
let rules = ig("file[abc].ts\n");
assert!(rules.is_ignored("filea.ts", false));
assert!(!rules.is_ignored("filed.ts", false));
}
#[test]
fn char_class_caret_negation_and_literal_caret() {
let rules = ig("file[^0-9].ts\n");
assert!(rules.is_ignored("fileA.ts", false));
assert!(!rules.is_ignored("file3.ts", false));
let rules = ig("lit[ab^].ts\n");
assert!(rules.is_ignored("lit^.ts", false));
assert!(rules.is_ignored("lita.ts", false));
assert!(!rules.is_ignored("litc.ts", false));
}
#[test]
fn dir_only_matches_directories_not_files() {
let rules = ig("build/\n");
assert!(rules.is_ignored("build", true));
assert!(rules.is_ignored("build/out.js", false));
assert!(rules.is_ignored("a/build/out.js", false));
assert!(rules.is_ignored("a/build", true));
assert!(!rules.is_ignored("build", false));
}
#[test]
fn plain_name_matches_both_file_and_dir_contents() {
let rules = ig("node_modules\n");
assert!(rules.is_ignored("node_modules", true));
assert!(rules.is_ignored("node_modules", false));
assert!(rules.is_ignored("node_modules/pkg/index.js", false));
assert!(rules.is_ignored("packages/x/node_modules/pkg/index.js", false));
}
#[test]
fn leading_double_star_matches_any_depth() {
let rules = ig("**/gen.ts\n");
assert!(rules.is_ignored("gen.ts", false));
assert!(rules.is_ignored("a/gen.ts", false));
assert!(rules.is_ignored("a/b/gen.ts", false));
}
#[test]
fn trailing_double_star_matches_contents() {
let rules = ig("logs/**\n");
assert!(rules.is_ignored("logs/today.log", false));
assert!(rules.is_ignored("logs/sub/today.log", false));
assert!(!rules.is_ignored("other/today.log", false));
assert!(!rules.is_ignored("logs", true));
}
#[test]
fn trailing_double_star_does_not_match_anchor_dir() {
let rules = ig("foo/**\n");
assert!(!rules.is_ignored("foo", true));
assert!(rules.is_ignored("foo/keep.ts", false));
assert!(rules.is_ignored("foo/sub", true));
assert!(rules.is_ignored("foo/sub/file.ts", false));
let rules = ig("foo/**\n!foo/keep.ts\n");
assert!(!rules.is_ignored("foo/keep.ts", false));
assert!(rules.is_ignored("foo/sub/file.ts", false));
}
#[test]
fn trailing_double_star_dir_only_spares_direct_files() {
let rules = ig("foo/**/\n");
assert!(!rules.is_ignored("foo", true));
assert!(!rules.is_ignored("foo/keep.ts", false));
assert!(rules.is_ignored("foo/sub", true));
assert!(rules.is_ignored("foo/sub/file.ts", false));
}
#[test]
fn middle_double_star_spans_directories() {
let rules = ig("a/**/b.ts\n");
assert!(rules.is_ignored("a/b.ts", false));
assert!(rules.is_ignored("a/x/b.ts", false));
assert!(rules.is_ignored("a/x/y/b.ts", false));
assert!(!rules.is_ignored("x/a/b.ts", false));
}
#[test]
fn multiple_double_stars_span_independently() {
let rules = ig("a/**/b/**/c.ts\n");
assert!(rules.is_ignored("a/b/c.ts", false)); assert!(rules.is_ignored("a/x/b/y/c.ts", false)); assert!(rules.is_ignored("a/x/y/b/z/w/c.ts", false)); assert!(!rules.is_ignored("a/b/x.ts", false)); assert!(!rules.is_ignored("a/x/c.ts", false)); assert!(!rules.is_ignored("x/a/b/c.ts", false)); }
#[test]
fn negation_reincludes() {
let rules = ig("*.ts\n!keep.ts\n");
assert!(rules.is_ignored("drop.ts", false));
assert!(rules.is_ignored("a/drop.ts", false));
assert!(!rules.is_ignored("keep.ts", false));
assert!(!rules.is_ignored("a/keep.ts", false));
}
#[test]
fn negation_order_is_last_match_wins() {
let rules = ig("*.ts\n!keep.ts\nkeep.ts\n");
assert!(rules.is_ignored("keep.ts", false));
}
#[test]
fn stack_is_reincluded_reports_explicit_negation_polarity() {
let mut stack = IgnoreStack::new();
stack.push_formatignore("", "*.ts\n!keep.ts\n");
assert!(stack.is_reincluded("keep.ts", PathKind::File)); assert!(!stack.is_reincluded("drop.ts", PathKind::File)); assert!(!stack.is_reincluded("other.js", PathKind::File));
let mut stack = IgnoreStack::new();
stack.push_formatignore("", "!build/\n");
assert!(stack.is_reincluded("build", PathKind::Directory));
assert!(!stack.is_reincluded("build", PathKind::File));
assert!(!stack.is_reincluded("src", PathKind::Directory));
let mut stack = IgnoreStack::new();
stack.push_formatignore("", "build/\n");
assert!(!stack.is_reincluded("build", PathKind::Directory));
assert!(stack.is_ignored("build", PathKind::Directory));
}
#[test]
fn stack_negation_under_only_counts_anchored_negations() {
let mut stack = IgnoreStack::new();
stack.push_formatignore("", "!dist/keep.ts\n");
let negation = stack.negation_under("dist").unwrap();
assert_eq!(negation.anchor_depth, 0);
assert_eq!(negation.source, IgnoreSource::Formatignore);
assert_eq!(
negation.rules,
[NegationRule {
leading: vec!["dist".into(), "keep.ts".into()],
tail: NegationTail::None,
pattern: "dist/keep.ts".into(),
}]
);
assert_eq!(stack.negation_under("build"), None);
let mut stack = IgnoreStack::new();
stack.push_formatignore(
"",
"!dist/*.ts\n!/build/**/keep.ts\n!out/sub/\n!dist/a\\ b/*/x.ts\n!dist/deep/**\n",
);
let dist = stack.negation_under("dist").unwrap();
assert_eq!(
dist.rules
.iter()
.map(|r| (r.leading.join("/"), r.tail, r.pattern.as_str()))
.collect::<Vec<_>>(),
[
("dist".to_string(), NegationTail::Children, "dist/*.ts"),
(
"dist/a b".to_string(),
NegationTail::Descendants,
"dist/a\\ b/*/x.ts"
),
(
"dist/deep".to_string(),
NegationTail::Descendants,
"dist/deep/**"
),
]
);
let anchored = stack.negation_under("build").unwrap();
assert_eq!(anchored.rules.len(), 1);
assert_eq!(anchored.rules[0].leading, ["build"]);
assert_eq!(anchored.rules[0].tail, NegationTail::Descendants);
assert_eq!(anchored.rules[0].pattern, "build/**/keep.ts");
let dir_only = stack.negation_under("out").unwrap();
assert_eq!(dir_only.rules.len(), 1);
assert_eq!(dir_only.rules[0].leading, ["out", "sub"]);
assert_eq!(dir_only.rules[0].tail, NegationTail::None);
assert_eq!(dir_only.rules[0].pattern, "out/sub/");
assert_eq!(stack.negation_under("out/sub"), None);
let mut stack = IgnoreStack::new();
stack.push_formatignore("", "!dist/**\\/x.ts\n");
assert_eq!(
stack.negation_under("dist").unwrap().rules[0].pattern,
"dist/**\\/x.ts"
);
let mut stack = IgnoreStack::new();
stack.push_formatignore("", "!dist/\n");
assert_eq!(stack.negation_under("dist"), None);
assert!(stack.is_reincluded("dist", PathKind::Directory));
let mut stack = IgnoreStack::new();
stack.push_formatignore("", "!keep.ts\n");
assert_eq!(stack.negation_under("dist"), None);
let mut stack = IgnoreStack::new();
stack.push_formatignore("", "dist/keep.ts\n");
assert_eq!(stack.negation_under("dist"), None);
let stack = IgnoreStack::new();
assert_eq!(stack.negation_under("dist"), None);
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "!dist/keep.ts\n");
assert_eq!(stack.negation_under("dist"), None);
}
#[test]
fn stack_gitignore_exclusion_reads_the_gitignore_layers_alone() {
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "same/\nvendor/lib/\n");
stack.push_formatignore("", "same/\nvendor/\nonly.ts\n");
let same = stack
.gitignore_exclusion("same", PathKind::Directory)
.unwrap();
assert_eq!(
(same.depth, same.anchor_depth, same.source),
(1, 0, IgnoreSource::Gitignore)
);
assert_eq!(
stack.exclusion("same", PathKind::Directory).unwrap().source,
IgnoreSource::Formatignore
);
let lib = stack
.gitignore_exclusion("vendor/lib", PathKind::Directory)
.unwrap();
assert_eq!(lib.depth, 2);
assert_eq!(
stack
.exclusion("vendor/lib", PathKind::Directory)
.unwrap()
.depth,
1
);
assert_eq!(stack.gitignore_exclusion("only.ts", PathKind::File), None);
assert!(stack.is_ignored("only.ts", PathKind::File));
}
#[test]
fn stack_negation_under_respects_layer_anchor() {
let mut stack = IgnoreStack::new();
stack.push_prettierignore("foo", "!dist/keep.ts\n");
let negation = stack.negation_under("foo/dist").unwrap();
assert_eq!(negation.anchor_depth, 1);
assert_eq!(negation.source, IgnoreSource::Prettierignore);
assert_eq!(negation.rules[0].leading, ["dist", "keep.ts"]);
assert_eq!(stack.negation_under("dist"), None);
let mut stack = IgnoreStack::new();
stack.push_formatignore("", "!src/gen/keep.ts\n");
assert!(stack.negation_under("src/gen").is_some());
assert!(stack.negation_under("src").is_some());
assert_eq!(stack.negation_under("src/gen/keep.ts"), None);
assert_eq!(stack.negation_under("src/other"), None);
let mut stack = IgnoreStack::new();
stack.push_formatignore("dist", "!sub/keep.ts\n");
assert_eq!(stack.negation_under("dist"), None);
assert!(stack.negation_under("dist/sub").is_some());
}
#[test]
fn stack_negation_under_reports_the_deepest_layer_holding_one() {
let mut stack = IgnoreStack::new();
stack.push_formatignore("", "!pkg/dist/a.ts\n!pkg/dist/sub/**\n");
stack.push_prettierignore("pkg", "!dist/b.ts\n");
stack.push_formatignore("lib", "!dist/c.ts\n");
let negation = stack.negation_under("pkg/dist").unwrap();
assert_eq!(
(negation.anchor_depth, negation.source),
(1, IgnoreSource::Prettierignore)
);
assert_eq!(
negation
.rules
.iter()
.map(|r| (r.leading.join("/"), r.pattern.as_str()))
.collect::<Vec<_>>(),
[
("dist/a.ts".to_string(), "dist/a.ts"),
("dist/sub".to_string(), "dist/sub/**"),
("dist/b.ts".to_string(), "dist/b.ts"),
]
);
}
#[test]
fn cannot_reinclude_under_excluded_dir() {
let rules = ig("build/\n!build/keep.js\n");
assert!(rules.is_ignored("build/keep.js", false));
}
#[test]
fn reincluding_the_dir_itself_allows_descendants() {
let rules = ig("build/\n!build/\n");
assert!(!rules.is_ignored("build/keep.js", false));
}
#[test]
fn escaped_special_characters_are_literal() {
let rules = ig("\\#notacomment.ts\n");
assert!(rules.is_ignored("#notacomment.ts", false));
let rules = ig("\\!bang.ts\n");
assert!(rules.is_ignored("!bang.ts", false));
let rules = ig("a\\*b.ts\n");
assert!(rules.is_ignored("a*b.ts", false));
assert!(!rules.is_ignored("axb.ts", false));
}
#[test]
fn trailing_backslash_is_an_invalid_pattern() {
let rules = ig("bar\\\n");
assert!(rules.is_empty()); assert!(!rules.is_ignored("bar", false));
assert!(!rules.is_ignored("bar\\", false));
let rules = ig("foo\\/\n");
assert!(rules.is_empty());
assert!(!rules.is_ignored("foo", true));
let rules = ig("foo\\\\\n");
assert!(!rules.is_empty());
assert!(rules.is_ignored("foo\\", false));
assert!(!rules.is_ignored("foo", false));
}
#[test]
fn bom_escaped_slash_empty_segment_and_open_class_parse_like_git() {
let rules = ig("\u{feff}first.ts\nsecond.ts\n");
assert!(rules.is_ignored("first.ts", false));
assert!(rules.is_ignored("second.ts", false));
let rules = ig("a\\/b.ts\n");
assert!(rules.is_ignored("a/b.ts", false));
assert!(!rules.is_ignored("a\\", true));
assert!(ig("foo//\n").is_empty());
assert!(ig("a//b\n").is_empty());
assert!(ig("q[bc.ts\n").is_empty());
assert!(!ig("q[bc.ts\n").is_ignored("q[bc.ts", false));
let rules = ig("x[z-a].ts\n");
assert!(rules.is_ignored("xz.ts", false));
assert!(!rules.is_ignored("xa.ts", false));
for (rule, hit, miss) in [
("x[/a].ts\n", "xa.ts", "sub/xa.ts"),
("y[\\/a].ts\n", "ya.ts", "yb.ts"),
("z[!/a].ts\n", "zb.ts", "za.ts"),
("w[]/a].ts\n", "w].ts", "wb.ts"),
("v[/a]/k.ts\n", "va/k.ts", "va/sub/k.ts"),
] {
let rules = ig(rule);
assert!(rules.is_ignored(hit, false), "{rule:?} should ignore {hit}");
assert!(
!rules.is_ignored(miss, false),
"{rule:?} should not ignore {miss}"
);
}
assert!(ig("q[/bc.ts\n").is_empty());
let rules = ig("a.ts\nb.ts\0junk.ts\nc.ts\0\r\n");
assert!(rules.is_ignored("b.ts", false));
assert!(!rules.is_ignored("b.ts\0junk.ts", false));
assert!(!rules.is_ignored("junk.ts", false));
assert!(rules.is_ignored("c.ts", false));
}
#[test]
fn class_escapes_star_runs_escaped_double_star_and_final_cr_parse_like_git() {
let rules = ig("x[\\a-c].ts\n");
assert!(rules.is_ignored("xb.ts", false));
assert!(!rules.is_ignored("x-.ts", false));
let rules = ig("x[a\\-c].ts\n");
assert!(rules.is_ignored("x-.ts", false));
assert!(!rules.is_ignored("xb.ts", false));
assert!(ig("x[a-\\c].ts\n").is_ignored("xb.ts", false));
let rules = ig("x[]-a].ts\n");
assert!(rules.is_ignored("x^.ts", false));
assert!(!rules.is_ignored("x-.ts", false));
assert!(ig("x[[-\\].ts\n").is_empty());
let rules = ig("a/***/b.ts\n");
assert!(rules.is_ignored("a/b.ts", false));
assert!(rules.is_ignored("a/x/y/b.ts", false));
let rules = ig("**\\/f.ts\n");
assert!(!rules.is_ignored("f.ts", false));
assert!(rules.is_ignored("s/f.ts", false));
assert!(ig("**/f.ts\n").is_ignored("f.ts", false));
assert!(ig("a.ts\nb.ts\r").is_ignored("b.ts", false));
}
#[test]
fn crlf_line_endings_parse_like_lf() {
let rules = ig("*.log\r\nbuild/\r\n!keep.log\r\n");
assert!(rules.is_ignored("debug.log", false));
assert!(rules.is_ignored("build/out.js", false));
assert!(!rules.is_ignored("keep.log", false));
assert!(!rules.is_ignored("src/app.ts", false));
}
#[test]
fn trailing_spaces_are_trimmed_unless_escaped() {
let rules = ig("foo.ts \n");
assert!(rules.is_ignored("foo.ts", false));
let rules = ig("café \n");
assert!(rules.is_ignored("café", false));
let rules = ig("foo\\ \n");
assert!(rules.is_ignored("foo ", false));
assert!(!rules.is_ignored("foo", false));
}
#[test]
fn realistic_prettierignore() {
let rules = ig(
"# generated\nnode_modules\ndist/\nbuild/\n*.min.js\n.svelte-kit/\ntests/fixtures/\n",
);
assert!(rules.is_ignored("dist/app.js", false));
assert!(rules.is_ignored("src/vendor/jquery.min.js", false));
assert!(rules.is_ignored("tests/fixtures/x/input.svelte", false));
assert!(rules.is_ignored(".svelte-kit/generated/root.svelte", false));
assert!(!rules.is_ignored("src/app.ts", false));
assert!(!rules.is_ignored("src/main.js", false));
}
#[test]
fn stack_single_root_layer_matches_ignore_rules() {
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "build/\n*.min.js\n!keep.min.js\n");
assert!(stack.is_ignored("build/out.js", PathKind::File));
assert!(stack.is_ignored("a/b.min.js", PathKind::File));
assert!(!stack.is_ignored("keep.min.js", PathKind::File));
assert!(!stack.is_ignored("src/app.ts", PathKind::File));
}
#[test]
fn stack_empty_and_blank_layers_ignore_nothing() {
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "");
stack.push_formatignore("", "# just a comment\n");
assert!(!stack.is_ignored("a/x.ts", PathKind::File));
assert!(stack.has_gitignore_layers()); }
fn oracle_stack() -> IgnoreStack {
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "*.log\nbuild/\n!keep.log\n/rootonly.ts\nfloat.ts\n");
stack.push_gitignore("a", "!*.log\nb/\n");
stack.push_gitignore("a/b", "!special.ts\n");
stack
}
#[test]
fn stack_deeper_negation_reincludes() {
let stack = oracle_stack();
assert!(stack.is_ignored("debug.log", PathKind::File));
assert!(!stack.is_ignored("a/debug.log", PathKind::File));
assert!(!stack.is_ignored("a/keep.log", PathKind::File));
assert!(!stack.is_ignored("keep.log", PathKind::File));
}
#[test]
fn stack_root_anchored_vs_floating() {
let stack = oracle_stack();
assert!(stack.is_ignored("rootonly.ts", PathKind::File));
assert!(!stack.is_ignored("a/rootonly.ts", PathKind::File));
assert!(stack.is_ignored("float.ts", PathKind::File));
assert!(stack.is_ignored("a/float.ts", PathKind::File));
}
#[test]
fn stack_parent_prune_blocks_deeper_negation_across_files() {
let stack = oracle_stack();
assert!(stack.is_ignored("a/b/x.ts", PathKind::File));
assert!(stack.is_ignored("a/b/special.ts", PathKind::File));
assert!(stack.is_ignored("build/x.ts", PathKind::File));
assert!(stack.is_ignored("packages/foo/build/y.ts", PathKind::File));
}
#[test]
fn stack_deeper_anchor_matches_relative_to_its_directory() {
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "");
stack.push_gitignore("pkg", "src/gen.ts\n");
assert!(stack.is_ignored("pkg/src/gen.ts", PathKind::File));
assert!(!stack.is_ignored("src/gen.ts", PathKind::File));
assert!(!stack.is_ignored("pkg/sub/src/gen.ts", PathKind::File));
}
#[test]
fn stack_override_layer_applies_after_gitignore() {
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "build/\n");
stack.push_formatignore("", "!build/\n");
assert!(!stack.is_ignored("build/keep.ts", PathKind::File));
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "build/\n");
stack.push_formatignore("", "!build/keep.ts\n");
assert!(stack.is_ignored("build/keep.ts", PathKind::File));
let mut stack = IgnoreStack::new();
stack.push_formatignore("", "*.snap\n");
assert!(stack.is_ignored("a/x.snap", PathKind::File));
assert!(!stack.is_ignored("a/x.ts", PathKind::File));
}
#[test]
fn stack_override_layer_overrides_a_deeper_gitignore() {
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "build/\n");
stack.push_gitignore("a", "build/\n");
stack.push_formatignore("", "!build/\n");
assert!(!stack.is_ignored("a/build/x.ts", PathKind::File));
}
#[test]
fn stack_override_layers_are_hierarchical() {
let mut stack = IgnoreStack::new();
stack.push_formatignore("", "*.snap\n"); stack.push_formatignore("a", "!*.snap\n"); assert!(stack.is_ignored("x.snap", PathKind::File));
assert!(stack.is_ignored("b/x.snap", PathKind::File));
assert!(!stack.is_ignored("a/x.snap", PathKind::File)); assert!(!stack.is_ignored("a/deep/x.snap", PathKind::File));
let mut stack = IgnoreStack::new();
stack.push_formatignore("pkg", "gen.ts\n"); assert!(stack.is_ignored("pkg/gen.ts", PathKind::File));
assert!(stack.is_ignored("pkg/sub/gen.ts", PathKind::File));
assert!(!stack.is_ignored("gen.ts", PathKind::File)); }
#[test]
fn stack_pop_override_unwinds_a_layer() {
let mut stack = IgnoreStack::new();
stack.push_formatignore("", "*.snap\n");
stack.push_formatignore("a", "!*.snap\n");
assert!(!stack.is_ignored("a/x.snap", PathKind::File));
stack.pop_override();
assert!(stack.is_ignored("a/x.snap", PathKind::File));
}
#[test]
fn stack_pop_gitignore_unwinds_a_layer() {
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "*.log\n");
stack.push_gitignore("a", "!*.log\n");
assert!(!stack.is_ignored("a/x.log", PathKind::File));
stack.pop_gitignore();
assert!(stack.is_ignored("a/x.log", PathKind::File));
}
#[test]
fn stack_directory_query_for_pruning() {
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "build/\nnode_modules\n");
assert!(stack.is_ignored("build", PathKind::Directory));
assert!(stack.is_ignored("a/node_modules", PathKind::Directory));
assert!(!stack.is_ignored("src", PathKind::Directory));
}
#[test]
fn stack_is_ignored_leaf_skips_the_ancestor_prune() {
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "build/\n");
assert!(stack.is_ignored("build", PathKind::Directory));
assert!(stack.is_ignored_leaf("build", PathKind::Directory));
assert!(stack.is_ignored("build/app.ts", PathKind::File));
assert!(!stack.is_ignored_leaf("build/app.ts", PathKind::File));
assert!(stack.is_ignored("build/sub", PathKind::Directory));
assert!(!stack.is_ignored_leaf("build/sub", PathKind::Directory));
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "*.log\n");
assert!(stack.is_ignored("a/b/x.log", PathKind::File));
assert!(stack.is_ignored_leaf("a/b/x.log", PathKind::File));
assert!(!stack.is_ignored_leaf("a/b/x.ts", PathKind::File));
}
#[test]
fn stack_exclusion_names_the_shallowest_excluded_prefix_and_its_file() {
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "build/\n*.log\n");
stack.push_gitignore("sub", "deep/\n");
stack.push_formatignore("", "gen/\nbuild/keep/\n");
stack.push_prettierignore("pkg", "out/\n");
assert_eq!(
stack.exclusion("build/sub/a.ts", PathKind::File),
excluded(1, 0, IgnoreSource::Gitignore)
);
assert_eq!(
stack.exclusion("build/keep/a.ts", PathKind::File),
excluded(1, 0, IgnoreSource::Gitignore)
);
assert_eq!(
stack.exclusion("build", PathKind::Directory),
excluded(1, 0, IgnoreSource::Gitignore)
);
assert_eq!(
stack.exclusion("a/b/x.log", PathKind::File),
excluded(3, 0, IgnoreSource::Gitignore)
);
assert_eq!(
stack.exclusion("sub/deep/a.ts", PathKind::File),
excluded(2, 1, IgnoreSource::Gitignore)
);
assert_eq!(
stack.exclusion("gen/sub", PathKind::Directory),
excluded(1, 0, IgnoreSource::Formatignore)
);
assert_eq!(
stack.exclusion("pkg/out/a.ts", PathKind::File),
excluded(2, 1, IgnoreSource::Prettierignore)
);
assert_eq!(stack.exclusion("src/a.ts", PathKind::File), None);
assert!(!stack.is_ignored("src/a.ts", PathKind::File));
assert_eq!(stack.exclusion("", PathKind::Directory), None);
let mut reincluded = IgnoreStack::new();
reincluded.push_gitignore("", "build/\n");
reincluded.push_formatignore("", "!build/\n");
assert_eq!(reincluded.exclusion("build/sub", PathKind::Directory), None);
let mut overridden = IgnoreStack::new();
overridden.push_gitignore("", "!keep.ts\n");
overridden.push_prettierignore("", "keep.ts\n");
assert_eq!(
overridden.exclusion("keep.ts", PathKind::File),
excluded(1, 0, IgnoreSource::Prettierignore)
);
}
#[test]
fn stack_override_exclusion_sets_the_gitignore_layers_aside() {
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "vendor/\n");
stack.push_formatignore("", "vendor/x.ts\n");
stack.push_prettierignore("pkg", "out/*.ts\n");
assert_eq!(
stack.exclusion("vendor/x.ts", PathKind::File),
excluded(1, 0, IgnoreSource::Gitignore)
);
assert_eq!(
stack.override_exclusion("vendor/x.ts", PathKind::File),
excluded(2, 0, IgnoreSource::Formatignore)
);
assert_eq!(
stack.override_exclusion("vendor/y.ts", PathKind::File),
None
);
assert_eq!(
stack.override_exclusion("pkg/out/a.ts", PathKind::File),
excluded(3, 1, IgnoreSource::Prettierignore)
);
assert_eq!(
stack.override_exclusion("pkg/out/a.ts", PathKind::File),
stack.exclusion("pkg/out/a.ts", PathKind::File)
);
let mut reincluded = IgnoreStack::new();
reincluded.push_gitignore("", "build/\n");
reincluded.push_formatignore("", "!build/\n");
assert_eq!(
reincluded.override_exclusion("build/a.ts", PathKind::File),
None
);
assert_eq!(reincluded.exclusion("build/a.ts", PathKind::File), None);
}
#[test]
fn stack_override_layer_source_names_the_file_a_directory_reads() {
let mut stack = IgnoreStack::new();
assert_eq!(stack.override_layer_source(""), None);
stack.push_gitignore("", "a\n");
assert_eq!(stack.override_layer_source(""), None);
stack.push_prettierignore("", "b\n");
stack.push_formatignore("src", "c\n");
assert_eq!(
stack.override_layer_source(""),
Some(IgnoreSource::Prettierignore)
);
assert_eq!(
stack.override_layer_source("src"),
Some(IgnoreSource::Formatignore)
);
assert_eq!(
stack.override_layer_source("./src/"),
Some(IgnoreSource::Formatignore)
);
assert_eq!(stack.override_layer_source("lib"), None);
stack.pop_override();
assert_eq!(stack.override_layer_source("src"), None);
}
}