@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 +17 -5
- package/extensions/search.ts +16 -2
- package/native/Cargo.toml +25 -0
- package/native/build.rs +3 -0
- package/native/src/lib.rs +408 -0
- package/native/src/score.rs +147 -0
- package/native/src/trigram.rs +259 -0
- package/native/src/walk.rs +113 -0
- package/package.json +6 -2
- package/src/engine.ts +20 -15
- package/src/format.ts +7 -1
- package/src/native.ts +174 -0
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
|
-
##
|
|
36
|
+
## Three engines, one interface
|
|
37
37
|
|
|
38
|
-
**
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/extensions/search.ts
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
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
|
package/native/build.rs
ADDED
|
@@ -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.
|
|
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
|
-
*
|
|
2
|
+
* Three engines, behind one shape.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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: ${
|
|
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
|
+
}
|