Skip to main content

Crate tsv_ignore

Crate tsv_ignore 

Source
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 (and git 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.ignorecase and matches case-insensitively there; reading machine-local git config would make the answer differ between machines.
  • Only .gitignore files. .git/info/exclude and core.excludesFile are 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’s ignore per 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 a std::path::Path over as its Normal components joined by /;
  • empty and . segments are dropped, so a leading / (/a is a), 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 the PathKind a query takes, never its spelling;
  • a name that is not UTF-8 is handed over in its lossy spelling, as the tsv CLI 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::exclusion found a path ignored. Both depths count the queried path’s segments as split_segments splits it.
IgnoreStack
A hierarchical, git-faithful ignore evaluator: a stack of per-directory .gitignore layers plus a parallel stack of per-directory override layers — the override layers (.formatignore / .prettierignore), read after every .gitignore so they win over it.

Enums§

IgnoreSource
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.
PathKind
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 every IgnoreStack query splits a path into, and the one Exclusion’s depths count in.