diff --git a/crates/tsv_ignore/src/glob.rs b/crates/tsv_ignore/src/glob.rs index 077054625..38e2ccab0 100644 --- a/crates/tsv_ignore/src/glob.rs +++ b/crates/tsv_ignore/src/glob.rs @@ -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` 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, + /// 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(), } } } @@ -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 { @@ -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, }, } @@ -207,13 +225,27 @@ 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` 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)); @@ -221,12 +253,12 @@ fn glob_seg_match(toks: &[Tok], seg: &[char]) -> bool { 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; } } @@ -234,8 +266,16 @@ fn glob_seg_match(toks: &[Tok], seg: &[char]) -> bool { // 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; } diff --git a/crates/tsv_ignore/src/lib.rs b/crates/tsv_ignore/src/lib.rs index 01bd3b91b..9288aaef9 100644 --- a/crates/tsv_ignore/src/lib.rs +++ b/crates/tsv_ignore/src/lib.rs @@ -345,13 +345,18 @@ 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. @@ -359,11 +364,19 @@ fn split_path(path: &str) -> Vec { 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> { - 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` @@ -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]