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.
Files changed (67) hide show
  1. package/README.md +188 -51
  2. package/docs/CHANGELOG.md +86 -1
  3. package/docs/TOKEN_COSTS.md +38 -0
  4. package/index.js +10 -175
  5. package/package.json +2 -1
  6. package/src/adapters/bash.js +14 -30
  7. package/src/adapters/errors.js +1 -9
  8. package/src/adapters/read-focus.js +98 -0
  9. package/src/adapters/read-image.js +51 -0
  10. package/src/adapters/read-json.js +42 -0
  11. package/src/adapters/read-text.js +71 -0
  12. package/src/adapters/read.js +66 -635
  13. package/src/bridge/catalog.js +3 -2
  14. package/src/bridge/host-bridge.js +35 -167
  15. package/src/bridge/tool-registry.js +104 -0
  16. package/src/bridge/trace.js +41 -0
  17. package/src/context/evidence-graph.js +249 -0
  18. package/src/context/evidence-rank.js +153 -0
  19. package/src/context/evidence.js +10 -424
  20. package/src/context/fuzzy.js +116 -43
  21. package/src/context/query.js +80 -0
  22. package/src/context/repo-index.js +23 -166
  23. package/src/context/search-files.js +19 -0
  24. package/src/context/search.js +2 -24
  25. package/src/context/snap-search.js +203 -0
  26. package/src/context/snap.js +5 -266
  27. package/src/context/source-entry.js +112 -0
  28. package/src/contract/bash.js +6 -1
  29. package/src/contract/program.js +36 -0
  30. package/src/contract/read.js +8 -53
  31. package/src/fs/check.js +1 -1
  32. package/src/fs/commit.js +161 -0
  33. package/src/fs/diff.js +11 -15
  34. package/src/fs/directory.js +79 -0
  35. package/src/fs/file-io.js +100 -0
  36. package/src/fs/glob.js +54 -0
  37. package/src/fs/json-size.js +54 -0
  38. package/src/fs/lines.js +117 -0
  39. package/src/fs/read-window.js +74 -0
  40. package/src/fs/session-resource.js +50 -0
  41. package/src/fs/text-ops.js +7 -227
  42. package/src/fs/vfs.js +5 -239
  43. package/src/fs/workspace.js +2 -1
  44. package/src/output/bottleneck.js +13 -67
  45. package/src/output/final.js +114 -0
  46. package/src/output/format.js +94 -5
  47. package/src/output/outcome.js +91 -0
  48. package/src/runtime/batch-input.js +68 -0
  49. package/src/runtime/guest-api.js +281 -0
  50. package/src/runtime/guest-worker.js +62 -333
  51. package/src/runtime/parallel.js +41 -39
  52. package/src/runtime/program-batch.js +21 -75
  53. package/src/runtime/program-file.js +3 -11
  54. package/src/runtime/program.js +141 -0
  55. package/src/runtime/reference.js +6 -5
  56. package/src/runtime/runtime.js +77 -253
  57. package/src/runtime/worker-pool.js +91 -0
  58. package/src/shared/decode.js +22 -8
  59. package/src/shared/image-worker.js +30 -0
  60. package/src/shared/image.js +78 -0
  61. package/src/shared/png.js +57 -0
  62. package/src/shared/result.js +77 -0
  63. package/src/shared/syntax-context.js +61 -3
  64. package/src/ui/host-render.js +104 -0
  65. package/src/ui/progress.js +51 -0
  66. package/src/ui/render.js +21 -421
  67. package/src/ui/trace.js +277 -0
@@ -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
- for (const t of this.access.get(filePath) || []) {
45
- if (t < cutoff) continue;
46
- total += Math.exp(-AI_DECAY * ((now - t) / 86400));
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 with backward tightening (fzf v1). Returns null or
78
- * { score, start, end }. Score: +16 boundary, +8 consecutive, +4 case match, −1 per gap char.
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 matchOnce(needle, hay, caseSensitive) {
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 null;
90
-
91
- if (firstAt < 0) firstAt = hi;
90
+ if (hi < 0) return { failAt: ni };
92
91
  hi++;
93
92
  }
94
93
 
95
- const end = hi;
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 = nCmp.length - 1; ni >= 0; ni--) {
100
- start = hayCmp.lastIndexOf(nCmp[ni], start - 1);
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(needle, nCmp, hay, hayCmp, start), start, end };
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(part, typosLeft, visit, best) {
126
- for (let i = 0; i < part.length; i++) {
127
- const m = visit(part.slice(0, i) + part.slice(i + 1), typosLeft - 1);
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
- function matchWithTypos(needle, hay, maxTypos, caseSensitive) {
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 = (part, typosLeft) => {
142
- const key = part + "\0" + typosLeft;
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
- let best = matchOnce(part, hay, caseSensitive);
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
- if (best) best = { ...best, typos: 0, exact: hay.toLowerCase() === part.toLowerCase() };
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(part, typosLeft, visit, best);
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(needle, maxTypos);
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
- const direct = matchOnce(needle, hay, caseSensitive);
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
- if (maxTypos <= 0 || needle.length < 3 || needle.length > 128) return null;
219
+ const needleLower = needle.toLowerCase();
220
+ const hayLower = hay.toLowerCase();
165
221
 
166
- return matchWithTypos(needle, hay, maxTypos, caseSensitive);
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(currentDir, candidateDir) {
175
- if (!currentDir) return 0;
176
- const a = currentDir.split("/").filter(Boolean);
177
- const b = candidateDir.split("/").filter(Boolean);
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, currentDir) {
195
- const matched = matchParts(parts, rel, maxTypos, caseSensitive);
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, parts[0]) + contextBoost(base, rel, ctx) + distancePenalty(currentDir, rel.slice(0, filenameStart));
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, currentDir);
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 = fuzzyMatch(parts[pi], rel, { maxTypos: pi === 0 ? maxTypos : Math.min(maxTypos, 1), caseSensitive });
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, needle) {
317
+ function filenameBonus(base, rel, filenameStart, first, needleLower) {
245
318
  if (first.start < filenameStart) return 0;
246
319
 
247
- return rel.slice(filenameStart).toLowerCase() === needle.toLowerCase() ? Math.floor((base * 2) / 5) : Math.floor(base / 5);
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
- import { extractStructuralSurface } from "./surface.js";
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 (!e) continue;
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 };
@@ -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