whycodes_index/
content.rs1use std::fs::File;
9use std::io::Read;
10use std::path::Path;
11
12use rustc_hash::{FxHashMap, FxHashSet};
13
14pub const TRIGRAM_LEN: usize = 3;
16pub const MAX_INDEX_BYTES: u64 = 1024 * 1024;
18const BINARY_SNIFF: usize = 8192;
20
21const REGEX_META: &[char] = &[
23 '.', '^', '$', '*', '+', '?', '(', ')', '[', ']', '{', '}', '|',
24];
25
26#[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 Binary,
39 Unknown,
41}
42
43#[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 pub fn upsert(&mut self, root: &Path, rel: &str, size: u64) {
80 self.insert(rel, Self::load(root, rel, size));
81 }
82
83 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 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 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 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
139pub fn pack_trigram(a: u8, b: u8, c: u8) -> u32 {
141 (u32::from(a) << 16) | (u32::from(b) << 8) | u32::from(c)
142}
143
144pub 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
159pub 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
177fn 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
212fn 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;