@pify/search 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -6
- package/extensions/search.ts +16 -2
- package/native/Cargo.toml +25 -0
- package/native/build.rs +3 -0
- package/native/src/lib.rs +508 -0
- package/native/src/score.rs +147 -0
- package/native/src/store.rs +194 -0
- package/native/src/trigram.rs +292 -0
- package/native/src/walk.rs +114 -0
- package/package.json +6 -2
- package/src/engine.ts +28 -15
- package/src/format.ts +14 -1
- package/src/native.ts +226 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
//! Fuzzy path matching and ranking.
|
|
2
|
+
//!
|
|
3
|
+
//! `find` answers with everything matching a glob, in filesystem order. The
|
|
4
|
+
//! question people actually ask is "the auth route file — you know the one",
|
|
5
|
+
//! which needs two things a glob cannot give: a match that tolerates how
|
|
6
|
+
//! people type, and an order that puts the likely file first.
|
|
7
|
+
//!
|
|
8
|
+
//! The model is fff's — match quality, plus frecency, recency and git state —
|
|
9
|
+
//! and the constants are kept identical to the TypeScript fallback so the two
|
|
10
|
+
//! engines rank the same way. A user who loses the native binary should get a
|
|
11
|
+
//! slower search, not a differently-ordered one.
|
|
12
|
+
|
|
13
|
+
pub const BONUS_EXACT_FILENAME: i32 = 300;
|
|
14
|
+
pub const BONUS_EXACT_STEM: i32 = 200;
|
|
15
|
+
pub const BONUS_FILENAME_PREFIX: i32 = 120;
|
|
16
|
+
pub const BONUS_IN_FILENAME: i32 = 60;
|
|
17
|
+
pub const BONUS_CONSECUTIVE: i32 = 12;
|
|
18
|
+
pub const BONUS_BOUNDARY: i32 = 18;
|
|
19
|
+
pub const PENALTY_LEADING: i32 = 2;
|
|
20
|
+
pub const PENALTY_TYPO: i32 = 40;
|
|
21
|
+
pub const PENALTY_DEPTH: i32 = 3;
|
|
22
|
+
|
|
23
|
+
/// One wrong letter in four is a different mistake from one in twenty.
|
|
24
|
+
pub fn max_typos(query: &str) -> usize {
|
|
25
|
+
(query.chars().count() / 4).clamp(2, 6)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
pub fn recency_boost(mtime_ms: i64, now_ms: i64) -> i32 {
|
|
29
|
+
if mtime_ms <= 0 {
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
let age = (now_ms - mtime_ms) / 1000;
|
|
33
|
+
match age {
|
|
34
|
+
a if a < 120 => 16,
|
|
35
|
+
a if a < 900 => 8,
|
|
36
|
+
a if a < 3_600 => 4,
|
|
37
|
+
a if a < 86_400 => 2,
|
|
38
|
+
a if a < 604_800 => 1,
|
|
39
|
+
_ => 0,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
pub fn git_boost(status: &str) -> i32 {
|
|
44
|
+
match status {
|
|
45
|
+
"modified" => 24,
|
|
46
|
+
"staged" => 20,
|
|
47
|
+
"untracked" => 12,
|
|
48
|
+
_ => 0,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
struct Match {
|
|
53
|
+
positions: Vec<usize>,
|
|
54
|
+
typos: usize,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Subsequence match within a typo budget. A skipped query character is the
|
|
58
|
+
/// typo — a slip or a transposition — and spending more than the budget means
|
|
59
|
+
/// this is simply not the file.
|
|
60
|
+
fn match_positions(haystack: &str, needle: &str, budget: usize) -> Option<Match> {
|
|
61
|
+
let hay: Vec<char> = haystack.chars().flat_map(|c| c.to_lowercase()).collect();
|
|
62
|
+
let need: Vec<char> = needle.chars().flat_map(|c| c.to_lowercase()).collect();
|
|
63
|
+
let mut positions = Vec::with_capacity(need.len());
|
|
64
|
+
let mut typos = 0usize;
|
|
65
|
+
let mut at = 0usize;
|
|
66
|
+
|
|
67
|
+
for &want in &need {
|
|
68
|
+
match hay[at.min(hay.len())..].iter().position(|&c| c == want) {
|
|
69
|
+
Some(offset) => {
|
|
70
|
+
let found = at + offset;
|
|
71
|
+
positions.push(found);
|
|
72
|
+
at = found + 1;
|
|
73
|
+
}
|
|
74
|
+
None => {
|
|
75
|
+
typos += 1;
|
|
76
|
+
if typos > budget {
|
|
77
|
+
return None;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if positions.is_empty() {
|
|
83
|
+
return None;
|
|
84
|
+
}
|
|
85
|
+
Some(Match { positions, typos })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fn is_boundary(chars: &[char], index: usize) -> bool {
|
|
89
|
+
if index == 0 {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
let prev = chars[index - 1];
|
|
93
|
+
prev == '/' || prev == '_' || prev == '-' || prev == '.' || (prev.is_lowercase() && chars[index].is_uppercase())
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pub struct Candidate<'a> {
|
|
97
|
+
pub path: &'a str,
|
|
98
|
+
pub frecency: i32,
|
|
99
|
+
pub git: &'a str,
|
|
100
|
+
pub mtime_ms: i64,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// Score one candidate, or `None` when the query does not match it at all.
|
|
104
|
+
pub fn score(candidate: &Candidate, query: &str, now_ms: i64) -> Option<i32> {
|
|
105
|
+
let base = candidate.frecency + git_boost(candidate.git) + recency_boost(candidate.mtime_ms, now_ms);
|
|
106
|
+
if query.is_empty() {
|
|
107
|
+
return Some(base);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let m = match_positions(candidate.path, query, max_typos(query))?;
|
|
111
|
+
let chars: Vec<char> = candidate.path.chars().collect();
|
|
112
|
+
|
|
113
|
+
let filename = candidate.path.rsplit('/').next().unwrap_or(candidate.path);
|
|
114
|
+
let lower_name = filename.to_lowercase();
|
|
115
|
+
let lower_query = query.to_lowercase();
|
|
116
|
+
let stem = match lower_name.rfind('.') {
|
|
117
|
+
Some(dot) if dot > 0 => &lower_name[..dot],
|
|
118
|
+
_ => lower_name.as_str(),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
let mut total = 0i32;
|
|
122
|
+
if lower_name == lower_query {
|
|
123
|
+
total += BONUS_EXACT_FILENAME;
|
|
124
|
+
} else if stem == lower_query {
|
|
125
|
+
total += BONUS_EXACT_STEM;
|
|
126
|
+
} else if lower_name.starts_with(&lower_query) {
|
|
127
|
+
total += BONUS_FILENAME_PREFIX;
|
|
128
|
+
} else if lower_name.contains(&lower_query) {
|
|
129
|
+
total += BONUS_IN_FILENAME;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (i, &at) in m.positions.iter().enumerate() {
|
|
133
|
+
if i > 0 && at == m.positions[i - 1] + 1 {
|
|
134
|
+
total += BONUS_CONSECUTIVE;
|
|
135
|
+
}
|
|
136
|
+
if at < chars.len() && is_boundary(&chars, at) {
|
|
137
|
+
total += BONUS_BOUNDARY;
|
|
138
|
+
}
|
|
139
|
+
if i == 0 {
|
|
140
|
+
total -= (at.min(40) as i32) * PENALTY_LEADING;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
total -= (m.typos as i32) * PENALTY_TYPO;
|
|
145
|
+
total -= (candidate.path.matches('/').count() as i32) * PENALTY_DEPTH;
|
|
146
|
+
Some(total + base)
|
|
147
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
//! Making the index outlive the process.
|
|
2
|
+
//!
|
|
3
|
+
//! An in-memory index is rebuilt every time pi starts. On this suite that
|
|
4
|
+
//! costs 33ms and nobody notices; on a hundred-thousand-file monorepo it is
|
|
5
|
+
//! seconds, paid again at every session, for a tree that has barely changed.
|
|
6
|
+
//! tgrep's answer is the whole reason it is fast in practice — build once,
|
|
7
|
+
//! persist, and on the next start reload and reconcile instead of rebuilding.
|
|
8
|
+
//!
|
|
9
|
+
//! The format is deliberately dull: a header, the file table, then the posting
|
|
10
|
+
//! lists, all little-endian. No mmap, because a memory-mapped file is a
|
|
11
|
+
//! different set of failure modes on every platform and the load is already
|
|
12
|
+
//! bounded by the disk read.
|
|
13
|
+
//!
|
|
14
|
+
//! Correctness rests on one rule. A stored entry is trusted only while its
|
|
15
|
+
//! size and mtime still match what is on disk; anything that differs, or is
|
|
16
|
+
//! new, or has vanished, is re-read before a query can see it. A stale index
|
|
17
|
+
//! that answers confidently is worse than no index at all.
|
|
18
|
+
|
|
19
|
+
use std::collections::HashMap;
|
|
20
|
+
use std::fs::File;
|
|
21
|
+
use std::io::{BufWriter, Write};
|
|
22
|
+
use std::path::Path;
|
|
23
|
+
|
|
24
|
+
/// Bumped whenever the layout changes. A mismatch is not an error — it means
|
|
25
|
+
/// the cache is from another version and the tree is simply re-indexed.
|
|
26
|
+
const MAGIC: &[u8; 8] = b"PIFYSRC2";
|
|
27
|
+
|
|
28
|
+
pub struct StoredFile {
|
|
29
|
+
pub rel: String,
|
|
30
|
+
pub size: u64,
|
|
31
|
+
pub mtime_ms: i64,
|
|
32
|
+
pub trigrams: Vec<u32>,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fn write_u32(out: &mut impl Write, value: u32) -> std::io::Result<()> {
|
|
36
|
+
out.write_all(&value.to_le_bytes())
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Trigram lists are sorted, so the gaps between neighbours are far smaller
|
|
40
|
+
/// than the values themselves and a varint spends one or two bytes where a
|
|
41
|
+
/// fixed `u32` spends four. This is what decides whether persisting is worth
|
|
42
|
+
/// it at all: a cache that is larger than the source it summarises costs more
|
|
43
|
+
/// to read back than the rebuild it was meant to avoid.
|
|
44
|
+
fn write_varint(out: &mut impl Write, mut value: u32) -> std::io::Result<()> {
|
|
45
|
+
let mut buf = [0u8; 5];
|
|
46
|
+
let mut len = 0;
|
|
47
|
+
loop {
|
|
48
|
+
let byte = (value & 0x7f) as u8;
|
|
49
|
+
value >>= 7;
|
|
50
|
+
if value == 0 {
|
|
51
|
+
buf[len] = byte;
|
|
52
|
+
len += 1;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
buf[len] = byte | 0x80;
|
|
56
|
+
len += 1;
|
|
57
|
+
}
|
|
58
|
+
out.write_all(&buf[..len])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Decoding reads from a slice rather than a `Read`. The index is millions of
|
|
62
|
+
/// varints, and one `read_exact` per byte through a `BufReader` spends more
|
|
63
|
+
/// time in the reader than in the decode; the whole file is a handful of
|
|
64
|
+
/// megabytes, so it is simply read once and walked in memory.
|
|
65
|
+
struct Cursor<'a> {
|
|
66
|
+
bytes: &'a [u8],
|
|
67
|
+
at: usize,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
impl<'a> Cursor<'a> {
|
|
71
|
+
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
|
|
72
|
+
let end = self.at.checked_add(n)?;
|
|
73
|
+
let slice = self.bytes.get(self.at..end)?;
|
|
74
|
+
self.at = end;
|
|
75
|
+
Some(slice)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fn u32(&mut self) -> Option<u32> {
|
|
79
|
+
Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn u64(&mut self) -> Option<u64> {
|
|
83
|
+
Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fn i64(&mut self) -> Option<i64> {
|
|
87
|
+
Some(i64::from_le_bytes(self.take(8)?.try_into().ok()?))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fn varint(&mut self) -> Option<u32> {
|
|
91
|
+
let mut value = 0u32;
|
|
92
|
+
let mut shift = 0;
|
|
93
|
+
loop {
|
|
94
|
+
let byte = *self.bytes.get(self.at)?;
|
|
95
|
+
self.at += 1;
|
|
96
|
+
// Five groups of seven bits is the most a u32 can occupy; anything
|
|
97
|
+
// longer is corruption, not a large number.
|
|
98
|
+
if shift > 28 {
|
|
99
|
+
return None;
|
|
100
|
+
}
|
|
101
|
+
value |= u32::from(byte & 0x7f) << shift;
|
|
102
|
+
if byte & 0x80 == 0 {
|
|
103
|
+
return Some(value);
|
|
104
|
+
}
|
|
105
|
+
shift += 7;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fn write_u64(out: &mut impl Write, value: u64) -> std::io::Result<()> {
|
|
111
|
+
out.write_all(&value.to_le_bytes())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Write atomically: a half-written index that still has a valid header would
|
|
115
|
+
/// be loaded and believed, so the rename is what makes it safe.
|
|
116
|
+
pub fn save(path: &Path, files: &[StoredFile]) -> std::io::Result<()> {
|
|
117
|
+
if let Some(parent) = path.parent() {
|
|
118
|
+
std::fs::create_dir_all(parent)?;
|
|
119
|
+
}
|
|
120
|
+
let temp = path.with_extension("tmp");
|
|
121
|
+
{
|
|
122
|
+
let mut out = BufWriter::new(File::create(&temp)?);
|
|
123
|
+
out.write_all(MAGIC)?;
|
|
124
|
+
write_u32(&mut out, files.len() as u32)?;
|
|
125
|
+
for file in files {
|
|
126
|
+
let bytes = file.rel.as_bytes();
|
|
127
|
+
write_u32(&mut out, bytes.len() as u32)?;
|
|
128
|
+
out.write_all(bytes)?;
|
|
129
|
+
write_u64(&mut out, file.size)?;
|
|
130
|
+
out.write_all(&file.mtime_ms.to_le_bytes())?;
|
|
131
|
+
write_u32(&mut out, file.trigrams.len() as u32)?;
|
|
132
|
+
let mut previous = 0u32;
|
|
133
|
+
for &t in &file.trigrams {
|
|
134
|
+
write_varint(&mut out, t - previous)?;
|
|
135
|
+
previous = t;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
out.flush()?;
|
|
139
|
+
}
|
|
140
|
+
std::fs::rename(&temp, path)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// Load, or `None` for anything unreadable, truncated or from another version.
|
|
144
|
+
/// A cache is an optimisation; a broken one costs a rebuild and never a result.
|
|
145
|
+
pub fn load(path: &Path) -> Option<Vec<StoredFile>> {
|
|
146
|
+
let bytes = std::fs::read(path).ok()?;
|
|
147
|
+
let mut input = Cursor { bytes: &bytes, at: 0 };
|
|
148
|
+
if input.take(MAGIC.len())? != MAGIC {
|
|
149
|
+
return None;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let count = input.u32()? as usize;
|
|
153
|
+
// A corrupt length field must not turn into a huge allocation.
|
|
154
|
+
if count > 5_000_000 {
|
|
155
|
+
return None;
|
|
156
|
+
}
|
|
157
|
+
let mut files = Vec::with_capacity(count.min(65_536));
|
|
158
|
+
for _ in 0..count {
|
|
159
|
+
let name_len = input.u32()? as usize;
|
|
160
|
+
if name_len > 4096 {
|
|
161
|
+
return None;
|
|
162
|
+
}
|
|
163
|
+
let rel = std::str::from_utf8(input.take(name_len)?).ok()?.to_string();
|
|
164
|
+
let size = input.u64()?;
|
|
165
|
+
let mtime_ms = input.i64()?;
|
|
166
|
+
let trigram_count = input.u32()? as usize;
|
|
167
|
+
if trigram_count > 4_000_000 {
|
|
168
|
+
return None;
|
|
169
|
+
}
|
|
170
|
+
let mut trigrams = Vec::with_capacity(trigram_count.min(8192));
|
|
171
|
+
let mut previous = 0u32;
|
|
172
|
+
for _ in 0..trigram_count {
|
|
173
|
+
previous = previous.checked_add(input.varint()?)?;
|
|
174
|
+
trigrams.push(previous);
|
|
175
|
+
}
|
|
176
|
+
files.push(StoredFile { rel, size, mtime_ms, trigrams });
|
|
177
|
+
}
|
|
178
|
+
Some(files)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/// What a stored index still knows, keyed by path, so a walk can ask whether
|
|
182
|
+
/// each file it finds needs re-reading.
|
|
183
|
+
pub fn index_by_path(files: Vec<StoredFile>) -> HashMap<String, StoredFile> {
|
|
184
|
+
let mut map = HashMap::with_capacity(files.len());
|
|
185
|
+
for file in files {
|
|
186
|
+
map.insert(file.rel.clone(), file);
|
|
187
|
+
}
|
|
188
|
+
map
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// A stored entry is only trusted while the file on disk still looks the same.
|
|
192
|
+
pub fn still_valid(stored: &StoredFile, size: u64, mtime_ms: i64) -> bool {
|
|
193
|
+
stored.size == size && stored.mtime_ms == mtime_ms
|
|
194
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
//! Trigram index over file contents.
|
|
2
|
+
//!
|
|
3
|
+
//! Every overlapping three-byte window of a file is packed into a `u32` —
|
|
4
|
+
//! `(a << 16) | (b << 8) | c` — which is injective, so a trigram is its own
|
|
5
|
+
//! hash and no two distinct windows collide. The index maps each trigram to
|
|
6
|
+
//! the files containing it, and a search intersects the posting lists of the
|
|
7
|
+
//! trigrams its pattern must contain. Only the files that survive are read.
|
|
8
|
+
//!
|
|
9
|
+
//! The index only ever *narrows*. Every candidate is matched for real
|
|
10
|
+
//! afterwards, so a wrong candidate costs time and never correctness — which
|
|
11
|
+
//! is what makes it safe to narrow aggressively.
|
|
12
|
+
|
|
13
|
+
use std::collections::{HashMap, HashSet};
|
|
14
|
+
|
|
15
|
+
pub type Trigram = u32;
|
|
16
|
+
|
|
17
|
+
/// A trigram key *is* its own hash, so the default SipHash is pure overhead on
|
|
18
|
+
/// a path that runs once per input byte. One multiply-xorshift replaces it;
|
|
19
|
+
/// the xorshift is not optional, because hashbrown takes the bucket index from
|
|
20
|
+
/// the low bits and the low bits of `value * K` depend only on the last byte.
|
|
21
|
+
#[derive(Default, Clone, Copy)]
|
|
22
|
+
pub struct TrigramHasher(u64);
|
|
23
|
+
|
|
24
|
+
impl std::hash::Hasher for TrigramHasher {
|
|
25
|
+
#[inline]
|
|
26
|
+
fn finish(&self) -> u64 {
|
|
27
|
+
self.0
|
|
28
|
+
}
|
|
29
|
+
#[inline]
|
|
30
|
+
fn write(&mut self, bytes: &[u8]) {
|
|
31
|
+
for &b in bytes {
|
|
32
|
+
self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
#[inline]
|
|
36
|
+
fn write_u32(&mut self, value: u32) {
|
|
37
|
+
let mixed = (u64::from(value)).wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
|
38
|
+
self.0 = mixed ^ (mixed >> 29);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#[derive(Default, Clone, Copy)]
|
|
43
|
+
pub struct BuildTrigramHasher;
|
|
44
|
+
|
|
45
|
+
impl std::hash::BuildHasher for BuildTrigramHasher {
|
|
46
|
+
type Hasher = TrigramHasher;
|
|
47
|
+
#[inline]
|
|
48
|
+
fn build_hasher(&self) -> TrigramHasher {
|
|
49
|
+
TrigramHasher::default()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type TrigramMap<V> = HashMap<Trigram, V, BuildTrigramHasher>;
|
|
54
|
+
|
|
55
|
+
/// Every distinct trigram in `bytes`, ASCII-folded so the index and the query
|
|
56
|
+
/// agree on case without storing both.
|
|
57
|
+
pub fn extract(bytes: &[u8], out: &mut HashSet<Trigram, BuildTrigramHasher>) {
|
|
58
|
+
out.clear();
|
|
59
|
+
if bytes.len() < 3 {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for window in bytes.windows(3) {
|
|
63
|
+
let a = window[0].to_ascii_lowercase() as u32;
|
|
64
|
+
let b = window[1].to_ascii_lowercase() as u32;
|
|
65
|
+
let c = window[2].to_ascii_lowercase() as u32;
|
|
66
|
+
out.insert((a << 16) | (b << 8) | c);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
pub fn of(text: &str) -> Vec<Trigram> {
|
|
71
|
+
let mut set = HashSet::with_hasher(BuildTrigramHasher);
|
|
72
|
+
extract(text.as_bytes(), &mut set);
|
|
73
|
+
let mut list: Vec<Trigram> = set.into_iter().collect();
|
|
74
|
+
list.sort_unstable();
|
|
75
|
+
list
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// What the index can be asked. `All` means the pattern gave nothing
|
|
79
|
+
/// indexable, so no candidate set is safe and everything must be read —
|
|
80
|
+
/// a different answer from "nothing matched", and conflating the two is how
|
|
81
|
+
/// an index silently starts hiding results.
|
|
82
|
+
#[derive(Debug, Clone)]
|
|
83
|
+
pub enum Plan {
|
|
84
|
+
And(Vec<Trigram>),
|
|
85
|
+
Or(Vec<Plan>),
|
|
86
|
+
All,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#[derive(Default)]
|
|
90
|
+
pub struct Index {
|
|
91
|
+
postings: TrigramMap<Vec<u32>>,
|
|
92
|
+
indexed: HashSet<u32>,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
impl Index {
|
|
96
|
+
pub fn new() -> Self {
|
|
97
|
+
Self {
|
|
98
|
+
postings: HashMap::with_hasher(BuildTrigramHasher),
|
|
99
|
+
indexed: HashSet::new(),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
pub fn len(&self) -> usize {
|
|
104
|
+
self.indexed.len()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
pub fn add(&mut self, file: u32, trigrams: &[Trigram]) {
|
|
108
|
+
if self.indexed.contains(&file) {
|
|
109
|
+
self.remove(file);
|
|
110
|
+
}
|
|
111
|
+
for &t in trigrams {
|
|
112
|
+
let list = self.postings.entry(t).or_default();
|
|
113
|
+
// Postings stay sorted so intersection is a merge, not a scan.
|
|
114
|
+
//
|
|
115
|
+
// Bulk builds add files in ascending id order, so the new id
|
|
116
|
+
// belongs at the end and one comparison settles it. Falling
|
|
117
|
+
// through to a binary search here would cost fifteen scattered
|
|
118
|
+
// probes on a list held by every file — which measured as most of
|
|
119
|
+
// the time spent building the index.
|
|
120
|
+
match list.last() {
|
|
121
|
+
Some(&last) if last >= file => match list.binary_search(&file) {
|
|
122
|
+
Ok(_) => {}
|
|
123
|
+
Err(at) => list.insert(at, file),
|
|
124
|
+
},
|
|
125
|
+
_ => list.push(file),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
self.indexed.insert(file);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
pub fn remove(&mut self, file: u32) {
|
|
132
|
+
if !self.indexed.remove(&file) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
self.postings.retain(|_, list| {
|
|
136
|
+
if let Ok(at) = list.binary_search(&file) {
|
|
137
|
+
list.remove(at);
|
|
138
|
+
}
|
|
139
|
+
!list.is_empty()
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// The index turned back the other way up: every trigram, grouped by the
|
|
144
|
+
/// file it came from, for writing the index out.
|
|
145
|
+
///
|
|
146
|
+
/// One pass over the postings rather than one pass *per file* — asking each
|
|
147
|
+
/// file separately would rescan the entire index for every file, which on a
|
|
148
|
+
/// large tree is quadratic and would cost more than rebuilding from source.
|
|
149
|
+
pub fn by_file(&self) -> HashMap<u32, Vec<Trigram>> {
|
|
150
|
+
let mut out: HashMap<u32, Vec<Trigram>> = HashMap::with_capacity(self.indexed.len());
|
|
151
|
+
for &file in &self.indexed {
|
|
152
|
+
out.insert(file, Vec::new());
|
|
153
|
+
}
|
|
154
|
+
for (&t, files) in &self.postings {
|
|
155
|
+
for file in files {
|
|
156
|
+
if let Some(list) = out.get_mut(file) {
|
|
157
|
+
list.push(t);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for list in out.values_mut() {
|
|
162
|
+
list.sort_unstable();
|
|
163
|
+
}
|
|
164
|
+
out
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Candidate file ids, or `None` meaning every file must be read.
|
|
168
|
+
pub fn candidates(&self, plan: &Plan) -> Option<Vec<u32>> {
|
|
169
|
+
match plan {
|
|
170
|
+
Plan::All => None,
|
|
171
|
+
Plan::Or(branches) => {
|
|
172
|
+
let mut union: Vec<u32> = Vec::new();
|
|
173
|
+
for branch in branches {
|
|
174
|
+
let part = self.candidates(branch)?;
|
|
175
|
+
union.extend_from_slice(&part);
|
|
176
|
+
}
|
|
177
|
+
union.sort_unstable();
|
|
178
|
+
union.dedup();
|
|
179
|
+
Some(union)
|
|
180
|
+
}
|
|
181
|
+
Plan::And(trigrams) => {
|
|
182
|
+
if trigrams.is_empty() {
|
|
183
|
+
return None;
|
|
184
|
+
}
|
|
185
|
+
let mut lists: Vec<&Vec<u32>> = Vec::with_capacity(trigrams.len());
|
|
186
|
+
for t in trigrams {
|
|
187
|
+
match self.postings.get(t) {
|
|
188
|
+
// A trigram nobody has means nothing can match — an
|
|
189
|
+
// empty answer, emphatically not "read everything".
|
|
190
|
+
None => return Some(Vec::new()),
|
|
191
|
+
Some(list) => lists.push(list),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Smallest first, so the working set only ever shrinks.
|
|
195
|
+
lists.sort_by_key(|l| l.len());
|
|
196
|
+
let mut result = lists[0].clone();
|
|
197
|
+
for list in &lists[1..] {
|
|
198
|
+
if result.is_empty() {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
result = intersect(&result, list);
|
|
202
|
+
}
|
|
203
|
+
Some(result)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/// Both sides are sorted, so this is a linear merge rather than a lookup loop.
|
|
210
|
+
fn intersect(a: &[u32], b: &[u32]) -> Vec<u32> {
|
|
211
|
+
let mut out = Vec::with_capacity(a.len().min(b.len()));
|
|
212
|
+
let (mut i, mut j) = (0usize, 0usize);
|
|
213
|
+
while i < a.len() && j < b.len() {
|
|
214
|
+
match a[i].cmp(&b[j]) {
|
|
215
|
+
std::cmp::Ordering::Equal => {
|
|
216
|
+
out.push(a[i]);
|
|
217
|
+
i += 1;
|
|
218
|
+
j += 1;
|
|
219
|
+
}
|
|
220
|
+
std::cmp::Ordering::Less => i += 1,
|
|
221
|
+
std::cmp::Ordering::Greater => j += 1,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
out
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// Literal runs of three or more characters — the only parts of a pattern that
|
|
228
|
+
/// imply required trigrams.
|
|
229
|
+
pub fn literal_runs(pattern: &str) -> Vec<String> {
|
|
230
|
+
let chars: Vec<char> = pattern.chars().collect();
|
|
231
|
+
let mut runs = Vec::new();
|
|
232
|
+
let mut current = String::new();
|
|
233
|
+
let mut i = 0usize;
|
|
234
|
+
while i < chars.len() {
|
|
235
|
+
let ch = chars[i];
|
|
236
|
+
if ch == '\\' {
|
|
237
|
+
if let Some(&next) = chars.get(i + 1) {
|
|
238
|
+
if !next.is_ascii_alphanumeric() {
|
|
239
|
+
current.push(next);
|
|
240
|
+
i += 2;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
runs.push(std::mem::take(&mut current));
|
|
245
|
+
i += 2;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// A quantifier applies to the character before it, so that character
|
|
249
|
+
// is not required either.
|
|
250
|
+
if matches!(ch, '?' | '*' | '+' | '{') {
|
|
251
|
+
current.pop();
|
|
252
|
+
runs.push(std::mem::take(&mut current));
|
|
253
|
+
i += 1;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if matches!(ch, '[' | ']' | '(' | ')' | '}' | '|' | '.' | '^' | '$') {
|
|
257
|
+
runs.push(std::mem::take(&mut current));
|
|
258
|
+
i += 1;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
current.push(ch);
|
|
262
|
+
i += 1;
|
|
263
|
+
}
|
|
264
|
+
runs.push(current);
|
|
265
|
+
runs.retain(|r| r.chars().count() >= 3);
|
|
266
|
+
runs
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
pub fn plan_for_literal(literal: &str) -> Plan {
|
|
270
|
+
let trigrams = of(literal);
|
|
271
|
+
if trigrams.is_empty() {
|
|
272
|
+
Plan::All
|
|
273
|
+
} else {
|
|
274
|
+
Plan::And(trigrams)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
pub fn plan_for_regex(pattern: &str) -> Plan {
|
|
279
|
+
if pattern.contains('|') {
|
|
280
|
+
let branches: Vec<Plan> = pattern.split('|').map(plan_for_regex).collect();
|
|
281
|
+
// One branch that can match anywhere makes the whole union unbounded.
|
|
282
|
+
if branches.iter().any(|b| matches!(b, Plan::All)) {
|
|
283
|
+
return Plan::All;
|
|
284
|
+
}
|
|
285
|
+
return Plan::Or(branches);
|
|
286
|
+
}
|
|
287
|
+
let runs = literal_runs(pattern);
|
|
288
|
+
match runs.iter().max_by_key(|r| r.len()) {
|
|
289
|
+
None => Plan::All,
|
|
290
|
+
Some(longest) => plan_for_literal(longest),
|
|
291
|
+
}
|
|
292
|
+
}
|