@pify/search 0.2.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 +28 -2
- package/native/src/lib.rs +108 -8
- package/native/src/store.rs +194 -0
- package/native/src/trigram.rs +36 -3
- package/native/src/walk.rs +1 -0
- package/package.json +1 -1
- package/src/engine.ts +8 -0
- package/src/format.ts +7 -0
- package/src/native.ts +57 -5
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Three modes because three different questions get asked: the exact string, a sha
|
|
|
35
35
|
|
|
36
36
|
## Three engines, one interface
|
|
37
37
|
|
|
38
|
-
**native** — this package's own Rust core, `native/`, built with napi. It indexes this suite (417 files) in **
|
|
38
|
+
**native** — this package's own Rust core, `native/`, built with napi. It indexes this suite (417 files) in about **40ms** cold, and a literal search then reads the **5** files the trigram index says could match rather than all 417. Searches come back in about a millisecond. Its index is [stored between sessions](#the-index-survives-the-process), so the second start does not pay for the first.
|
|
39
39
|
|
|
40
40
|
**fff** — [`@ff-labs/fff-node`](https://github.com/dmtrKovalenko/fff), used when it is installed and the native core is not. A mature engine with its own watcher and git integration.
|
|
41
41
|
|
|
@@ -61,6 +61,32 @@ Every overlapping three-byte window of every text file is a *trigram*, packed in
|
|
|
61
61
|
|
|
62
62
|
The index only ever **narrows**; every surviving candidate is still matched for real, so a wrong candidate costs time and never correctness. The rule that makes it safe: a pattern with nothing indexable — `\d+`, a fuzzy query, one branch of an alternation that could match anywhere — reports "no candidate set is safe" and everything is read. Confusing *that* with "nothing matched" is how an index starts silently hiding results, so the two are different values throughout.
|
|
63
63
|
|
|
64
|
+
## The index survives the process
|
|
65
|
+
|
|
66
|
+
An index rebuilt at every start is one you pay for at every start. The native engine writes its index to disk and, next time, reloads it and reconciles instead of re-reading the tree.
|
|
67
|
+
|
|
68
|
+
Measured on a synthetic 20,000-file tree (`node native/bench.mjs 20000`):
|
|
69
|
+
|
|
70
|
+
| | build | files read | grep |
|
|
71
|
+
|---|---|---|---|
|
|
72
|
+
| cold (no stored index) | 1320ms | 20,000 | <1ms |
|
|
73
|
+
| warm (unchanged tree) | **188ms** | 0 | <1ms |
|
|
74
|
+
|
|
75
|
+
**7×**, and the cost that remains is the directory walk, not the files. Editing one file re-reads one file.
|
|
76
|
+
|
|
77
|
+
Correctness rests on a single rule: a stored entry is trusted only while its **size and mtime still match what is on disk**. Anything changed, new, or vanished is re-read before a query can see it. A cache that answers confidently for a file that has moved on is worse than no cache. The reload path is checked against the rebuild path on every supported platform (`native/persist.mjs`, run in CI) — same totals, same lines, same order — including that a corrupt or truncated index is *discarded* rather than half-trusted.
|
|
78
|
+
|
|
79
|
+
Trigram lists are stored as varint deltas, which is what makes this worth doing at all: 881KB for this suite, ~2.2KB per file, about a third of the naive encoding. Reading a cache that is larger than the sources it summarises costs more than the rebuild it was meant to avoid.
|
|
80
|
+
|
|
81
|
+
The index lives in the platform cache directory — `%LOCALAPPDATA%`, `~/Library/Caches`, `$XDG_CACHE_HOME` — keyed by a hash of the absolute root, never inside your working tree.
|
|
82
|
+
|
|
83
|
+
| variable | effect |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `PIFY_SEARCH_NO_CACHE=1` | never store an index; rebuild every start |
|
|
86
|
+
| `PIFY_SEARCH_CACHE_DIR` | store indexes somewhere else |
|
|
87
|
+
| `PIFY_SEARCH_TIMING=1` | print how long the walk, the reload and the inversion each took |
|
|
88
|
+
| `PIFY_SEARCH_ENGINE` | `builtin` or `fff` to force an engine |
|
|
89
|
+
|
|
64
90
|
## Ranking
|
|
65
91
|
|
|
66
92
|
Frecency decays on a three-day half-life — an agent session is shorter and more concentrated than a human's week, so yesterday's file should not outrank today's. Every `read`, `edit` or `write` in the session counts as an access. History is capped at seven days and 128 timestamps per file, so the store cannot grow without bound.
|
|
@@ -71,7 +97,7 @@ This package adds two tools; it does not replace `find`, `grep` or `multi_grep`.
|
|
|
71
97
|
|
|
72
98
|
## Command
|
|
73
99
|
|
|
74
|
-
`/search` — which engine is running, the indexed root,
|
|
100
|
+
`/search` — which engine is running, the indexed root, how many files it holds, and how much of the index came back from the stored copy rather than from disk.
|
|
75
101
|
|
|
76
102
|
## License
|
|
77
103
|
|
package/native/src/lib.rs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
#![deny(clippy::all)]
|
|
10
10
|
|
|
11
11
|
mod score;
|
|
12
|
+
mod store;
|
|
12
13
|
mod trigram;
|
|
13
14
|
mod walk;
|
|
14
15
|
|
|
@@ -67,6 +68,9 @@ pub struct SearchIndex {
|
|
|
67
68
|
by_path: RwLock<HashMap<String, u32>>,
|
|
68
69
|
content: RwLock<Index>,
|
|
69
70
|
frecency: RwLock<HashMap<String, i32>>,
|
|
71
|
+
cache: Option<PathBuf>,
|
|
72
|
+
reused: u32,
|
|
73
|
+
rebuilt: u32,
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
#[napi]
|
|
@@ -74,10 +78,23 @@ impl SearchIndex {
|
|
|
74
78
|
/// Build the index. Walking and content extraction run in parallel;
|
|
75
79
|
/// nothing is memory-mapped, so this behaves the same on every platform.
|
|
76
80
|
#[napi(constructor)]
|
|
77
|
-
pub fn new(root: String, max_files: Option<u32>) -> Result<Self> {
|
|
81
|
+
pub fn new(root: String, max_files: Option<u32>, cache_path: Option<String>) -> Result<Self> {
|
|
78
82
|
let root_path = PathBuf::from(&root);
|
|
79
83
|
let cap = max_files.unwrap_or(200_000) as usize;
|
|
84
|
+
let t0 = std::time::Instant::now();
|
|
80
85
|
let found = walk::collect(&root_path, cap);
|
|
86
|
+
let t_walk = t0.elapsed();
|
|
87
|
+
|
|
88
|
+
// Anything the last run already extracted and that still looks the
|
|
89
|
+
// same on disk is reused; only genuinely changed files are re-read.
|
|
90
|
+
// That is the whole point of persisting — a session on a large tree
|
|
91
|
+
// should pay for what changed, not for the tree.
|
|
92
|
+
let cache = cache_path.as_ref().map(PathBuf::from);
|
|
93
|
+
let t1 = std::time::Instant::now();
|
|
94
|
+
let loaded = cache.as_ref().and_then(|p| store::load(p));
|
|
95
|
+
let t_load = t1.elapsed();
|
|
96
|
+
let had_cache = loaded.is_some();
|
|
97
|
+
let mut known = loaded.map(store::index_by_path).unwrap_or_default();
|
|
81
98
|
|
|
82
99
|
let mut entries = Vec::with_capacity(found.len());
|
|
83
100
|
let mut by_path = HashMap::with_capacity(found.len());
|
|
@@ -91,13 +108,25 @@ impl SearchIndex {
|
|
|
91
108
|
});
|
|
92
109
|
}
|
|
93
110
|
|
|
111
|
+
let mut reused = 0usize;
|
|
112
|
+
let mut carried: Vec<(u32, Vec<u32>)> = Vec::new();
|
|
113
|
+
let mut stale: Vec<(u32, &walk::Found)> = Vec::new();
|
|
114
|
+
for (i, file) in found.iter().enumerate() {
|
|
115
|
+
match known.remove(&file.rel) {
|
|
116
|
+
Some(entry) if store::still_valid(&entry, file.size, file.mtime_ms) => {
|
|
117
|
+
reused += 1;
|
|
118
|
+
carried.push((i as u32, entry.trigrams));
|
|
119
|
+
}
|
|
120
|
+
_ => stale.push((i as u32, file)),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
94
124
|
// Reading and extracting is the expensive half and is embarrassingly
|
|
95
125
|
// parallel; building the map from the results is not, so it is done
|
|
96
126
|
// once at the end rather than behind a lock per file.
|
|
97
|
-
let extracted: Vec<(u32, Vec<u32>)> =
|
|
127
|
+
let extracted: Vec<(u32, Vec<u32>)> = stale
|
|
98
128
|
.par_iter()
|
|
99
|
-
.
|
|
100
|
-
.filter_map(|(i, file)| {
|
|
129
|
+
.filter_map(|(id, file)| {
|
|
101
130
|
if !walk::index_content(&file.rel, file.size) {
|
|
102
131
|
return None;
|
|
103
132
|
}
|
|
@@ -109,22 +138,93 @@ impl SearchIndex {
|
|
|
109
138
|
trigram::extract(&bytes, &mut set);
|
|
110
139
|
let mut list: Vec<u32> = set.into_iter().collect();
|
|
111
140
|
list.sort_unstable();
|
|
112
|
-
Some((
|
|
141
|
+
Some((*id, list))
|
|
113
142
|
})
|
|
114
143
|
.collect();
|
|
115
144
|
|
|
145
|
+
// Merged and sorted by id before insertion, which is not cosmetic:
|
|
146
|
+
// posting lists are kept sorted, so adding files in ascending order
|
|
147
|
+
// appends to each list, while the interleaved order that `carried` and
|
|
148
|
+
// `extracted` arrive in would insert into the middle and memmove the
|
|
149
|
+
// tail of every list it touches.
|
|
150
|
+
let rebuilt = extracted.len() as u32;
|
|
151
|
+
let mut all: Vec<(u32, Vec<u32>)> = carried;
|
|
152
|
+
all.extend(extracted);
|
|
153
|
+
all.sort_unstable_by_key(|(id, _)| *id);
|
|
154
|
+
|
|
155
|
+
let t2 = std::time::Instant::now();
|
|
116
156
|
let mut index = Index::new();
|
|
117
|
-
for (id, trigrams) in &
|
|
157
|
+
for (id, trigrams) in &all {
|
|
118
158
|
index.add(*id, trigrams);
|
|
119
159
|
}
|
|
160
|
+
let t_index = t2.elapsed();
|
|
161
|
+
// Off unless asked for. The three phases have very different costs and
|
|
162
|
+
// guessing which one dominates is how the first version of this spent
|
|
163
|
+
// most of its time rebuilding an index it had just loaded.
|
|
164
|
+
if std::env::var_os("PIFY_SEARCH_TIMING").is_some() {
|
|
165
|
+
eprintln!(" walk {t_walk:?} load {t_load:?} invert {t_index:?}");
|
|
166
|
+
}
|
|
120
167
|
|
|
121
|
-
|
|
168
|
+
// Rewriting an unchanged index costs a full re-inversion and a
|
|
169
|
+
// multi-megabyte write on every start, which on a large tree is most
|
|
170
|
+
// of what reloading was supposed to save. Write only when the tree
|
|
171
|
+
// actually moved: something re-read, something gone, or no cache yet.
|
|
172
|
+
let vanished = !known.is_empty();
|
|
173
|
+
let dirty = !had_cache || rebuilt > 0 || vanished;
|
|
174
|
+
|
|
175
|
+
let this = Self {
|
|
122
176
|
root: root_path,
|
|
123
177
|
entries: RwLock::new(entries),
|
|
124
178
|
by_path: RwLock::new(by_path),
|
|
125
179
|
content: RwLock::new(index),
|
|
126
180
|
frecency: RwLock::new(HashMap::new()),
|
|
127
|
-
|
|
181
|
+
cache,
|
|
182
|
+
reused: reused as u32,
|
|
183
|
+
rebuilt,
|
|
184
|
+
};
|
|
185
|
+
if dirty {
|
|
186
|
+
this.persist();
|
|
187
|
+
}
|
|
188
|
+
Ok(this)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// Write the index back, best effort. A cache that cannot be written costs
|
|
192
|
+
/// the next session a rebuild, never this one a result.
|
|
193
|
+
fn persist(&self) {
|
|
194
|
+
let Some(path) = self.cache.as_ref() else { return };
|
|
195
|
+
let (Ok(entries), Ok(index)) = (self.entries.read(), self.content.read()) else {
|
|
196
|
+
return;
|
|
197
|
+
};
|
|
198
|
+
let mut by_file = index.by_file();
|
|
199
|
+
let mut files = Vec::with_capacity(by_file.len());
|
|
200
|
+
for (id, entry) in entries.iter().enumerate() {
|
|
201
|
+
if let Some(trigrams) = by_file.remove(&(id as u32)) {
|
|
202
|
+
files.push(store::StoredFile {
|
|
203
|
+
rel: entry.path.clone(),
|
|
204
|
+
size: entry.size,
|
|
205
|
+
mtime_ms: entry.mtime_ms,
|
|
206
|
+
trigrams,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
let _ = store::save(path, &files);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// How much of the last index survived, so the saving is observable.
|
|
214
|
+
#[napi]
|
|
215
|
+
pub fn reused_count(&self) -> u32 {
|
|
216
|
+
self.reused
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#[napi]
|
|
220
|
+
pub fn rebuilt_count(&self) -> u32 {
|
|
221
|
+
self.rebuilt
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// Flush the index to its cache file.
|
|
225
|
+
#[napi]
|
|
226
|
+
pub fn save(&self) {
|
|
227
|
+
self.persist();
|
|
128
228
|
}
|
|
129
229
|
|
|
130
230
|
#[napi]
|
|
@@ -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
|
+
}
|
package/native/src/trigram.rs
CHANGED
|
@@ -111,9 +111,18 @@ impl Index {
|
|
|
111
111
|
for &t in trigrams {
|
|
112
112
|
let list = self.postings.entry(t).or_default();
|
|
113
113
|
// Postings stay sorted so intersection is a merge, not a scan.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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),
|
|
117
126
|
}
|
|
118
127
|
}
|
|
119
128
|
self.indexed.insert(file);
|
|
@@ -131,6 +140,30 @@ impl Index {
|
|
|
131
140
|
});
|
|
132
141
|
}
|
|
133
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
|
+
|
|
134
167
|
/// Candidate file ids, or `None` meaning every file must be read.
|
|
135
168
|
pub fn candidates(&self, plan: &Plan) -> Option<Vec<u32>> {
|
|
136
169
|
match plan {
|
package/native/src/walk.rs
CHANGED
package/package.json
CHANGED
package/src/engine.ts
CHANGED
|
@@ -69,6 +69,14 @@ export interface SearchEngine {
|
|
|
69
69
|
dispose(): void;
|
|
70
70
|
/** How many files the index holds, when the engine can say. */
|
|
71
71
|
indexed?(): number;
|
|
72
|
+
/**
|
|
73
|
+
* How the last index build was paid for: files whose contents came back from
|
|
74
|
+
* the stored index versus files that had to be read. Only an engine that
|
|
75
|
+
* persists its index can answer, and it is worth surfacing — "reused 0" on a
|
|
76
|
+
* tree that has not changed is the visible symptom of a cache that is
|
|
77
|
+
* silently not working.
|
|
78
|
+
*/
|
|
79
|
+
stats?(): { reused: number; rebuilt: number };
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
/** fff calls it "plain"; this package calls it what a user would call it. */
|
package/src/format.ts
CHANGED
|
@@ -56,6 +56,13 @@ export function formatStatus(engine: SearchEngine | null, root: string): string
|
|
|
56
56
|
];
|
|
57
57
|
const count = engine.indexed?.();
|
|
58
58
|
if (typeof count === "number") lines.push(`Files: ${count} indexed`);
|
|
59
|
+
const stats = engine.stats?.();
|
|
60
|
+
if (stats && stats.reused + stats.rebuilt > 0) {
|
|
61
|
+
lines.push(
|
|
62
|
+
`Index: ${stats.reused} reused from cache, ${stats.rebuilt} read from disk` +
|
|
63
|
+
(stats.reused === 0 ? " (first run for this tree)" : ""),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
59
66
|
if (engine.name === "builtin") {
|
|
60
67
|
lines.push(
|
|
61
68
|
"",
|
package/src/native.ts
CHANGED
|
@@ -3,18 +3,23 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A Rust index built for this package: the same trigram narrowing and the same
|
|
5
5
|
* scoring constants as the TypeScript fallback, compiled. It builds an index
|
|
6
|
-
* of this suite — 417 files — in about
|
|
6
|
+
* of this suite — 417 files — in about 40ms, and a literal search then reads 5
|
|
7
7
|
* of those files instead of all of them.
|
|
8
8
|
*
|
|
9
|
+
* The index is stored between sessions, so a second start on an unchanged tree
|
|
10
|
+
* reloads instead of re-reading it. See `cachePathFor` for where it lives.
|
|
11
|
+
*
|
|
9
12
|
* The binary is an optional dependency per platform, in the napi convention.
|
|
10
13
|
* If none of them installed, this returns null and the caller falls back;
|
|
11
14
|
* nothing here ever throws for a missing binary, because a missing binary is
|
|
12
15
|
* the ordinary case this package is designed to survive.
|
|
13
16
|
*/
|
|
14
17
|
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
15
19
|
import { createRequire } from "node:module";
|
|
16
20
|
import { existsSync } from "node:fs";
|
|
17
|
-
import {
|
|
21
|
+
import { homedir, tmpdir } from "node:os";
|
|
22
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
18
23
|
import { fileURLToPath } from "node:url";
|
|
19
24
|
|
|
20
25
|
import type { ContentHit, FileHit, GrepOptions, Page, SearchEngine, FindOptions } from "./engine.ts";
|
|
@@ -30,6 +35,11 @@ interface NativePage<T> {
|
|
|
30
35
|
interface NativeIndex {
|
|
31
36
|
fileCount(): number;
|
|
32
37
|
indexedCount(): number;
|
|
38
|
+
/** Files whose contents came from the stored index instead of from disk. */
|
|
39
|
+
reusedCount(): number;
|
|
40
|
+
/** Files that had to be read because they were new or had changed. */
|
|
41
|
+
rebuiltCount(): number;
|
|
42
|
+
save(): void;
|
|
33
43
|
touch(path: string): void;
|
|
34
44
|
refresh(path: string): void;
|
|
35
45
|
forget(path: string): void;
|
|
@@ -44,7 +54,39 @@ interface NativeIndex {
|
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
interface NativeModule {
|
|
47
|
-
SearchIndex: new (root: string, maxFiles?: number) => NativeIndex;
|
|
57
|
+
SearchIndex: new (root: string, maxFiles?: number, cachePath?: string) => NativeIndex;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where a tree's stored index lives.
|
|
62
|
+
*
|
|
63
|
+
* Not inside the repository: an index is a derived artifact, it is large, and
|
|
64
|
+
* writing one into someone's working tree means it shows up in their `git
|
|
65
|
+
* status` and their diffs. It goes in the platform's cache directory, keyed by
|
|
66
|
+
* the absolute path of the root so two checkouts of the same project keep
|
|
67
|
+
* their own.
|
|
68
|
+
*
|
|
69
|
+
* `PIFY_SEARCH_CACHE_DIR` relocates it and `PIFY_SEARCH_NO_CACHE=1` turns it
|
|
70
|
+
* off, which is how the no-cache path stays tested on a machine that has one.
|
|
71
|
+
*/
|
|
72
|
+
export function cachePathFor(root: string): string | undefined {
|
|
73
|
+
if (process.env.PIFY_SEARCH_NO_CACHE === "1") return undefined;
|
|
74
|
+
|
|
75
|
+
const base =
|
|
76
|
+
process.env.PIFY_SEARCH_CACHE_DIR ??
|
|
77
|
+
(process.platform === "win32"
|
|
78
|
+
? join(process.env.LOCALAPPDATA ?? tmpdir(), "pify-search")
|
|
79
|
+
: process.platform === "darwin"
|
|
80
|
+
? join(homedir(), "Library", "Caches", "pify-search")
|
|
81
|
+
: join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "pify-search"));
|
|
82
|
+
|
|
83
|
+
const absolute = resolve(root);
|
|
84
|
+
// The readable part is for a human looking at the cache directory; the hash
|
|
85
|
+
// is what actually makes the name unique, since two projects can share a
|
|
86
|
+
// basename and paths differ only in case on some platforms.
|
|
87
|
+
const label = (basename(absolute) || "root").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 40);
|
|
88
|
+
const digest = createHash("sha256").update(absolute.toLowerCase()).digest("hex").slice(0, 16);
|
|
89
|
+
return join(base, `${label}-${digest}.idx`);
|
|
48
90
|
}
|
|
49
91
|
|
|
50
92
|
/** The napi triple for this host, matching how the binaries are published. */
|
|
@@ -104,7 +146,7 @@ export function loadNative(root: string, maxFiles?: number): SearchEngine | null
|
|
|
104
146
|
|
|
105
147
|
let index: NativeIndex;
|
|
106
148
|
try {
|
|
107
|
-
index = new mod.SearchIndex(root, maxFiles);
|
|
149
|
+
index = new mod.SearchIndex(root, maxFiles, cachePathFor(root));
|
|
108
150
|
} catch {
|
|
109
151
|
return null;
|
|
110
152
|
}
|
|
@@ -167,8 +209,18 @@ export function loadNative(root: string, maxFiles?: number): SearchEngine | null
|
|
|
167
209
|
return 0;
|
|
168
210
|
}
|
|
169
211
|
},
|
|
212
|
+
stats() {
|
|
213
|
+
try {
|
|
214
|
+
return { reused: index.reusedCount(), rebuilt: index.rebuiltCount() };
|
|
215
|
+
} catch {
|
|
216
|
+
return { reused: 0, rebuilt: 0 };
|
|
217
|
+
}
|
|
218
|
+
},
|
|
170
219
|
dispose() {
|
|
171
|
-
//
|
|
220
|
+
// Deliberately not saving here. Anything `refresh` changed during the
|
|
221
|
+
// session has a new mtime on disk, so the next start sees the mismatch
|
|
222
|
+
// and re-reads those files anyway — writing the whole index out at exit
|
|
223
|
+
// would cost a multi-megabyte write to save a handful of file reads.
|
|
172
224
|
},
|
|
173
225
|
};
|
|
174
226
|
}
|