pi-supernova 0.0.8 → 0.0.15
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/CHANGELOG.md +85 -2
- package/README.md +36 -12
- package/bottleneck.js +1 -1
- package/catalog.js +20 -15
- package/check.js +166 -0
- package/config.default.json +1 -0
- package/config.js +1 -1
- package/decode.js +0 -10
- package/evidence.js +429 -0
- package/fuzzy.js +182 -0
- package/guest-worker.js +14 -11
- package/host-bridge.js +184 -74
- package/index.js +65 -42
- package/ledger.js +178 -0
- package/omp-frame.js +7 -22
- package/outline.js +80 -0
- package/package.json +8 -1
- package/render-measure.js +2 -30
- package/render.js +46 -150
- package/repo-index.js +273 -0
- package/runtime.js +3 -20
- package/search.js +141 -0
- package/snap.js +86 -108
- package/surface.js +24 -12
- package/vfs.js +7 -6
- package/workspace.js +33 -2
package/repo-index.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { extractStructuralSurface } from "./surface.js";
|
|
4
|
+
import { Frecency } from "./fuzzy.js";
|
|
5
|
+
import { relativeSlash } from "./workspace.js";
|
|
6
|
+
|
|
7
|
+
// In-process workspace index: the gitignore-aware file list comes from one
|
|
8
|
+
// \`rg --files\` spawn and is then reused; file text, lowercase text, and the
|
|
9
|
+
// structural surface are cached per path and validated by mtime. snap/grep/glob
|
|
10
|
+
// read from here instead of spawning, so a warm call is sub-millisecond.
|
|
11
|
+
|
|
12
|
+
// With a working fs.watch the list only refreshes on change; the TTL is the fallback when watching fails.
|
|
13
|
+
const LIST_TTL_MS = 10_000;
|
|
14
|
+
const WATCHED_TTL_MS = 5 * 60_000;
|
|
15
|
+
const WATCH_DEBOUNCE_MS = 150;
|
|
16
|
+
const MAX_INDEXED_FILES = 4000;
|
|
17
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
18
|
+
const BINARY_EXT = new Set([
|
|
19
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", ".tgz", ".tar", ".bz2", ".xz", ".7z",
|
|
20
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp3", ".mp4", ".mov", ".wav", ".ogg", ".webm", ".wasm", ".class",
|
|
21
|
+
".jar", ".so", ".dylib", ".dll", ".exe", ".bin", ".o", ".a", ".node", ".lock", ".sqlite", ".sqlite3", ".db",
|
|
22
|
+
]);
|
|
23
|
+
const REGEX_SPECIAL = /[.+^${}()|\\]/g;
|
|
24
|
+
const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
|
|
25
|
+
const EMPTY = Object.freeze([]);
|
|
26
|
+
const DEF_PATTERN = /^(?:pub\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(function|class|def|fn|const|let|interface|type|struct|enum)\s+([a-zA-Z0-9_$]+)/;
|
|
27
|
+
|
|
28
|
+
/** Declared identifier on a line (function/class/const/…), or ""; the same rule snap and grep use. */
|
|
29
|
+
export function declaredName(line) {
|
|
30
|
+
return DEF_PATTERN.exec(String(line).trim())?.[2] ?? "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isTextCandidate(filePath) {
|
|
34
|
+
return !BINARY_EXT.has(path.extname(filePath).toLowerCase());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Translate one glob token at index i → [regexSource, nextIndex]. */
|
|
38
|
+
function globToken(glob, i) {
|
|
39
|
+
const ch = glob[i];
|
|
40
|
+
if (ch === "*" && glob[i + 1] === "*") {
|
|
41
|
+
const slashAfter = glob[i + 2] === "/";
|
|
42
|
+
return [slashAfter ? "(?:.*/)?" : ".*", i + (slashAfter ? 3 : 2)];
|
|
43
|
+
}
|
|
44
|
+
if (ch === "*") return ["[^/]*", i + 1];
|
|
45
|
+
if (ch === "?") return ["[^/]", i + 1];
|
|
46
|
+
if (ch === "{" || ch === "[") return globGroup(glob, i, ch);
|
|
47
|
+
return [ch.replace(REGEX_SPECIAL, "\\$&"), i + 1];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** {a,b} alternation or [..] class starting at i. */
|
|
51
|
+
function globGroup(glob, i, open) {
|
|
52
|
+
const close = open === "{" ? "}" : "]";
|
|
53
|
+
const end = glob.indexOf(close, i);
|
|
54
|
+
if (end < 0) throw new SyntaxError("unclosed " + open + " in glob");
|
|
55
|
+
const inner = glob.slice(i + 1, end);
|
|
56
|
+
const source = open === "{" ? "(?:" + inner.split(",").map(globBody).join("|") + ")" : "[" + inner + "]";
|
|
57
|
+
return [source, end + 1];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function globBody(glob) {
|
|
61
|
+
let source = "";
|
|
62
|
+
let i = 0;
|
|
63
|
+
while (i < glob.length) {
|
|
64
|
+
const [piece, next] = globToken(glob, i);
|
|
65
|
+
source += piece;
|
|
66
|
+
i = next;
|
|
67
|
+
}
|
|
68
|
+
return source;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** gitignore-style glob (rg -g) → RegExp over a "/"-separated relative path. No slash ⇒ basename match anywhere. */
|
|
72
|
+
export function globToRegExp(glob) {
|
|
73
|
+
const body = globBody(glob);
|
|
74
|
+
return new RegExp(glob.includes("/") ? "^" + body + "$" : "(?:^|/)" + body + "$");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class WorkspaceIndex {
|
|
78
|
+
constructor(runCommand) {
|
|
79
|
+
this.runCommand = runCommand;
|
|
80
|
+
this.lists = new Map();
|
|
81
|
+
this.entries = new Map();
|
|
82
|
+
this.watchers = new Map();
|
|
83
|
+
this.frecency = new Frecency();
|
|
84
|
+
this.gitModified = new Map(); // root → Set(relative "/"-joined paths)
|
|
85
|
+
this.lastTouched = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
invalidate() {
|
|
89
|
+
this.lists.clear();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** fff frecency: every read/edit is an access; the newest one is the "current file" for distance penalties. */
|
|
93
|
+
touch(relPath) {
|
|
94
|
+
this.frecency.record(relPath);
|
|
95
|
+
this.lastTouched = relPath;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
watch(root) {
|
|
99
|
+
if (this.watchers.has(root)) return this.watchers.get(root);
|
|
100
|
+
let ok = false;
|
|
101
|
+
try {
|
|
102
|
+
let timer = null;
|
|
103
|
+
const watcher = fs.watch(root, { recursive: true }, () => {
|
|
104
|
+
if (timer) return;
|
|
105
|
+
timer = setTimeout(() => {
|
|
106
|
+
timer = null;
|
|
107
|
+
this.lists.clear();
|
|
108
|
+
this.gitModified.delete(root);
|
|
109
|
+
}, WATCH_DEBOUNCE_MS);
|
|
110
|
+
});
|
|
111
|
+
watcher.on("error", () => {
|
|
112
|
+
this.watchers.set(root, false);
|
|
113
|
+
this.lists.clear();
|
|
114
|
+
});
|
|
115
|
+
if (typeof watcher.unref === "function") watcher.unref();
|
|
116
|
+
ok = true;
|
|
117
|
+
} catch {
|
|
118
|
+
ok = false;
|
|
119
|
+
}
|
|
120
|
+
this.watchers.set(root, ok);
|
|
121
|
+
return ok;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Paths git reports as modified/added/untracked (fff's git-status boost); one spawn per list refresh. */
|
|
125
|
+
async modifiedFiles(root) {
|
|
126
|
+
const cached = this.gitModified.get(root);
|
|
127
|
+
if (cached) return cached;
|
|
128
|
+
const set = new Set();
|
|
129
|
+
try {
|
|
130
|
+
const res = await this.runCommand(["git", "status", "--porcelain", "-z", "--untracked-files=all"], { cwd: root, timeoutMs: 5_000 });
|
|
131
|
+
if (res.exitCode === 0) {
|
|
132
|
+
for (const row of res.stdout.split("\0")) {
|
|
133
|
+
if (row.length > 3) set.add(row.slice(3));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch {}
|
|
137
|
+
this.gitModified.set(root, set);
|
|
138
|
+
return set;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
mtimeSeconds(filePath) {
|
|
142
|
+
const e = this.entries.get(filePath);
|
|
143
|
+
if (e) return e.mtimeMs / 1000;
|
|
144
|
+
try {
|
|
145
|
+
return fs.statSync(filePath).mtimeMs / 1000;
|
|
146
|
+
} catch {
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Absolute, sorted file list for a root; gitignore-aware via rg; cached for LIST_TTL_MS. */
|
|
152
|
+
async files(root, includeHidden = false) {
|
|
153
|
+
const key = root + "\0" + (includeHidden ? "h" : "");
|
|
154
|
+
const cached = this.lists.get(key);
|
|
155
|
+
const ttl = this.watch(root) ? WATCHED_TTL_MS : LIST_TTL_MS;
|
|
156
|
+
if (cached && Date.now() - cached.at < ttl) return cached.files;
|
|
157
|
+
const args = ["rg", "--files"];
|
|
158
|
+
if (includeHidden) args.push("--hidden");
|
|
159
|
+
args.push("-g", "!.git/**", "-g", "!**/.git/**", root);
|
|
160
|
+
let files = [];
|
|
161
|
+
try {
|
|
162
|
+
const res = await this.runCommand(args, { cwd: root, timeoutMs: 15_000 });
|
|
163
|
+
files = res.stdout.split("\n").map((f) => f.trim()).filter(Boolean).map((f) => path.resolve(root, f)).sort();
|
|
164
|
+
} catch {
|
|
165
|
+
files = [];
|
|
166
|
+
}
|
|
167
|
+
this.lists.set(key, { files, at: Date.now() });
|
|
168
|
+
return files;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Cached {text, lower, ext, surface?} for a file, re-read when mtime/size changed. Null for unreadable, binary, or huge files. */
|
|
172
|
+
entry(filePath) {
|
|
173
|
+
if (!isTextCandidate(filePath)) return null;
|
|
174
|
+
let stat;
|
|
175
|
+
try {
|
|
176
|
+
stat = fs.statSync(filePath);
|
|
177
|
+
} catch {
|
|
178
|
+
this.entries.delete(filePath);
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null;
|
|
182
|
+
const cached = this.entries.get(filePath);
|
|
183
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached;
|
|
184
|
+
let text;
|
|
185
|
+
try {
|
|
186
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
if (text.includes("\0")) return null;
|
|
191
|
+
const created = { text, lower: text.toLowerCase(), mtimeMs: stat.mtimeMs, size: stat.size, ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
|
|
192
|
+
this.entries.set(filePath, created);
|
|
193
|
+
return created;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
static fromText(filePath, text) {
|
|
197
|
+
return { text, lower: text.toLowerCase(), ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Per-line raw text, lowercase text, declared identifier (or ""), and identifier tokens, computed once per entry. */
|
|
201
|
+
static linesOf(entry) {
|
|
202
|
+
if (entry.lines) return entry.lines;
|
|
203
|
+
const raw = entry.text.split("\n");
|
|
204
|
+
const lower = new Array(raw.length);
|
|
205
|
+
const defNames = new Array(raw.length);
|
|
206
|
+
const idents = new Array(raw.length);
|
|
207
|
+
for (let i = 0; i < raw.length; i++) {
|
|
208
|
+
const trimmed = raw[i].trim();
|
|
209
|
+
lower[i] = trimmed.toLowerCase();
|
|
210
|
+
defNames[i] = DEF_PATTERN.exec(trimmed)?.[2].toLowerCase() ?? "";
|
|
211
|
+
idents[i] = trimmed.match(IDENT_TOKEN) || EMPTY;
|
|
212
|
+
}
|
|
213
|
+
entry.lines = { raw, lower, defNames, idents };
|
|
214
|
+
return entry.lines;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Declaration spans [start, end] (1-based, inclusive) in file order, trailing blank lines trimmed.
|
|
219
|
+
* A span runs to the line before the next declaration; the file's leading header is not a span.
|
|
220
|
+
*/
|
|
221
|
+
static spansOf(entry) {
|
|
222
|
+
if (entry.spans) return entry.spans;
|
|
223
|
+
const { items, lineCount } = WorkspaceIndex.surfaceOf(entry);
|
|
224
|
+
const { lower } = WorkspaceIndex.linesOf(entry);
|
|
225
|
+
const spans = [];
|
|
226
|
+
for (let i = 0; i < items.length; i++) {
|
|
227
|
+
const start = items[i].line;
|
|
228
|
+
let end = Math.min(i + 1 < items.length ? items[i + 1].line - 1 : lineCount, lineCount);
|
|
229
|
+
while (end > start && lower[end - 1] === "") end--;
|
|
230
|
+
spans.push({ start, end, name: items[i].name, kind: items[i].kind, isExport: items[i].isExport === true });
|
|
231
|
+
}
|
|
232
|
+
entry.spans = spans;
|
|
233
|
+
return spans;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
static surfaceOf(entry) {
|
|
237
|
+
if (!entry.surface) entry.surface = extractStructuralSurface(entry.text, entry.ext);
|
|
238
|
+
return entry.surface;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** True when the list is small enough to scan in-process instead of spawning rg. */
|
|
242
|
+
canScan(files) {
|
|
243
|
+
return files.length <= MAX_INDEXED_FILES;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Files whose lowercase text contains any (or every) needle; needles are lowercase. */
|
|
247
|
+
filesContaining(files, needles, anyOf) {
|
|
248
|
+
const hits = [];
|
|
249
|
+
for (const filePath of files) {
|
|
250
|
+
const e = this.entry(filePath);
|
|
251
|
+
if (!e) continue;
|
|
252
|
+
const found = anyOf ? needles.some((n) => e.lower.includes(n)) : needles.every((n) => e.lower.includes(n));
|
|
253
|
+
if (found) hits.push(filePath);
|
|
254
|
+
}
|
|
255
|
+
return hits;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Structured grep rows {rel, line, text, def}; def marks lines whose declared name itself matches. */
|
|
259
|
+
grepRows(files, regex, root) {
|
|
260
|
+
const out = [];
|
|
261
|
+
const nameRegex = new RegExp(regex.source, "i");
|
|
262
|
+
for (const filePath of files) {
|
|
263
|
+
const e = this.entry(filePath);
|
|
264
|
+
if (!e || !regex.test(e.text)) continue;
|
|
265
|
+
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
266
|
+
const rel = relativeSlash(root, filePath);
|
|
267
|
+
for (let i = 0; i < raw.length; i++) {
|
|
268
|
+
if (regex.test(raw[i])) out.push({ rel, line: i + 1, text: raw[i], def: defNames[i] !== "" && nameRegex.test(defNames[i]) });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
}
|
package/runtime.js
CHANGED
|
@@ -94,15 +94,6 @@ export function warmGuestWorker(config) {
|
|
|
94
94
|
return handle.ready;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/** Terminate every guest worker (tests, shutdown). */
|
|
98
|
-
export async function shutdownGuestWorkers() {
|
|
99
|
-
if (!idleWorker) return;
|
|
100
|
-
const handle = idleWorker;
|
|
101
|
-
idleWorker = null;
|
|
102
|
-
handle.dead = true;
|
|
103
|
-
await handle.worker.terminate();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
97
|
const RPC_METHODS = {
|
|
107
98
|
call: (nova, args) => {
|
|
108
99
|
if (!isFunction(nova?.call)) throw new Error("nova.call unavailable");
|
|
@@ -122,17 +113,9 @@ const RPC_METHODS = {
|
|
|
122
113
|
if (!isFunction(nova?.describe)) throw new Error("nova.describe unavailable");
|
|
123
114
|
return nova.describe(args[0]);
|
|
124
115
|
},
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
},
|
|
129
|
-
snap: (nova, args) => {
|
|
130
|
-
if (isFunction(nova?.snap)) return nova.snap(args[0], args[1]);
|
|
131
|
-
return nova.call("snap", { query: args[0], path: args[1] });
|
|
132
|
-
},
|
|
133
|
-
speculateBegin: (nova) => (isFunction(nova?.speculateBegin) ? nova.speculateBegin() : undefined),
|
|
134
|
-
speculateCommit: (nova) => (isFunction(nova?.speculateCommit) ? nova.speculateCommit() : undefined),
|
|
135
|
-
speculateRollback: (nova) => (isFunction(nova?.speculateRollback) ? nova.speculateRollback() : undefined),
|
|
116
|
+
speculateBegin: (nova) => nova?.speculateBegin?.(),
|
|
117
|
+
speculateCommit: (nova) => nova?.speculateCommit?.(),
|
|
118
|
+
speculateRollback: (nova) => nova?.speculateRollback?.(),
|
|
136
119
|
};
|
|
137
120
|
|
|
138
121
|
async function dispatchRpc(nova, method, args) {
|
package/search.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { WorkspaceIndex, globToRegExp } from "./repo-index.js";
|
|
3
|
+
import { rankPaths, smartCase, fuzzyMatch } from "./fuzzy.js";
|
|
4
|
+
import { runCommand, relativeSlash } from "./workspace.js";
|
|
5
|
+
|
|
6
|
+
// Search served from the in-process index: fuzzy path find (fff port), smart-case grep with
|
|
7
|
+
// definition-first rows and fuzzy fallback, glob listing. rg is spawned only for trees too
|
|
8
|
+
// large to scan in-process.
|
|
9
|
+
|
|
10
|
+
function textResult(text, details) {
|
|
11
|
+
return { content: [{ type: "text", text: String(text ?? "") }], details: details || {} };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function rgGrepArgs(pattern, params, searchPath) {
|
|
15
|
+
const args = ["--line-number", "--no-heading", "--color", "never"];
|
|
16
|
+
if (params?.caseSensitive !== true) args.push("--ignore-case");
|
|
17
|
+
if (params?.glob) args.push("--glob", String(params.glob));
|
|
18
|
+
args.push("--", pattern, searchPath);
|
|
19
|
+
return args;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** rg --files, then find(1) when rg is unavailable; both accept an optional glob/name pattern. */
|
|
23
|
+
export async function listWithTools(searchDir, pattern, cwd, signal) {
|
|
24
|
+
const args = ["--files"];
|
|
25
|
+
if (pattern) args.push("-g", pattern);
|
|
26
|
+
const res = await runCommand(["rg", ...args, searchDir], { cwd, timeoutMs: 30_000, signal }).catch(() => null);
|
|
27
|
+
if (res && (res.exitCode === 0 || res.exitCode === 1)) return textResult(res.stdout, { via: "rg" });
|
|
28
|
+
const findArgs = [searchDir];
|
|
29
|
+
if (pattern) findArgs.push("-name", pattern);
|
|
30
|
+
const findRes = await runCommand(["find", ...findArgs], { cwd, timeoutMs: 30_000, signal });
|
|
31
|
+
return textResult(findRes.stdout, { via: "find" });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const GLOB_CHARS = /[*?[\]{}]/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* fffind: a pattern without glob characters is a fuzzy, typo-tolerant, frecency-ranked path query.
|
|
38
|
+
* Returns "path" rows (best first) or null when the pattern is a real glob.
|
|
39
|
+
*/
|
|
40
|
+
export async function fuzzyFind(index, root, cwd, pattern, limit = 20) {
|
|
41
|
+
if (!pattern || GLOB_CHARS.test(pattern)) return null;
|
|
42
|
+
const files = await index.files(root);
|
|
43
|
+
if (!index.canScan(files)) return null;
|
|
44
|
+
const rel = files.map((f) => relativeSlash(cwd, f));
|
|
45
|
+
const absolute = new Map(rel.map((r, i) => [r, files[i]]));
|
|
46
|
+
// mtime is only consulted for paths that matched; never stat the whole tree.
|
|
47
|
+
const mtimeOf = (r) => index.mtimeSeconds(absolute.get(r));
|
|
48
|
+
const ranked = rankPaths(pattern, rel, { frecency: index.frecency, mtimeOf, modified: await index.modifiedFiles(cwd), currentFile: index.lastTouched });
|
|
49
|
+
// fff weak-match detector: when nothing matches exactly and the best is mostly typos, say so instead of flooding.
|
|
50
|
+
const rows = ranked.slice(0, limit);
|
|
51
|
+
if (rows.length === 0) return "";
|
|
52
|
+
return rows.map((r) => r.path).join("\n") + "\n";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** fff-style grep: smart-case, definition lines first, fuzzy fallback when the literal has no hits. */
|
|
56
|
+
export async function grepIndexed(index, pattern, params, searchPath, cwd) {
|
|
57
|
+
const compiled = grepRegex(pattern, params);
|
|
58
|
+
if (!compiled) return null;
|
|
59
|
+
const { regex, caseSensitive } = compiled;
|
|
60
|
+
let files = await index.files(searchPath);
|
|
61
|
+
if (!index.canScan(files)) return null;
|
|
62
|
+
if (params?.glob) {
|
|
63
|
+
const matcher = globToRegExp(String(params.glob));
|
|
64
|
+
files = files.filter((f) => matcher.test(relativeSlash(cwd, f)));
|
|
65
|
+
}
|
|
66
|
+
const rows = index.grepRows(files, regex, cwd);
|
|
67
|
+
const fallback = rows.length === 0 && /^[\w$.-]{4,}$/.test(pattern) ? fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) : rows;
|
|
68
|
+
return formatGrepRows(fallback, grepLimit(params));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function grepLimit(params) {
|
|
72
|
+
return Number.isInteger(params?.limit) && params.limit > 0 ? params.limit : 200;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function grepRegex(pattern, params) {
|
|
76
|
+
const caseSensitive = params?.caseSensitive === true || (params?.caseSensitive !== false && smartCase(pattern));
|
|
77
|
+
try {
|
|
78
|
+
return { regex: new RegExp(pattern, caseSensitive ? "" : "i"), caseSensitive };
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Zero literal hits: retry each line fuzzily (1 typo, 2 for long names) within a tight span, so IsOffTheRecord finds is_off_the_record. */
|
|
85
|
+
function fuzzyGrepRows(index, files, pattern, cwd, caseSensitive) {
|
|
86
|
+
const maxTypos = pattern.length >= 8 ? 2 : 1;
|
|
87
|
+
const rows = [];
|
|
88
|
+
for (const filePath of files) {
|
|
89
|
+
const e = index.entry(filePath);
|
|
90
|
+
if (!e) continue;
|
|
91
|
+
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
92
|
+
const rel = relativeSlash(cwd, filePath);
|
|
93
|
+
for (let i = 0; i < raw.length && rows.length <= 400; i++) {
|
|
94
|
+
const m = fuzzyMatch(pattern, raw[i], { maxTypos, caseSensitive });
|
|
95
|
+
if (!m || m.end - m.start > pattern.length + 2) continue;
|
|
96
|
+
rows.push({ rel, line: i + 1, text: raw[i], def: defNames[i] !== "" && fuzzyMatch(pattern, defNames[i], { maxTypos }) !== null });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return rows;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** fff definition-first hinting: files that declare the name come first, declarations first within a file; one header per file. */
|
|
103
|
+
function formatGrepRows(rows, limit) {
|
|
104
|
+
if (rows.length === 0) return "";
|
|
105
|
+
const groups = new Map();
|
|
106
|
+
for (const r of rows) {
|
|
107
|
+
if (!groups.has(r.rel)) groups.set(r.rel, []);
|
|
108
|
+
groups.get(r.rel).push(r);
|
|
109
|
+
}
|
|
110
|
+
const files = [...groups.values()].sort((a, b) => Number(b.some((r) => r.def)) - Number(a.some((r) => r.def)));
|
|
111
|
+
let out = "";
|
|
112
|
+
let shown = 0;
|
|
113
|
+
for (const group of files) {
|
|
114
|
+
if (shown >= limit) break;
|
|
115
|
+
out += group[0].rel + "\n";
|
|
116
|
+
group.sort((a, b) => Number(b.def) - Number(a.def) || a.line - b.line);
|
|
117
|
+
for (const r of group) {
|
|
118
|
+
if (shown++ >= limit) break;
|
|
119
|
+
out += " " + r.line + (r.def ? "*" : ":") + " " + r.text.trim() + "\n";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (rows.length > limit) out += "… " + (rows.length - limit) + " more matches (pass limit or narrow the pattern)\n";
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** rg --files [-g pattern] served from the index; null when the tree is too large. */
|
|
127
|
+
export async function listIndexed(index, root, cwd, pattern) {
|
|
128
|
+
const files = await index.files(root);
|
|
129
|
+
if (!index.canScan(files)) return null;
|
|
130
|
+
const rel = files.map((f) => path.relative(cwd, f).split(path.sep).join("/"));
|
|
131
|
+
if (!pattern) return rel.length ? rel.join("\n") + "\n" : "";
|
|
132
|
+
let matcher;
|
|
133
|
+
try {
|
|
134
|
+
matcher = globToRegExp(pattern);
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const hits = rel.filter((f) => matcher.test(f));
|
|
139
|
+
return hits.length ? hits.join("\n") + "\n" : "";
|
|
140
|
+
}
|
|
141
|
+
|