Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 56 additions & 16 deletions crates/tsv_ignore/src/glob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,28 @@ pub(crate) enum ClassItem {
Range(char, char),
}

/// A path segment paired with its chars, collected once. `match_segments` runs
/// the same path against many rules across many ancestor prefixes; collecting
/// each segment's chars here (in [`path_segments`](super::path_segments), once
/// per path) keeps the inner glob match from re-collecting them per rule. `text`
/// powers the cheap `&str` anchor comparison in `Layer::relativize`.
/// A path segment, classified once per query as all-ASCII or not.
///
/// `match_segments` runs the same path against many rules across many ancestor
/// prefixes, so the inner glob match wants O(1) indexed character access. For an
/// ASCII segment — every segment of essentially every real path — the segment's
/// own bytes already *are* that array, so the flag buys the whole amortization
/// with no collection at all: one `is_ascii` word-scan per segment replaces the
/// `Vec<char>` it used to collect (a heap allocation, plus its `realloc` growth
/// chain, per segment per query). `text` also powers the cheap `&str` anchor
/// comparison in `Layer::relativize`.
#[derive(Debug)]
pub(crate) struct PathSeg<'a> {
pub(crate) text: &'a str,
chars: Vec<char>,
/// Whether `text` is wholly ASCII, so a byte index is a character index.
ascii: bool,
}

impl<'a> PathSeg<'a> {
pub(crate) fn new(text: &'a str) -> Self {
Self {
text,
chars: text.chars().collect(),
ascii: text.is_ascii(),
}
}
}
Expand Down Expand Up @@ -169,6 +175,18 @@ fn class_matches(negated: bool, items: &[ClassItem], c: char) -> bool {
hit != negated
}

/// The `char` at byte offset `i` of a **non-ASCII** segment (`i` must be a char
/// boundary), paired with its UTF-8 width. Only this path decodes UTF-8; an
/// ASCII segment reads its byte inline in [`glob_seg_match`], which keeps that
/// loop indexing a hoisted byte slice — the shape the win depends on, since it
/// is what lets the bounds check and the stride fold away.
fn char_at(s: &str, i: usize) -> (char, usize) {
// `i` is a char boundary, so this always yields; the fallback is unreachable
// and, being a replacement char, could only fail to match anyway.
let c = s[i..].chars().next().unwrap_or(char::REPLACEMENT_CHARACTER);
(c, c.len_utf8())
}

