Expand description
gitignore-style path matching for tsv’s file discovery.
tsv is non-configurable for style, but file scope is the one sanctioned
carve-out: gitignore-shaped files say which files tsv format reformats.
This crate is the shared, pure-Rust matcher behind that feature — used
natively by the tsv CLI and, through tsv’s npm packages, by the JS CLI and the VS Code
extension. Part of tsv. It implements the
gitignore(5) pattern format,
the same grammar prettier matches via its ignore dependency and git matches
for .gitignore.
The matcher is one type, IgnoreStack — a hierarchical, git-faithful evaluator: a
stack of per-directory .gitignore layers plus a parallel stack of per-directory
override layers (.formatignore), each layer one ignore file’s rules anchored at its
directory, evaluated against paths relative to the format root (Paths) with
git’s last-match-wins + parent-directory-prune semantics (oracle:
git check-ignore --no-index). At each path level every .gitignore layer is
evaluated, then every override layer, so an override-layer ! can re-include a
gitignore’d path (subject to git’s parent-dir rule) and a deeper layer wins over a
shallower one.
Locating the files, resolving paths relative to them, and walking directories
are the callers’ jobs. The caller decides which files become layers (tsv reads
.formatignore hierarchically and, inside a git repo, .prettierignore
hierarchically too — each shadowed by a sibling .formatignore); a layer records
which of the three it was read from, which changes nothing about matching and is
what a diagnostic about a match names (IgnoreStack::exclusion).
use tsv_ignore::{IgnoreStack, PathKind};
let mut stack = IgnoreStack::new();
stack.push_gitignore("", "build/\n*.log\n"); // root .gitignore
stack.push_gitignore("a", "!*.log\n"); // a/.gitignore re-includes logs
stack.push_formatignore("", "*.snap\n"); // root override layer, applied after the gitignores
assert!(stack.is_ignored("build/out.js", PathKind::File));
assert!(stack.is_ignored("debug.log", PathKind::File));
assert!(!stack.is_ignored("a/debug.log", PathKind::File)); // a deeper `!` wins
assert!(stack.is_ignored("test/x.snap", PathKind::File)); // via the override layer
assert!(stack.is_ignored("build", PathKind::Directory)); // `build/` matches a directory
assert!(!stack.is_ignored("build", PathKind::File)); // ...never a file of that name§Differences from git
The .gitignore half answers as git check-ignore --no-index does, except:
- No index. A matcher reads rules, not git’s index, so a tracked file a rule matches
is ignored here, where plain
git check-ignore(andgit status) exempt it. A caller that wants git’s view of a tracked file asks git. - Always case-sensitive. Git on macOS and Windows sets
core.ignorecaseand matches case-insensitively there; reading machine-local git config would make the answer differ between machines. - Only
.gitignorefiles..git/info/excludeandcore.excludesFileare local, uncommitted sources, not read for the same reason. - Per code point.
?,*and[...]match per Unicode code point, where git matches per byte (and prettier’signoreper UTF-16 unit), so the three can disagree on a non-ASCII name. - No POSIX bracket classes.
[[:alpha:]]is read as ordinary class members, as prettier’s matcher reads it. - A
**glued to a literal prefix (foo**/bar.ts) is two stars within one segment, as gitignore(5) says; git’s matcher crosses directories there.
§A walk
A directory’s own .gitignore never classifies that directory — its parent’s rules do —
so a walk asks about a child directory BEFORE pushing the child’s layers, then descends,
then pops them. Below the root, only a directory the stack did not ignore is entered, so
a file inside is asked with is_ignored_leaf, which
skips the ancestor walk is_ignored repeats:
use tsv_ignore::{IgnoreStack, PathKind};
// walks `tree` (a stand-in filesystem: each directory's `.gitignore` and entries)
fn walk(stack: &mut IgnoreStack, dir: &str, tree: &Tree, found: &mut Vec<String>) {
let pushed = tree.gitignore(dir).is_some();
if let Some(content) = tree.gitignore(dir) {
stack.push_gitignore(dir, content);
}
for (name, is_dir) in tree.children(dir) {
let child = if dir.is_empty() { name.to_string() } else { format!("{dir}/{name}") };
if is_dir {
if !stack.is_ignored_leaf(&child, PathKind::Directory) {
walk(stack, &child, tree, found);
}
} else if !stack.is_ignored_leaf(&child, PathKind::File) {
found.push(child);
}
}
if pushed {
stack.pop_gitignore();
}
}
let mut found = Vec::new();
walk(&mut IgnoreStack::new(), "", &Tree, &mut found);
assert_eq!(found, ["src/b.ts", "a.ts"]);§Paths
Every path a query or a push takes is relative to the format root — the directory
the caller scopes its walk to, typically the repository root — and /-separated:
\is never a separator, on any platform — a caller on Windows converts first: hand astd::path::Pathover as itsNormalcomponents joined by/;- empty and
.segments are dropped, so a leading/(/aisa), a doubled//and a./change nothing; ..is a literal segment, never a step up — a caller resolves it first;- a trailing
/is an empty segment, dropped like the rest: whether a path is a directory is thePathKinda query takes, never its spelling; - a name that is not UTF-8 is handed over in its lossy spelling, as the
tsvCLI does. Each maximal invalid sequence is then one U+FFFD, which a?matches where git needs one per byte (the per-code-point difference above), and two names that differ only inside such a sequence read alike.
§Stability
The public signatures follow 0.x semver like every tsv crate: a breaking change ships
only in a minor release. The items hidden from these docs — the IgnoreStack queries
that exist for tsv’s discovery policy, and the Negation* types one of them returns —
are tsv’s own plumbing for tsv_discover, outside semver.
Structs§
- Exclusion
- Where
IgnoreStack::exclusionfound a path ignored. Both depths count the queried path’s segments assplit_segmentssplits it. - Ignore
Stack - A hierarchical, git-faithful ignore evaluator: a stack of per-directory
.gitignorelayers plus a parallel stack of per-directory override layers — the override layers (.formatignore/.prettierignore), read after every.gitignoreso they win over it.
Enums§
- Ignore
Source - The file a layer’s rules were read from — what a diagnostic about an ignored path
names, and what decides how it is undone: a
.gitignore’d path is re-included from an override layer, which is read after every.gitignore, while an override layer’s own rule is the user’s to narrow. - Path
Kind - Whether a queried path is a directory — the one fact about a path the matcher cannot
read off its text, and the one trailing-
/patterns (build/) turn on: a directory pattern matches a directory, never a file of the same name.
Functions§
- split_
segments - A
/-separated path’s meaningful segments — the unit everyIgnoreStackquery splits a path into, and the oneExclusion’s depths count in.