@pify/search 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -6
- package/extensions/search.ts +16 -2
- package/native/Cargo.toml +25 -0
- package/native/build.rs +3 -0
- package/native/src/lib.rs +508 -0
- package/native/src/score.rs +147 -0
- package/native/src/store.rs +194 -0
- package/native/src/trigram.rs +292 -0
- package/native/src/walk.rs +114 -0
- package/package.json +6 -2
- package/src/engine.ts +28 -15
- package/src/format.ts +14 -1
- package/src/native.ts +226 -0
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 about **40ms** cold, and a literal search then reads the **5** files the trigram index says could match rather than all 417. Searches come back in about a millisecond. Its index is [stored between sessions](#the-index-survives-the-process), so the second start does not pay for the first.
|
|
39
39
|
|
|
40
|
-
**
|
|
40
|
+
**fff** — [`@ff-labs/fff-node`](https://github.com/dmtrKovalenko/fff), used when it is installed and the native core is not. A mature engine with its own watcher and git integration.
|
|
41
41
|
|
|
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
|
|
|
@@ -49,6 +61,32 @@ Every overlapping three-byte window of every text file is a *trigram*, packed in
|
|
|
49
61
|
|
|
50
62
|
The index only ever **narrows**; every surviving candidate is still matched for real, so a wrong candidate costs time and never correctness. The rule that makes it safe: a pattern with nothing indexable — `\d+`, a fuzzy query, one branch of an alternation that could match anywhere — reports "no candidate set is safe" and everything is read. Confusing *that* with "nothing matched" is how an index starts silently hiding results, so the two are different values throughout.
|
|
51
63
|
|
|
64
|
+
## The index survives the process
|
|
65
|
+
|
|
66
|
+
An index rebuilt at every start is one you pay for at every start. The native engine writes its index to disk and, next time, reloads it and reconciles instead of re-reading the tree.
|
|
67
|
+
|
|
68
|
+
Measured on a synthetic 20,000-file tree (`node native/bench.mjs 20000`):
|
|
69
|
+
|
|
70
|
+
| | build | files read | grep |
|
|
71
|
+
|---|---|---|---|
|
|
72
|
+
| cold (no stored index) | 1320ms | 20,000 | <1ms |
|
|
73
|
+
| warm (unchanged tree) | **188ms** | 0 | <1ms |
|
|
74
|
+
|
|
75
|
+
**7×**, and the cost that remains is the directory walk, not the files. Editing one file re-reads one file.
|
|
76
|
+
|
|
77
|
+
Correctness rests on a single rule: a stored entry is trusted only while its **size and mtime still match what is on disk**. Anything changed, new, or vanished is re-read before a query can see it. A cache that answers confidently for a file that has moved on is worse than no cache. The reload path is checked against the rebuild path on every supported platform (`native/persist.mjs`, run in CI) — same totals, same lines, same order — including that a corrupt or truncated index is *discarded* rather than half-trusted.
|
|
78
|
+
|
|
79
|
+
Trigram lists are stored as varint deltas, which is what makes this worth doing at all: 881KB for this suite, ~2.2KB per file, about a third of the naive encoding. Reading a cache that is larger than the sources it summarises costs more than the rebuild it was meant to avoid.
|
|
80
|
+
|
|
81
|
+
The index lives in the platform cache directory — `%LOCALAPPDATA%`, `~/Library/Caches`, `$XDG_CACHE_HOME` — keyed by a hash of the absolute root, never inside your working tree.
|
|
82
|
+
|
|
83
|
+
| variable | effect |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `PIFY_SEARCH_NO_CACHE=1` | never store an index; rebuild every start |
|
|
86
|
+
| `PIFY_SEARCH_CACHE_DIR` | store indexes somewhere else |
|
|
87
|
+
| `PIFY_SEARCH_TIMING=1` | print how long the walk, the reload and the inversion each took |
|
|
88
|
+
| `PIFY_SEARCH_ENGINE` | `builtin` or `fff` to force an engine |
|
|
89
|
+
|
|
52
90
|
## Ranking
|
|
53
91
|
|
|
54
92
|
Frecency decays on a three-day half-life — an agent session is shorter and more concentrated than a human's week, so yesterday's file should not outrank today's. Every `read`, `edit` or `write` in the session counts as an access. History is capped at seven days and 128 timestamps per file, so the store cannot grow without bound.
|
|
@@ -59,7 +97,7 @@ This package adds two tools; it does not replace `find`, `grep` or `multi_grep`.
|
|
|
59
97
|
|
|
60
98
|
## Command
|
|
61
99
|
|
|
62
|
-
`/search` — which engine is running, the indexed root,
|
|
100
|
+
`/search` — which engine is running, the indexed root, how many files it holds, and how much of the index came back from the stored copy rather than from disk.
|
|
63
101
|
|
|
64
102
|
## License
|
|
65
103
|
|
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,508 @@
|
|
|
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 store;
|
|
13
|
+
mod trigram;
|
|
14
|
+
mod walk;
|
|
15
|
+
|
|
16
|
+
use napi::bindgen_prelude::*;
|
|
17
|
+
use napi_derive::napi;
|
|
18
|
+
use rayon::prelude::*;
|
|
19
|
+
use std::collections::HashMap;
|
|
20
|
+
use std::path::{Path, PathBuf};
|
|
21
|
+
use std::sync::RwLock;
|
|
22
|
+
|
|
23
|
+
use trigram::{Index, Plan};
|
|
24
|
+
|
|
25
|
+
struct Entry {
|
|
26
|
+
path: String,
|
|
27
|
+
absolute: PathBuf,
|
|
28
|
+
size: u64,
|
|
29
|
+
mtime_ms: i64,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[napi(object)]
|
|
33
|
+
pub struct FileHit {
|
|
34
|
+
pub path: String,
|
|
35
|
+
pub score: i32,
|
|
36
|
+
pub size: f64,
|
|
37
|
+
pub modified_ms: f64,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#[napi(object)]
|
|
41
|
+
pub struct ContentHit {
|
|
42
|
+
pub path: String,
|
|
43
|
+
pub line: u32,
|
|
44
|
+
pub text: String,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[napi(object)]
|
|
48
|
+
pub struct FindPage {
|
|
49
|
+
pub items: Vec<FileHit>,
|
|
50
|
+
pub total: u32,
|
|
51
|
+
/// Offset for the next page, or -1 when this was the last.
|
|
52
|
+
pub next: i32,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[napi(object)]
|
|
56
|
+
pub struct GrepPage {
|
|
57
|
+
pub items: Vec<ContentHit>,
|
|
58
|
+
pub total: u32,
|
|
59
|
+
pub next: i32,
|
|
60
|
+
/// Files actually opened, so a caller can see the index doing its job.
|
|
61
|
+
pub scanned: u32,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#[napi]
|
|
65
|
+
pub struct SearchIndex {
|
|
66
|
+
root: PathBuf,
|
|
67
|
+
entries: RwLock<Vec<Entry>>,
|
|
68
|
+
by_path: RwLock<HashMap<String, u32>>,
|
|
69
|
+
content: RwLock<Index>,
|
|
70
|
+
frecency: RwLock<HashMap<String, i32>>,
|
|
71
|
+
cache: Option<PathBuf>,
|
|
72
|
+
reused: u32,
|
|
73
|
+
rebuilt: u32,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#[napi]
|
|
77
|
+
impl SearchIndex {
|
|
78
|
+
/// Build the index. Walking and content extraction run in parallel;
|
|
79
|
+
/// nothing is memory-mapped, so this behaves the same on every platform.
|
|
80
|
+
#[napi(constructor)]
|
|
81
|
+
pub fn new(root: String, max_files: Option<u32>, cache_path: Option<String>) -> Result<Self> {
|
|
82
|
+
let root_path = PathBuf::from(&root);
|
|
83
|
+
let cap = max_files.unwrap_or(200_000) as usize;
|
|
84
|
+
let t0 = std::time::Instant::now();
|
|
85
|
+
let found = walk::collect(&root_path, cap);
|
|
86
|
+
let t_walk = t0.elapsed();
|
|
87
|
+
|
|
88
|
+
// Anything the last run already extracted and that still looks the
|
|
89
|
+
// same on disk is reused; only genuinely changed files are re-read.
|
|
90
|
+
// That is the whole point of persisting — a session on a large tree
|
|
91
|
+
// should pay for what changed, not for the tree.
|
|
92
|
+
let cache = cache_path.as_ref().map(PathBuf::from);
|
|
93
|
+
let t1 = std::time::Instant::now();
|
|
94
|
+
let loaded = cache.as_ref().and_then(|p| store::load(p));
|
|
95
|
+
let t_load = t1.elapsed();
|
|
96
|
+
let had_cache = loaded.is_some();
|
|
97
|
+
let mut known = loaded.map(store::index_by_path).unwrap_or_default();
|
|
98
|
+
|
|
99
|
+
let mut entries = Vec::with_capacity(found.len());
|
|
100
|
+
let mut by_path = HashMap::with_capacity(found.len());
|
|
101
|
+
for (i, file) in found.iter().enumerate() {
|
|
102
|
+
by_path.insert(file.rel.clone(), i as u32);
|
|
103
|
+
entries.push(Entry {
|
|
104
|
+
path: file.rel.clone(),
|
|
105
|
+
absolute: file.absolute.clone(),
|
|
106
|
+
size: file.size,
|
|
107
|
+
mtime_ms: file.mtime_ms,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let mut reused = 0usize;
|
|
112
|
+
let mut carried: Vec<(u32, Vec<u32>)> = Vec::new();
|
|
113
|
+
let mut stale: Vec<(u32, &walk::Found)> = Vec::new();
|
|
114
|
+
for (i, file) in found.iter().enumerate() {
|
|
115
|
+
match known.remove(&file.rel) {
|
|
116
|
+
Some(entry) if store::still_valid(&entry, file.size, file.mtime_ms) => {
|
|
117
|
+
reused += 1;
|
|
118
|
+
carried.push((i as u32, entry.trigrams));
|
|
119
|
+
}
|
|
120
|
+
_ => stale.push((i as u32, file)),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Reading and extracting is the expensive half and is embarrassingly
|
|
125
|
+
// parallel; building the map from the results is not, so it is done
|
|
126
|
+
// once at the end rather than behind a lock per file.
|
|
127
|
+
let extracted: Vec<(u32, Vec<u32>)> = stale
|
|
128
|
+
.par_iter()
|
|
129
|
+
.filter_map(|(id, file)| {
|
|
130
|
+
if !walk::index_content(&file.rel, file.size) {
|
|
131
|
+
return None;
|
|
132
|
+
}
|
|
133
|
+
let bytes = std::fs::read(&file.absolute).ok()?;
|
|
134
|
+
if walk::looks_binary(&bytes) {
|
|
135
|
+
return None;
|
|
136
|
+
}
|
|
137
|
+
let mut set = std::collections::HashSet::with_hasher(trigram::BuildTrigramHasher);
|
|
138
|
+
trigram::extract(&bytes, &mut set);
|
|
139
|
+
let mut list: Vec<u32> = set.into_iter().collect();
|
|
140
|
+
list.sort_unstable();
|
|
141
|
+
Some((*id, list))
|
|
142
|
+
})
|
|
143
|
+
.collect();
|
|
144
|
+
|
|
145
|
+
// Merged and sorted by id before insertion, which is not cosmetic:
|
|
146
|
+
// posting lists are kept sorted, so adding files in ascending order
|
|
147
|
+
// appends to each list, while the interleaved order that `carried` and
|
|
148
|
+
// `extracted` arrive in would insert into the middle and memmove the
|
|
149
|
+
// tail of every list it touches.
|
|
150
|
+
let rebuilt = extracted.len() as u32;
|
|
151
|
+
let mut all: Vec<(u32, Vec<u32>)> = carried;
|
|
152
|
+
all.extend(extracted);
|
|
153
|
+
all.sort_unstable_by_key(|(id, _)| *id);
|
|
154
|
+
|
|
155
|
+
let t2 = std::time::Instant::now();
|
|
156
|
+
let mut index = Index::new();
|
|
157
|
+
for (id, trigrams) in &all {
|
|
158
|
+
index.add(*id, trigrams);
|
|
159
|
+
}
|
|
160
|
+
let t_index = t2.elapsed();
|
|
161
|
+
// Off unless asked for. The three phases have very different costs and
|
|
162
|
+
// guessing which one dominates is how the first version of this spent
|
|
163
|
+
// most of its time rebuilding an index it had just loaded.
|
|
164
|
+
if std::env::var_os("PIFY_SEARCH_TIMING").is_some() {
|
|
165
|
+
eprintln!(" walk {t_walk:?} load {t_load:?} invert {t_index:?}");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Rewriting an unchanged index costs a full re-inversion and a
|
|
169
|
+
// multi-megabyte write on every start, which on a large tree is most
|
|
170
|
+
// of what reloading was supposed to save. Write only when the tree
|
|
171
|
+
// actually moved: something re-read, something gone, or no cache yet.
|
|
172
|
+
let vanished = !known.is_empty();
|
|
173
|
+
let dirty = !had_cache || rebuilt > 0 || vanished;
|
|
174
|
+
|
|
175
|
+
let this = Self {
|
|
176
|
+
root: root_path,
|
|
177
|
+
entries: RwLock::new(entries),
|
|
178
|
+
by_path: RwLock::new(by_path),
|
|
179
|
+
content: RwLock::new(index),
|
|
180
|
+
frecency: RwLock::new(HashMap::new()),
|
|
181
|
+
cache,
|
|
182
|
+
reused: reused as u32,
|
|
183
|
+
rebuilt,
|
|
184
|
+
};
|
|
185
|
+
if dirty {
|
|
186
|
+
this.persist();
|
|
187
|
+
}
|
|
188
|
+
Ok(this)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// Write the index back, best effort. A cache that cannot be written costs
|
|
192
|
+
/// the next session a rebuild, never this one a result.
|
|
193
|
+
fn persist(&self) {
|
|
194
|
+
let Some(path) = self.cache.as_ref() else { return };
|
|
195
|
+
let (Ok(entries), Ok(index)) = (self.entries.read(), self.content.read()) else {
|
|
196
|
+
return;
|
|
197
|
+
};
|
|
198
|
+
let mut by_file = index.by_file();
|
|
199
|
+
let mut files = Vec::with_capacity(by_file.len());
|
|
200
|
+
for (id, entry) in entries.iter().enumerate() {
|
|
201
|
+
if let Some(trigrams) = by_file.remove(&(id as u32)) {
|
|
202
|
+
files.push(store::StoredFile {
|
|
203
|
+
rel: entry.path.clone(),
|
|
204
|
+
size: entry.size,
|
|
205
|
+
mtime_ms: entry.mtime_ms,
|
|
206
|
+
trigrams,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
let _ = store::save(path, &files);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// How much of the last index survived, so the saving is observable.
|
|
214
|
+
#[napi]
|
|
215
|
+
pub fn reused_count(&self) -> u32 {
|
|
216
|
+
self.reused
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#[napi]
|
|
220
|
+
pub fn rebuilt_count(&self) -> u32 {
|
|
221
|
+
self.rebuilt
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// Flush the index to its cache file.
|
|
225
|
+
#[napi]
|
|
226
|
+
pub fn save(&self) {
|
|
227
|
+
self.persist();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#[napi]
|
|
231
|
+
pub fn file_count(&self) -> u32 {
|
|
232
|
+
self.entries.read().map(|e| e.len() as u32).unwrap_or(0)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#[napi]
|
|
236
|
+
pub fn indexed_count(&self) -> u32 {
|
|
237
|
+
self.content.read().map(|c| c.len() as u32).unwrap_or(0)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/// Note that a path was used, so frecency can favour it later.
|
|
241
|
+
#[napi]
|
|
242
|
+
pub fn touch(&self, path: String) {
|
|
243
|
+
let key = walk::normalize(&path);
|
|
244
|
+
if let Ok(mut frecency) = self.frecency.write() {
|
|
245
|
+
let entry = frecency.entry(key).or_insert(0);
|
|
246
|
+
*entry = (*entry + 25).min(200);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// Re-read one path: a create or a modify. Removing is `forget`.
|
|
251
|
+
#[napi]
|
|
252
|
+
pub fn refresh(&self, path: String) -> Result<()> {
|
|
253
|
+
let absolute = self.root.join(&path);
|
|
254
|
+
let rel = walk::relative(&self.root, &absolute);
|
|
255
|
+
let Ok(meta) = std::fs::metadata(&absolute) else {
|
|
256
|
+
return Ok(());
|
|
257
|
+
};
|
|
258
|
+
if !meta.is_file() {
|
|
259
|
+
return Ok(());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let id = {
|
|
263
|
+
let mut by_path = self.by_path.write().map_err(lock_err)?;
|
|
264
|
+
let mut entries = self.entries.write().map_err(lock_err)?;
|
|
265
|
+
match by_path.get(&rel) {
|
|
266
|
+
Some(&id) => {
|
|
267
|
+
if let Some(entry) = entries.get_mut(id as usize) {
|
|
268
|
+
entry.size = meta.len();
|
|
269
|
+
entry.mtime_ms = walk::mtime_ms(&meta);
|
|
270
|
+
}
|
|
271
|
+
id
|
|
272
|
+
}
|
|
273
|
+
None => {
|
|
274
|
+
let id = entries.len() as u32;
|
|
275
|
+
entries.push(Entry {
|
|
276
|
+
path: rel.clone(),
|
|
277
|
+
absolute: absolute.clone(),
|
|
278
|
+
size: meta.len(),
|
|
279
|
+
mtime_ms: walk::mtime_ms(&meta),
|
|
280
|
+
});
|
|
281
|
+
by_path.insert(rel.clone(), id);
|
|
282
|
+
id
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
let mut index = self.content.write().map_err(lock_err)?;
|
|
288
|
+
if !walk::index_content(&rel, meta.len()) {
|
|
289
|
+
index.remove(id);
|
|
290
|
+
return Ok(());
|
|
291
|
+
}
|
|
292
|
+
match std::fs::read(&absolute) {
|
|
293
|
+
Ok(bytes) if !walk::looks_binary(&bytes) => {
|
|
294
|
+
let mut set = std::collections::HashSet::with_hasher(trigram::BuildTrigramHasher);
|
|
295
|
+
trigram::extract(&bytes, &mut set);
|
|
296
|
+
let mut list: Vec<u32> = set.into_iter().collect();
|
|
297
|
+
list.sort_unstable();
|
|
298
|
+
index.add(id, &list);
|
|
299
|
+
}
|
|
300
|
+
_ => index.remove(id),
|
|
301
|
+
}
|
|
302
|
+
Ok(())
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#[napi]
|
|
306
|
+
pub fn forget(&self, path: String) -> Result<()> {
|
|
307
|
+
let rel = walk::normalize(&path);
|
|
308
|
+
let id = { self.by_path.read().map_err(lock_err)?.get(&rel).copied() };
|
|
309
|
+
if let Some(id) = id {
|
|
310
|
+
self.content.write().map_err(lock_err)?.remove(id);
|
|
311
|
+
self.by_path.write().map_err(lock_err)?.remove(&rel);
|
|
312
|
+
}
|
|
313
|
+
Ok(())
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#[napi]
|
|
317
|
+
pub fn find(&self, query: String, limit: u32, offset: u32, now_ms: f64) -> Result<FindPage> {
|
|
318
|
+
let entries = self.entries.read().map_err(lock_err)?;
|
|
319
|
+
let frecency = self.frecency.read().map_err(lock_err)?;
|
|
320
|
+
let now = now_ms as i64;
|
|
321
|
+
|
|
322
|
+
let mut scored: Vec<(i32, usize)> = entries
|
|
323
|
+
.par_iter()
|
|
324
|
+
.enumerate()
|
|
325
|
+
.filter_map(|(i, entry)| {
|
|
326
|
+
let candidate = score::Candidate {
|
|
327
|
+
path: &entry.path,
|
|
328
|
+
frecency: frecency.get(&entry.path).copied().unwrap_or(0),
|
|
329
|
+
git: "",
|
|
330
|
+
mtime_ms: entry.mtime_ms,
|
|
331
|
+
};
|
|
332
|
+
score::score(&candidate, &query, now).map(|s| (s, i))
|
|
333
|
+
})
|
|
334
|
+
.collect();
|
|
335
|
+
|
|
336
|
+
// Ties break on path so two runs of the same query agree.
|
|
337
|
+
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| entries[a.1].path.cmp(&entries[b.1].path)));
|
|
338
|
+
|
|
339
|
+
let total = scored.len();
|
|
340
|
+
let start = (offset as usize).min(total);
|
|
341
|
+
let end = (start + limit as usize).min(total);
|
|
342
|
+
let items = scored[start..end]
|
|
343
|
+
.iter()
|
|
344
|
+
.map(|&(s, i)| FileHit {
|
|
345
|
+
path: entries[i].path.clone(),
|
|
346
|
+
score: s,
|
|
347
|
+
size: entries[i].size as f64,
|
|
348
|
+
modified_ms: entries[i].mtime_ms as f64,
|
|
349
|
+
})
|
|
350
|
+
.collect();
|
|
351
|
+
|
|
352
|
+
Ok(FindPage {
|
|
353
|
+
items,
|
|
354
|
+
total: total as u32,
|
|
355
|
+
next: if end < total { end as i32 } else { -1 },
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/// `mode` is "literal", "regex" or "fuzzy".
|
|
360
|
+
#[napi]
|
|
361
|
+
pub fn grep(
|
|
362
|
+
&self,
|
|
363
|
+
pattern: String,
|
|
364
|
+
mode: String,
|
|
365
|
+
limit: u32,
|
|
366
|
+
offset: u32,
|
|
367
|
+
case_insensitive: bool,
|
|
368
|
+
) -> Result<GrepPage> {
|
|
369
|
+
if pattern.is_empty() {
|
|
370
|
+
return Ok(GrepPage { items: vec![], total: 0, next: -1, scanned: 0 });
|
|
371
|
+
}
|
|
372
|
+
let entries = self.entries.read().map_err(lock_err)?;
|
|
373
|
+
let index = self.content.read().map_err(lock_err)?;
|
|
374
|
+
|
|
375
|
+
// Fuzzy has no required substring, so the index cannot narrow it.
|
|
376
|
+
// Saying so beats narrowing wrongly and losing matches.
|
|
377
|
+
let plan = match mode.as_str() {
|
|
378
|
+
"fuzzy" => Plan::All,
|
|
379
|
+
"regex" => trigram::plan_for_regex(&pattern),
|
|
380
|
+
_ => trigram::plan_for_literal(&pattern),
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
let ids: Vec<u32> = match index.candidates(&plan) {
|
|
384
|
+
Some(list) => list,
|
|
385
|
+
None => (0..entries.len() as u32).collect(),
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
let needle = if case_insensitive { pattern.to_lowercase() } else { pattern.clone() };
|
|
389
|
+
let budget = score::max_typos(&pattern);
|
|
390
|
+
|
|
391
|
+
// Regex mode must actually run a regex. Falling back to `contains`
|
|
392
|
+
// made `declared\w+` match nothing at all while reporting a clean
|
|
393
|
+
// zero, which reads exactly like "this does not exist".
|
|
394
|
+
let compiled = if mode == "regex" {
|
|
395
|
+
match regex::RegexBuilder::new(&pattern)
|
|
396
|
+
.case_insensitive(case_insensitive)
|
|
397
|
+
.build()
|
|
398
|
+
{
|
|
399
|
+
Ok(re) => Some(re),
|
|
400
|
+
Err(e) => return Err(Error::from_reason(format!("invalid regex: {e}"))),
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
None
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
let mut hits: Vec<ContentHit> = ids
|
|
407
|
+
.par_iter()
|
|
408
|
+
.filter_map(|&id| {
|
|
409
|
+
let entry = entries.get(id as usize)?;
|
|
410
|
+
if entry.size > walk::MAX_SEARCHABLE_BYTES {
|
|
411
|
+
return None;
|
|
412
|
+
}
|
|
413
|
+
let bytes = std::fs::read(&entry.absolute).ok()?;
|
|
414
|
+
if walk::looks_binary(&bytes) {
|
|
415
|
+
return None;
|
|
416
|
+
}
|
|
417
|
+
let text = String::from_utf8_lossy(&bytes);
|
|
418
|
+
let mut found = Vec::new();
|
|
419
|
+
for (n, line) in text.lines().enumerate() {
|
|
420
|
+
let matched = match &compiled {
|
|
421
|
+
Some(re) => re.is_match(line),
|
|
422
|
+
None => {
|
|
423
|
+
let hay = if case_insensitive { line.to_lowercase() } else { line.to_string() };
|
|
424
|
+
if mode == "fuzzy" {
|
|
425
|
+
fuzzy_line(&hay, &needle, budget)
|
|
426
|
+
} else {
|
|
427
|
+
hay.contains(&needle)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
if matched {
|
|
432
|
+
found.push(ContentHit {
|
|
433
|
+
path: entry.path.clone(),
|
|
434
|
+
line: (n + 1) as u32,
|
|
435
|
+
text: line.chars().take(400).collect(),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if found.is_empty() { None } else { Some(found) }
|
|
440
|
+
})
|
|
441
|
+
.flatten()
|
|
442
|
+
.collect();
|
|
443
|
+
|
|
444
|
+
hits.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.line.cmp(&b.line)));
|
|
445
|
+
|
|
446
|
+
let total = hits.len();
|
|
447
|
+
let start = (offset as usize).min(total);
|
|
448
|
+
let end = (start + limit as usize).min(total);
|
|
449
|
+
Ok(GrepPage {
|
|
450
|
+
items: hits.drain(..).skip(start).take(end - start).collect(),
|
|
451
|
+
total: total as u32,
|
|
452
|
+
next: if end < total { end as i32 } else { -1 },
|
|
453
|
+
scanned: ids.len() as u32,
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/// Characters in order, within the typo budget and close enough together to be
|
|
459
|
+
/// one word rather than three scattered letters.
|
|
460
|
+
fn fuzzy_line(hay: &str, needle: &str, budget: usize) -> bool {
|
|
461
|
+
let hay: Vec<char> = hay.chars().collect();
|
|
462
|
+
let need: Vec<char> = needle.chars().collect();
|
|
463
|
+
if need.is_empty() {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
let span = (need.len() * 3).max(need.len() + 8);
|
|
467
|
+
for start in 0..hay.len() {
|
|
468
|
+
let mut typos = 0usize;
|
|
469
|
+
let mut at = start;
|
|
470
|
+
let mut matched = 0usize;
|
|
471
|
+
for &want in &need {
|
|
472
|
+
match hay[at.min(hay.len())..].iter().position(|&c| c == want) {
|
|
473
|
+
Some(offset) if at + offset - start <= span => {
|
|
474
|
+
at += offset + 1;
|
|
475
|
+
matched += 1;
|
|
476
|
+
}
|
|
477
|
+
_ => {
|
|
478
|
+
typos += 1;
|
|
479
|
+
if typos > budget {
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if typos <= budget && matched + budget >= need.len() && matched > 0 {
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
false
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
fn lock_err<T>(_: T) -> Error {
|
|
493
|
+
Error::from_reason("search index lock poisoned")
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/// Exposed so the JavaScript side can assert the two engines agree.
|
|
497
|
+
#[napi]
|
|
498
|
+
pub fn literal_runs(pattern: String) -> Vec<String> {
|
|
499
|
+
trigram::literal_runs(&pattern)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#[napi]
|
|
503
|
+
pub fn max_typos(query: String) -> u32 {
|
|
504
|
+
score::max_typos(&query) as u32
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
#[allow(dead_code)]
|
|
508
|
+
fn unused(_: &Path) {}
|