/// Matches a pattern's segments against a path's segments, with `**` consuming
/// zero or more path segments.
pub(crate) fn match_segments(pat: &[Seg], path: &[PathSeg<'_>]) -> bool {
Expand Down Expand Up @@ -198,7 +216,7 @@ pub(crate) fn match_segments(pat: &[Seg], path: &[PathSeg<'_>]) -> bool {
}
}
Some((Seg::Glob(toks), rest)) => match path.split_first() {
Some((head, tail)) if glob_seg_match(toks, &head.chars) => match_segments(rest, tail),
Some((head, tail)) if glob_seg_match(toks, head) => match_segments(rest, tail),
_ => false,
},
}
Expand All @@ -207,35 +225,57 @@ pub(crate) fn match_segments(pat: &[Seg], path: &[PathSeg<'_>]) -> bool {
/// Matches one path segment against a segment's tokens. Classic two-pointer
/// glob match with single-star backtracking; segments are short so this is
/// cheap.
fn glob_seg_match(toks: &[Tok], seg: &[char]) -> bool {
///
/// Both cursors index *bytes*, but every advance steps one whole code point, so
/// `?`, `*` and `[...]` stay code-point granular — the granularity
/// `glob_is_code_point_granular` pins. `si` is therefore always on a char
/// boundary. On an ASCII segment (the universal case) the character *is* the
/// byte and the step is a constant 1, so a read costs exactly what indexing a
/// pre-collected `Vec<char>` did; `seg.ascii` is loop-invariant, so the decode
/// branch unswitches rather than running per character.
fn glob_seg_match(toks: &[Tok], seg: &PathSeg<'_>) -> bool {
let bytes = seg.text.as_bytes();
let mut ti = 0;
let mut si = 0;
// (token index after the last `*`, path index where that `*` started)
// (token index after the last `*`, byte offset where that `*` started)
let mut star: Option<(usize, usize)> = None;
while si < seg.len() {
while si < bytes.len() {
if ti < toks.len() {
let (c, width) = if seg.ascii {
(char::from(bytes[si]), 1)
} else {
char_at(seg.text, si)
};
let matched = match &toks[ti] {
Tok::Star => {
star = Some((ti + 1, si));
ti += 1;
continue;
}
Tok::Question => true,
Tok::Lit(c) => *c == seg[si],
Tok::Class { negated, items } => class_matches(*negated, items, seg[si]),
Tok::Lit(lit) => *lit == c,
Tok::Class { negated, items } => class_matches(*negated, items, c),
};
if matched {
ti += 1;
si += 1;
si += width;
continue;
}
}
// mismatch (or tokens exhausted) — backtrack to the last `*` and let it
// swallow one more character
if let Some((sti, ssi)) = star {
ti = sti;
si = ssi + 1;
star = Some((sti, ssi + 1));
// `ssi` was recorded inside the loop, so it is a valid char boundary
// below the segment's length — advancing by that char's width keeps
// it one.
let width = if seg.ascii {
1
} else {
char_at(seg.text, ssi).1
};
si = ssi + width;
star = Some((sti, si));
} else {
return false;
}
Expand Down
43 changes: 33 additions & 10 deletions crates/tsv_ignore/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,25 +345,38 @@ impl IgnoreStack {
}
}

/// Split a `/`-separated path into its meaningful segments, dropping empty and
/// `.` components (so `""`, `"."`, `"a//b"`, and `"./a"` all normalize cleanly).
/// The shared primitive behind every path-to-segments conversion here.
/// Whether one `/`-delimited component of a path is a meaningful segment. Empty
/// and `.` components are dropped, so `""`, `"."`, `"a//b"`, and `"./a"` all
/// normalize cleanly. The single definition of "segment" — every
/// path-to-segments conversion here filters on this, each keeping its own split
/// so the hot one ([`path_segments`]) is free to pre-size.
fn is_segment(component: &str) -> bool {
!component.is_empty() && component != "."
}

/// A `/`-separated path's meaningful segments.
fn split_segments(path: &str) -> Vec<&str> {
path.split('/')
.filter(|s| !s.is_empty() && *s != ".")
.collect()
path.split('/').filter(|s| is_segment(s)).collect()
}

/// [`split_segments`] into owned segments, for a layer's stored anchor.
fn split_path(path: &str) -> Vec<String> {
split_segments(path).into_iter().map(String::from).collect()
}

/// [`split_segments`] into [`PathSeg`]s, collecting each segment's chars once so
/// the glob matcher never re-collects them across rules/prefixes. The form every
/// path query (`is_ignored`, `is_reincluded`) feeds into the matcher.
/// [`split_segments`] into [`PathSeg`]s — the form every path query
/// (`is_ignored`, `is_ignored_leaf`, `is_reincluded`) feeds into the matcher.
///
/// Built in **one** pre-sized pass, unlike [`split_path`]: this runs per file
/// *and* per ancestor prefix during discovery, where neither collecting an
/// intermediate `Vec<&str>` first (`PathSeg` is wider, so `collect` cannot reuse
/// that allocation) nor the doubling growth chain is worth paying for. The
/// separator count is an upper bound — empty and `.` components only make it
/// slack.
fn path_segments(path: &str) -> Vec<PathSeg<'_>> {
split_segments(path).into_iter().map(PathSeg::new).collect()
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
}

/// Walk `path`'s ancestors top-down, returning `true` as soon as `polarity`
Expand Down Expand Up @@ -565,6 +578,16 @@ mod tests {
let rules = ig("x[a-z].ts\n");
assert!(rules.is_ignored("xa.ts", false));
assert!(!rules.is_ignored("xé.ts", false));

// `*` backtracking advances by whole code points as well — the retry
// after a mismatch resumes one *character* past the star, not one byte,
// so a multibyte segment neither mis-aligns nor (since the matcher
// indexes bytes) lands mid-character.
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]
Expand Down