pi-supernova 0.8.2 → 0.9.1
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 +188 -51
- package/docs/CHANGELOG.md +86 -1
- package/docs/TOKEN_COSTS.md +38 -0
- package/index.js +10 -175
- package/package.json +2 -1
- package/src/adapters/bash.js +14 -30
- package/src/adapters/errors.js +1 -9
- package/src/adapters/read-focus.js +98 -0
- package/src/adapters/read-image.js +51 -0
- package/src/adapters/read-json.js +42 -0
- package/src/adapters/read-text.js +71 -0
- package/src/adapters/read.js +66 -635
- package/src/bridge/catalog.js +3 -2
- package/src/bridge/host-bridge.js +35 -167
- package/src/bridge/tool-registry.js +104 -0
- package/src/bridge/trace.js +41 -0
- package/src/context/evidence-graph.js +249 -0
- package/src/context/evidence-rank.js +153 -0
- package/src/context/evidence.js +10 -424
- package/src/context/fuzzy.js +116 -43
- package/src/context/query.js +80 -0
- package/src/context/repo-index.js +23 -166
- package/src/context/search-files.js +19 -0
- package/src/context/search.js +2 -24
- package/src/context/snap-search.js +203 -0
- package/src/context/snap.js +5 -266
- package/src/context/source-entry.js +112 -0
- package/src/contract/bash.js +6 -1
- package/src/contract/program.js +36 -0
- package/src/contract/read.js +8 -53
- package/src/fs/check.js +1 -1
- package/src/fs/commit.js +161 -0
- package/src/fs/diff.js +11 -15
- package/src/fs/directory.js +79 -0
- package/src/fs/file-io.js +100 -0
- package/src/fs/glob.js +54 -0
- package/src/fs/json-size.js +54 -0
- package/src/fs/lines.js +117 -0
- package/src/fs/read-window.js +74 -0
- package/src/fs/session-resource.js +50 -0
- package/src/fs/text-ops.js +7 -227
- package/src/fs/vfs.js +5 -239
- package/src/fs/workspace.js +2 -1
- package/src/output/bottleneck.js +13 -67
- package/src/output/final.js +114 -0
- package/src/output/format.js +94 -5
- package/src/output/outcome.js +91 -0
- package/src/runtime/batch-input.js +68 -0
- package/src/runtime/guest-api.js +281 -0
- package/src/runtime/guest-worker.js +62 -333
- package/src/runtime/parallel.js +41 -39
- package/src/runtime/program-batch.js +21 -75
- package/src/runtime/program-file.js +3 -11
- package/src/runtime/program.js +141 -0
- package/src/runtime/reference.js +6 -5
- package/src/runtime/runtime.js +77 -253
- package/src/runtime/worker-pool.js +91 -0
- package/src/shared/decode.js +22 -8
- package/src/shared/image-worker.js +30 -0
- package/src/shared/image.js +78 -0
- package/src/shared/png.js +57 -0
- package/src/shared/result.js +77 -0
- package/src/shared/syntax-context.js +61 -3
- package/src/ui/host-render.js +104 -0
- package/src/ui/progress.js +51 -0
- package/src/ui/render.js +21 -421
- package/src/ui/trace.js +277 -0
package/src/context/fuzzy.js
CHANGED
|
@@ -40,10 +40,13 @@ export class Frecency {
|
|
|
40
40
|
score(filePath, mtimeSec, now = Date.now() / 1000) {
|
|
41
41
|
let total = 0;
|
|
42
42
|
const cutoff = now - AI_MAX_HISTORY_DAYS * 86400;
|
|
43
|
+
const stamps = this.access.get(filePath);
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
if (stamps) {
|
|
46
|
+
for (const t of stamps) {
|
|
47
|
+
if (t < cutoff) continue;
|
|
48
|
+
total += Math.exp(-AI_DECAY * ((now - t) / 86400));
|
|
49
|
+
}
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
if (mtimeSec) {
|
|
@@ -74,33 +77,42 @@ function isBoundary(hay, i) {
|
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
/**
|
|
77
|
-
* Greedy forward match
|
|
78
|
-
*
|
|
80
|
+
* Greedy forward scan. Returns the match end or the needle index that failed
|
|
81
|
+
* (failAt), which the typo retry uses to prune deletions provably unable to
|
|
82
|
+
* match (see matchWithTypos).
|
|
79
83
|
*/
|
|
80
|
-
function
|
|
81
|
-
const hayCmp = caseSensitive ? hay : hay.toLowerCase();
|
|
82
|
-
const nCmp = caseSensitive ? needle : needle.toLowerCase();
|
|
84
|
+
function scanForward(nCmp, hayCmp) {
|
|
83
85
|
let hi = 0;
|
|
84
|
-
let firstAt = -1;
|
|
85
86
|
|
|
86
87
|
for (let ni = 0; ni < nCmp.length; ni++) {
|
|
87
88
|
hi = hayCmp.indexOf(nCmp[ni], hi);
|
|
88
89
|
|
|
89
|
-
if (hi < 0) return
|
|
90
|
-
|
|
91
|
-
if (firstAt < 0) firstAt = hi;
|
|
90
|
+
if (hi < 0) return { failAt: ni };
|
|
92
91
|
hi++;
|
|
93
92
|
}
|
|
94
93
|
|
|
95
|
-
|
|
94
|
+
return { end: hi, failAt: -1 };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Greedy forward match with backward tightening (fzf v1). Returns null or
|
|
99
|
+
* { score, start, end }. Score: +16 boundary, +8 consecutive, +4 case match, −1 per gap char.
|
|
100
|
+
* Lowered strings arrive precomputed: the needle once per query, the haystack
|
|
101
|
+
* once per path — never re-lowered per part or per typo variant.
|
|
102
|
+
*/
|
|
103
|
+
function matchOnce(part, pCmp, hay, hayCmp) {
|
|
104
|
+
const scan = scanForward(pCmp, hayCmp);
|
|
105
|
+
|
|
106
|
+
if (scan.failAt >= 0) return null;
|
|
107
|
+
const end = scan.end;
|
|
96
108
|
// Tighten: walk backwards from end to find the latest possible start.
|
|
97
109
|
let start = end;
|
|
98
110
|
|
|
99
|
-
for (let ni =
|
|
100
|
-
start = hayCmp.lastIndexOf(
|
|
111
|
+
for (let ni = pCmp.length - 1; ni >= 0; ni--) {
|
|
112
|
+
start = hayCmp.lastIndexOf(pCmp[ni], start - 1);
|
|
101
113
|
}
|
|
102
114
|
|
|
103
|
-
return { score: scoreAlignment(
|
|
115
|
+
return { score: scoreAlignment(part, pCmp, hay, hayCmp, start), start, end };
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
/** +16 boundary, +8 consecutive, +4 exact-case, −1 per skipped haystack char. */
|
|
@@ -122,9 +134,14 @@ function scoreAlignment(needle, nCmp, hay, hayCmp, start) {
|
|
|
122
134
|
return score;
|
|
123
135
|
}
|
|
124
136
|
|
|
125
|
-
function considerShorter(
|
|
126
|
-
for (let i = 0; i
|
|
127
|
-
const m = visit(
|
|
137
|
+
function considerShorter(sub, subCmp, subLower, typosLeft, visit, best, maxDel) {
|
|
138
|
+
for (let i = 0; i <= maxDel; i++) {
|
|
139
|
+
const m = visit(
|
|
140
|
+
sub.slice(0, i) + sub.slice(i + 1),
|
|
141
|
+
subCmp.slice(0, i) + subCmp.slice(i + 1),
|
|
142
|
+
subLower.slice(0, i) + subLower.slice(i + 1),
|
|
143
|
+
typosLeft - 1,
|
|
144
|
+
);
|
|
128
145
|
|
|
129
146
|
if (!m) continue;
|
|
130
147
|
const scored = { ...m, score: m.score - 12, typos: m.typos + 1, exact: false };
|
|
@@ -135,46 +152,95 @@ function considerShorter(part, typosLeft, visit, best) {
|
|
|
135
152
|
return best;
|
|
136
153
|
}
|
|
137
154
|
|
|
138
|
-
|
|
155
|
+
// Failure pruning (exact, not heuristic): a deletion strictly after the
|
|
156
|
+
// fail index preserves the failing prefix, so that child fails too — and
|
|
157
|
+
// every deeper success deletes an early char first, which the unpruned
|
|
158
|
+
// order reaches with the same typo count via memo. On success all
|
|
159
|
+
// deletions are still explored (a shorter variant can outscore the -12).
|
|
160
|
+
// This turns full-miss retries from O(len^typos) attempts into O(len×typos).
|
|
161
|
+
function matchWithTypos(part, pCmp, partLower, hay, hayCmp, hayLowerOrNull, maxTypos) {
|
|
139
162
|
const memo = new Map();
|
|
163
|
+
let hayLower = hayLowerOrNull;
|
|
140
164
|
|
|
141
|
-
const visit = (
|
|
142
|
-
const key =
|
|
165
|
+
const visit = (sub, subCmp, subLower, typosLeft) => {
|
|
166
|
+
const key = sub + "\0" + typosLeft;
|
|
143
167
|
|
|
144
168
|
if (memo.has(key)) return memo.get(key);
|
|
145
|
-
|
|
169
|
+
const scan = scanForward(subCmp, hayCmp);
|
|
170
|
+
let best = null;
|
|
171
|
+
let maxDel = sub.length - 1;
|
|
172
|
+
|
|
173
|
+
if (scan.failAt < 0) {
|
|
174
|
+
let start = scan.end;
|
|
175
|
+
|
|
176
|
+
for (let ni = subCmp.length - 1; ni >= 0; ni--) {
|
|
177
|
+
start = hayCmp.lastIndexOf(subCmp[ni], start - 1);
|
|
178
|
+
}
|
|
146
179
|
|
|
147
|
-
|
|
180
|
+
if (hayLower === null) hayLower = hay.toLowerCase();
|
|
181
|
+
best = {
|
|
182
|
+
score: scoreAlignment(sub, subCmp, hay, hayCmp, start),
|
|
183
|
+
start,
|
|
184
|
+
end: scan.end,
|
|
185
|
+
typos: 0,
|
|
186
|
+
exact: hayLower === subLower,
|
|
187
|
+
};
|
|
188
|
+
} else {
|
|
189
|
+
maxDel = scan.failAt;
|
|
190
|
+
}
|
|
148
191
|
|
|
149
|
-
if (typosLeft > 0) best = considerShorter(
|
|
192
|
+
if (typosLeft > 0) best = considerShorter(sub, subCmp, subLower, typosLeft, visit, best, maxDel);
|
|
150
193
|
memo.set(key, best);
|
|
151
194
|
|
|
152
195
|
return best;
|
|
153
196
|
};
|
|
154
197
|
|
|
155
|
-
return visit(
|
|
198
|
+
return visit(part, pCmp, partLower, maxTypos);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function matchPart(part, pCmp, partLower, hay, hayCmp, hayLowerOrNull, maxTypos) {
|
|
202
|
+
const direct = matchOnce(part, pCmp, hay, hayCmp);
|
|
203
|
+
|
|
204
|
+
if (direct) {
|
|
205
|
+
const hayLower = hayLowerOrNull === null ? hay.toLowerCase() : hayLowerOrNull;
|
|
206
|
+
|
|
207
|
+
return { ...direct, typos: 0, exact: hayLower === partLower };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (maxTypos <= 0 || part.length < 3 || part.length > 128) return null;
|
|
211
|
+
|
|
212
|
+
return matchWithTypos(part, pCmp, partLower, hay, hayCmp, hayLowerOrNull, maxTypos);
|
|
156
213
|
}
|
|
157
214
|
|
|
158
215
|
/** Best match allowing up to maxTypos skipped needle characters. */
|
|
159
216
|
export function fuzzyMatch(needle, hay, { maxTypos = 0, caseSensitive = false } = {}) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (direct) return { ...direct, typos: 0, exact: hay.toLowerCase() === needle.toLowerCase() };
|
|
217
|
+
if (caseSensitive) return matchPart(needle, needle, needle.toLowerCase(), hay, hay, null, maxTypos);
|
|
163
218
|
|
|
164
|
-
|
|
219
|
+
const needleLower = needle.toLowerCase();
|
|
220
|
+
const hayLower = hay.toLowerCase();
|
|
165
221
|
|
|
166
|
-
return
|
|
222
|
+
return matchPart(needle, needleLower, needleLower, hay, hayLower, hayLower, maxTypos);
|
|
167
223
|
}
|
|
168
224
|
|
|
169
225
|
export function smartCase(query) {
|
|
170
226
|
return /[A-Z]/.test(query);
|
|
171
227
|
}
|
|
172
228
|
|
|
229
|
+
function splitDirSegs(dir) {
|
|
230
|
+
return dir.split("/").filter(Boolean);
|
|
231
|
+
}
|
|
232
|
+
|
|
173
233
|
/** fff distance penalty: directory hops from the current file's directory, floor −20. */
|
|
174
|
-
function distancePenalty(
|
|
175
|
-
if (!
|
|
176
|
-
|
|
177
|
-
|
|
234
|
+
function distancePenalty(currentSegs, candidateDir, dirCache) {
|
|
235
|
+
if (!currentSegs) return 0;
|
|
236
|
+
let b = dirCache.get(candidateDir);
|
|
237
|
+
|
|
238
|
+
if (!b) {
|
|
239
|
+
b = splitDirSegs(candidateDir);
|
|
240
|
+
dirCache.set(candidateDir, b);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const a = currentSegs;
|
|
178
244
|
let common = 0;
|
|
179
245
|
|
|
180
246
|
while (common < a.length && common < b.length && a[common] === b[common]) common++;
|
|
@@ -191,13 +257,14 @@ function partTypos(parts, ctx) {
|
|
|
191
257
|
return ctx.maxTypos ?? (parts[0].length >= 6 ? 2 : parts[0].length >= 4 ? 1 : 0);
|
|
192
258
|
}
|
|
193
259
|
|
|
194
|
-
function scoredPath(rel, parts, maxTypos, caseSensitive, ctx,
|
|
195
|
-
const
|
|
260
|
+
function scoredPath(rel, parts, partLower, maxTypos, caseSensitive, ctx, currentSegs, dirCache) {
|
|
261
|
+
const hayCmp = caseSensitive ? rel : rel.toLowerCase();
|
|
262
|
+
const matched = matchParts(parts, partLower, rel, hayCmp, caseSensitive ? null : hayCmp, maxTypos, caseSensitive);
|
|
196
263
|
|
|
197
264
|
if (!matched) return null;
|
|
198
265
|
const { base, first, exact } = matched;
|
|
199
266
|
const filenameStart = rel.lastIndexOf("/") + 1;
|
|
200
|
-
const boosts = filenameBonus(base, rel, filenameStart, first,
|
|
267
|
+
const boosts = filenameBonus(base, rel, filenameStart, first, partLower[0]) + contextBoost(base, rel, ctx) + distancePenalty(currentSegs, rel.slice(0, filenameStart), dirCache);
|
|
201
268
|
|
|
202
269
|
return { path: rel, score: base + boosts, exact, typos: first.typos };
|
|
203
270
|
}
|
|
@@ -208,11 +275,17 @@ export function rankPaths(query, paths, ctx = {}) {
|
|
|
208
275
|
if (parts.length === 0 || parts.length > 16) return [];
|
|
209
276
|
const caseSensitive = smartCase(query);
|
|
210
277
|
const maxTypos = partTypos(parts, ctx);
|
|
278
|
+
// Per-query hoists: lowered parts once (not once per path per part), the
|
|
279
|
+
// current directory split once (not once per candidate), plus a
|
|
280
|
+
// per-call cache for candidate directory segments (paths share dirs).
|
|
281
|
+
const partLower = parts.map((p) => p.toLowerCase());
|
|
211
282
|
const currentDir = ctx.currentFile ? ctx.currentFile.slice(0, ctx.currentFile.lastIndexOf("/") + 1) : "";
|
|
283
|
+
const currentSegs = currentDir ? splitDirSegs(currentDir) : null;
|
|
284
|
+
const dirCache = new Map();
|
|
212
285
|
const out = [];
|
|
213
286
|
|
|
214
287
|
for (const rel of paths) {
|
|
215
|
-
const scored = scoredPath(rel, parts, maxTypos, caseSensitive, ctx,
|
|
288
|
+
const scored = scoredPath(rel, parts, partLower, maxTypos, caseSensitive, ctx, currentSegs, dirCache);
|
|
216
289
|
|
|
217
290
|
if (scored) out.push(scored);
|
|
218
291
|
}
|
|
@@ -223,13 +296,13 @@ export function rankPaths(query, paths, ctx = {}) {
|
|
|
223
296
|
}
|
|
224
297
|
|
|
225
298
|
/** Every query part must match; later parts get at most one typo (fff narrows per part). Score is the average. */
|
|
226
|
-
function matchParts(parts, rel, maxTypos, caseSensitive) {
|
|
299
|
+
function matchParts(parts, partLower, rel, hayCmp, hayLowerOrNull, maxTypos, caseSensitive) {
|
|
227
300
|
let sum = 0;
|
|
228
301
|
let first = null;
|
|
229
302
|
let exact = true;
|
|
230
303
|
|
|
231
304
|
for (let pi = 0; pi < parts.length; pi++) {
|
|
232
|
-
const m =
|
|
305
|
+
const m = matchPart(parts[pi], caseSensitive ? parts[pi] : partLower[pi], partLower[pi], rel, hayCmp, hayLowerOrNull, pi === 0 ? maxTypos : Math.min(maxTypos, 1));
|
|
233
306
|
|
|
234
307
|
if (!m) return null;
|
|
235
308
|
first ??= m;
|
|
@@ -241,10 +314,10 @@ function matchParts(parts, rel, maxTypos, caseSensitive) {
|
|
|
241
314
|
}
|
|
242
315
|
|
|
243
316
|
/** fff: exact filename +40% of base, any filename match +20%. */
|
|
244
|
-
function filenameBonus(base, rel, filenameStart, first,
|
|
317
|
+
function filenameBonus(base, rel, filenameStart, first, needleLower) {
|
|
245
318
|
if (first.start < filenameStart) return 0;
|
|
246
319
|
|
|
247
|
-
return rel.slice(filenameStart).toLowerCase() ===
|
|
320
|
+
return rel.slice(filenameStart).toLowerCase() === needleLower ? Math.floor((base * 2) / 5) : Math.floor(base / 5);
|
|
248
321
|
}
|
|
249
322
|
|
|
250
323
|
/** fff: frecency boost base·f/100 and +15% for git-modified files. */
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import {isString} from '../shared/decode.js';
|
|
3
|
+
import {isTestPath} from '../fs/workspace.js';
|
|
4
|
+
|
|
5
|
+
const STOP_WORDS = new Set([
|
|
6
|
+
"the", "a", "an", "and", "or", "in", "on", "at", "to", "for", "of", "with",
|
|
7
|
+
"by", "from", "is", "it", "this", "that", "where", "how", "what", "which",
|
|
8
|
+
"file", "code", "function", "class", "method", "find", "get", "look", "are", "does", "do",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".rs", ".py", ".go"]);
|
|
12
|
+
|
|
13
|
+
const TYPED_EXT = new Set([".ts", ".tsx", ".rs", ".go"]);
|
|
14
|
+
|
|
15
|
+
const BUILD_DIRS = new Set(["node_modules", "dist", "target"]);
|
|
16
|
+
|
|
17
|
+
const TEST_WORDS = new Set(["test", "tests", "testing", "spec", "specs"]);
|
|
18
|
+
|
|
19
|
+
const TYPE_WORDS = new Set(["type", "types", "interface", "interfaces", "schema", "schemas"]);
|
|
20
|
+
|
|
21
|
+
const DOC_WORDS = new Set(["doc", "docs", "documentation", "readme"]);
|
|
22
|
+
|
|
23
|
+
const MAX_NEEDLE_CHARS = 128;
|
|
24
|
+
|
|
25
|
+
/** Light suffix stripping so "terminated" ⊇ "terminat" matches "terminate"; deterministic, no dictionary. */
|
|
26
|
+
export function stem(token) {
|
|
27
|
+
if (token.length < 5) return token;
|
|
28
|
+
|
|
29
|
+
return token.replace(/(ations?|ings?|ed|es|e|s|ly|ers?)$/, (m) => (token.length - m.length >= 4 ? "" : m));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function tokenizeQuery(query) {
|
|
33
|
+
if (!isString(query) || !query.trim()) return { tokens: [], wantsTest: false, wantsType: false, wantsDoc: false };
|
|
34
|
+
const words = query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-zA-Z0-9_]+/);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
tokens: [...new Set(words.filter(word => word.length > 1 && !STOP_WORDS.has(word)))],
|
|
38
|
+
wantsTest: words.some(word => TEST_WORDS.has(word)),
|
|
39
|
+
wantsType: words.some(word => TYPE_WORDS.has(word)),
|
|
40
|
+
wantsDoc: words.some(word => DOC_WORDS.has(word)),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function tokenPathScore(base, words, normalized, tokens) {
|
|
45
|
+
let score = 0;
|
|
46
|
+
|
|
47
|
+
for (const token of tokens) {
|
|
48
|
+
// base === token + "." without the concat alloc: same verdict, no garbage.
|
|
49
|
+
if (base === token || (base.length > token.length && base[token.length] === "." && base.startsWith(token))) score += 60;
|
|
50
|
+
else if (base.includes(token)) score += 30;
|
|
51
|
+
else if (words.includes(token)) score += 15;
|
|
52
|
+
else if (normalized.includes(token)) score += 5;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return score;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function topologyPenalty(normalized, flags) {
|
|
59
|
+
const parts = normalized.split("/");
|
|
60
|
+
|
|
61
|
+
if (parts.some(part => BUILD_DIRS.has(part))) return -100;
|
|
62
|
+
const test = isTestPath(normalized);
|
|
63
|
+
|
|
64
|
+
if (test && !flags.wantsTest) return -50;
|
|
65
|
+
if (!test && flags.wantsTest) return -20;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function scorePathTopology(filePath, tokens, flags) {
|
|
69
|
+
const normalized = filePath.replaceAll("\\", "/").toLowerCase();
|
|
70
|
+
const penalty = topologyPenalty(normalized, flags);
|
|
71
|
+
|
|
72
|
+
if (penalty !== undefined) return penalty;
|
|
73
|
+
const ext = path.extname(normalized);
|
|
74
|
+
let score = SOURCE_EXT.has(ext) && !flags.wantsDoc ? 5 : 0;
|
|
75
|
+
|
|
76
|
+
if (flags.wantsType && TYPED_EXT.has(ext)) score += 10;
|
|
77
|
+
|
|
78
|
+
return score + tokenPathScore(path.basename(normalized), normalized.split(/[^a-zA-Z0-9]+/), normalized, tokens);
|
|
79
|
+
}
|
|
80
|
+
export { SOURCE_EXT, MAX_NEEDLE_CHARS };
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
export {globToRegExp} from '../fs/glob.js';
|
|
2
|
+
export {declaredName} from './source-entry.js';
|
|
3
|
+
import {fromText,linesOf,spansOf,surfaceOf} from './source-entry.js';
|
|
1
4
|
import * as fs from "node:fs";
|
|
2
5
|
import * as path from "node:path";
|
|
3
|
-
|
|
6
|
+
|
|
4
7
|
import { isFunction } from "../shared/decode.js";
|
|
5
8
|
import { Frecency } from "./fuzzy.js";
|
|
6
9
|
import { relativeSlash } from "../fs/workspace.js";
|
|
@@ -29,126 +32,10 @@ const BINARY_EXT = new Set([
|
|
|
29
32
|
".jar", ".so", ".dylib", ".dll", ".exe", ".bin", ".o", ".a", ".node", ".lock", ".sqlite", ".sqlite3", ".db",
|
|
30
33
|
]);
|
|
31
34
|
|
|
32
|
-
const REGEX_SPECIAL = /[.+^${}()|\\]/g;
|
|
33
|
-
|
|
34
|
-
const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
|
|
35
|
-
|
|
36
|
-
const EMPTY = Object.freeze([]);
|
|
37
|
-
|
|
38
|
-
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_$]+)|([A-Z][A-Z0-9_$]*)\s*(?::[^=\n]+)?=)/;
|
|
39
|
-
|
|
40
|
-
/** Declared identifier on a line (function/class/UPPER_CASE constant/…), or ""; the same rule snap and grep use. */
|
|
41
|
-
export function declaredName(line) {
|
|
42
|
-
const match = DEF_PATTERN.exec(String(line).trim());
|
|
43
|
-
|
|
44
|
-
return match?.[2] ?? match?.[3] ?? "";
|
|
45
|
-
}
|
|
46
|
-
|
|
47
35
|
function isTextCandidate(filePath) {
|
|
48
36
|
return !BINARY_EXT.has(path.extname(filePath).toLowerCase());
|
|
49
37
|
}
|
|
50
38
|
|
|
51
|
-
/** Translate one glob token at index i → [regexSource, nextIndex]. */
|
|
52
|
-
function globToken(glob, i) {
|
|
53
|
-
const ch = glob[i];
|
|
54
|
-
|
|
55
|
-
if (ch === "*" && glob[i + 1] === "*") {
|
|
56
|
-
const slashAfter = glob[i + 2] === "/";
|
|
57
|
-
|
|
58
|
-
return [slashAfter ? "(?:.*/)?" : ".*", i + (slashAfter ? 3 : 2)];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (ch === "*") return ["[^/]*", i + 1];
|
|
62
|
-
|
|
63
|
-
if (ch === "?") return ["[^/]", i + 1];
|
|
64
|
-
|
|
65
|
-
if (ch === "{" || ch === "[") return globGroup(glob, i, ch);
|
|
66
|
-
|
|
67
|
-
return [ch.replace(REGEX_SPECIAL, "\\$&"), i + 1];
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** {a,b} alternation or [..] class starting at i. */
|
|
71
|
-
function globGroup(glob, i, open) {
|
|
72
|
-
const close = open === "{" ? "}" : "]";
|
|
73
|
-
const end = glob.indexOf(close, i);
|
|
74
|
-
|
|
75
|
-
if (end < 0) throw new SyntaxError("unclosed " + open + " in glob");
|
|
76
|
-
const inner = glob.slice(i + 1, end);
|
|
77
|
-
const source = open === "{"
|
|
78
|
-
? "(?:" + inner.split(",").map(globBody).join("|") + ")"
|
|
79
|
-
: "[" + (inner.startsWith("!") ? "^" + inner.slice(1) : inner) + "]";
|
|
80
|
-
|
|
81
|
-
return [source, end + 1];
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function globBody(glob) {
|
|
85
|
-
let source = "";
|
|
86
|
-
let i = 0;
|
|
87
|
-
|
|
88
|
-
while (i < glob.length) {
|
|
89
|
-
const [piece, next] = globToken(glob, i);
|
|
90
|
-
source += piece;
|
|
91
|
-
i = next;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return source;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** gitignore-style glob (rg -g) → RegExp over a "/"-separated relative path. No slash ⇒ basename match anywhere. */
|
|
98
|
-
export function globToRegExp(glob) {
|
|
99
|
-
const body = globBody(glob);
|
|
100
|
-
|
|
101
|
-
return new RegExp(glob.includes("/") ? "^" + body + "$" : "(?:^|/)" + body + "$");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function lineIndent(raw, i) {
|
|
105
|
-
return raw[i].length - raw[i].trimStart().length;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function pythonDeclarationEnd(raw, lower, start, lineCount) {
|
|
109
|
-
const base = lineIndent(raw, start - 1);
|
|
110
|
-
let end = start;
|
|
111
|
-
|
|
112
|
-
for (let i = start; i < lineCount; i++) {
|
|
113
|
-
if (lower[i] === "") { end = i + 1; continue; }
|
|
114
|
-
if (lineIndent(raw, i) <= base) break;
|
|
115
|
-
end = i + 1;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return Math.min(end, lineCount);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function braceDelta(text) {
|
|
122
|
-
let depth = 0;
|
|
123
|
-
|
|
124
|
-
for (const ch of text) {
|
|
125
|
-
if (ch === "{") depth++;
|
|
126
|
-
else if (ch === "}") depth--;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return depth;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function braceDeclarationEnd(raw, start, lineCount) {
|
|
133
|
-
let depth = braceDelta(raw[start - 1] ?? "");
|
|
134
|
-
|
|
135
|
-
if (depth <= 0) return start;
|
|
136
|
-
|
|
137
|
-
for (let i = start; i < raw.length; i++) {
|
|
138
|
-
depth += braceDelta(raw[i]);
|
|
139
|
-
|
|
140
|
-
if (depth <= 0) return i + 1;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return lineCount;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function declarationEnd(raw, lower, start, lineCount, ext) {
|
|
147
|
-
if (ext === ".py") return pythonDeclarationEnd(raw, lower, start, lineCount);
|
|
148
|
-
|
|
149
|
-
return braceDeclarationEnd(raw, start, lineCount);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
39
|
function readFdBuffer(fd, buffer) {
|
|
153
40
|
let offset = 0;
|
|
154
41
|
|
|
@@ -226,6 +113,19 @@ function grepEntryRows(e, filePath, root, regex, nameRegex, out) {
|
|
|
226
113
|
}
|
|
227
114
|
}
|
|
228
115
|
|
|
116
|
+
// One alternation scan per file instead of one full scan per needle — same
|
|
117
|
+
// verdict as needles.some(includes). Needles are escaped: they arrive as
|
|
118
|
+
// literals that may carry regex syntax.
|
|
119
|
+
function anyOfProbe(needles) {
|
|
120
|
+
return new RegExp(needles.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fileMatchesNeedles(entry, needles, anyOf, probe) {
|
|
124
|
+
if (probe) return probe.test(entry.lower);
|
|
125
|
+
|
|
126
|
+
return anyOf ? needles.some((n) => entry.lower.includes(n)) : needles.every((n) => entry.lower.includes(n));
|
|
127
|
+
}
|
|
128
|
+
|
|
229
129
|
export class WorkspaceIndex {
|
|
230
130
|
constructor(runCommand) {
|
|
231
131
|
this.runCommand = runCommand;
|
|
@@ -450,59 +350,18 @@ export class WorkspaceIndex {
|
|
|
450
350
|
return this.storeCreated(filePath, loaded);
|
|
451
351
|
}
|
|
452
352
|
|
|
453
|
-
static fromText(filePath, text) {
|
|
454
|
-
return { text, lower: text.toLowerCase(), ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
|
|
455
|
-
}
|
|
353
|
+
static fromText(filePath, text) { return fromText(filePath, text); }
|
|
456
354
|
|
|
457
355
|
/** Per-line raw text, lowercase text, declared identifier (or ""), and identifier tokens, computed once per entry. */
|
|
458
|
-
static linesOf(entry) {
|
|
459
|
-
if (entry.lines) return entry.lines;
|
|
460
|
-
const raw = entry.text.split("\n");
|
|
461
|
-
const lower = [];
|
|
462
|
-
const defNames = [];
|
|
463
|
-
const idents = [];
|
|
464
|
-
|
|
465
|
-
for (let i = 0; i < raw.length; i++) {
|
|
466
|
-
const trimmed = raw[i].trim();
|
|
467
|
-
lower[i] = trimmed.toLowerCase();
|
|
468
|
-
const declared = DEF_PATTERN.exec(trimmed);
|
|
469
|
-
defNames[i] = (declared?.[2] ?? declared?.[3] ?? "").toLowerCase();
|
|
470
|
-
idents[i] = trimmed.match(IDENT_TOKEN) || EMPTY;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
entry.lines = { raw, lower, defNames, idents };
|
|
474
|
-
|
|
475
|
-
return entry.lines;
|
|
476
|
-
}
|
|
356
|
+
static linesOf(entry) { return linesOf(entry); }
|
|
477
357
|
|
|
478
358
|
/**
|
|
479
359
|
* Declaration spans [start, end] (1-based, inclusive). Nested bodies stay inside the parent
|
|
480
360
|
* (brace-matched for JS-like, indent for Python). The file's leading header is not a span.
|
|
481
361
|
*/
|
|
482
|
-
static spansOf(entry) {
|
|
483
|
-
if (entry.spans) return entry.spans;
|
|
484
|
-
const { items, lineCount } = WorkspaceIndex.surfaceOf(entry);
|
|
485
|
-
const { lower, raw } = WorkspaceIndex.linesOf(entry);
|
|
486
|
-
const spans = [];
|
|
487
|
-
|
|
488
|
-
for (let i = 0; i < items.length; i++) {
|
|
489
|
-
const start = items[i].line;
|
|
490
|
-
let end = declarationEnd(raw, lower, start, lineCount, entry.ext);
|
|
491
|
-
|
|
492
|
-
while (end > start && lower[end - 1] === "") end--;
|
|
493
|
-
spans.push({ start, end, name: items[i].name, kind: items[i].kind, isExport: items[i].isExport === true });
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
entry.spans = spans;
|
|
497
|
-
|
|
498
|
-
return spans;
|
|
499
|
-
}
|
|
362
|
+
static spansOf(entry) { return spansOf(entry); }
|
|
500
363
|
|
|
501
|
-
static surfaceOf(entry) {
|
|
502
|
-
if (!entry.surface) entry.surface = extractStructuralSurface(entry.text, entry.ext);
|
|
503
|
-
|
|
504
|
-
return entry.surface;
|
|
505
|
-
}
|
|
364
|
+
static surfaceOf(entry) { return surfaceOf(entry); }
|
|
506
365
|
|
|
507
366
|
/** True when the list is small enough to scan in-process instead of spawning rg. */
|
|
508
367
|
canScan(files) {
|
|
@@ -512,14 +371,12 @@ export class WorkspaceIndex {
|
|
|
512
371
|
/** Files whose lowercase text contains any (or every) needle; needles are lowercase. */
|
|
513
372
|
filesContaining(files, needles, anyOf) {
|
|
514
373
|
const hits = [];
|
|
374
|
+
const probe = anyOf && needles.length > 1 ? anyOfProbe(needles) : null;
|
|
515
375
|
|
|
516
376
|
for (const filePath of files) {
|
|
517
377
|
const e = this.entry(filePath);
|
|
518
378
|
|
|
519
|
-
if (
|
|
520
|
-
const found = anyOf ? needles.some((n) => e.lower.includes(n)) : needles.every((n) => e.lower.includes(n));
|
|
521
|
-
|
|
522
|
-
if (found) hits.push(filePath);
|
|
379
|
+
if (e && fileMatchesNeedles(e, needles, anyOf, probe)) hits.push(filePath);
|
|
523
380
|
}
|
|
524
381
|
|
|
525
382
|
return hits;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import {WorkspaceIndex} from './repo-index.js';
|
|
3
|
+
|
|
4
|
+
function pendingInScope(root, pendingPaths) {
|
|
5
|
+
return pendingPaths.filter(file => {
|
|
6
|
+
const relative = path.relative(root, file);
|
|
7
|
+
|
|
8
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative));
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function overlaySearchEntry(index, filePath, overlayText) {
|
|
13
|
+
const pending = overlayText(filePath);
|
|
14
|
+
|
|
15
|
+
return pending === undefined
|
|
16
|
+
? index.entry(filePath)
|
|
17
|
+
: Buffer.byteLength(pending, "utf8") <= 512 * 1024 ? WorkspaceIndex.fromText(filePath, pending) : null;
|
|
18
|
+
}
|
|
19
|
+
export { pendingInScope, overlaySearchEntry };
|
package/src/context/search.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {textResult} from '../shared/result.js';
|
|
2
|
+
import {pendingInScope,overlaySearchEntry} from './search-files.js';
|
|
1
3
|
import * as fs from "node:fs/promises";
|
|
2
4
|
import * as path from "node:path";
|
|
3
5
|
import { isString } from "../shared/decode.js";
|
|
@@ -5,14 +7,6 @@ import { WorkspaceIndex, globToRegExp } from "./repo-index.js";
|
|
|
5
7
|
import { rankPaths, smartCase, fuzzyMatch } from "./fuzzy.js";
|
|
6
8
|
import { runCommand, relativeSlash } from "../fs/workspace.js";
|
|
7
9
|
|
|
8
|
-
// Search served from the in-process index: fuzzy path find (fff port), smart-case grep with
|
|
9
|
-
// definition-first rows and fuzzy fallback, glob listing. rg is spawned only for trees too
|
|
10
|
-
// large to scan in-process.
|
|
11
|
-
|
|
12
|
-
function textResult(text, details) {
|
|
13
|
-
return { content: [{ type: "text", text: String(text ?? "") }], details: details || {} };
|
|
14
|
-
}
|
|
15
|
-
|
|
16
10
|
async function candidateFileList(index, root, includeHidden = false, signal) {
|
|
17
11
|
const stat = await fs.stat(root).catch(() => null);
|
|
18
12
|
|
|
@@ -21,14 +15,6 @@ async function candidateFileList(index, root, includeHidden = false, signal) {
|
|
|
21
15
|
return index.files(root, includeHidden, signal);
|
|
22
16
|
}
|
|
23
17
|
|
|
24
|
-
function pendingInScope(root, pendingPaths) {
|
|
25
|
-
return pendingPaths.filter(file => {
|
|
26
|
-
const relative = path.relative(root, file);
|
|
27
|
-
|
|
28
|
-
return relative === "" || (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative));
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
18
|
function parseMatchRecord(line, truncated, isLast) {
|
|
33
19
|
if (!line) return { skip: true };
|
|
34
20
|
|
|
@@ -248,14 +234,6 @@ function grepRegex(pattern, params) {
|
|
|
248
234
|
}
|
|
249
235
|
}
|
|
250
236
|
|
|
251
|
-
function overlaySearchEntry(index, filePath, overlayText) {
|
|
252
|
-
const pending = overlayText(filePath);
|
|
253
|
-
|
|
254
|
-
return pending === undefined
|
|
255
|
-
? index.entry(filePath)
|
|
256
|
-
: Buffer.byteLength(pending, "utf8") <= 512 * 1024 ? WorkspaceIndex.fromText(filePath, pending) : null;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
237
|
function fuzzyLineRow(pattern, rawLine, defName, rel, line, maxTypos, caseSensitive) {
|
|
260
238
|
const m = fuzzyMatch(pattern, rawLine, { maxTypos, caseSensitive });
|
|
261
239
|
|