@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 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 **33ms**, 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.
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, and how many files it holds.
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>)> = found
127
+ let extracted: Vec<(u32, Vec<u32>)> = stale
98
128
  .par_iter()
99
- .enumerate()
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((i as u32, list))
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 &extracted {
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
- Ok(Self {
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
+ }
@@ -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
- match list.binary_search(&file) {
115
- Ok(_) => {}
116
- Err(at) => list.insert(at, file),
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 {
@@ -27,6 +27,7 @@ const BINARY_EXTENSIONS: &[&str] = &[
27
27
  "woff2", "eot", "db", "sqlite", "sqlite3", "pack", "idx",
28
28
  ];
29
29
 
30
+ #[derive(Debug)]
30
31
  pub struct Found {
31
32
  pub rel: String,
32
33
  pub absolute: PathBuf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/search",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Fuzzy file finding and indexed content search for pi, with no native binary and no daemon",
5
5
  "keywords": [
6
6
  "pi-package",
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 33ms, and a literal search then reads 5
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 { dirname, join } from "node:path";
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
- // The Rust side owns nothing that outlives the object.
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
  }