greplm_core/trigram.rs
1//! Trigram extraction and query decomposition.
2//!
3//! A trigram is a 3-byte sequence. The index maps each trigram to the set of
4//! documents that contain it. A query is satisfiable only in documents that
5//! contain *every* trigram of the query literal, so we can intersect posting
6//! lists to get a small candidate set before verifying with the real matcher.
7
8/// A trigram, stored big-endian so byte order matches numeric order (required
9/// for the FST term dictionary, whose keys must be lexicographically sorted).
10pub type Trigram = [u8; 3];
11
12/// One AND-group of trigrams: a document must contain every member.
13pub type TrigramGroup = Vec<Trigram>;
14
15/// A disjunction of AND-groups (disjunctive normal form): a document passes
16/// when at least one group is fully present.
17pub type TrigramDnf = Vec<TrigramGroup>;
18
19pub(crate) fn key_of(w: &[u8]) -> u32 {
20 (u32::from(w[0]) << 16) | (u32::from(w[1]) << 8) | u32::from(w[2])
21}
22
23pub(crate) fn tri_of(key: u32) -> Trigram {
24 [(key >> 16) as u8, (key >> 8) as u8, key as u8]
25}
26
27std::thread_local! {
28 /// A 2^24-bit membership set (2 MiB) used to deduplicate trigram keys
29 /// *during* the scan. Reused across calls on the same thread; after each
30 /// use the set bits are cleared by replaying the distinct-key list, so
31 /// reset costs O(distinct) instead of a 2 MiB memset per file.
32 static SEEN: std::cell::RefCell<Box<[u64]>> =
33 std::cell::RefCell::new(vec![0u64; 1 << 18].into_boxed_slice());
34}
35
36/// Extract the distinct trigrams present in `data`, sorted ascending.
37///
38/// Each 3-byte window is encoded as a `u32` key and deduplicated on the fly
39/// against a thread-local bitset, so the scan is O(n) and the subsequent sort
40/// runs over *distinct* keys only (typically 10-50x fewer than windows for
41/// source code). This replaces sorting every window (O(n log n) with a
42/// transient allocation of 4 bytes per input byte). The big-endian encoding
43/// means the sorted order is exactly the lexicographic order the FST term
44/// dictionary requires.
45pub fn extract(data: &[u8]) -> Vec<Trigram> {
46 if data.len() < 3 {
47 return Vec::new();
48 }
49 SEEN.with(|seen| {
50 let mut seen = seen.borrow_mut();
51 let mut keys: Vec<u32> = Vec::new();
52 for w in data.windows(3) {
53 let k = key_of(w);
54 let word = (k >> 6) as usize;
55 let bit = 1u64 << (k & 63);
56 if seen[word] & bit == 0 {
57 seen[word] |= bit;
58 keys.push(k);
59 }
60 }
61 // Every set bit corresponds to exactly one pushed key, so zeroing each
62 // key's word clears the whole set (idempotent for keys sharing a word).
63 for &k in &keys {
64 seen[(k >> 6) as usize] = 0;
65 }
66 keys.sort_unstable();
67 keys.into_iter().map(tri_of).collect()
68 })
69}
70
71/// Extract the trigrams of a literal needle, sorted and deduplicated. Returns an
72/// empty vec when the needle is shorter than 3 bytes (meaning: trigram filtering
73/// can't help and the caller must scan all candidates).
74pub fn literal_trigrams(needle: &[u8]) -> Vec<Trigram> {
75 extract(needle)
76}
77
78/// A boolean query over trigrams: a conjunction of DNFs. A document is a
79/// candidate when it satisfies *every* DNF, where a DNF is satisfied when at
80/// least one of its AND-groups is fully present in the document.
81///
82/// This one shape expresses everything the planner produces:
83///
84/// * an exact literal is one DNF with a single AND-group (all its trigrams);
85/// * a case-insensitive literal contributes one DNF per needle window, each a
86/// disjunction of single-trigram groups (the window's fold variants);
87/// * a regex contributes a DNF for its required prefix literals and another
88/// for its required suffix literals.
89///
90/// A DNF that cannot filter (it is empty, or contains an empty group, which
91/// would make it trivially true) is ignored. An empty query means "scan
92/// everything".
93#[derive(Debug, Default, Clone)]
94pub struct TrigramQuery {
95 pub dnfs: Vec<TrigramDnf>,
96}
97
98impl TrigramQuery {
99 /// True when no usable trigram constraints exist and all documents are
100 /// candidates.
101 pub fn is_unconstrained(&self) -> bool {
102 !self.dnfs.iter().any(dnf_filters)
103 }
104
105 pub fn from_literal(needle: &[u8]) -> TrigramQuery {
106 let tris = literal_trigrams(needle);
107 if tris.is_empty() {
108 TrigramQuery::default()
109 } else {
110 TrigramQuery {
111 dnfs: vec![vec![tris]],
112 }
113 }
114 }
115
116 /// Build a case-insensitive literal query. Each 3-byte window of the needle
117 /// becomes one DNF listing every byte sequence the window can begin with in
118 /// a match, so the trigram index can still prune candidates without false
119 /// negatives.
120 ///
121 /// The matcher folds case Unicode-aware, so a window's variants are not
122 /// just its ASCII case permutations: `s`/`S` also matches U+017F (LATIN
123 /// SMALL LETTER LONG S) and `k`/`K` also matches U+212A (KELVIN SIGN),
124 /// whose UTF-8 encodings are multi-byte. For each window we enumerate every
125 /// combination of per-character fold forms and take the first three bytes
126 /// of each — exactly the set of trigrams a match of that window can start
127 /// with. Windows containing non-ASCII needle bytes are skipped (their fold
128 /// forms aren't enumerable this way), which only widens the candidate set.
129 pub fn from_literal_ci(needle: &[u8]) -> TrigramQuery {
130 if needle.len() < 3 {
131 return TrigramQuery::default();
132 }
133 let mut dnfs: Vec<TrigramDnf> = Vec::new();
134 for w in needle.windows(3) {
135 if let Some(clause) = ci_window_trigrams([w[0], w[1], w[2]]) {
136 dnfs.push(clause.into_iter().map(|t| vec![t]).collect());
137 }
138 }
139 if dnfs.iter().any(dnf_filters) {
140 TrigramQuery { dnfs }
141 } else {
142 TrigramQuery::default()
143 }
144 }
145}
146
147/// True when a DNF actually constrains matching: it has at least one group and
148/// no empty group (an empty group is trivially satisfied, disabling the DNF).
149pub fn dnf_filters(dnf: &TrigramDnf) -> bool {
150 !dnf.is_empty() && dnf.iter().all(|g| !g.is_empty())
151}
152
153/// A fold form: up to 3 UTF-8 bytes plus its length. Stack-only so query
154/// planning stays allocation-free per character.
155type FoldForm = ([u8; 3], usize);
156
157/// The byte sequences a single needle byte can match under the matcher's
158/// case-insensitive (Unicode simple fold) semantics, or `None` when they are
159/// not enumerable (non-ASCII bytes, whose folded forms shift window
160/// alignment unpredictably). Returns the number of forms written to `out`.
161fn fold_forms(b: u8, out: &mut [FoldForm; 3]) -> Option<usize> {
162 if b >= 0x80 {
163 return None;
164 }
165 if !b.is_ascii_alphabetic() {
166 out[0] = ([b, 0, 0], 1);
167 return Some(1);
168 }
169 out[0] = ([b.to_ascii_lowercase(), 0, 0], 1);
170 out[1] = ([b.to_ascii_uppercase(), 0, 0], 1);
171 match b.to_ascii_lowercase() {
172 // U+017F LATIN SMALL LETTER LONG S folds to 's'.
173 b's' => {
174 out[2] = ([0xC5, 0xBF, 0], 2);
175 Some(3)
176 }
177 // U+212A KELVIN SIGN folds to 'k'.
178 b'k' => {
179 out[2] = ([0xE2, 0x84, 0xAA], 3);
180 Some(3)
181 }
182 _ => Some(2),
183 }
184}
185
186/// All trigrams a case-insensitive match of window `w` can begin with: for
187/// every combination of per-character fold forms, the first three bytes of the
188/// concatenation (each form is >= 1 byte, so three forms always cover a
189/// trigram). At most 3^3 = 27 combinations; deduplicated and sorted.
190fn ci_window_trigrams(w: Trigram) -> Option<Vec<Trigram>> {
191 let mut forms = [[([0u8; 3], 0usize); 3]; 3];
192 let mut counts = [0usize; 3];
193 for i in 0..3 {
194 counts[i] = fold_forms(w[i], &mut forms[i])?;
195 }
196 let mut out: Vec<Trigram> = Vec::with_capacity(counts[0] * counts[1] * counts[2]);
197 let mut buf = [0u8; 9];
198 for a in &forms[0][..counts[0]] {
199 for b in &forms[1][..counts[1]] {
200 for c in &forms[2][..counts[2]] {
201 buf[..a.1].copy_from_slice(&a.0[..a.1]);
202 buf[a.1..a.1 + b.1].copy_from_slice(&b.0[..b.1]);
203 buf[a.1 + b.1..a.1 + b.1 + c.1].copy_from_slice(&c.0[..c.1]);
204 out.push([buf[0], buf[1], buf[2]]);
205 }
206 }
207 }
208 out.sort_unstable();
209 out.dedup();
210 Some(out)
211}
212
213/// Build a trigram query from a regular expression by extracting required
214/// literal substrings: the prefixes any match must start with *and* the
215/// suffixes any match must end with, each contributing an independent DNF.
216/// If neither side yields usable literals we fall back to an unconstrained
217/// query (scan all candidates).
218pub fn regex_trigrams(pattern: &str, case_insensitive: bool) -> TrigramQuery {
219 use regex_syntax::hir::literal::{ExtractKind, Extractor};
220 use regex_syntax::ParserBuilder;
221
222 let hir = match ParserBuilder::new()
223 .case_insensitive(case_insensitive)
224 .build()
225 .parse(pattern)
226 {
227 Ok(h) => h,
228 Err(_) => return TrigramQuery::default(),
229 };
230
231 /// Turn an extracted literal sequence into a DNF (one AND-group per
232 /// literal). Returns `None` when any literal is too short to filter,
233 /// since requiring the remaining groups could drop real matches.
234 fn dnf_of(seq: ®ex_syntax::hir::literal::Seq) -> Option<TrigramDnf> {
235 let lits = seq.literals()?;
236 let mut dnf: TrigramDnf = Vec::with_capacity(lits.len());
237 for lit in lits {
238 let tris = literal_trigrams(lit.as_bytes());
239 if tris.is_empty() {
240 return None;
241 }
242 dnf.push(tris);
243 }
244 if dnf.is_empty() {
245 None
246 } else {
247 Some(dnf)
248 }
249 }
250
251 let prefix = dnf_of(&Extractor::new().extract(&hir));
252 let suffix = {
253 let mut ex = Extractor::new();
254 ex.kind(ExtractKind::Suffix);
255 dnf_of(&ex.extract(&hir))
256 };
257
258 let mut dnfs: Vec<TrigramDnf> = Vec::new();
259 if let Some(p) = prefix {
260 dnfs.push(p);
261 }
262 if let Some(s) = suffix {
263 // A fully literal pattern yields identical prefix and suffix sets;
264 // evaluating the same DNF twice would just double posting work.
265 if dnfs.first() != Some(&s) {
266 dnfs.push(s);
267 }
268 }
269 if dnfs.is_empty() {
270 TrigramQuery::default()
271 } else {
272 TrigramQuery { dnfs }
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn extract_basic() {
282 let set = extract(b"abcd");
283 assert!(set.contains(b"abc"));
284 assert!(set.contains(b"bcd"));
285 assert_eq!(set.len(), 2);
286 }
287
288 #[test]
289 fn extract_is_sorted_and_deduped() {
290 let set = extract(b"abcabcabc");
291 let mut sorted = set.clone();
292 sorted.sort_unstable();
293 sorted.dedup();
294 assert_eq!(set, sorted, "extract must return sorted, distinct trigrams");
295 }
296
297 #[test]
298 fn short_input_has_no_trigrams() {
299 assert!(extract(b"ab").is_empty());
300 assert!(literal_trigrams(b"ab").is_empty());
301 }
302
303 #[test]
304 fn literal_query_is_constrained() {
305 let q = TrigramQuery::from_literal(b"function");
306 assert!(!q.is_unconstrained());
307 // Short literals cannot use the trigram filter.
308 assert!(TrigramQuery::from_literal(b"fn").is_unconstrained());
309 }
310
311 #[test]
312 fn regex_extracts_required_literal() {
313 let q = regex_trigrams("error_handler", false);
314 assert!(!q.is_unconstrained());
315 // A literal pattern must not evaluate the same DNF twice.
316 assert_eq!(q.dnfs.len(), 1);
317 }
318
319 #[test]
320 fn regex_extracts_suffix_literals() {
321 // The prefix literal ("fn ") is too short to filter, but the required
322 // suffix "_handler" is selective; the query must be constrained by it.
323 let q = regex_trigrams(r"fn \w+_handler", false);
324 assert!(
325 !q.is_unconstrained(),
326 "suffix literal should constrain the query: {q:?}"
327 );
328 }
329
330 #[test]
331 fn case_insensitive_literal_is_constrained() {
332 let q = TrigramQuery::from_literal_ci(b"Foo");
333 assert!(!q.is_unconstrained());
334 // One DNF per 3-byte window; "Foo" has one window of 3 letters =>
335 // 2^3 single-trigram groups.
336 assert_eq!(q.dnfs.len(), 1);
337 let tris: Vec<Trigram> = q.dnfs[0].iter().map(|g| g[0]).collect();
338 assert!(tris.contains(b"foo"));
339 assert!(tris.contains(b"FOO"));
340 assert!(tris.contains(b"Foo"));
341 assert_eq!(tris.len(), 8);
342 // Short needles cannot be filtered.
343 assert!(TrigramQuery::from_literal_ci(b"fo").is_unconstrained());
344 }
345
346 #[test]
347 fn ci_skips_windows_with_non_ascii_bytes() {
348 // "café" => windows "caf", "af\xC3", "f\xC3\xA9". Only the all-ASCII
349 // "caf" window is enumerable; the others span the multibyte 'é' whose
350 // uppercase form ('É') has different bytes, so requiring them would drop
351 // real matches.
352 let q = TrigramQuery::from_literal_ci("café".as_bytes());
353 assert_eq!(q.dnfs.len(), 1);
354 let tris: Vec<Trigram> = q.dnfs[0].iter().map(|g| g[0]).collect();
355 assert!(tris.contains(b"caf"));
356 assert!(tris.contains(b"CAF"));
357 }
358
359 #[test]
360 fn ci_kelvin_and_long_s_windows_stay_constrained() {
361 // Windows containing 's'/'k' used to be dropped entirely (the fold
362 // class includes a non-ASCII character), degrading common needles to
363 // full scans. They are now kept by enumerating the multi-byte fold
364 // forms, so every all-ASCII needle stays constrained.
365 for needle in [&b"class"[..], b"list", b"make", b"kayak"] {
366 let q = TrigramQuery::from_literal_ci(needle);
367 assert!(
368 !q.is_unconstrained(),
369 "{needle:?} should be constrained: {q:?}"
370 );
371 }
372 // The 's' window clause must include the long-s byte prefix so a
373 // haystack containing U+017F still passes the filter.
374 let q = TrigramQuery::from_literal_ci(b"las");
375 let tris: Vec<Trigram> = q.dnfs[0].iter().map(|g| g[0]).collect();
376 assert!(tris.contains(b"las"));
377 assert!(tris.contains(b"LAS"));
378 assert!(
379 tris.contains(&[b'l', b'a', 0xC5]),
380 "expected long-s prefix variant, got {tris:?}"
381 );
382 }
383
384 /// Documents *why* the fold forms include 's'/'k' specials: the matcher's
385 /// Unicode-aware case folding makes /k/i match U+212A and /s/i match
386 /// U+017F, while a non-special letter like /a/i does not match U+00E5
387 /// ('å'). If this ever changes upstream, `fold_forms` must be revisited.
388 #[test]
389 fn regex_ci_folds_kelvin_and_long_s() {
390 let ci = |pat: &str, hay: &str| {
391 regex::bytes::RegexBuilder::new(®ex::escape(pat))
392 .case_insensitive(true)
393 .build()
394 .unwrap()
395 .is_match(hay.as_bytes())
396 };
397 assert!(ci("k", "\u{212A}"), "/k/i should match KELVIN SIGN");
398 assert!(
399 ci("s", "\u{017F}"),
400 "/s/i should match LATIN SMALL LETTER LONG S"
401 );
402 assert!(!ci("a", "\u{00E5}"), "/a/i should not match 'å'");
403 }
404
405 /// End-to-end soundness: whenever the case-insensitive matcher accepts a
406 /// haystack, the trigram filter must also keep it (no false negatives).
407 #[test]
408 fn ci_filter_never_drops_a_match() {
409 // (needle, haystack) pairs the Unicode-aware matcher accepts.
410 let matching: &[(&str, &str)] = &[
411 ("café", "a CAFÉ here"), // non-ASCII fold
412 ("café", "tiny café shop"), // exact bytes
413 ("class", "MyClass {}"), // plain 's'
414 ("class", "cla\u{017F}s X"), // long s in the haystack
415 ("foobar", "FOOBAR()"), // plain ASCII
416 ("make", "MAKEFILE"), // plain 'k'
417 ("make", "ma\u{212A}e it"), // KELVIN SIGN in the haystack
418 ("kayak", "KAYAK"), // multiple 'k's
419 ("kayak", "kaya\u{212A} trip"), // trailing Kelvin
420 ("string", "STRING s"),
421 ];
422 for (needle, hay) in matching {
423 let re = regex::bytes::RegexBuilder::new(®ex::escape(needle))
424 .case_insensitive(true)
425 .build()
426 .unwrap();
427 assert!(
428 re.is_match(hay.as_bytes()),
429 "test setup: {needle:?} must match {hay:?}"
430 );
431
432 let q = TrigramQuery::from_literal_ci(needle.as_bytes());
433 assert!(
434 filter_keeps(&q, hay.as_bytes()),
435 "filter wrongly dropped {hay:?} for needle {needle:?}"
436 );
437 }
438 }
439
440 /// Soundness for the exact-case and regex planners too.
441 #[test]
442 fn literal_and_regex_filters_keep_their_matches() {
443 let hay = b"pub fn segment_writer_flush(x: u32) {}";
444 let lit = TrigramQuery::from_literal(b"segment_writer");
445 assert!(filter_keeps(&lit, hay));
446
447 let re = regex_trigrams(r"fn \w+_flush", false);
448 assert!(filter_keeps(&re, hay));
449 }
450
451 /// Mirror of `Segment::candidates` evaluation for an in-memory document:
452 /// the doc passes when, for every filtering DNF, at least one group's
453 /// trigrams are all present.
454 fn filter_keeps(q: &TrigramQuery, haystack: &[u8]) -> bool {
455 let doc = extract(haystack);
456 let has = |t: &Trigram| doc.binary_search(t).is_ok();
457 q.dnfs
458 .iter()
459 .filter(|d| dnf_filters(d))
460 .all(|dnf| dnf.iter().any(|group| group.iter().all(&has)))
461 }
462}