@pify/search 0.1.0 → 0.2.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
@@ -33,15 +33,27 @@ The other half is the shape of the question. `find` wants a glob; people want *"
33
33
 
34
34
  Three modes because three different questions get asked: the exact string, a shape, and *"something like this"* for when you do not know how it is spelled.
35
35
 
36
- ## Two engines, one interface
36
+ ## Three engines, one interface
37
37
 
38
- **Fast path** — [`@ff-labs/fff-node`](https://github.com/dmtrKovalenko/fff), a Rust index with a live file watcher, typo-resistant matching, git status and frecency. It scans this whole suite in about 80ms. Installed as an *optional* dependency: if the platform binary lands, it is used.
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.
39
39
 
40
- **Fallback** — pure TypeScript, no dependencies. A trigram index for content, a fuzzy scorer for paths, an `fs.watch` subscription to stay current. Slower, and it runs wherever pi runs.
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
 
42
- The fallback is the point. A native binary is a promise you cannot always keep: an unsupported platform, a locked-down install, a blocked postinstall — any of those, and a binary-only search extension is one that silently does nothing. `/search` says which engine is live.
42
+ **builtin** pure TypeScript, no dependencies, no binary. A trigram index for content, a fuzzy scorer for paths, an `fs.watch` subscription to stay current.
43
43
 
44
- Both are checked against each other on a real tree (`test/live/engines.mjs`, 21/21): the same files found, the same literal matches, the same refusal to search `node_modules`, and cursors that advance rather than repeat.
44
+ The fallback is the point. A native binary is a promise you cannot always keep: an unsupported platform, a locked-down install, a blocked postinstall — any of those, and a binary-only search extension is one that silently does nothing.
45
+
46
+ All three are checked against each other on a real tree (`test/live/engines.mjs`, **33/33**): the same files found, the same literal matches, the same refusal to search `node_modules`, cursors that advance rather than repeat — and native and builtin **rank identically**, because they share their scoring constants on purpose. Losing the binary should change how fast a search is, never how it is ordered. `/search` says which engine is live.
47
+
48
+ ### Building the native core
49
+
50
+ ```bash
51
+ npm run build:native # cargo build --release --manifest-path native/Cargo.toml
52
+ ```
53
+
54
+ The result is picked up automatically from `native/target/release/`. CI builds and smoke-tests six targets — win32 x64/arm64, darwin x64/arm64, linux x64/arm64 — on every tag.
55
+
56
+ **Honest status:** only `win32-x64` has been built and verified by hand; the other five are proven by CI and nothing more. Per-platform npm packages (`@pify/search-<triple>`) are not published yet, so an installed copy of this package uses fff if you have it and the TypeScript engine otherwise. The loader already looks for them, so publishing is additive.
45
57
 
46
58
  ## How the content index works
47
59
 
@@ -30,6 +30,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
30
30
  import { dirname, join } from "node:path";
31
31
 
32
32
  import { loadFff, type SearchEngine } from "../src/engine.ts";
33
+ import { loadNative } from "../src/native.ts";
33
34
  import { builtinEngine } from "../src/builtin.ts";
34
35
  import { parseHistory, pruneHistory, type History } from "../src/frecency.ts";
35
36
  import { formatFiles, formatMatches, formatStatus } from "../src/format.ts";
@@ -73,7 +74,9 @@ export default function searchExtension(pi: ExtensionAPI) {
73
74
  root = ctx.cwd;
74
75
  historyFile = historyPath(ctx.cwd);
75
76
  starting = (async () => {
76
- const fast = await loadFff(root);
77
+ // Preference order: this package's own core, then fff if the user
78
+ // has it, then the fallback that always works.
79
+ const fast = loadNative(root) ?? (await loadFff(root));
77
80
  const chosen =
78
81
  fast ?? builtinEngine(root, { history: loadHistory(), onHistoryChange: saveHistory });
79
82
  await chosen.ready(READY_TIMEOUT_MS);
@@ -156,10 +159,21 @@ export default function searchExtension(pi: ExtensionAPI) {
156
159
  const name = (event as { toolName?: string }).toolName;
157
160
  if (name !== "read" && name !== "edit" && name !== "write") return undefined;
158
161
  const path = (event as { input?: { path?: unknown } }).input?.path;
159
- if (typeof path === "string" && engine?.touch) engine.touch(path);
162
+ if (typeof path === "string") engine?.touch?.(path);
160
163
  return undefined;
161
164
  });
162
165
 
166
+ pi.on("tool_result", async (event) => {
167
+ // The agent just changed a file, so the index is stale for it. Re-reading
168
+ // one path is cheap; noticing later that a search missed a line the agent
169
+ // itself wrote is not.
170
+ const name = (event as { toolName?: string }).toolName;
171
+ if (name !== "edit" && name !== "write") return;
172
+ if ((event as { isError?: boolean }).isError === true) return;
173
+ const path = (event as { input?: Record<string, unknown> }).input?.path;
174
+ if (typeof path === "string") engine?.refresh?.(path);
175
+ });
176
+
163
177
  // ── Lifecycle ────────────────────────────────────────────────────────
164
178
 
165
179
  pi.on("session_start", async (_event, ctx) => {
@@ -0,0 +1,25 @@
1
+ [package]
2
+ name = "pify-search-core"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ license = "MIT"
6
+ publish = false
7
+
8
+ [lib]
9
+ crate-type = ["cdylib"]
10
+
11
+ [dependencies]
12
+ napi = { version = "2", default-features = false, features = ["napi4"] }
13
+ napi-derive = "2"
14
+ walkdir = "2"
15
+ rayon = "1"
16
+ regex = "1"
17
+
18
+ [build-dependencies]
19
+ napi-build = "2"
20
+
21
+ [profile.release]
22
+ lto = true
23
+ opt-level = 3
24
+ codegen-units = 1
25
+ strip = true
@@ -0,0 +1,3 @@
1
+ fn main() {
2
+ napi_build::setup();
3
+ }
@@ -0,0 +1,408 @@
1
+ //! The native core of `@pify/search`.
2
+ //!
3
+ //! An in-memory file index with a trigram index over contents, exposed to
4
+ //! Node through napi. It answers the same two questions as the TypeScript
5
+ //! fallback and gives the same answers — the scoring constants are copied
6
+ //! across deliberately, so losing the binary changes how fast a search is and
7
+ //! never how it is ordered.
8
+
9
+ #![deny(clippy::all)]
10
+
11
+ mod score;
12
+ mod trigram;
13
+ mod walk;
14
+
15
+ use napi::bindgen_prelude::*;
16
+ use napi_derive::napi;
17
+ use rayon::prelude::*;
18
+ use std::collections::HashMap;
19
+ use std::path::{Path, PathBuf};
20
+ use std::sync::RwLock;
21
+
22
+ use trigram::{Index, Plan};
23
+
24
+ struct Entry {
25
+ path: String,
26
+ absolute: PathBuf,
27
+ size: u64,
28
+ mtime_ms: i64,
29
+ }
30
+
31
+ #[napi(object)]
32
+ pub struct FileHit {
33
+ pub path: String,
34
+ pub score: i32,
35
+ pub size: f64,
36
+ pub modified_ms: f64,
37
+ }
38
+
39
+ #[napi(object)]
40
+ pub struct ContentHit {
41
+ pub path: String,
42
+ pub line: u32,
43
+ pub text: String,
44
+ }
45
+
46
+ #[napi(object)]
47
+ pub struct FindPage {
48
+ pub items: Vec<FileHit>,
49
+ pub total: u32,
50
+ /// Offset for the next page, or -1 when this was the last.
51
+ pub next: i32,
52
+ }
53
+
54
+ #[napi(object)]
55
+ pub struct GrepPage {
56
+ pub items: Vec<ContentHit>,
57
+ pub total: u32,
58
+ pub next: i32,
59
+ /// Files actually opened, so a caller can see the index doing its job.
60
+ pub scanned: u32,
61
+ }
62
+
63
+ #[napi]
64
+ pub struct SearchIndex {
65
+ root: PathBuf,
66
+ entries: RwLock<Vec<Entry>>,
67
+ by_path: RwLock<HashMap<String, u32>>,
68
+ content: RwLock<Index>,
69
+ frecency: RwLock<HashMap<String, i32>>,
70
+ }
71
+
72
+ #[napi]
73
+ impl SearchIndex {
74
+ /// Build the index. Walking and content extraction run in parallel;
75
+ /// nothing is memory-mapped, so this behaves the same on every platform.
76
+ #[napi(constructor)]
77
+ pub fn new(root: String, max_files: Option<u32>) -> Result<Self> {
78
+ let root_path = PathBuf::from(&root);
79
+ let cap = max_files.unwrap_or(200_000) as usize;
80
+ let found = walk::collect(&root_path, cap);
81
+
82
+ let mut entries = Vec::with_capacity(found.len());
83
+ let mut by_path = HashMap::with_capacity(found.len());
84
+ for (i, file) in found.iter().enumerate() {
85
+ by_path.insert(file.rel.clone(), i as u32);
86
+ entries.push(Entry {
87
+ path: file.rel.clone(),
88
+ absolute: file.absolute.clone(),
89
+ size: file.size,
90
+ mtime_ms: file.mtime_ms,
91
+ });
92
+ }
93
+
94
+ // Reading and extracting is the expensive half and is embarrassingly
95
+ // parallel; building the map from the results is not, so it is done
96
+ // once at the end rather than behind a lock per file.
97
+ let extracted: Vec<(u32, Vec<u32>)> = found
98
+ .par_iter()
99
+ .enumerate()
100
+ .filter_map(|(i, file)| {
101
+ if !walk::index_content(&file.rel, file.size) {
102
+ return None;
103
+ }
104
+ let bytes = std::fs::read(&file.absolute).ok()?;
105
+ if walk::looks_binary(&bytes) {
106
+ return None;
107
+ }
108
+ let mut set = std::collections::HashSet::with_hasher(trigram::BuildTrigramHasher);
109
+ trigram::extract(&bytes, &mut set);
110
+ let mut list: Vec<u32> = set.into_iter().collect();
111
+ list.sort_unstable();
112
+ Some((i as u32, list))
113
+ })
114
+ .collect();
115
+
116
+ let mut index = Index::new();
117
+ for (id, trigrams) in &extracted {
118
+ index.add(*id, trigrams);
119
+ }
120
+
121
+ Ok(Self {
122
+ root: root_path,
123
+ entries: RwLock::new(entries),
124
+ by_path: RwLock::new(by_path),
125
+ content: RwLock::new(index),
126
+ frecency: RwLock::new(HashMap::new()),
127
+ })
128
+ }
129
+
130
+ #[napi]
131
+ pub fn file_count(&self) -> u32 {
132
+ self.entries.read().map(|e| e.len() as u32).unwrap_or(0)
133
+ }
134
+
135
+ #[napi]
136
+ pub fn indexed_count(&self) -> u32 {
137
+ self.content.read().map(|c| c.len() as u32).unwrap_or(0)
138
+ }
139
+
140
+ /// Note that a path was used, so frecency can favour it later.
141
+ #[napi]
142
+ pub fn touch(&self, path: String) {
143
+ let key = walk::normalize(&path);
144
+ if let Ok(mut frecency) = self.frecency.write() {
145
+ let entry = frecency.entry(key).or_insert(0);
146
+ *entry = (*entry + 25).min(200);
147
+ }
148
+ }
149
+
150
+ /// Re-read one path: a create or a modify. Removing is `forget`.
151
+ #[napi]
152
+ pub fn refresh(&self, path: String) -> Result<()> {
153
+ let absolute = self.root.join(&path);
154
+ let rel = walk::relative(&self.root, &absolute);
155
+ let Ok(meta) = std::fs::metadata(&absolute) else {
156
+ return Ok(());
157
+ };
158
+ if !meta.is_file() {
159
+ return Ok(());
160
+ }
161
+
162
+ let id = {
163
+ let mut by_path = self.by_path.write().map_err(lock_err)?;
164
+ let mut entries = self.entries.write().map_err(lock_err)?;
165
+ match by_path.get(&rel) {
166
+ Some(&id) => {
167
+ if let Some(entry) = entries.get_mut(id as usize) {
168
+ entry.size = meta.len();
169
+ entry.mtime_ms = walk::mtime_ms(&meta);
170
+ }
171
+ id
172
+ }
173
+ None => {
174
+ let id = entries.len() as u32;
175
+ entries.push(Entry {
176
+ path: rel.clone(),
177
+ absolute: absolute.clone(),
178
+ size: meta.len(),
179
+ mtime_ms: walk::mtime_ms(&meta),
180
+ });
181
+ by_path.insert(rel.clone(), id);
182
+ id
183
+ }
184
+ }
185
+ };
186
+
187
+ let mut index = self.content.write().map_err(lock_err)?;
188
+ if !walk::index_content(&rel, meta.len()) {
189
+ index.remove(id);
190
+ return Ok(());
191
+ }
192
+ match std::fs::read(&absolute) {
193
+ Ok(bytes) if !walk::looks_binary(&bytes) => {
194
+ let mut set = std::collections::HashSet::with_hasher(trigram::BuildTrigramHasher);
195
+ trigram::extract(&bytes, &mut set);
196
+ let mut list: Vec<u32> = set.into_iter().collect();
197
+ list.sort_unstable();
198
+ index.add(id, &list);
199
+ }
200
+ _ => index.remove(id),
201
+ }
202
+ Ok(())
203
+ }
204
+
205
+ #[napi]
206
+ pub fn forget(&self, path: String) -> Result<()> {
207
+ let rel = walk::normalize(&path);
208
+ let id = { self.by_path.read().map_err(lock_err)?.get(&rel).copied() };
209
+ if let Some(id) = id {
210
+ self.content.write().map_err(lock_err)?.remove(id);
211
+ self.by_path.write().map_err(lock_err)?.remove(&rel);
212
+ }
213
+ Ok(())
214
+ }
215
+
216
+ #[napi]
217
+ pub fn find(&self, query: String, limit: u32, offset: u32, now_ms: f64) -> Result<FindPage> {
218
+ let entries = self.entries.read().map_err(lock_err)?;
219
+ let frecency = self.frecency.read().map_err(lock_err)?;
220
+ let now = now_ms as i64;
221
+
222
+ let mut scored: Vec<(i32, usize)> = entries
223
+ .par_iter()
224
+ .enumerate()
225
+ .filter_map(|(i, entry)| {
226
+ let candidate = score::Candidate {
227
+ path: &entry.path,
228
+ frecency: frecency.get(&entry.path).copied().unwrap_or(0),
229
+ git: "",
230
+ mtime_ms: entry.mtime_ms,
231
+ };
232
+ score::score(&candidate, &query, now).map(|s| (s, i))
233
+ })
234
+ .collect();
235
+
236
+ // Ties break on path so two runs of the same query agree.
237
+ scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| entries[a.1].path.cmp(&entries[b.1].path)));
238
+
239
+ let total = scored.len();
240
+ let start = (offset as usize).min(total);
241
+ let end = (start + limit as usize).min(total);
242
+ let items = scored[start..end]
243
+ .iter()
244
+ .map(|&(s, i)| FileHit {
245
+ path: entries[i].path.clone(),
246
+ score: s,
247
+ size: entries[i].size as f64,
248
+ modified_ms: entries[i].mtime_ms as f64,
249
+ })
250
+ .collect();
251
+
252
+ Ok(FindPage {
253
+ items,
254
+ total: total as u32,
255
+ next: if end < total { end as i32 } else { -1 },
256
+ })
257
+ }
258
+
259
+ /// `mode` is "literal", "regex" or "fuzzy".
260
+ #[napi]
261
+ pub fn grep(
262
+ &self,
263
+ pattern: String,
264
+ mode: String,
265
+ limit: u32,
266
+ offset: u32,
267
+ case_insensitive: bool,
268
+ ) -> Result<GrepPage> {
269
+ if pattern.is_empty() {
270
+ return Ok(GrepPage { items: vec![], total: 0, next: -1, scanned: 0 });
271
+ }
272
+ let entries = self.entries.read().map_err(lock_err)?;
273
+ let index = self.content.read().map_err(lock_err)?;
274
+
275
+ // Fuzzy has no required substring, so the index cannot narrow it.
276
+ // Saying so beats narrowing wrongly and losing matches.
277
+ let plan = match mode.as_str() {
278
+ "fuzzy" => Plan::All,
279
+ "regex" => trigram::plan_for_regex(&pattern),
280
+ _ => trigram::plan_for_literal(&pattern),
281
+ };
282
+
283
+ let ids: Vec<u32> = match index.candidates(&plan) {
284
+ Some(list) => list,
285
+ None => (0..entries.len() as u32).collect(),
286
+ };
287
+
288
+ let needle = if case_insensitive { pattern.to_lowercase() } else { pattern.clone() };
289
+ let budget = score::max_typos(&pattern);
290
+
291
+ // Regex mode must actually run a regex. Falling back to `contains`
292
+ // made `declared\w+` match nothing at all while reporting a clean
293
+ // zero, which reads exactly like "this does not exist".
294
+ let compiled = if mode == "regex" {
295
+ match regex::RegexBuilder::new(&pattern)
296
+ .case_insensitive(case_insensitive)
297
+ .build()
298
+ {
299
+ Ok(re) => Some(re),
300
+ Err(e) => return Err(Error::from_reason(format!("invalid regex: {e}"))),
301
+ }
302
+ } else {
303
+ None
304
+ };
305
+
306
+ let mut hits: Vec<ContentHit> = ids
307
+ .par_iter()
308
+ .filter_map(|&id| {
309
+ let entry = entries.get(id as usize)?;
310
+ if entry.size > walk::MAX_SEARCHABLE_BYTES {
311
+ return None;
312
+ }
313
+ let bytes = std::fs::read(&entry.absolute).ok()?;
314
+ if walk::looks_binary(&bytes) {
315
+ return None;
316
+ }
317
+ let text = String::from_utf8_lossy(&bytes);
318
+ let mut found = Vec::new();
319
+ for (n, line) in text.lines().enumerate() {
320
+ let matched = match &compiled {
321
+ Some(re) => re.is_match(line),
322
+ None => {
323
+ let hay = if case_insensitive { line.to_lowercase() } else { line.to_string() };
324
+ if mode == "fuzzy" {
325
+ fuzzy_line(&hay, &needle, budget)
326
+ } else {
327
+ hay.contains(&needle)
328
+ }
329
+ }
330
+ };
331
+ if matched {
332
+ found.push(ContentHit {
333
+ path: entry.path.clone(),
334
+ line: (n + 1) as u32,
335
+ text: line.chars().take(400).collect(),
336
+ });
337
+ }
338
+ }
339
+ if found.is_empty() { None } else { Some(found) }
340
+ })
341
+ .flatten()
342
+ .collect();
343
+
344
+ hits.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.line.cmp(&b.line)));
345
+
346
+ let total = hits.len();
347
+ let start = (offset as usize).min(total);
348
+ let end = (start + limit as usize).min(total);
349
+ Ok(GrepPage {
350
+ items: hits.drain(..).skip(start).take(end - start).collect(),
351
+ total: total as u32,
352
+ next: if end < total { end as i32 } else { -1 },
353
+ scanned: ids.len() as u32,
354
+ })
355
+ }
356
+ }
357
+
358
+ /// Characters in order, within the typo budget and close enough together to be
359
+ /// one word rather than three scattered letters.
360
+ fn fuzzy_line(hay: &str, needle: &str, budget: usize) -> bool {
361
+ let hay: Vec<char> = hay.chars().collect();
362
+ let need: Vec<char> = needle.chars().collect();
363
+ if need.is_empty() {
364
+ return false;
365
+ }
366
+ let span = (need.len() * 3).max(need.len() + 8);
367
+ for start in 0..hay.len() {
368
+ let mut typos = 0usize;
369
+ let mut at = start;
370
+ let mut matched = 0usize;
371
+ for &want in &need {
372
+ match hay[at.min(hay.len())..].iter().position(|&c| c == want) {
373
+ Some(offset) if at + offset - start <= span => {
374
+ at += offset + 1;
375
+ matched += 1;
376
+ }
377
+ _ => {
378
+ typos += 1;
379
+ if typos > budget {
380
+ break;
381
+ }
382
+ }
383
+ }
384
+ }
385
+ if typos <= budget && matched + budget >= need.len() && matched > 0 {
386
+ return true;
387
+ }
388
+ }
389
+ false
390
+ }
391
+
392
+ fn lock_err<T>(_: T) -> Error {
393
+ Error::from_reason("search index lock poisoned")
394
+ }
395
+
396
+ /// Exposed so the JavaScript side can assert the two engines agree.
397
+ #[napi]
398
+ pub fn literal_runs(pattern: String) -> Vec<String> {
399
+ trigram::literal_runs(&pattern)
400
+ }
401
+
402
+ #[napi]
403
+ pub fn max_typos(query: String) -> u32 {
404
+ score::max_typos(&query) as u32
405
+ }
406
+
407
+ #[allow(dead_code)]
408
+ fn unused(_: &Path) {}
@@ -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,259 @@
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
+ match list.binary_search(&file) {
115
+ Ok(_) => {}
116
+ Err(at) => list.insert(at, file),
117
+ }
118
+ }
119
+ self.indexed.insert(file);
120
+ }
121
+
122
+ pub fn remove(&mut self, file: u32) {
123
+ if !self.indexed.remove(&file) {
124
+ return;
125
+ }
126
+ self.postings.retain(|_, list| {
127
+ if let Ok(at) = list.binary_search(&file) {
128
+ list.remove(at);
129
+ }
130
+ !list.is_empty()
131
+ });
132
+ }
133
+
134
+ /// Candidate file ids, or `None` meaning every file must be read.
135
+ pub fn candidates(&self, plan: &Plan) -> Option<Vec<u32>> {
136
+ match plan {
137
+ Plan::All => None,
138
+ Plan::Or(branches) => {
139
+ let mut union: Vec<u32> = Vec::new();
140
+ for branch in branches {
141
+ let part = self.candidates(branch)?;
142
+ union.extend_from_slice(&part);
143
+ }
144
+ union.sort_unstable();
145
+ union.dedup();
146
+ Some(union)
147
+ }
148
+ Plan::And(trigrams) => {
149
+ if trigrams.is_empty() {
150
+ return None;
151
+ }
152
+ let mut lists: Vec<&Vec<u32>> = Vec::with_capacity(trigrams.len());
153
+ for t in trigrams {
154
+ match self.postings.get(t) {
155
+ // A trigram nobody has means nothing can match — an
156
+ // empty answer, emphatically not "read everything".
157
+ None => return Some(Vec::new()),
158
+ Some(list) => lists.push(list),
159
+ }
160
+ }
161
+ // Smallest first, so the working set only ever shrinks.
162
+ lists.sort_by_key(|l| l.len());
163
+ let mut result = lists[0].clone();
164
+ for list in &lists[1..] {
165
+ if result.is_empty() {
166
+ break;
167
+ }
168
+ result = intersect(&result, list);
169
+ }
170
+ Some(result)
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ /// Both sides are sorted, so this is a linear merge rather than a lookup loop.
177
+ fn intersect(a: &[u32], b: &[u32]) -> Vec<u32> {
178
+ let mut out = Vec::with_capacity(a.len().min(b.len()));
179
+ let (mut i, mut j) = (0usize, 0usize);
180
+ while i < a.len() && j < b.len() {
181
+ match a[i].cmp(&b[j]) {
182
+ std::cmp::Ordering::Equal => {
183
+ out.push(a[i]);
184
+ i += 1;
185
+ j += 1;
186
+ }
187
+ std::cmp::Ordering::Less => i += 1,
188
+ std::cmp::Ordering::Greater => j += 1,
189
+ }
190
+ }
191
+ out
192
+ }
193
+
194
+ /// Literal runs of three or more characters — the only parts of a pattern that
195
+ /// imply required trigrams.
196
+ pub fn literal_runs(pattern: &str) -> Vec<String> {
197
+ let chars: Vec<char> = pattern.chars().collect();
198
+ let mut runs = Vec::new();
199
+ let mut current = String::new();
200
+ let mut i = 0usize;
201
+ while i < chars.len() {
202
+ let ch = chars[i];
203
+ if ch == '\\' {
204
+ if let Some(&next) = chars.get(i + 1) {
205
+ if !next.is_ascii_alphanumeric() {
206
+ current.push(next);
207
+ i += 2;
208
+ continue;
209
+ }
210
+ }
211
+ runs.push(std::mem::take(&mut current));
212
+ i += 2;
213
+ continue;
214
+ }
215
+ // A quantifier applies to the character before it, so that character
216
+ // is not required either.
217
+ if matches!(ch, '?' | '*' | '+' | '{') {
218
+ current.pop();
219
+ runs.push(std::mem::take(&mut current));
220
+ i += 1;
221
+ continue;
222
+ }
223
+ if matches!(ch, '[' | ']' | '(' | ')' | '}' | '|' | '.' | '^' | '$') {
224
+ runs.push(std::mem::take(&mut current));
225
+ i += 1;
226
+ continue;
227
+ }
228
+ current.push(ch);
229
+ i += 1;
230
+ }
231
+ runs.push(current);
232
+ runs.retain(|r| r.chars().count() >= 3);
233
+ runs
234
+ }
235
+
236
+ pub fn plan_for_literal(literal: &str) -> Plan {
237
+ let trigrams = of(literal);
238
+ if trigrams.is_empty() {
239
+ Plan::All
240
+ } else {
241
+ Plan::And(trigrams)
242
+ }
243
+ }
244
+
245
+ pub fn plan_for_regex(pattern: &str) -> Plan {
246
+ if pattern.contains('|') {
247
+ let branches: Vec<Plan> = pattern.split('|').map(plan_for_regex).collect();
248
+ // One branch that can match anywhere makes the whole union unbounded.
249
+ if branches.iter().any(|b| matches!(b, Plan::All)) {
250
+ return Plan::All;
251
+ }
252
+ return Plan::Or(branches);
253
+ }
254
+ let runs = literal_runs(pattern);
255
+ match runs.iter().max_by_key(|r| r.len()) {
256
+ None => Plan::All,
257
+ Some(longest) => plan_for_literal(longest),
258
+ }
259
+ }
@@ -0,0 +1,113 @@
1
+ //! What belongs in the index.
2
+ //!
3
+ //! An index holding `node_modules` answers slowly and wrongly — the file you
4
+ //! meant is buried under ten thousand you did not. The rules are deliberately
5
+ //! the same list the TypeScript fallback uses, so the two engines index the
6
+ //! same tree and a search cannot find a file under one engine and miss it
7
+ //! under the other.
8
+
9
+ use std::path::{Path, PathBuf};
10
+ use walkdir::WalkDir;
11
+
12
+ pub const MAX_INDEXABLE_BYTES: u64 = 2 * 1024 * 1024;
13
+ pub const MAX_SEARCHABLE_BYTES: u64 = 10 * 1024 * 1024;
14
+
15
+ const SKIP_DIRS: &[&str] = &[
16
+ ".git", ".hg", ".svn", "node_modules", ".venv", "venv", "__pycache__", ".mypy_cache",
17
+ ".pytest_cache", ".ruff_cache", "dist", "build", "out", "target", ".next", ".nuxt",
18
+ ".svelte-kit", ".turbo", ".cache", ".gradle", ".idea", ".vscode-test", "coverage",
19
+ ".nyc_output", "vendor", "Pods", ".terraform",
20
+ ];
21
+
22
+ const BINARY_EXTENSIONS: &[&str] = &[
23
+ "png", "jpg", "jpeg", "gif", "bmp", "ico", "webp", "avif", "tiff", "psd", "mp3", "mp4", "wav",
24
+ "ogg", "flac", "avi", "mov", "mkv", "webm", "zip", "gz", "tgz", "bz2", "xz", "7z", "rar", "jar",
25
+ "war", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "exe", "dll", "so", "dylib", "bin",
26
+ "o", "a", "lib", "obj", "pdb", "class", "pyc", "pyo", "wasm", "node", "ttf", "otf", "woff",
27
+ "woff2", "eot", "db", "sqlite", "sqlite3", "pack", "idx",
28
+ ];
29
+
30
+ pub struct Found {
31
+ pub rel: String,
32
+ pub absolute: PathBuf,
33
+ pub size: u64,
34
+ pub mtime_ms: i64,
35
+ }
36
+
37
+ pub fn normalize(path: &str) -> String {
38
+ path.replace('\\', "/").trim_start_matches("./").to_string()
39
+ }
40
+
41
+ pub fn relative(root: &Path, absolute: &Path) -> String {
42
+ match absolute.strip_prefix(root) {
43
+ Ok(rel) => normalize(&rel.to_string_lossy()),
44
+ Err(_) => normalize(&absolute.to_string_lossy()),
45
+ }
46
+ }
47
+
48
+ pub fn mtime_ms(meta: &std::fs::Metadata) -> i64 {
49
+ meta.modified()
50
+ .ok()
51
+ .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
52
+ .map(|d| d.as_millis() as i64)
53
+ .unwrap_or(0)
54
+ }
55
+
56
+ fn extension_of(path: &str) -> &str {
57
+ let name = path.rsplit('/').next().unwrap_or(path);
58
+ match name.rfind('.') {
59
+ Some(dot) if dot > 0 => &name[dot + 1..],
60
+ _ => "",
61
+ }
62
+ }
63
+
64
+ /// Whether this file's *content* is worth putting in the trigram index.
65
+ pub fn index_content(rel: &str, size: u64) -> bool {
66
+ if size == 0 || size > MAX_INDEXABLE_BYTES {
67
+ return false;
68
+ }
69
+ let ext = extension_of(rel).to_ascii_lowercase();
70
+ !BINARY_EXTENSIONS.contains(&ext.as_str())
71
+ }
72
+
73
+ /// A NUL byte in the first few kilobytes: cheaper and more reliable than
74
+ /// trusting an extension, since a `.dat` may be text and a `.txt` may not.
75
+ pub fn looks_binary(bytes: &[u8]) -> bool {
76
+ bytes.iter().take(8192).any(|&b| b == 0)
77
+ }
78
+
79
+ pub fn collect(root: &Path, max_files: usize) -> Vec<Found> {
80
+ let mut out = Vec::new();
81
+ let walker = WalkDir::new(root)
82
+ .max_depth(24)
83
+ .follow_links(false)
84
+ .into_iter()
85
+ .filter_entry(|entry| {
86
+ if entry.depth() == 0 {
87
+ return true;
88
+ }
89
+ let name = entry.file_name().to_string_lossy();
90
+ if entry.file_type().is_dir() {
91
+ return !SKIP_DIRS.contains(&name.as_ref());
92
+ }
93
+ name != ".DS_Store"
94
+ });
95
+
96
+ for entry in walker.flatten() {
97
+ if out.len() >= max_files {
98
+ break;
99
+ }
100
+ if !entry.file_type().is_file() {
101
+ continue;
102
+ }
103
+ let Ok(meta) = entry.metadata() else { continue };
104
+ let absolute = entry.path().to_path_buf();
105
+ out.push(Found {
106
+ rel: relative(root, &absolute),
107
+ absolute,
108
+ size: meta.len(),
109
+ mtime_ms: mtime_ms(&meta),
110
+ });
111
+ }
112
+ out
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/search",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -30,6 +30,9 @@
30
30
  "extensions",
31
31
  "src",
32
32
  "skills",
33
+ "native/src",
34
+ "native/Cargo.toml",
35
+ "native/build.rs",
33
36
  "README.md",
34
37
  "LICENSE"
35
38
  ],
@@ -44,7 +47,8 @@
44
47
  "scripts": {
45
48
  "typecheck": "tsc --noEmit",
46
49
  "test": "bun test",
47
- "prepublishOnly": "npm run typecheck && npm test"
50
+ "prepublishOnly": "npm run typecheck && npm test",
51
+ "build:native": "cargo build --release --manifest-path native/Cargo.toml"
48
52
  },
49
53
  "peerDependencies": {
50
54
  "@earendil-works/pi-ai": "*",
package/src/engine.ts CHANGED
@@ -1,21 +1,23 @@
1
1
  /**
2
- * The two engines, behind one shape.
2
+ * Three engines, behind one shape.
3
3
  *
4
- * The fast path is the real thing: `@ff-labs/fff-node`, a Rust index with a
5
- * live watcher, typo-resistant matching, git status and frecency built in. It
6
- * scans this whole suite in about 80ms and is not something worth
7
- * reimplementing.
4
+ * **native** this package's own Rust core, compiled per platform. It indexes
5
+ * this suite (417 files) in about 33ms, and a literal search then reads the 5
6
+ * files the trigram index says could match rather than all 417.
8
7
  *
9
- * The slow path exists because a native binary is a promise you cannot always
10
- * keep. An unsupported platform, a locked-down install, a postinstall that
11
- * never ran — any of those and a binary-only search extension is an extension
12
- * that does nothing. The fallback is pure TypeScript with no dependencies: a
13
- * trigram index for content, a fuzzy scorer for paths. It is slower, and it
14
- * works everywhere pi does.
8
+ * **fff** `@ff-labs/fff-node`, used when it is installed and the native core
9
+ * is not. A mature engine with its own watcher and git integration.
15
10
  *
16
- * Both answer the same questions, so the tools never learn which one they got.
17
- * `/search` says which is live, because a user comparing timings deserves to
18
- * know why.
11
+ * **builtin** pure TypeScript, no dependencies, no binary. It exists because
12
+ * a native binary is a promise you cannot always keep: an unsupported
13
+ * platform, a locked-down install, a postinstall that never ran. A
14
+ * binary-only search extension in any of those cases is one that silently does
15
+ * nothing.
16
+ *
17
+ * All three answer the same questions, so the tools never learn which one they
18
+ * got. The scoring constants are shared deliberately between the native core
19
+ * and the fallback, so losing the binary changes how fast a search is and not
20
+ * how it is ordered. `/search` says which engine is live.
19
21
  */
20
22
 
21
23
  export interface FileHit {
@@ -54,13 +56,16 @@ export interface GrepOptions extends FindOptions {
54
56
  }
55
57
 
56
58
  export interface SearchEngine {
57
- readonly name: "fff" | "builtin";
59
+ readonly name: "native" | "fff" | "builtin";
58
60
  /** Resolve once the first index build has landed. */
59
61
  ready(timeoutMs: number): Promise<boolean>;
60
62
  find(query: string, options?: FindOptions): Promise<Page<FileHit>>;
61
63
  grep(pattern: string, options?: GrepOptions): Promise<Page<ContentHit>>;
62
64
  /** Note that a path was used, so frecency can favour it later. */
63
65
  touch?(path: string): void;
66
+ /** Re-read one path after a change, when the engine can. */
67
+ refresh?(path: string): void;
68
+ forget?(path: string): void;
64
69
  dispose(): void;
65
70
  /** How many files the index holds, when the engine can say. */
66
71
  indexed?(): number;
package/src/format.ts CHANGED
@@ -45,7 +45,13 @@ export function formatStatus(engine: SearchEngine | null, root: string): string
45
45
  return "No search engine is running. fffind and ffgrep will start one on first use.";
46
46
  }
47
47
  const lines = [
48
- `Engine: ${engine.name === "fff" ? "fff (native index, live watcher)" : "builtin (pure TypeScript, no native binary)"}`,
48
+ `Engine: ${
49
+ engine.name === "native"
50
+ ? "native (this package's Rust core)"
51
+ : engine.name === "fff"
52
+ ? "fff (@ff-labs/fff-node)"
53
+ : "builtin (pure TypeScript, no native binary)"
54
+ }`,
49
55
  `Root: ${root}`,
50
56
  ];
51
57
  const count = engine.indexed?.();
package/src/native.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The native core, when there is one.
3
+ *
4
+ * A Rust index built for this package: the same trigram narrowing and the same
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
7
+ * of those files instead of all of them.
8
+ *
9
+ * The binary is an optional dependency per platform, in the napi convention.
10
+ * If none of them installed, this returns null and the caller falls back;
11
+ * nothing here ever throws for a missing binary, because a missing binary is
12
+ * the ordinary case this package is designed to survive.
13
+ */
14
+
15
+ import { createRequire } from "node:module";
16
+ import { existsSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import type { ContentHit, FileHit, GrepOptions, Page, SearchEngine, FindOptions } from "./engine.ts";
21
+
22
+ interface NativePage<T> {
23
+ items: T[];
24
+ total: number;
25
+ /** Offset of the next page, or -1 when this was the last. */
26
+ next: number;
27
+ scanned?: number;
28
+ }
29
+
30
+ interface NativeIndex {
31
+ fileCount(): number;
32
+ indexedCount(): number;
33
+ touch(path: string): void;
34
+ refresh(path: string): void;
35
+ forget(path: string): void;
36
+ find(query: string, limit: number, offset: number, nowMs: number): NativePage<FileHit>;
37
+ grep(
38
+ pattern: string,
39
+ mode: string,
40
+ limit: number,
41
+ offset: number,
42
+ caseInsensitive: boolean,
43
+ ): NativePage<ContentHit>;
44
+ }
45
+
46
+ interface NativeModule {
47
+ SearchIndex: new (root: string, maxFiles?: number) => NativeIndex;
48
+ }
49
+
50
+ /** The napi triple for this host, matching how the binaries are published. */
51
+ export function tripleOf(platform: string, arch: string): string | null {
52
+ const key = `${platform}-${arch}`;
53
+ const known: Record<string, string> = {
54
+ "win32-x64": "win32-x64",
55
+ "win32-arm64": "win32-arm64",
56
+ "darwin-x64": "darwin-x64",
57
+ "darwin-arm64": "darwin-arm64",
58
+ "linux-x64": "linux-x64",
59
+ "linux-arm64": "linux-arm64",
60
+ };
61
+ return known[key] ?? null;
62
+ }
63
+
64
+ function candidatePaths(triple: string): string[] {
65
+ const here = dirname(fileURLToPath(import.meta.url));
66
+ const root = join(here, "..");
67
+ return [
68
+ // A locally built binary, which is how the package is developed.
69
+ join(root, `pify-search.${triple}.node`),
70
+ join(root, "native", "target", "release", `pify-search.${triple}.node`),
71
+ ];
72
+ }
73
+
74
+ /**
75
+ * Load the native core, or null when this platform has no binary.
76
+ * `PIFY_SEARCH_ENGINE=builtin` forces the fallback, which is how both paths
77
+ * get tested on a machine that has the binary.
78
+ */
79
+ export function loadNative(root: string, maxFiles?: number): SearchEngine | null {
80
+ if (process.env.PIFY_SEARCH_ENGINE === "builtin" || process.env.PIFY_SEARCH_ENGINE === "fff") {
81
+ return null;
82
+ }
83
+ const triple = tripleOf(process.platform, process.arch);
84
+ if (!triple) return null;
85
+
86
+ const require = createRequire(import.meta.url);
87
+ let mod: NativeModule | null = null;
88
+ for (const path of candidatePaths(triple)) {
89
+ if (!existsSync(path)) continue;
90
+ try {
91
+ mod = require(path) as NativeModule;
92
+ break;
93
+ } catch {
94
+ // A binary that will not load is the same as one that is not there.
95
+ }
96
+ }
97
+ if (!mod) {
98
+ try {
99
+ mod = require(`@pify/search-${triple}`) as NativeModule;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ let index: NativeIndex;
106
+ try {
107
+ index = new mod.SearchIndex(root, maxFiles);
108
+ } catch {
109
+ return null;
110
+ }
111
+
112
+ const pageOf = <T>(page: NativePage<T>): Page<T> => ({
113
+ items: page.items,
114
+ total: page.total,
115
+ cursor: page.next >= 0 ? String(page.next) : null,
116
+ });
117
+ const offsetOf = (cursor?: string) => {
118
+ const n = Number.parseInt(cursor ?? "0", 10);
119
+ return Number.isFinite(n) && n > 0 ? n : 0;
120
+ };
121
+
122
+ return {
123
+ name: "native",
124
+ async ready() {
125
+ // The index is built in the constructor, so by here it is done.
126
+ return true;
127
+ },
128
+ async find(query: string, options: FindOptions = {}) {
129
+ return pageOf(index.find(query, options.limit ?? 20, offsetOf(options.cursor), Date.now()));
130
+ },
131
+ async grep(pattern: string, options: GrepOptions = {}) {
132
+ return pageOf(
133
+ index.grep(
134
+ pattern,
135
+ options.mode ?? "literal",
136
+ options.limit ?? 20,
137
+ offsetOf(options.cursor),
138
+ options.caseInsensitive !== false,
139
+ ),
140
+ );
141
+ },
142
+ touch(path: string) {
143
+ try {
144
+ index.touch(path);
145
+ } catch {
146
+ // Frecency is a ranking nicety, never a reason to fail a search.
147
+ }
148
+ },
149
+ refresh(path: string) {
150
+ try {
151
+ index.refresh(path);
152
+ } catch {
153
+ // A file we cannot re-read stays as it was.
154
+ }
155
+ },
156
+ forget(path: string) {
157
+ try {
158
+ index.forget(path);
159
+ } catch {
160
+ // ditto
161
+ }
162
+ },
163
+ indexed() {
164
+ try {
165
+ return index.fileCount();
166
+ } catch {
167
+ return 0;
168
+ }
169
+ },
170
+ dispose() {
171
+ // The Rust side owns nothing that outlives the object.
172
+ },
173
+ };
174
+ }