feat: Prefiltering for regex and multigrep (#361)

This commit is contained in:
Dmitriy Kovalenko
2026-04-09 10:33:28 -07:00
committed by GitHub
parent 7979004d77
commit 0fd47ab72d
13 changed files with 1312 additions and 245 deletions
Generated
+1
View File
@@ -738,6 +738,7 @@ dependencies = [
"rand",
"rayon",
"regex",
"regex-syntax",
"serde",
"serde_json",
"smallvec",
+1
View File
@@ -42,6 +42,7 @@ parking_lot = "0.12"
pathdiff = "0.2.1"
rayon = "1.8.0"
regex = "1.11"
regex-syntax = "0.8"
smallvec = { version = "1.13", features = ["const_generics", "union"] }
thiserror = "2.0.10"
tracing = "0.1"
+2
View File
@@ -4,6 +4,8 @@ extend-exclude = ["/CHANGELOG.md", "data/filetypes/base.lua"]
[default.extend-words]
noice = "noice"
fo = "fo"
ba = "ba"
ue = "ue"
[default]
extend-ignore-re = [
+3
View File
@@ -495,12 +495,15 @@ pub unsafe extern "C" fn fff_multi_grep(
trim_whitespace: false,
};
let overlay_guard = picker.bigram_overlay().map(|o| o.read());
let result = fff::multi_grep_search(
picker.get_files(),
&patterns,
constraint_refs,
&options,
picker.cache_budget(),
picker.bigram_index(),
overlay_guard.as_deref(),
None,
);
let grep_result = FffGrepResult::from_core(&result);
+1
View File
@@ -51,6 +51,7 @@ once_cell = { workspace = true }
parking_lot = { workspace = true }
pathdiff = { workspace = true }
regex = { workspace = true }
regex-syntax = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
smartstring = { version = "1.0.1", features = ["serde"] }
+6
View File
@@ -518,4 +518,10 @@ impl BigramOverlay {
pub(crate) fn tombstones(&self) -> &[u64] {
&self.tombstones
}
/// Get all modified file indices (for conservative overlay merging when
/// we can't extract precise bigrams, e.g. regex patterns).
pub(crate) fn modified_indices(&self) -> Vec<usize> {
self.modified.keys().copied().collect()
}
}
+908
View File
@@ -0,0 +1,908 @@
//! Regex → bigram decomposition for the inverted bigram index.
//!
//! Parses a regex pattern with `regex-syntax`, walks the HIR to extract
//! guaranteed bigram keys (u16), and evaluates them as an AND/OR query tree
//! against [`BigramFilter`]'s inverted posting lists.
//!
//! Two bigram types are extracted:
//! - **Consecutive** (gap=0): adjacent byte pairs `(pattern[i], pattern[i+1])`
//! - **Sparse-1** (gap=1): pairs across a single-byte wildcard, e.g. `a.b → (a,b)`
//!
//! The sparse-1 extraction is the key feature: regex patterns like `foo.bar`
//! yield the cross-boundary sparse-1 bigram `(o,b)` that provides strong
//! filtering even when the `.` prevents any consecutive cross-boundary bigram.
use crate::bigram_filter::BigramFilter;
use regex_syntax::hir::{Class, Hir, HirKind};
use smallvec::SmallVec;
use std::borrow::Cow;
/// Maximum byte values to enumerate from a character class.
/// Larger classes are treated as unknown (no bigram extractable).
const MAX_CLASS_EXPAND: usize = 16;
#[inline]
fn consec_key(a: u8, b: u8) -> Option<u16> {
let al = a.to_ascii_lowercase();
let bl = b.to_ascii_lowercase();
if (32..=126).contains(&al) && (32..=126).contains(&bl) {
Some((al as u16) << 8 | bl as u16)
} else {
None
}
}
#[derive(Debug, Clone)]
pub enum BigramQuery {
Any,
/// A consecutive bigram key to look up in the main index.
Consec(u16),
/// A skip-1 bigram key to look up in the skip sub-index.
Skip1(u16),
/// All children must match (intersect posting lists).
And(Vec<BigramQuery>),
/// At least one child must match (union posting lists).
Or(Vec<BigramQuery>),
}
/// SIMD-friendly bitwise OR of two equal-length bitsets.
#[inline]
fn bitset_or(a: &mut [u64], b: &[u64]) {
a.iter_mut().zip(b.iter()).for_each(|(x, y)| *x |= *y);
}
/// SIMD-friendly bitwise AND of two equal-length bitsets.
#[inline]
fn bitset_and(a: &mut [u64], b: &[u64]) {
a.iter_mut().zip(b.iter()).for_each(|(x, y)| *x &= *y);
}
impl BigramQuery {
pub fn is_any(&self) -> bool {
matches!(self, BigramQuery::Any)
}
pub(crate) fn evaluate(&self, index: &BigramFilter) -> Option<Vec<u64>> {
self.evaluate_cow(index).map(Cow::into_owned)
}
fn evaluate_cow<'a>(&self, index: &'a BigramFilter) -> Option<Cow<'a, [u64]>> {
match self {
BigramQuery::Any => None,
BigramQuery::Consec(key) => {
let col = index.lookup()[*key as usize];
if col == u16::MAX {
return None;
}
let words = index.words();
let offset = col as usize * words;
let data = index.dense_data();
if offset + words > data.len() {
return None;
}
Some(Cow::Borrowed(&data[offset..offset + words]))
}
BigramQuery::Skip1(key) => {
let skip = index.skip_index()?;
let col = skip.lookup()[*key as usize];
if col == u16::MAX {
return None;
}
let words = skip.words();
let offset = col as usize * words;
let data = skip.dense_data();
if offset + words > data.len() {
return None;
}
Some(Cow::Borrowed(&data[offset..offset + words]))
}
BigramQuery::And(children) => {
let mut result: Option<Vec<u64>> = None;
for child in children {
if let Some(child_bits) = child.evaluate_cow(index) {
result = Some(match result {
None => child_bits.into_owned(),
Some(mut r) => {
bitset_and(&mut r, &child_bits);
r
}
});
}
}
result.map(Cow::Owned)
}
BigramQuery::Or(children) => {
if children.is_empty() {
return None;
}
let mut result: Option<Vec<u64>> = None;
for child in children {
match child.evaluate_cow(index) {
// Any branch can't be filtered → whole OR can't be filtered
None => return None,
Some(child_bits) => {
result = Some(match result {
None => child_bits.into_owned(),
Some(mut r) => {
bitset_or(&mut r, &child_bits);
r
}
});
}
}
}
result.map(Cow::Owned)
}
}
}
}
/// Intermediate state tracked during HIR traversal for bigram extraction.
struct HirInfo {
query: BigramQuery,
/// Possible first bytes (lowercased, printable ASCII) when this node matches.
first: Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>,
/// Possible last bytes.
last: Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>,
/// Whether this node can match the empty string.
can_be_empty: bool,
}
impl HirInfo {
fn empty() -> Self {
Self {
query: BigramQuery::Any,
first: None,
last: None,
can_be_empty: true,
}
}
}
pub(crate) fn regex_to_bigram_query(pattern: &str) -> BigramQuery {
let mut parser = regex_syntax::ParserBuilder::new()
.unicode(false)
.utf8(false)
.build();
let hir = match parser.parse(pattern) {
Ok(h) => h,
Err(_) => return BigramQuery::Any,
};
decompose(&hir).query
}
fn decompose(hir: &Hir) -> HirInfo {
let can_be_empty = hir.properties().minimum_len().is_none_or(|n| n == 0);
match hir.kind() {
HirKind::Empty => HirInfo::empty(),
HirKind::Literal(lit) => decompose_literal(lit.0.as_ref()),
HirKind::Class(class) => {
let bytes = expand_class(class);
match bytes {
Some(b) if !b.is_empty() => HirInfo {
query: BigramQuery::Any,
first: Some(b.clone()),
last: Some(b),
can_be_empty,
},
_ => HirInfo {
query: BigramQuery::Any,
first: None,
last: None,
can_be_empty,
},
}
}
HirKind::Look(_) => HirInfo::empty(),
HirKind::Repetition(rep) => {
let inner = decompose(&rep.sub);
if rep.min == 0 {
HirInfo {
query: BigramQuery::Any,
first: inner.first,
last: inner.last,
can_be_empty: true,
}
} else {
// min >= 1: inner bigrams guaranteed
let mut qs = Vec::new();
if !inner.query.is_any() {
qs.push(inner.query.clone());
}
// min >= 2: cross-boundary between consecutive occurrences
if rep.min >= 2 {
push_cross_consec(&mut qs, inner.last.as_deref(), inner.first.as_deref());
}
HirInfo {
query: simplify_and(qs),
first: inner.first,
last: inner.last,
can_be_empty,
}
}
}
HirKind::Capture(cap) => decompose(&cap.sub),
HirKind::Concat(parts) => decompose_concat(parts),
HirKind::Alternation(alts) => decompose_alternation(alts),
}
}
/// Extract bigrams from a literal byte sequence.
fn decompose_literal(bytes: &[u8]) -> HirInfo {
if bytes.is_empty() {
return HirInfo::empty();
}
let lower: SmallVec<[u8; 64]> = bytes.iter().map(|b| b.to_ascii_lowercase()).collect();
if lower.len() == 1 {
let b = lower[0];
let first = if (32..=126).contains(&b) {
Some(SmallVec::from_slice(&[b]))
} else {
None
};
return HirInfo {
query: BigramQuery::Any,
first: first.clone(),
last: first,
can_be_empty: false,
};
}
let mut qs: Vec<BigramQuery> = Vec::new();
// Consecutive bigrams
for w in lower.windows(2) {
if let Some(k) = consec_key(w[0], w[1]) {
qs.push(BigramQuery::Consec(k));
}
}
// Skip-1 bigrams from the literal itself
if lower.len() >= 3 {
for i in 0..lower.len() - 2 {
if let Some(k) = consec_key(lower[i], lower[i + 2]) {
qs.push(BigramQuery::Skip1(k));
}
}
}
let first_byte = lower[0];
let last_byte = *lower.last().unwrap();
HirInfo {
query: simplify_and(qs),
first: if (32..=126).contains(&first_byte) {
Some(SmallVec::from_slice(&[first_byte]))
} else {
None
},
last: if (32..=126).contains(&last_byte) {
Some(SmallVec::from_slice(&[last_byte]))
} else {
None
},
can_be_empty: false,
}
}
fn decompose_concat(parts: &[Hir]) -> HirInfo {
if parts.is_empty() {
return HirInfo::empty();
}
let infos: Vec<HirInfo> = parts.iter().map(decompose).collect();
let mut qs: Vec<BigramQuery> = Vec::new();
// 1. Collect child bigrams
for info in &infos {
if !info.query.is_any() {
qs.push(info.query.clone());
}
}
// 2. Dense cross-boundary between adjacent mandatory parts
for pair in infos.windows(2) {
if !pair[0].can_be_empty && !pair[1].can_be_empty {
push_cross_consec(&mut qs, pair[0].last.as_deref(), pair[1].first.as_deref());
}
}
// 3. Sparse-1 cross-boundary: across a single 1-byte-wide middle part.
// Catches `foo.bar` → sparse-1 `(o,b)` across the dot.
if parts.len() >= 3 {
for i in 0..parts.len() - 2 {
let left = &infos[i];
let mid = &parts[i + 1];
let right = &infos[i + 2];
let min_len = mid.properties().minimum_len();
let max_len = mid.properties().maximum_len();
let is_1byte = min_len == Some(1) && max_len == Some(1);
if is_1byte && !left.can_be_empty && !right.can_be_empty {
push_cross_skip1(&mut qs, left.last.as_deref(), right.first.as_deref());
}
}
}
let first = collect_first(&infos);
let last = collect_last(&infos);
let can_be_empty = infos.iter().all(|i| i.can_be_empty);
HirInfo {
query: simplify_and(qs),
first,
last,
can_be_empty,
}
}
fn decompose_alternation(alts: &[Hir]) -> HirInfo {
if alts.is_empty() {
return HirInfo::empty();
}
let infos: Vec<HirInfo> = alts.iter().map(decompose).collect();
let query = simplify_or(infos.iter().map(|i| i.query.clone()).collect());
let first = merge_byte_sets(infos.iter().map(|i| &i.first));
let last = merge_byte_sets(infos.iter().map(|i| &i.last));
let can_be_empty = infos.iter().any(|i| i.can_be_empty);
HirInfo {
query,
first,
last,
can_be_empty,
}
}
// ─── Helpers ─────────────────────────────────────────────────────────
fn expand_class(class: &Class) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
let mut bytes: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
match class {
Class::Bytes(bc) => {
for range in bc.ranges() {
let count = (range.end() as usize) - (range.start() as usize) + 1;
if bytes.len() + count > MAX_CLASS_EXPAND {
return None;
}
for b in range.start()..=range.end() {
if (32..=126).contains(&b) {
let lower = b.to_ascii_lowercase();
if !bytes.contains(&lower) {
bytes.push(lower);
}
}
}
}
}
Class::Unicode(uc) => {
for range in uc.ranges() {
let start = range.start() as u32;
let end = range.end() as u32;
if start > 127 {
continue;
}
let ascii_end = end.min(126) as u8;
let ascii_start = start.max(32) as u8;
if ascii_start > ascii_end {
continue;
}
let count = (ascii_end - ascii_start) as usize + 1;
if bytes.len() + count > MAX_CLASS_EXPAND {
return None;
}
for b in ascii_start..=ascii_end {
let lower = b.to_ascii_lowercase();
if !bytes.contains(&lower) {
bytes.push(lower);
}
}
}
}
}
if bytes.is_empty() { None } else { Some(bytes) }
}
/// Push consecutive cross-product bigrams into `qs`.
fn push_cross_consec(qs: &mut Vec<BigramQuery>, last: Option<&[u8]>, first: Option<&[u8]>) {
if let Some(q) = cross_product(last, first, false) {
qs.push(q);
}
}
/// Push skip-1 cross-product bigrams into `qs`.
fn push_cross_skip1(qs: &mut Vec<BigramQuery>, last: Option<&[u8]>, first: Option<&[u8]>) {
if let Some(q) = cross_product(last, first, true) {
qs.push(q);
}
}
fn cross_product(last: Option<&[u8]>, first: Option<&[u8]>, skip: bool) -> Option<BigramQuery> {
let last = last?;
let first = first?;
let n = last.len() * first.len();
if n == 0 || n > MAX_CLASS_EXPAND * MAX_CLASS_EXPAND {
return None;
}
let mut bigrams: Vec<BigramQuery> = Vec::with_capacity(n);
for &l in last {
for &f in first {
if let Some(k) = consec_key(l, f) {
let node = if skip {
BigramQuery::Skip1(k)
} else {
BigramQuery::Consec(k)
};
bigrams.push(node);
}
}
}
match bigrams.len() {
0 => None,
1 => Some(bigrams.into_iter().next().unwrap()),
_ => Some(simplify_or(bigrams)),
}
}
fn collect_first(infos: &[HirInfo]) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
for info in infos {
if let Some(ref bytes) = info.first {
for &b in bytes {
if !result.contains(&b) {
if result.len() >= MAX_CLASS_EXPAND {
return None;
}
result.push(b);
}
}
} else if !info.can_be_empty {
return None;
}
if !info.can_be_empty {
break;
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
fn collect_last(infos: &[HirInfo]) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
for info in infos.iter().rev() {
if let Some(ref bytes) = info.last {
for &b in bytes {
if !result.contains(&b) {
if result.len() >= MAX_CLASS_EXPAND {
return None;
}
result.push(b);
}
}
} else if !info.can_be_empty {
return None;
}
if !info.can_be_empty {
break;
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
fn merge_byte_sets<'a>(
iter: impl Iterator<Item = &'a Option<SmallVec<[u8; MAX_CLASS_EXPAND]>>>,
) -> Option<SmallVec<[u8; MAX_CLASS_EXPAND]>> {
let mut result: SmallVec<[u8; MAX_CLASS_EXPAND]> = SmallVec::new();
for opt in iter {
match opt {
None => return None,
Some(bytes) => {
for &b in bytes {
if !result.contains(&b) {
if result.len() >= MAX_CLASS_EXPAND {
return None;
}
result.push(b);
}
}
}
}
}
if result.is_empty() {
None
} else {
Some(result)
}
}
fn simplify_and(children: Vec<BigramQuery>) -> BigramQuery {
let mut flat: Vec<BigramQuery> = Vec::new();
for child in children {
match child {
BigramQuery::Any => {}
BigramQuery::And(inner) => flat.extend(inner),
other => flat.push(other),
}
}
match flat.len() {
0 => BigramQuery::Any,
1 => flat.into_iter().next().unwrap(),
_ => BigramQuery::And(flat),
}
}
fn simplify_or(children: Vec<BigramQuery>) -> BigramQuery {
if children.iter().any(|c| c.is_any()) {
return BigramQuery::Any;
}
let mut flat: Vec<BigramQuery> = Vec::new();
for child in children {
match child {
BigramQuery::Or(inner) => flat.extend(inner),
other => flat.push(other),
}
}
match flat.len() {
0 => BigramQuery::Any,
1 => flat.into_iter().next().unwrap(),
_ => BigramQuery::Or(flat),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bigram_filter::BigramIndexBuilder;
/// Build a tiny index from the given file contents for testing.
fn build_test_index(files: &[&[u8]]) -> BigramFilter {
let n = files.len();
let consec_builder = BigramIndexBuilder::new(n);
let skip_builder = BigramIndexBuilder::new(n);
for (i, content) in files.iter().enumerate() {
consec_builder.add_file_content(&skip_builder, i, content);
}
let mut idx = consec_builder.compress(Some(0));
idx.set_skip_index(skip_builder.compress(Some(0)));
idx
}
#[test]
fn literal_pattern() {
let idx = build_test_index(&[
b"hello world", // 0: contains "hello"
b"goodbye world", // 1: no "hello"
b"say hello there", // 2: contains "hello"
]);
let q = regex_to_bigram_query("hello");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(!BigramFilter::is_candidate(&candidates, 1));
assert!(BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn alternation() {
let idx = build_test_index(&[
b"has foo in it", // 0
b"has bar in it", // 1
b"has xyz in it", // 2
]);
let q = regex_to_bigram_query("foo|bar");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(BigramFilter::is_candidate(&candidates, 1));
// xyz doesn't contain foo or bar bigrams
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn wildcard_concat() {
let idx = build_test_index(&[
b"foo something bar", // 0
b"foo only", // 1: has foo but not bar
b"only bar", // 2: has bar but not foo
]);
let q = regex_to_bigram_query("foo.*bar");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
// file 1 and 2 should be filtered (missing bigrams from "bar" / "foo")
assert!(!BigramFilter::is_candidate(&candidates, 1));
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn sparse1_across_dot() {
// "a.b" should produce a skip-1 bigram (a,b)
let idx = build_test_index(&[
b"axb", // 0: has sparse-1 (a,b)
b"ayb", // 1: has sparse-1 (a,b)
b"xyz", // 2: no (a,b) at all
]);
let q = regex_to_bigram_query("a.b");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(BigramFilter::is_candidate(&candidates, 1));
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn sparse1_across_digit() {
// "foo\dbar" → sparse-1 (o,b) across \d
let idx = build_test_index(&[
b"foo3bar baz", // 0: has all bigrams
b"foobar baz", // 1: has consecutive (o,b) but pattern needs sparse-1
b"xyz only", // 2: no relevant bigrams
]);
let q = regex_to_bigram_query(r"foo\dbar");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
// file 1 may or may not match depending on what bigrams are in the index
// (it has all the literal bigrams and also o,b as both consec and skip-1)
// The important thing is file 2 is excluded:
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn pure_wildcard_is_any() {
let q = regex_to_bigram_query(".*");
assert!(q.is_any());
}
#[test]
fn single_char_is_any() {
let q = regex_to_bigram_query("a");
assert!(q.is_any());
}
#[test]
fn invalid_regex_is_any() {
let q = regex_to_bigram_query("[invalid");
assert!(q.is_any());
}
#[test]
fn optional_group_excluded() {
// (bar)? is optional — its bigrams are not required
let q = regex_to_bigram_query("foo(bar)?baz");
assert!(!q.is_any());
let idx = build_test_index(&[
b"foobaz content", // 0: has foo+baz bigrams (bar absent)
b"foobarbaz content", // 1: has everything
b"xyz only", // 2: nothing
]);
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(BigramFilter::is_candidate(&candidates, 1));
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn repetition_min2_cross_boundary() {
// (ab){2,} → bigram "ab" + cross-boundary "b","a"
let q = regex_to_bigram_query("(ab){2,}");
assert!(!q.is_any());
let idx = build_test_index(&[
b"ababab", // 0: has "ab" and "b"->"a"
b"abonly", // 1: has "ab" but not "b"->"a"
b"xyz", // 2: nothing
]);
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(!BigramFilter::is_candidate(&candidates, 2));
}
#[test]
fn two_dots_no_sparse1() {
// "a..b" — two 1-byte parts between a and b, not a single 1-byte part
// No sparse-1 (a,b) should be extracted
let q = regex_to_bigram_query("a..b");
// Single-char literals with 2 unknown bytes between → Any
assert!(q.is_any());
}
#[test]
fn character_class_cross_boundary() {
// [abc]de → cross-boundary OR(ad,bd,cd) + bigram de
// All three class variants must appear in the corpus so the OR
// branches are tracked in the index (untracked bigrams make the
// OR conservatively return None, which is correct but untestable).
let idx = build_test_index(&[
b"ade content", // 0: has ad
b"bde content", // 1: has bd
b"cde content", // 2: has cd
b"xde content", // 3: has de but not ad/bd/cd
]);
let q = regex_to_bigram_query("[abc]de");
assert!(!q.is_any());
let candidates = q.evaluate(&idx).unwrap();
assert!(BigramFilter::is_candidate(&candidates, 0));
assert!(BigramFilter::is_candidate(&candidates, 1));
assert!(BigramFilter::is_candidate(&candidates, 2));
// file 3 doesn't have ad/bd/cd so should be filtered
assert!(!BigramFilter::is_candidate(&candidates, 3));
}
// ── Helpers for inspecting query trees ──────────────────────────
fn has_consec(q: &BigramQuery, a: u8, b: u8) -> bool {
let Some(key) = consec_key(a, b) else {
return false;
};
match q {
BigramQuery::Consec(k) => *k == key,
BigramQuery::And(cs) | BigramQuery::Or(cs) => cs.iter().any(|c| has_consec(c, a, b)),
_ => false,
}
}
fn has_skip1(q: &BigramQuery, a: u8, b: u8) -> bool {
let Some(key) = consec_key(a, b) else {
return false;
};
match q {
BigramQuery::Skip1(k) => *k == key,
BigramQuery::And(cs) | BigramQuery::Or(cs) => cs.iter().any(|c| has_skip1(c, a, b)),
_ => false,
}
}
/// Bigram expectation: `("ab", is_skip1)`.
/// The 2-char str is the byte pair; C = consecutive, S = skip-1.
type Bg = (&'static str, bool);
const C: bool = false;
const S: bool = true;
/// Top 15+ commonly used regex patterns from
/// https://digitalfortress.tech/tips/top-15-commonly-used-regex/
/// plus typical grep patterns used by agentic tools.
///
/// Each entry: `(regex, Option<&[Bg]>)`.
/// - `None` → pure classes / unsupported syntax, Any is acceptable.
/// - `Some(&[..])` → must be non-Any, and every listed bigram must appear.
#[test]
fn common_regex_patterns() {
#[rustfmt::skip]
let cases: &[(&str, Option<&[Bg]>)] = &[
// ── Pure-class / anchor / unsupported → Any is fine ──────
(r"^\d+$", None), // 1. whole numbers
(r"^\d*\.\d+$", None), // 2. decimals
(r"^\d*(\.\d+)?$", None), // 3. whole + decimal
(r"^-?\d*(\.\d+)?$", None), // 4. neg/pos decimal
(r"[-]?[0-9]+[,.]?[0-9]*([/][0-9]+[,.]?[0-9]*)*", None), // 5. fractions
(r"^[a-zA-Z0-9]*$", None), // 6. alphanumeric
(r"^[a-zA-Z0-9 ]*$", None), // 7. alphanum + space
(r"^([a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})*$", None), // 8. email
(r"^([a-z0-9_\.\+-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$", None), // 9. email v2
(r"(?=(.*[0-9]))(?=.*[!@#$%^&*()\[\]{}\-_+=~`|:;<>,./?\x5c])(?=.*[a-z])(?=(.*[A-Z]))(?=(.*)).{8,}", None), // 10. complex pw
(r"(?=(.*[0-9]))((?=.*[A-Za-z0-9])(?=.*[A-Z])(?=.*[a-z]))^.{8,}$", None), // 11. moderate pw
(r"^[a-z0-9_-]{3,16}$", None), // 12. username
(r"(https?://)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)", None), // 14. URL optional
(r"^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$", None), // 15. IPv4
(r"(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))", None), // 16. IPv6
(r"[12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])", None), // 17. date
(r"^(0?[1-9]|1[0-2]):[0-5][0-9]$", None), // 18. time 12h
(r"((1[0-2]|0?[1-9]):([0-5][0-9]) ?([AaPp][Mm]))", None), // 19. time AM/PM
(r"^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$", None), // 20. time 24h
(r"^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$", None), // 21. time 24h v2
(r"(?:[01]\d|2[0123]):(?:[012345]\d):(?:[012345]\d)", None), // 22. time+sec
(r"</?[\w\s]*>|<.+[\W]>", None), // 23. HTML tag
(r"\bon\w+=\S+(?=.*>)", None), // 24. inline JS
(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", None), // 25. slug
(r"(\b\w+\b)(?=.*\b\1\b)", None), // 26. dup words
(r"^[\w,\s-]+\.[A-Za-z]{3}$", None), // 27. filename
(r"^[A-PR-WY][1-9]\d\s?\d{4}[1-9]$", None), // 28. HK ID
// ── Patterns with extractable literal bigrams ────────────
// 13. URL with required protocol
(r"https?://(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)", Some(&[
("ht", C), ("tt", C), ("tp", C), // from "http"
("ht", S), ("tp", S), // from "http" skip-1
(":/", C), ("//", C), // from "://"
])),
// 29. fn\s+\w+
(r"fn\s+\w+", Some(&[
("fn", C), // from "fn"
("n ", C), // cross-boundary: 'n' → \s starts ' '
])),
// 30. use\s+crate::
(r"use\s+crate::", Some(&[
("us", C), ("se", C), ("ue", S), // from "use"
("cr", C), ("ra", C), ("at", C), // from "crate"
("te", C), ("::", C),
("ca", S), ("rt", S), ("ae", S), // "crate" skip-1
])),
// 31. unwrap\(\)|expect\(
(r"unwrap\(\)|expect\(", Some(&[
("nw", C), ("wr", C), ("ra", C), // "unwrap("
("ap", C), ("p(", C),
("xp", C), ("pe", C), ("ec", C), // "expect("
("ct", C), ("t(", C),
])),
// 32. TODO|FIXME|HACK
(r"TODO|FIXME|HACK", Some(&[
("to", C), ("od", C), ("do", C), // "TODO"
("fi", C), ("ix", C), ("xm", C), // "FIXME"
("me", C),
("ha", C), ("ac", C), ("ck", C), // "HACK"
("hc", S), ("ak", S), // "HACK" skip-1
])),
];
for (i, &(pattern, expected)) in cases.iter().enumerate() {
let q = regex_to_bigram_query(pattern);
if let Some(bigrams) = expected {
assert!(
!q.is_any(),
"#{i} {pattern:?}: expected bigrams but got Any"
);
for &(pair, skip) in bigrams {
let b = pair.as_bytes();
debug_assert_eq!(b.len(), 2, "bigram must be 2 chars: {pair:?}");
let found = if skip {
has_skip1(&q, b[0], b[1])
} else {
has_consec(&q, b[0], b[1])
};
let kind = if skip { "skip-1" } else { "consec" };
assert!(found, "#{i} {pattern:?}: missing {kind} bigram {pair:?}");
}
}
}
}
}
+103 -13
View File
@@ -7,6 +7,7 @@
use crate::{
BigramFilter, BigramOverlay,
bigram_query::regex_to_bigram_query,
constraints::apply_constraints,
extract_bigrams,
sort_buffer::sort_with_buffer,
@@ -924,12 +925,15 @@ impl Sink for AhoCorasickSink<'_> {
/// searches because Aho-Corasick uses SIMD-accelerated multi-needle matching.
///
/// Returns the same `GrepResult` type as `grep_search`.
#[allow(clippy::too_many_arguments)]
pub fn multi_grep_search<'a>(
files: &'a [FileItem],
patterns: &[&str],
constraints: &[fff_query_parser::Constraint<'_>],
options: &GrepSearchOptions,
budget: &ContentCacheBudget,
bigram_index: Option<&BigramFilter>,
bigram_overlay: Option<&BigramOverlay>,
is_cancelled: Option<&AtomicBool>,
) -> GrepResult<'a> {
let total_files = files.len();
@@ -942,6 +946,51 @@ pub fn multi_grep_search<'a>(
};
}
// Bigram prefiltering: OR the candidate bitsets for each pattern.
// A file is a candidate if it matches ANY of the patterns' bigrams.
let bigram_candidates = if let Some(idx) = bigram_index
&& idx.is_ready()
{
let mut combined: Option<Vec<u64>> = None;
for pattern in patterns {
if let Some(candidates) = idx.query(pattern.as_bytes()) {
combined = Some(match combined {
None => candidates,
Some(mut acc) => {
// OR: file is candidate if it matches any pattern
acc.iter_mut()
.zip(candidates.iter())
.for_each(|(a, b)| *a |= *b);
acc
}
});
}
}
if let Some(ref mut candidates) = combined
&& let Some(overlay) = bigram_overlay
{
// Clear tombstoned files
for (r, t) in candidates.iter_mut().zip(overlay.tombstones().iter()) {
*r &= !t;
}
// Add modified files whose bigrams match any pattern
for pattern in patterns {
let pattern_bigrams = extract_bigrams(pattern.as_bytes());
for file_idx in overlay.query_modified(&pattern_bigrams) {
let word = file_idx / 64;
if word < candidates.len() {
candidates[word] |= 1u64 << (file_idx % 64);
}
}
}
}
combined
} else {
None
};
let (mut files_to_search, mut filtered_file_count) =
prepare_files_to_search(files, constraints, options);
@@ -955,6 +1004,15 @@ pub fn multi_grep_search<'a>(
filtered_file_count = retry_count;
}
// Apply bigram prefilter to the file list
if let Some(ref candidates) = bigram_candidates {
let base_ptr = files.as_ptr();
files_to_search.retain(|f| {
let file_idx = unsafe { (*f as *const FileItem).offset_from(base_ptr) as usize };
BigramFilter::is_candidate(candidates, file_idx)
});
}
if files_to_search.is_empty() {
return GrepResult {
total_files,
@@ -1842,24 +1900,50 @@ pub fn grep_search<'a>(
let pattern_len = finder_pattern.len() as u32;
// Bigram prefiltering: query the inverted index + merge overlay.
let bigram_candidates = if regex.is_none()
&& let Some(idx) = bigram_index
// For PlainText mode: extract bigrams directly from the literal pattern.
// For Regex mode: decompose the regex HIR into an AND/OR bigram query tree
// and evaluate it against the inverted index (supports alternation, optional
// groups, character classes, and sparse-1 bigrams across single-byte wildcards).
let bigram_candidates = if let Some(idx) = bigram_index
&& idx.is_ready()
&& let Some(mut candidates) = idx.query(effective_pattern.as_bytes())
{
if let Some(overlay) = bigram_overlay {
let pattern_bigrams = extract_bigrams(effective_pattern.as_bytes());
for (r, t) in candidates.iter_mut().zip(overlay.tombstones().iter()) {
*r &= !t;
}
for file_idx in overlay.query_modified(&pattern_bigrams) {
let word = file_idx / 64;
if word < candidates.len() {
candidates[word] |= 1u64 << (file_idx % 64);
let raw_candidates = if regex.is_none() {
// PlainText or regex-fallback-to-plain: literal bigram query
idx.query(effective_pattern.as_bytes())
} else {
// Regex mode: decompose pattern into bigram query tree
let bq = regex_to_bigram_query(&effective_pattern);
if !bq.is_any() { bq.evaluate(idx) } else { None }
};
if let Some(mut candidates) = raw_candidates {
if let Some(overlay) = bigram_overlay {
// Clear tombstoned (deleted) files from candidates
for (r, t) in candidates.iter_mut().zip(overlay.tombstones().iter()) {
*r &= !t;
}
if regex.is_none() {
let pattern_bigrams = extract_bigrams(effective_pattern.as_bytes());
for file_idx in overlay.query_modified(&pattern_bigrams) {
let word = file_idx / 64;
if word < candidates.len() {
candidates[word] |= 1u64 << (file_idx % 64);
}
}
} else {
for file_idx in overlay.modified_indices() {
let word = file_idx / 64;
if word < candidates.len() {
candidates[word] |= 1u64 << (file_idx % 64);
}
}
}
}
Some(candidates)
} else {
None
}
Some(candidates)
} else {
None
};
@@ -2226,6 +2310,8 @@ mod tests {
&options,
&ContentCacheBudget::unlimited(),
None,
None,
None,
);
// Should find matches from file1 (GrepMode, GrepMatch) and file2 (PlainTextMatcher)
@@ -2264,6 +2350,8 @@ mod tests {
&options,
&ContentCacheBudget::unlimited(),
None,
None,
None,
);
assert_eq!(
result2.matches.len(),
@@ -2279,6 +2367,8 @@ mod tests {
&options,
&ContentCacheBudget::unlimited(),
None,
None,
None,
);
assert_eq!(
result3.matches.len(),
+1
View File
@@ -94,6 +94,7 @@
mod background_watcher;
mod bigram_filter;
pub mod bigram_query;
mod constraints;
mod db_healthcheck;
mod error;
@@ -288,6 +288,80 @@ fn new_file_findable_after_add() {
}
}
/// Verify that a file modified after index build is findable via regex grep
/// through the overlay. This catches a regression where `extract_bigrams` on
/// the raw regex string (e.g. "NEEDLE.*HERE") produces bogus bigrams containing
/// `.` and `*`, causing `query_modified` to miss the file.
#[test]
fn modified_file_findable_via_regex_overlay() {
let tmp = TempDir::new().unwrap();
let base = tmp.path();
fs::write(base.join("alpha.txt"), "hello world\nfoo bar\n").unwrap();
fs::write(
base.join("beta.txt"),
"some other content\nnothing special\n",
)
.unwrap();
let shared_picker = SharedPicker::default();
let shared_frecency = SharedFrecency::default();
FilePicker::new_with_shared_state(
shared_picker.clone(),
shared_frecency.clone(),
FilePickerOptions {
base_path: base.to_string_lossy().to_string(),
warmup_mmap_cache: true,
mode: FFFMode::Neovim,
..Default::default()
},
)
.unwrap();
wait_for_bigram(&shared_picker);
// Advance mtime past the scan timestamp so the cache is invalidated.
std::thread::sleep(Duration::from_millis(1100));
// Write content that matches the regex "NEEDLE.*HERE" into beta.txt.
let modified_path = base.join("beta.txt");
fs::write(
&modified_path,
"some other content\nNEEDLE is right HERE\nnothing special\n",
)
.unwrap();
{
let mut guard = shared_picker.write().unwrap();
let picker = guard.as_mut().unwrap();
assert!(picker.on_create_or_modify(&modified_path).is_some());
}
// Regex grep should find the modified file through the overlay.
{
let guard = shared_picker.read().unwrap();
let picker = guard.as_ref().unwrap();
let parsed = parse_grep_query("NEEDLE.*HERE");
let opts = GrepSearchOptions {
mode: GrepMode::Regex,
..grep_opts()
};
let result = picker.grep(&parsed, &opts);
assert!(
!result.matches.is_empty(),
"Regex grep should find NEEDLE.*HERE in modified file via overlay"
);
assert!(result.matches[0].line_content.contains("NEEDLE"));
}
if let Ok(mut guard) = shared_picker.write() {
if let Some(ref mut picker) = *guard {
picker.stop_background_monitor();
}
}
}
// ── Helpers ─────────────────────────────────────────────────────────────
fn grep_opts() -> GrepSearchOptions {
+11 -2
View File
@@ -591,8 +591,17 @@ impl FffServer {
let files = picker.get_files();
let budget = picker.cache_budget();
let result =
grep::multi_grep_search(files, &patterns_refs, constraints, &options, budget, None);
let overlay_guard = picker.bigram_overlay().map(|o| o.read());
let result = grep::multi_grep_search(
files,
&patterns_refs,
constraints,
&options,
budget,
picker.bigram_index(),
overlay_guard.as_deref(),
None,
);
let file_refs: Vec<&FileItem> = result.files.to_vec();
if result.matches.is_empty() && file_offset == 0 {
+4 -3
View File
@@ -37,7 +37,6 @@ path = "src/bin/grep_vs_rg.rs"
[[bin]]
name = "bench_grep_query"
ppath = "src/bin/bench_grep_query.rs"
path = "src/bin/bench_grep_query.rs"
[dependencies]
@@ -49,8 +48,10 @@ thiserror = { workspace = true }
tracing = { workspace = true }
# Local crates
fff = { package = "fff-search", path = "../fff-core" , version = "0.5.1", features = ["mimalloc-collect"] }
fff-query-parser = { path = "../fff-query-parser" , version = "0.5.2" }
fff = { package = "fff-search", path = "../fff-core", version = "0.5.1", features = [
"mimalloc-collect",
] }
fff-query-parser = { path = "../fff-query-parser", version = "0.5.2" }
# External dependencies
blake3 = "1.8.2"
+197 -227
View File
@@ -86,10 +86,7 @@ async function waitForFileStatus(
}
/** Poll until a file is gone from the index, or the timeout is exceeded. */
async function waitForFileGone(
finder: FileFinder,
name: string,
): Promise<boolean> {
async function waitForFileGone(finder: FileFinder, name: string): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
if (findFile(finder, name) === undefined) return true;
@@ -99,10 +96,7 @@ async function waitForFileGone(
}
/** Poll until the total file count reaches the expected value, or the timeout is exceeded. */
async function waitForFileCount(
finder: FileFinder,
count: number,
): Promise<number> {
async function waitForFileCount(finder: FileFinder, count: number): Promise<number> {
const start = Date.now();
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
const result = finder.fileSearch("", { pageSize: 200 });
@@ -129,269 +123,245 @@ async function waitForGrep(
return finder.grep(pattern, options);
}
describe.skipIf(process.platform === "win32")(
"Git lifecycle integration",
() => {
let tmpDir: string;
let finder: FileFinder;
describe.skipIf(process.platform === "win32")("Git lifecycle integration", () => {
let tmpDir: string;
let finder: FileFinder;
beforeAll(async () => {
// Create temp directory and initialise a git repo with two committed files.
// Use realpathSync to resolve symlinks (macOS /var -> /private/var) so
// that git2's resolved workdir paths match the file picker's base_path.
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-git-test-")));
beforeAll(async () => {
// Create temp directory and initialise a git repo with two committed files.
// Use realpathSync to resolve symlinks (macOS /var -> /private/var) so
// that git2's resolved workdir paths match the file picker's base_path.
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-git-test-")));
git(tmpDir, "init", "-b", "main");
// Need at least one commit for status to work properly
writeFileSync(join(tmpDir, "hello.txt"), "hello world\n");
writeFileSync(join(tmpDir, "readme.md"), "# Test Project\n");
mkdirSync(join(tmpDir, "src"));
writeFileSync(
join(tmpDir, "src", "main.rs"),
'fn main() { println?."hi"); }\n',
);
git(tmpDir, "add", "-A");
git(tmpDir, "commit", "-m", "initial commit");
git(tmpDir, "init", "-b", "main");
// Need at least one commit for status to work properly
writeFileSync(join(tmpDir, "hello.txt"), "hello world\n");
writeFileSync(join(tmpDir, "readme.md"), "# Test Project\n");
mkdirSync(join(tmpDir, "src"));
writeFileSync(join(tmpDir, "src", "main.rs"), 'fn main() { println?."hi"); }\n');
git(tmpDir, "add", "-A");
git(tmpDir, "commit", "-m", "initial commit");
// Create the FileFinder instance
const result = FileFinder.create({ basePath: tmpDir });
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.error);
finder = result.value;
// Create the FileFinder instance
const result = FileFinder.create({ basePath: tmpDir });
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.error);
finder = result.value;
// Wait for the initial scan to finish
const scanResult = finder.waitForScan(10_000);
expect(scanResult.ok).toBe(true);
// Wait for the initial scan to finish
const scanResult = finder.waitForScan(10_000);
expect(scanResult.ok).toBe(true);
// Poll getScanProgress until the watcher is ready so that
// filesystem events (file creates, deletes) are detected.
const start = Date.now();
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
const progress = finder.getScanProgress();
if (progress.ok && progress.value.isWatcherReady) break;
await sleep(POLL_INTERVAL_MS);
}
// Poll getScanProgress until the watcher is ready so that
// filesystem events (file creates, deletes) are detected.
const start = Date.now();
while (Date.now() - start < WATCHER_TIMEOUT_MS) {
const progress = finder.getScanProgress();
expect(progress.ok).toBe(true);
if (progress.ok) {
expect(progress.value.isWatcherReady).toBe(true);
}
});
if (progress.ok && progress.value.isWatcherReady) break;
await sleep(POLL_INTERVAL_MS);
}
const progress = finder.getScanProgress();
expect(progress.ok).toBe(true);
if (progress.ok) {
expect(progress.value.isWatcherReady).toBe(true);
}
});
afterAll(() => {
finder?.destroy();
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true });
}
});
afterAll(() => {
finder?.destroy();
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true });
}
});
test("initial scan indexes all committed files", () => {
const result = finder.fileSearch("", { pageSize: 200 });
expect(result.ok).toBe(true);
if (!result.ok) return;
test("initial scan indexes all committed files", () => {
const result = finder.fileSearch("", { pageSize: 200 });
expect(result.ok).toBe(true);
if (!result.ok) return;
const names = result.value.items.map((i) => i.relativePath).sort();
expect(names).toContain("hello.txt");
expect(names).toContain("readme.md");
expect(names).toContain("src/main.rs");
expect(result.value.totalFiles).toBe(3);
});
const names = result.value.items.map((i) => i.relativePath).sort();
expect(names).toContain("hello.txt");
expect(names).toContain("readme.md");
expect(names).toContain("src/main.rs");
expect(result.value.totalFiles).toBe(3);
});
test("committed files have clean git status", async () => {
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("clean");
test("committed files have clean git status", async () => {
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("clean");
const main = await waitForFileStatus(finder, "main.rs", "clean");
expect(main).toBeDefined();
expect(main?.gitStatus).toBe("clean");
});
const main = await waitForFileStatus(finder, "main.rs", "clean");
expect(main).toBeDefined();
expect(main?.gitStatus).toBe("clean");
});
test("new untracked file appears with 'untracked' status", async () => {
writeFileSync(join(tmpDir, "new_file.ts"), "export const x = 1;\n");
test("new untracked file appears with 'untracked' status", async () => {
writeFileSync(join(tmpDir, "new_file.ts"), "export const x = 1;\n");
const newFile = await waitForFileStatus(
finder,
"new_file.ts",
"untracked",
);
expect(newFile).toBeDefined();
expect(newFile?.gitStatus).toBe("untracked");
const newFile = await waitForFileStatus(finder, "new_file.ts", "untracked");
expect(newFile).toBeDefined();
expect(newFile?.gitStatus).toBe("untracked");
// Total should now be 4
const total = await waitForFileCount(finder, 4);
expect(total).toBe(4);
});
// Total should now be 4
const total = await waitForFileCount(finder, 4);
expect(total).toBe(4);
});
test("staging a new file changes status to 'staged_new'", async () => {
git(tmpDir, "add", "new_file.ts");
test("staging a new file changes status to 'staged_new'", async () => {
git(tmpDir, "add", "new_file.ts");
const newFile = await waitForFileStatus(
finder,
"new_file.ts",
"staged_new",
);
expect(newFile).toBeDefined();
expect(newFile?.gitStatus).toBe("staged_new");
});
const newFile = await waitForFileStatus(finder, "new_file.ts", "staged_new");
expect(newFile).toBeDefined();
expect(newFile?.gitStatus).toBe("staged_new");
});
test("committing makes the file 'clean'", async () => {
git(tmpDir, "commit", "-m", "add new_file");
test("committing makes the file 'clean'", async () => {
git(tmpDir, "commit", "-m", "add new_file");
const newFile = await waitForFileStatus(finder, "new_file.ts", "clean");
expect(newFile).toBeDefined();
expect(newFile?.gitStatus).toBe("clean");
});
const newFile = await waitForFileStatus(finder, "new_file.ts", "clean");
expect(newFile).toBeDefined();
expect(newFile?.gitStatus).toBe("clean");
});
test("modifying a tracked file changes status to 'modified'", async () => {
writeFileSync(
join(tmpDir, "hello.txt"),
"hello world\nupdated content\n",
);
test("modifying a tracked file changes status to 'modified'", async () => {
writeFileSync(join(tmpDir, "hello.txt"), "hello world\nupdated content\n");
const hello = await waitForFileStatus(finder, "hello.txt", "modified");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("modified");
});
const hello = await waitForFileStatus(finder, "hello.txt", "modified");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("modified");
});
test("staging a modification changes status to 'staged_modified'", async () => {
git(tmpDir, "add", "hello.txt");
test("staging a modification changes status to 'staged_modified'", async () => {
git(tmpDir, "add", "hello.txt");
const hello = await waitForFileStatus(
finder,
"hello.txt",
"staged_modified",
);
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("staged_modified");
});
const hello = await waitForFileStatus(finder, "hello.txt", "staged_modified");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("staged_modified");
});
test("committing the modification returns to 'clean'", async () => {
git(tmpDir, "commit", "-m", "update hello");
test("committing the modification returns to 'clean'", async () => {
git(tmpDir, "commit", "-m", "update hello");
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("clean");
});
const hello = await waitForFileStatus(finder, "hello.txt", "clean");
expect(hello).toBeDefined();
expect(hello?.gitStatus).toBe("clean");
});
test("deleting a file removes it from the index", async () => {
unlinkSync(join(tmpDir, "new_file.ts"));
test("deleting a file removes it from the index", async () => {
unlinkSync(join(tmpDir, "new_file.ts"));
const gone = await waitForFileGone(finder, "new_file.ts");
expect(gone).toBe(true);
const gone = await waitForFileGone(finder, "new_file.ts");
expect(gone).toBe(true);
// Total should be back to 3
const total = await waitForFileCount(finder, 3);
expect(total).toBe(3);
});
// Total should be back to 3
const total = await waitForFileCount(finder, 3);
expect(total).toBe(3);
});
test("adding a file in a subdirectory works", async () => {
writeFileSync(join(tmpDir, "src", "utils.rs"), "pub fn helper() {}\n");
test("adding a file in a subdirectory works", async () => {
writeFileSync(join(tmpDir, "src", "utils.rs"), "pub fn helper() {}\n");
const utils = await waitForFileStatus(finder, "utils.rs", "untracked");
expect(utils).toBeDefined();
expect(utils?.relativePath).toBe("src/utils.rs");
expect(utils?.gitStatus).toBe("untracked");
});
const utils = await waitForFileStatus(finder, "utils.rs", "untracked");
expect(utils).toBeDefined();
expect(utils?.relativePath).toBe("src/utils.rs");
expect(utils?.gitStatus).toBe("untracked");
});
test("live grep finds content in a newly added file", async () => {
writeFileSync(
join(tmpDir, "src", "searchtarget.rs"),
'const UNIQUE_NEEDLE: &str = "xylophone_waterfall_97";\n',
);
test("live grep finds content in a newly added file", async () => {
writeFileSync(
join(tmpDir, "src", "searchtarget.rs"),
'const UNIQUE_NEEDLE: &str = "xylophone_waterfall_97";\n',
);
await waitForFile(finder, "searchtarget.rs");
await waitForFile(finder, "searchtarget.rs");
const result = await waitForGrep(
finder,
"xylophone_waterfall_97",
{ mode: "plain" },
(n) => n > 0,
);
expect(result?.ok).toBe(true);
if (!result?.ok) return;
const result = await waitForGrep(
finder,
"xylophone_waterfall_97",
{ mode: "plain" },
(n) => n > 0,
);
expect(result?.ok).toBe(true);
if (!result?.ok) return;
expect(result.value.totalMatched).toBeGreaterThan(0);
const match = result.value.items.find(
(m) => m.relativePath === "src/searchtarget.rs",
);
expect(match).toBeDefined();
expect(match!.lineContent).toContain("xylophone_waterfall_97");
});
expect(result.value.totalMatched).toBeGreaterThan(0);
const match = result.value.items.find(
(m) => m.relativePath === "src/searchtarget.rs",
);
expect(match).toBeDefined();
expect(match!.lineContent).toContain("xylophone_waterfall_97");
});
test("live grep no longer finds content after file is deleted", async () => {
unlinkSync(join(tmpDir, "src", "searchtarget.rs"));
test("live grep no longer finds content after file is deleted", async () => {
unlinkSync(join(tmpDir, "src", "searchtarget.rs"));
const result = await waitForGrep(
finder,
"xylophone_waterfall_97",
{ mode: "plain" },
(n) => n === 0,
);
expect(result?.ok).toBe(true);
if (!result?.ok) return;
const result = await waitForGrep(
finder,
"xylophone_waterfall_97",
{ mode: "plain" },
(n) => n === 0,
);
expect(result?.ok).toBe(true);
if (!result?.ok) return;
expect(result.value.totalMatched).toBe(0);
expect(result.value.items.length).toBe(0);
});
expect(result.value.totalMatched).toBe(0);
expect(result.value.items.length).toBe(0);
});
test("file in a newly created directory is discoverable", async () => {
// Create a brand-new directory that didn't exist during the initial scan,
// then add a file inside it. The watcher must dynamically pick up the new
// directory and index the file.
mkdirSync(join(tmpDir, "lib"));
writeFileSync(
join(tmpDir, "lib", "helpers.ts"),
"export function add(a: number, b: number) { return a + b; }\n",
);
test("file in a newly created directory is discoverable", async () => {
// Create a brand-new directory that didn't exist during the initial scan,
// then add a file inside it. The watcher must dynamically pick up the new
// directory and index the file.
mkdirSync(join(tmpDir, "lib"));
writeFileSync(
join(tmpDir, "lib", "helpers.ts"),
"export function add(a: number, b: number) { return a + b; }\n",
);
const helpers = await waitForFile(finder, "helpers.ts");
expect(helpers).toBeDefined();
expect(helpers?.relativePath).toBe("lib/helpers.ts");
});
const helpers = await waitForFile(finder, "helpers.ts");
expect(helpers).toBeDefined();
expect(helpers?.relativePath).toBe("lib/helpers.ts");
});
test("files in gitignored directories are not indexed", async () => {
// Commit a .gitignore rule first so it's established repo state before
// the ignored directory is created. This tests the watch-level filtering
// (is_path_ignored in the debouncer callback), not a rescan triggered
// by a .gitignore change.
writeFileSync(join(tmpDir, ".gitignore"), "build_output/\n");
git(tmpDir, "add", ".gitignore");
git(tmpDir, "commit", "-m", "add gitignore");
test("files in gitignored directories are not indexed", async () => {
// Commit a .gitignore rule first so it's established repo state before
// the ignored directory is created. This tests the watch-level filtering
// (is_path_ignored in the debouncer callback), not a rescan triggered
// by a .gitignore change.
writeFileSync(join(tmpDir, ".gitignore"), "build_output/\n");
git(tmpDir, "add", ".gitignore");
git(tmpDir, "commit", "-m", "add gitignore");
// Wait for the watcher to settle after the commit.
await waitForFile(finder, ".gitignore");
// Wait for the watcher to settle after the commit.
await waitForFile(finder, ".gitignore");
// Now create the ignored directory and add a file inside it.
mkdirSync(join(tmpDir, "build_output"));
writeFileSync(
join(tmpDir, "build_output", "artifact.bin"),
"should not appear\n",
);
// Now create the ignored directory and add a file inside it.
mkdirSync(join(tmpDir, "build_output"));
writeFileSync(join(tmpDir, "build_output", "artifact.bin"), "should not appear\n");
// Create a non-ignored file as a synchronisation barrier — once it's
// indexed, the watcher has processed the same batch of events.
writeFileSync(join(tmpDir, "canary.txt"), "visible\n");
const canary = await waitForFile(finder, "canary.txt");
expect(canary).toBeDefined();
// Create a non-ignored file as a synchronisation barrier — once it's
// indexed, the watcher has processed the same batch of events.
writeFileSync(join(tmpDir, "canary.txt"), "visible\n");
const canary = await waitForFile(finder, "canary.txt");
expect(canary).toBeDefined();
// The ignored file must NOT appear in the index.
const artifact = findFile(finder, "artifact.bin");
expect(artifact).toBeUndefined();
});
// The ignored file must NOT appear in the index.
const artifact = findFile(finder, "artifact.bin");
expect(artifact).toBeUndefined();
});
test("full add-commit cycle for subdirectory file", async () => {
git(tmpDir, "add", "src/utils.rs");
test("full add-commit cycle for subdirectory file", async () => {
git(tmpDir, "add", "src/utils.rs");
let utils = await waitForFileStatus(finder, "utils.rs", "staged_new");
expect(utils).toBeDefined();
expect(utils?.gitStatus).toBe("staged_new");
let utils = await waitForFileStatus(finder, "utils.rs", "staged_new");
expect(utils).toBeDefined();
expect(utils?.gitStatus).toBe("staged_new");
git(tmpDir, "commit", "-m", "add utils");
git(tmpDir, "commit", "-m", "add utils");
utils = await waitForFileStatus(finder, "utils.rs", "clean");
expect(utils).toBeDefined();
expect(utils?.gitStatus).toBe("clean");
});
},
);
utils = await waitForFileStatus(finder, "utils.rs", "clean");
expect(utils).toBeDefined();
expect(utils?.gitStatus).toBe("clean");
});
});