pi-supernova 0.8.2 → 0.9.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 +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/query.js +71 -0
- package/src/context/repo-index.js +8 -162
- package/src/context/search-files.js +19 -0
- package/src/context/search.js +2 -24
- package/src/context/snap-search.js +202 -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
|
@@ -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
|
|
|
@@ -450,59 +337,18 @@ export class WorkspaceIndex {
|
|
|
450
337
|
return this.storeCreated(filePath, loaded);
|
|
451
338
|
}
|
|
452
339
|
|
|
453
|
-
static fromText(filePath, text) {
|
|
454
|
-
return { text, lower: text.toLowerCase(), ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
|
|
455
|
-
}
|
|
340
|
+
static fromText(filePath, text) { return fromText(filePath, text); }
|
|
456
341
|
|
|
457
342
|
/** 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
|
-
}
|
|
343
|
+
static linesOf(entry) { return linesOf(entry); }
|
|
477
344
|
|
|
478
345
|
/**
|
|
479
346
|
* Declaration spans [start, end] (1-based, inclusive). Nested bodies stay inside the parent
|
|
480
347
|
* (brace-matched for JS-like, indent for Python). The file's leading header is not a span.
|
|
481
348
|
*/
|
|
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
|
-
}
|
|
349
|
+
static spansOf(entry) { return spansOf(entry); }
|
|
495
350
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
return spans;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
static surfaceOf(entry) {
|
|
502
|
-
if (!entry.surface) entry.surface = extractStructuralSurface(entry.text, entry.ext);
|
|
503
|
-
|
|
504
|
-
return entry.surface;
|
|
505
|
-
}
|
|
351
|
+
static surfaceOf(entry) { return surfaceOf(entry); }
|
|
506
352
|
|
|
507
353
|
/** True when the list is small enough to scan in-process instead of spawning rg. */
|
|
508
354
|
canScan(files) {
|
|
@@ -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
|
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import {isString} from '../shared/decode.js';
|
|
3
|
+
import {truncateChars} from '../output/format.js';
|
|
4
|
+
import {extractStructuralSurface} from './surface.js';
|
|
5
|
+
import {scorePathTopology,stem,SOURCE_EXT,MAX_NEEDLE_CHARS} from './query.js';
|
|
6
|
+
|
|
7
|
+
const MAX_SEARCH_CHARS = 2 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function inScope(filePath, dir, includeHidden) {
|
|
10
|
+
const relative = path.relative(dir, filePath);
|
|
11
|
+
|
|
12
|
+
if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) return false;
|
|
13
|
+
const parts = relative.split(path.sep);
|
|
14
|
+
|
|
15
|
+
return !parts.includes(".git") && (includeHidden || !parts.some(part => part.startsWith(".") && part.length > 1));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeCandidate(filePath, dir, query, tokens, flags) {
|
|
19
|
+
const relative = path.relative(dir, filePath);
|
|
20
|
+
const lower = relative.toLowerCase();
|
|
21
|
+
const base = path.basename(lower);
|
|
22
|
+
|
|
23
|
+
const extension = path.extname(base);
|
|
24
|
+
const stemBase = extension ? base.slice(0, -extension.length) : base;
|
|
25
|
+
const exactPath = lower === query.toLowerCase() || base === query.toLowerCase()
|
|
26
|
+
|| stemBase === query.toLowerCase();
|
|
27
|
+
|
|
28
|
+
const needles = tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
|
|
29
|
+
|
|
30
|
+
return { path: filePath, pathScore: scorePathTopology(relative, tokens, flags), exactPath,
|
|
31
|
+
pathCoverage: tokens.filter((token, index) => lower.includes(needles[index] ?? token)).length,
|
|
32
|
+
matched: new Set(), exactDefinition: false, definitionCoverage: 0, lineCoverage: 0,
|
|
33
|
+
line: 1, signature: "", context: new Map(), recent: [], anchorScore: -1, exactLines: new Set() };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function bestDeclaration(items, query, tokens, needles) {
|
|
37
|
+
let declaration;
|
|
38
|
+
let definitionCoverage = 0;
|
|
39
|
+
let exact = false;
|
|
40
|
+
const queryLower = query.toLowerCase();
|
|
41
|
+
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
const name = item.name.toLowerCase();
|
|
44
|
+
const itemExact = name === queryLower;
|
|
45
|
+
const coverage = tokens.filter((token, index) => name.includes(needles[index] ?? token)).length;
|
|
46
|
+
|
|
47
|
+
if (itemExact || coverage > definitionCoverage) { declaration = item; definitionCoverage = coverage; exact = itemExact; }
|
|
48
|
+
if (exact) break;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { declaration, definitionCoverage, exact };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function applyMatch(candidate, lineNumber, text, query, tokens, needles, lower) {
|
|
55
|
+
const matches = tokens.filter((token, index) => lower.includes(needles[index] ?? token));
|
|
56
|
+
|
|
57
|
+
for (const token of matches) candidate.matched.add(token);
|
|
58
|
+
const ext = path.extname(candidate.path).toLowerCase();
|
|
59
|
+
const items = SOURCE_EXT.has(ext) ? extractStructuralSurface(text, ext).items : [];
|
|
60
|
+
const { declaration, definitionCoverage, exact } = bestDeclaration(items, query, tokens, needles);
|
|
61
|
+
const score = (exact ? 10000 : 0) + definitionCoverage * 40 + matches.length;
|
|
62
|
+
|
|
63
|
+
if (exact) candidate.exactLines.add(lineNumber);
|
|
64
|
+
|
|
65
|
+
if (score > candidate.anchorScore) {
|
|
66
|
+
candidate.anchorScore = score;
|
|
67
|
+
candidate.line = lineNumber;
|
|
68
|
+
candidate.signature = truncateChars(declaration?.signature ?? "", 240, "signature").text;
|
|
69
|
+
candidate.exactDefinition = exact;
|
|
70
|
+
candidate.definitionCoverage = definitionCoverage;
|
|
71
|
+
candidate.lineCoverage = matches.length;
|
|
72
|
+
candidate.context.clear();
|
|
73
|
+
|
|
74
|
+
for (const [number, line] of candidate.recent) if (number >= lineNumber - 2) candidate.context.set(number, line);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function inspectLine(candidate, lineNumber, raw, query, tokens, needles, isMatch) {
|
|
79
|
+
const text = raw.replace(/\r?\n$/, "");
|
|
80
|
+
const lower = text.toLowerCase();
|
|
81
|
+
|
|
82
|
+
if (isMatch) applyMatch(candidate, lineNumber, text, query, tokens, needles, lower);
|
|
83
|
+
const excerpt = truncateChars(text, 240, "source line").text;
|
|
84
|
+
|
|
85
|
+
if (lineNumber >= candidate.line - 2 && lineNumber <= candidate.line + 4) candidate.context.set(lineNumber, excerpt);
|
|
86
|
+
candidate.recent.push([lineNumber, excerpt]);
|
|
87
|
+
|
|
88
|
+
if (candidate.recent.length > 2) candidate.recent.shift();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function inspectOverlay(candidate, text, needles, query, tokens, signal) {
|
|
92
|
+
let start = 0, line = 1, truncated = false;
|
|
93
|
+
|
|
94
|
+
// Keep only the candidate and its short context, not another copy of every
|
|
95
|
+
// line in a staged document. Oversized individual lines disclose uncertainty.
|
|
96
|
+
while (start < text.length) {
|
|
97
|
+
if ((line & 127) === 0) signal?.throwIfAborted();
|
|
98
|
+
const newline = text.indexOf("\n", start);
|
|
99
|
+
const end = newline < 0 ? text.length : newline + 1;
|
|
100
|
+
|
|
101
|
+
if (end - start > MAX_SEARCH_CHARS) truncated = true;
|
|
102
|
+
else {
|
|
103
|
+
const row = text.slice(start, end);
|
|
104
|
+
const lower = row.toLowerCase();
|
|
105
|
+
inspectLine(candidate, line, row, query, tokens, needles, needles.some(needle => lower.includes(needle)));
|
|
106
|
+
}
|
|
107
|
+
start = end;
|
|
108
|
+
line++;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return truncated;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseRgRecord(line, truncated, isLast) {
|
|
115
|
+
if (!line) return null;
|
|
116
|
+
|
|
117
|
+
try { return JSON.parse(line); } catch (error) {
|
|
118
|
+
if (truncated && isLast) return undefined;
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function absorbRgHit(candidates, record, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles) {
|
|
124
|
+
if (record.type !== "match" && record.type !== "context") return;
|
|
125
|
+
const data = record.data;
|
|
126
|
+
|
|
127
|
+
if (!data?.path?.text || !isString(data.lines?.text)) return;
|
|
128
|
+
const filePath = path.resolve(dir, data.path.text);
|
|
129
|
+
|
|
130
|
+
if (!inScope(filePath, dir, includeHidden) || overlayText(filePath) !== undefined) return;
|
|
131
|
+
let candidate = candidates.get(filePath);
|
|
132
|
+
|
|
133
|
+
if (!candidate) {
|
|
134
|
+
candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
|
|
135
|
+
candidates.set(filePath, candidate);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
inspectLine(candidate, data.line_number, data.lines.text, query, tokens, needles, record.type === "match");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, signal) {
|
|
142
|
+
let overlayTruncated = false;
|
|
143
|
+
|
|
144
|
+
for (const filePath of pendingPaths) {
|
|
145
|
+
const pending = overlayText(filePath);
|
|
146
|
+
|
|
147
|
+
if (pending === undefined) continue;
|
|
148
|
+
const candidate = makeCandidate(filePath, candidateRoot, query, tokens, flags);
|
|
149
|
+
overlayTruncated = inspectOverlay(candidate, pending, needles, query, tokens, signal) || overlayTruncated;
|
|
150
|
+
|
|
151
|
+
if (candidate.matched.size) candidates.set(filePath, candidate);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return overlayTruncated;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function rgSearchArgs(includeHidden, searchNeedles, focusFile, dir) {
|
|
158
|
+
const args = ["rg", "--json", "--fixed-strings", "--ignore-case", "--before-context", "2", "--after-context", "4"];
|
|
159
|
+
|
|
160
|
+
if (includeHidden) args.push("--hidden");
|
|
161
|
+
args.push("-g", "!.git/**", "-g", "!**/.git/**");
|
|
162
|
+
for (const needle of searchNeedles) args.push("-e", needle);
|
|
163
|
+
args.push("--", focusFile ?? dir);
|
|
164
|
+
|
|
165
|
+
return args;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function runContentSearch({ dir, includeHidden, searchNeedles, run, overlayText, signal, diskFiles, focusFile }) {
|
|
169
|
+
const args = rgSearchArgs(includeHidden, searchNeedles, focusFile, dir);
|
|
170
|
+
const response = diskFiles || (focusFile && overlayText(focusFile) === undefined)
|
|
171
|
+
? await run(args, { cwd: focusFile ? path.dirname(focusFile) : dir, timeoutMs: 15000, maxOutputChars: MAX_SEARCH_CHARS, signal })
|
|
172
|
+
: { stdout: "", stderr: "", exitCode: 1 };
|
|
173
|
+
|
|
174
|
+
if (response.exitCode !== 0 && response.exitCode !== 1) throw new Error("source search failed: " + response.stderr.trim());
|
|
175
|
+
|
|
176
|
+
return response;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, signal) {
|
|
180
|
+
const records = response.stdout.split("\n");
|
|
181
|
+
|
|
182
|
+
for (let i = 0; i < records.length; i++) {
|
|
183
|
+
if ((i & 127) === 0) signal?.throwIfAborted();
|
|
184
|
+
const record = parseRgRecord(records[i], response.outputTruncated, i === records.length - 1);
|
|
185
|
+
|
|
186
|
+
if (record === undefined) break;
|
|
187
|
+
if (!record) continue;
|
|
188
|
+
absorbRgHit(candidates, record, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function contentCandidates({ dir, includeHidden, query, tokens, flags, pendingPaths, run, overlayText, signal, exact, diskFiles, focusFile }) {
|
|
193
|
+
const needles = exact ? [query.toLowerCase().slice(0, MAX_NEEDLE_CHARS)] : tokens.map(token => stem(token).slice(0, MAX_NEEDLE_CHARS));
|
|
194
|
+
const candidateRoot = focusFile ? path.dirname(focusFile) : dir;
|
|
195
|
+
const candidates = new Map();
|
|
196
|
+
const response = await runContentSearch({ dir, includeHidden, searchNeedles: [...new Set(needles)], run, overlayText, signal, diskFiles, focusFile });
|
|
197
|
+
absorbRgRecords(candidates, response, dir, includeHidden, overlayText, candidateRoot, query, tokens, flags, needles, signal);
|
|
198
|
+
const overlayTruncated = overlayCandidates(candidates, pendingPaths, overlayText, candidateRoot, query, tokens, flags, needles, signal);
|
|
199
|
+
|
|
200
|
+
return { candidates, truncated: response.outputTruncated === true || overlayTruncated };
|
|
201
|
+
}
|
|
202
|
+
export { inScope, makeCandidate, contentCandidates, MAX_SEARCH_CHARS };
|