Skip to main content

whycodes_index/
content.rs

1//! In-memory content trigrams for grep candidate pruning.
2//!
3//! Overlapping 3-byte trigrams, the same filter Microsoft tgrep uses, but
4//! resident in the workspace index (no disk index, no TCP server). The grep
5//! tool still confirms with `grep-searcher`; this layer only drops files that
6//! cannot contain a required literal. Files not yet indexed are never dropped.
7
8use std::fs::File;
9use std::io::Read;
10use std::path::Path;
11
12use rustc_hash::{FxHashMap, FxHashSet};
13
14/// Bytes of an overlapping trigram.
15pub const TRIGRAM_LEN: usize = 3;
16/// Files larger than this are skipped (grep also refuses to search them).
17pub const MAX_INDEX_BYTES: u64 = 1024 * 1024;
18/// NUL sniff window; matches the file-tools binary check.
19const BINARY_SNIFF: usize = 8192;
20
21/// Regex metacharacters that mean the pattern is not a single literal.
22const REGEX_META: &[char] = &[
23    '.', '^', '$', '*', '+', '?', '(', ')', '[', ']', '{', '}', '|',
24];
25
26/// Per-root overlay: relative path → unique sorted trigrams.
27#[derive(Debug, Default)]
28pub struct ContentIndex {
29    files: FxHashMap<Box<str>, Vec<u32>>,
30    skipped: FxHashSet<Box<str>>,
31    complete: bool,
32}
33
34#[derive(Debug, PartialEq, Eq)]
35enum Load {
36    Bytes(Vec<u8>),
37    /// NUL / binary — grep will skip these too.
38    Binary,
39    /// Missing, symlink, directory, too large, or I/O error. Never prune.
40    Unknown,
41}
42
43/// One file's indexed form, built by [`ContentIndex::load`].
44#[derive(Debug)]
45pub(crate) enum Posting {
46    Trigrams(Vec<u32>),
47    Binary,
48    Unknown,
49}
50
51impl ContentIndex {
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    pub fn clear(&mut self) {
57        self.files.clear();
58        self.skipped.clear();
59        self.complete = false;
60    }
61
62    pub fn is_complete(&self) -> bool {
63        self.complete
64    }
65
66    pub fn mark_complete(&mut self) {
67        self.complete = true;
68    }
69
70    pub fn indexed_len(&self) -> usize {
71        self.files.len()
72    }
73
74    pub fn skipped_len(&self) -> usize {
75        self.skipped.len()
76    }
77
78    /// Re-read `rel` under `root` and replace its posting.
79    pub fn upsert(&mut self, root: &Path, rel: &str, size: u64) {
80        self.insert(rel, Self::load(root, rel, size));
81    }
82
83    /// Read `rel` under `root` without touching the index, so the file read
84    /// (slow on network mounts) can run while no lock is held.
85    pub(crate) fn load(root: &Path, rel: &str, size: u64) -> Posting {
86        match load_file(&root.join(rel), size) {
87            Load::Binary => Posting::Binary,
88            Load::Bytes(bytes) => Posting::Trigrams(extract_indexed_trigrams(&bytes)),
89            Load::Unknown => Posting::Unknown,
90        }
91    }
92
93    /// Replace `rel`'s posting with one built by [`Self::load`].
94    pub(crate) fn insert(&mut self, rel: &str, posting: Posting) {
95        self.remove(rel);
96        match posting {
97            Posting::Binary => {
98                self.skipped.insert(rel.into());
99            }
100            Posting::Trigrams(trigrams) => {
101                self.files.insert(rel.into(), trigrams);
102            }
103            Posting::Unknown => {}
104        }
105    }
106
107    pub fn remove(&mut self, rel: &str) {
108        self.files.remove(rel);
109        self.skipped.remove(rel);
110    }
111
112    /// Drop `rel` and every descendant (`rel/…`).
113    pub fn remove_tree(&mut self, rel: &str) {
114        self.remove(rel);
115        let prefix = tree_prefix(rel);
116        self.files.retain(|k, _| !k.starts_with(&prefix));
117        self.skipped.retain(|k| !k.starts_with(&prefix));
118    }
119
120    /// `true` when `rel` might contain every `required` trigram.
121    ///
122    /// Unknown paths (not yet filled, read error, symlink, too large) return
123    /// `true` so a partial overlay never hides a match. Binary paths return
124    /// `false` — the grep engine would skip them too.
125    pub fn may_match(&self, rel: &str, required: &[u32]) -> bool {
126        if required.is_empty() {
127            return true;
128        }
129        if self.skipped.contains(rel) {
130            return false;
131        }
132        match self.files.get(rel) {
133            None => true,
134            Some(tris) => contains_all(tris, required),
135        }
136    }
137}
138
139/// Pack three bytes into a `u32` key.
140pub fn pack_trigram(a: u8, b: u8, c: u8) -> u32 {
141    (u32::from(a) << 16) | (u32::from(b) << 8) | u32::from(c)
142}
143
144/// Unique sorted overlapping 3-byte trigrams. Empty when `bytes` is shorter
145/// than [`TRIGRAM_LEN`].
146pub fn extract_trigrams(bytes: &[u8]) -> Vec<u32> {
147    if bytes.len() < TRIGRAM_LEN {
148        return Vec::new();
149    }
150    let mut v: Vec<u32> = bytes
151        .windows(TRIGRAM_LEN)
152        .map(|w| pack_trigram(w[0], w[1], w[2]))
153        .collect();
154    v.sort_unstable();
155    v.dedup();
156    v
157}
158
159/// Required trigrams for a grep pattern, or `None` when the overlay must not
160/// prune (regex, too short, or Unicode case-fold).
161pub fn required_trigrams(pattern: &str, case_insensitive: bool) -> Option<Vec<u32>> {
162    if skip_unicode_casefold(pattern, case_insensitive) {
163        return None;
164    }
165    let literal = as_literal(pattern)?;
166    let bytes = if case_insensitive {
167        ascii_lower(literal.as_bytes())
168    } else {
169        literal.into_bytes()
170    };
171    if bytes.len() < TRIGRAM_LEN {
172        return None;
173    }
174    Some(extract_trigrams(&bytes))
175}
176
177/// Original-case trigrams plus ASCII-lowercased ones so a case-insensitive
178/// query can prune without hiding a case-sensitive match (false positives
179/// are confirmed by the grep engine; false negatives are not).
180///
181/// The result stays resident for the daemon's life, so it gives back the
182/// spare capacity: the vector is built from one entry per byte and dedup
183/// keeps only a fraction of them.
184fn extract_indexed_trigrams(bytes: &[u8]) -> Vec<u32> {
185    let mut v = extract_trigrams(bytes);
186    let lower = ascii_lower(bytes);
187    if lower.as_slice() != bytes {
188        v.extend(extract_trigrams(&lower));
189        v.sort_unstable();
190        v.dedup();
191    }
192    v.shrink_to_fit();
193    v
194}
195
196fn skip_unicode_casefold(pattern: &str, case_insensitive: bool) -> bool {
197    case_insensitive && pattern.bytes().any(|b| !b.is_ascii())
198}
199
200fn ascii_lower(bytes: &[u8]) -> Vec<u8> {
201    bytes.iter().map(|b| b.to_ascii_lowercase()).collect()
202}
203
204fn tree_prefix(rel: &str) -> String {
205    format!("{rel}/")
206}
207
208fn contains_all(hay: &[u32], needles: &[u32]) -> bool {
209    needles.iter().all(|n| hay.binary_search(n).is_ok())
210}
211
212/// Unescaped regex → `None`. Escaped metas become their literal character.
213fn as_literal(pattern: &str) -> Option<String> {
214    let mut out = String::with_capacity(pattern.len());
215    let mut chars = pattern.chars();
216    while let Some(c) = chars.next() {
217        if c == '\\' {
218            let n = chars.next()?;
219            if is_regex_meta(n) || n == '\\' {
220                out.push(n);
221            } else {
222                return None;
223            }
224        } else if is_regex_meta(c) {
225            return None;
226        } else {
227            out.push(c);
228        }
229    }
230    Some(out)
231}
232
233fn is_regex_meta(c: char) -> bool {
234    REGEX_META.contains(&c)
235}
236
237fn load_file(path: &Path, size_hint: u64) -> Load {
238    if size_hint > MAX_INDEX_BYTES {
239        return too_large();
240    }
241    classify_metadata(path)
242}
243
244fn classify_metadata(path: &Path) -> Load {
245    let Ok(md) = std::fs::symlink_metadata(path) else {
246        return missing_path();
247    };
248    if md.file_type().is_symlink() || md.is_dir() {
249        return skip_non_file();
250    }
251    if md.len() > MAX_INDEX_BYTES {
252        return too_large();
253    }
254    read_text_or_binary(path)
255}
256
257fn read_text_or_binary(path: &Path) -> Load {
258    let Ok(mut f) = File::open(path) else {
259        return open_failed();
260    };
261    let mut sniff = [0u8; BINARY_SNIFF];
262    classify_sniff(f.read(&mut sniff), &sniff, &mut f)
263}
264
265fn classify_sniff(result: std::io::Result<usize>, sniff: &[u8], f: &mut File) -> Load {
266    match result {
267        Ok(n) => {
268            let n = n.min(sniff.len());
269            if sniff[..n].contains(&0) {
270                return Load::Binary;
271            }
272            let mut buf = Vec::with_capacity(n);
273            buf.extend_from_slice(&sniff[..n]);
274            if n == BINARY_SNIFF {
275                append_rest(f, &mut buf)
276            } else {
277                Load::Bytes(buf)
278            }
279        }
280        Err(err) => {
281            tracing::debug!(error = %err, "content index sniff read failed");
282            read_failed()
283        }
284    }
285}
286
287fn append_rest(f: &mut File, buf: &mut Vec<u8>) -> Load {
288    let cap = MAX_INDEX_BYTES.saturating_sub(BINARY_SNIFF as u64);
289    let mut rest = Vec::new();
290    classify_append(f.take(cap).read_to_end(&mut rest), buf, rest)
291}
292
293fn classify_append(result: std::io::Result<usize>, buf: &mut Vec<u8>, rest: Vec<u8>) -> Load {
294    match result {
295        Ok(_) => {
296            buf.extend(rest);
297            Load::Bytes(std::mem::take(buf))
298        }
299        Err(err) => {
300            tracing::debug!(error = %err, "content index append read failed");
301            append_failed()
302        }
303    }
304}
305
306fn missing_path() -> Load {
307    Load::Unknown
308}
309
310fn skip_non_file() -> Load {
311    Load::Unknown
312}
313
314fn open_failed() -> Load {
315    Load::Unknown
316}
317
318fn read_failed() -> Load {
319    Load::Unknown
320}
321
322fn append_failed() -> Load {
323    Load::Unknown
324}
325
326fn too_large() -> Load {
327    Load::Unknown
328}
329
330#[cfg(test)]
331#[path = "content_tests.rs"]
332mod tests;