pi-hashline-edit-pro 7.0.0 → 7.1.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 +39 -11
- package/index.ts +31 -28
- package/package.json +1 -1
- package/src/auto-read-all.ts +7 -7
- package/src/config-ui.ts +6 -1
- package/src/config.ts +6 -1
- package/src/edit-common.ts +16 -8
- package/src/grep.ts +78 -10
- package/src/hashline/resolve.ts +16 -5
- package/src/model-gate.ts +1 -0
- package/src/outline/index.ts +5 -2
- package/src/outline/parsers.ts +2 -2
- package/src/outline/render.ts +156 -82
- package/src/outline/vendor/README.md +4 -0
- package/src/outline/vendor/parsers/blade.ts +7 -2
- package/src/outline/vendor/parsers/index.ts +3 -2
- package/src/outline/vendor/parsers/java.ts +144 -331
- package/src/outline/vendor/parsers/manager.ts +289 -439
- package/src/outline/vendor/parsers/php.ts +16 -81
- package/src/outline/vendor/parsers/sass.ts +15 -20
- package/src/outline/vendor/parsers/scss.ts +0 -8
- package/src/outline/vendor/types.ts +0 -1
- package/src/outline-tool.ts +382 -0
- package/src/prompts.ts +1 -0
- package/src/read.ts +3 -1
- package/src/replace-undo.ts +13 -0
- package/src/structured.ts +32 -0
- package/tool-prompts/outline-guidelines.md +2 -0
- package/tool-prompts/outline-snippet.md +1 -0
- package/tool-prompts/outline.md +7 -0
- package/tool-prompts/read-guidelines.md +1 -1
package/src/grep.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { loadP, loadGuidelines } from "./prompts";
|
|
|
15
15
|
import { normReq } from "./payload-contract";
|
|
16
16
|
import { DEFAULT_EDIT_FLAGS, withGrepPrompts, type EditToolFlags } from "./edit-common";
|
|
17
17
|
import { abortIf, clipLine, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines, gutterWidth } from "./utils";
|
|
18
|
+
import type { LineEnding } from "./normalize";
|
|
18
19
|
import { withAnchorSession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
|
|
19
20
|
import { serveRows } from "./served";
|
|
20
21
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -139,6 +140,62 @@ function grepHeadFragment(line: string): string {
|
|
|
139
140
|
return head.length < line.length ? `${head}...` : head;
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
function lineStartsOf(text: string): number[] {
|
|
144
|
+
const starts = [0];
|
|
145
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
146
|
+
if (text.charCodeAt(index) === 10 && index + 1 < text.length) starts.push(index + 1);
|
|
147
|
+
}
|
|
148
|
+
return starts;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function physicalStartsOf(text: string, separators: readonly LineEnding[]): number[] {
|
|
152
|
+
const starts = [0];
|
|
153
|
+
let separatorIndex = 0;
|
|
154
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
155
|
+
if (text.charCodeAt(index) !== 10) continue;
|
|
156
|
+
const separator = separators[separatorIndex] ?? "\n";
|
|
157
|
+
separatorIndex += 1;
|
|
158
|
+
if (separator === "\n" || separator === "\r\n") starts.push(index + 1);
|
|
159
|
+
}
|
|
160
|
+
return starts;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function lineIndexOf(starts: readonly number[], position: number): number {
|
|
164
|
+
let low = 0;
|
|
165
|
+
let high = starts.length - 1;
|
|
166
|
+
while (low < high) {
|
|
167
|
+
const mid = (low + high + 1) >>> 1;
|
|
168
|
+
if (starts[mid]! <= position) low = mid;
|
|
169
|
+
else high = mid - 1;
|
|
170
|
+
}
|
|
171
|
+
return low;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function toNormalizedMatches(
|
|
175
|
+
norm: { normalized: string; endingSeparators: readonly LineEnding[] },
|
|
176
|
+
matches: RgMatchLine[],
|
|
177
|
+
): RgMatchLine[] {
|
|
178
|
+
if (!norm.endingSeparators.includes("\r")) return matches;
|
|
179
|
+
const text = norm.normalized;
|
|
180
|
+
const starts = lineStartsOf(text);
|
|
181
|
+
const physical = physicalStartsOf(text, norm.endingSeparators);
|
|
182
|
+
const lines = visLines(text);
|
|
183
|
+
return matches.map((match) => {
|
|
184
|
+
const base = physical[match.line - 1] ?? 0;
|
|
185
|
+
const physicalLine = text.slice(base, physical[match.line] ?? text.length);
|
|
186
|
+
const position = base + byteOffsetToCharIndex(physicalLine, match.start);
|
|
187
|
+
const index = lineIndexOf(starts, position);
|
|
188
|
+
const lineText = lines[index] ?? "";
|
|
189
|
+
const columnStart = Math.min(position - starts[index]!, lineText.length);
|
|
190
|
+
const columnEnd = Math.min(base + byteOffsetToCharIndex(physicalLine, match.end) - starts[index]!, lineText.length);
|
|
191
|
+
return {
|
|
192
|
+
line: index + 1,
|
|
193
|
+
start: Buffer.byteLength(lineText.slice(0, columnStart), "utf-8"),
|
|
194
|
+
end: Buffer.byteLength(lineText.slice(0, Math.max(columnStart, columnEnd)), "utf-8"),
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
142
199
|
function makeHitFromIndices(
|
|
143
200
|
norm: { normalized: string; fileHashes: string[]; absolutePath: string; hadUtf8DecodeErrors: boolean },
|
|
144
201
|
displayPath: string,
|
|
@@ -247,6 +304,11 @@ async function insideGitRepo(start: string): Promise<boolean> {
|
|
|
247
304
|
}
|
|
248
305
|
}
|
|
249
306
|
|
|
307
|
+
interface RgSearch {
|
|
308
|
+
matches: Map<string, RgMatchLine[]>;
|
|
309
|
+
skipped?: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
250
312
|
async function collectRgMatches(
|
|
251
313
|
rgPath: string,
|
|
252
314
|
pattern: string,
|
|
@@ -254,8 +316,8 @@ async function collectRgMatches(
|
|
|
254
316
|
req: GrepReq,
|
|
255
317
|
signal: AbortSignal | undefined,
|
|
256
318
|
repoRooted: boolean,
|
|
257
|
-
): Promise<
|
|
258
|
-
const args = ["--json", "--line-number", "--color=never", "--hidden", "--glob", "!.git"];
|
|
319
|
+
): Promise<RgSearch> {
|
|
320
|
+
const args = ["--json", "--line-number", "--color=never", "--hidden", "--crlf", "--glob", "!.git"];
|
|
259
321
|
if (!repoRooted) args.push("--no-require-git");
|
|
260
322
|
const wanted = req.limit ?? 100;
|
|
261
323
|
args.push("--max-count", String(wanted + 1));
|
|
@@ -267,7 +329,7 @@ async function collectRgMatches(
|
|
|
267
329
|
if (req.literal) args.push("--fixed-strings");
|
|
268
330
|
args.push("--", pattern, searchPath);
|
|
269
331
|
const result = new Map<string, RgMatchLine[]>();
|
|
270
|
-
return await new Promise<
|
|
332
|
+
return await new Promise<RgSearch>((resolve, reject) => {
|
|
271
333
|
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
272
334
|
const rl = createInterface({ input: child.stdout });
|
|
273
335
|
let stderr = "";
|
|
@@ -331,12 +393,16 @@ async function collectRgMatches(
|
|
|
331
393
|
}
|
|
332
394
|
if (code !== 0 && code !== 1) {
|
|
333
395
|
const detail = clipLine(stderr.trim(), 300);
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
396
|
+
if (result.size === 0) {
|
|
397
|
+
reject(new Error(detail
|
|
398
|
+
? `[E_GREP_FAILED] ripgrep exited with code ${code}: ${detail}`
|
|
399
|
+
: `[E_GREP_FAILED] ripgrep exited with code ${code}.`));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
resolve({ matches: result, ...(detail ? { skipped: detail } : {}) });
|
|
337
403
|
return;
|
|
338
404
|
}
|
|
339
|
-
resolve(result);
|
|
405
|
+
resolve({ matches: result });
|
|
340
406
|
});
|
|
341
407
|
});
|
|
342
408
|
}
|
|
@@ -541,7 +607,8 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
541
607
|
const readGrepFile = makeGrepReader("real");
|
|
542
608
|
const readGrepFileShadow = makeGrepReader("shadow");
|
|
543
609
|
const repoRooted = await insideGitRepo(globRoot);
|
|
544
|
-
const
|
|
610
|
+
const rgSearch = await collectRgMatches(rgPath, req.pattern, base, req, signal, repoRooted);
|
|
611
|
+
const rgMatches = rgSearch.matches;
|
|
545
612
|
const sortedFiles = [...rgMatches.keys()].sort(cmp);
|
|
546
613
|
for (let f = 0; f < sortedFiles.length; f++) {
|
|
547
614
|
abortIf(signal);
|
|
@@ -552,7 +619,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
552
619
|
if (!matchesGlob(absPath)) continue;
|
|
553
620
|
const norm = await readGrepFileShadow(absPath);
|
|
554
621
|
if (!norm) continue;
|
|
555
|
-
const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), fileMatches, context, totalForFile, fileMatches.length);
|
|
622
|
+
const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), toNormalizedMatches(norm, fileMatches), context, totalForFile, fileMatches.length);
|
|
556
623
|
const display = displayRowsForHit(hit);
|
|
557
624
|
totalRows += display.length;
|
|
558
625
|
for (const r of display) totalBytes += Buffer.byteLength(r, "utf-8") + 1;
|
|
@@ -574,7 +641,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
574
641
|
if (!matchesGlob(absPath)) continue;
|
|
575
642
|
const norm = await readGrepFile(absPath);
|
|
576
643
|
if (!norm) continue;
|
|
577
|
-
const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), fileMatches, context, totalForFile, Math.min(totalForFile, remaining));
|
|
644
|
+
const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), toNormalizedMatches(norm, fileMatches), context, totalForFile, Math.min(totalForFile, remaining));
|
|
578
645
|
const display = displayRowsForHit(hit);
|
|
579
646
|
const keptRows: string[] = [];
|
|
580
647
|
const keptHashes: string[] = [];
|
|
@@ -624,6 +691,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
|
|
|
624
691
|
if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
|
|
625
692
|
if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
|
|
626
693
|
if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; narrow the search.]`);
|
|
694
|
+
if (rgSearch.skipped !== undefined) notes.push(`[grep: some paths could not be searched, so these results are partial: ${rgSearch.skipped}]`);
|
|
627
695
|
const truncated = limitTruncated || rowTruncated;
|
|
628
696
|
const truncation: TruncationResult | undefined = rowTruncated
|
|
629
697
|
? {
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -113,9 +113,12 @@ export function fmtMismatchWithHashes(
|
|
|
113
113
|
for (let ln = from; ln <= to; ln++) {
|
|
114
114
|
const h = fileHashes[ln - 1]!;
|
|
115
115
|
const c = fileLines[ln - 1] ?? "";
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
const shown = clipLine(c);
|
|
117
|
+
if (shown === c) {
|
|
118
|
+
hashes.push(h);
|
|
119
|
+
servedMap.set(h, lineChecksum(c));
|
|
120
|
+
}
|
|
121
|
+
rows.push(` ${ln}: ${h}│${shown}`);
|
|
119
122
|
}
|
|
120
123
|
out.push("");
|
|
121
124
|
out.push(` Current context around resolved anchor "${ctx.hash}" (line ${ctx.line}):\n${rows.join("\n")}`);
|
|
@@ -441,12 +444,18 @@ export function assertRangeServed(
|
|
|
441
444
|
const rows: string[] = [];
|
|
442
445
|
const shownHashes: string[] = [];
|
|
443
446
|
const shownMap = new Map<string, string>();
|
|
447
|
+
const clippedLines: number[] = [];
|
|
444
448
|
for (let line = startLine; line < startLine + shownLength; line++) {
|
|
445
449
|
const hash = fileHashes[line - 1]!;
|
|
446
450
|
const content = fileLines[line - 1]!;
|
|
451
|
+
const shown = clipLine(content);
|
|
452
|
+
rows.push(fmtRow(hash, shown));
|
|
453
|
+
if (shown !== content) {
|
|
454
|
+
clippedLines.push(line);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
447
457
|
shownHashes.push(hash);
|
|
448
458
|
shownMap.set(hash, lineChecksum(content));
|
|
449
|
-
rows.push(fmtRow(hash, clipLine(content)));
|
|
450
459
|
}
|
|
451
460
|
const location = filePath ? ` in ${filePath}` : "";
|
|
452
461
|
const rangeDescription = `the replaced range (lines ${startLine}-${endLine})${location}`;
|
|
@@ -457,7 +466,9 @@ export function assertRangeServed(
|
|
|
457
466
|
const capHint =
|
|
458
467
|
rangeLength > shownLength
|
|
459
468
|
? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read()${filePath ? ` on ${filePath}` : ""} with offset=${startLine + shownLength} to see the rest.]`
|
|
460
|
-
:
|
|
469
|
+
: clippedLines.length > 0
|
|
470
|
+
? `\n\n[${clippedLines.length === 1 ? `Line ${clippedLines[0]} is` : `Lines ${clippedLines.join(", ")} are`} clipped above and not editable from here; call read()${filePath ? ` on ${filePath}` : ""} before editing.]`
|
|
471
|
+
: "\n\nRetry with the fresh anchors above without a read.";
|
|
461
472
|
const message =
|
|
462
473
|
`[E_RANGE_STALE] ${mismatchText}. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
|
|
463
474
|
throw new RangeStaleError(message, shownHashes, shownMap);
|
package/src/model-gate.ts
CHANGED
package/src/outline/index.ts
CHANGED
|
@@ -18,7 +18,7 @@ export async function buildFileOutline(input: FileOutlineInput): Promise<Outline
|
|
|
18
18
|
const parser = await loadOutlineParser();
|
|
19
19
|
const parsed = parser === undefined ? undefined : await parser.parse(input.displayPath, input.content);
|
|
20
20
|
if (parsed !== undefined && parsed.symbols.length > 0) {
|
|
21
|
-
|
|
21
|
+
const outline = renderSymbolOutline({
|
|
22
22
|
displayPath: input.displayPath,
|
|
23
23
|
languageName: parsed.languageName,
|
|
24
24
|
totalLines: lines.length,
|
|
@@ -29,8 +29,9 @@ export async function buildFileOutline(input: FileOutlineInput): Promise<Outline
|
|
|
29
29
|
...(input.maxDepth !== undefined ? { maxDepth: input.maxDepth } : {}),
|
|
30
30
|
...(input.maxBytes !== undefined ? { maxBytes: input.maxBytes } : {}),
|
|
31
31
|
});
|
|
32
|
+
return parsed.hasSyntaxErrors ? { ...outline, hasSyntaxErrors: true } : outline;
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
+
const preview = renderPreviewOutline({
|
|
34
35
|
displayPath: input.displayPath,
|
|
35
36
|
lines,
|
|
36
37
|
hashes: input.hashes,
|
|
@@ -38,6 +39,8 @@ export async function buildFileOutline(input: FileOutlineInput): Promise<Outline
|
|
|
38
39
|
...(input.previewTailLines !== undefined ? { tailLines: input.previewTailLines } : {}),
|
|
39
40
|
...(input.maxBytes !== undefined ? { maxBytes: input.maxBytes } : {}),
|
|
40
41
|
});
|
|
42
|
+
if (lines.length === 0) return preview;
|
|
43
|
+
return { ...preview, preview: parsed === undefined ? "unsupported" : "no-symbols", ...(parsed?.hasSyntaxErrors ? { hasSyntaxErrors: true } : {}) };
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export { loadOutlineParser } from "./parsers";
|
package/src/outline/parsers.ts
CHANGED
|
@@ -4,12 +4,12 @@ export interface OutlineSymbol {
|
|
|
4
4
|
startLine: number;
|
|
5
5
|
endLine: number;
|
|
6
6
|
children?: OutlineSymbol[];
|
|
7
|
-
detail?: string;
|
|
8
7
|
}
|
|
9
8
|
|
|
10
9
|
export interface OutlineParse {
|
|
11
10
|
symbols: OutlineSymbol[];
|
|
12
11
|
languageName: string;
|
|
12
|
+
hasSyntaxErrors: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface OutlineParser {
|
|
@@ -25,7 +25,7 @@ async function load(): Promise<OutlineParser | undefined> {
|
|
|
25
25
|
parse: async (path, content) => {
|
|
26
26
|
if (parsers.detectLanguage(path) === null) return undefined;
|
|
27
27
|
const parsed = await parsers.parseSourceFile(path, content);
|
|
28
|
-
return { symbols: parsed.symbols, languageName: parsed.languageName };
|
|
28
|
+
return { symbols: parsed.symbols, languageName: parsed.languageName, hasSyntaxErrors: parsed.hasError === true };
|
|
29
29
|
},
|
|
30
30
|
};
|
|
31
31
|
} catch (error) {
|
package/src/outline/render.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { HASH_SEP } from "../hashline";
|
|
2
3
|
import { clipLine } from "../utils";
|
|
3
4
|
import type { OutlineSymbol } from "./parsers";
|
|
4
5
|
|
|
5
6
|
const DEFAULT_MAX_ROWS = 400;
|
|
6
7
|
const DEFAULT_MAX_DEPTH = 6;
|
|
7
|
-
const MAX_DETAIL_CHARS = 60;
|
|
8
8
|
const PREVIEW_HEAD_LINES = 20;
|
|
9
9
|
const PREVIEW_TAIL_LINES = 10;
|
|
10
10
|
const PREVIEW_LINE_CHARS = 200;
|
|
11
11
|
|
|
12
|
+
function formatMoreLines(count: number): string {
|
|
13
|
+
return `... (${count} more ${count === 1 ? "line" : "lines"})`;
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
export interface OutlineRow {
|
|
13
17
|
line: number;
|
|
14
18
|
anchor: string;
|
|
@@ -20,6 +24,9 @@ export interface OutlineRender {
|
|
|
20
24
|
rows: OutlineRow[];
|
|
21
25
|
servedHashes: string[];
|
|
22
26
|
truncated: boolean;
|
|
27
|
+
complete: boolean;
|
|
28
|
+
preview?: "unsupported" | "no-symbols";
|
|
29
|
+
hasSyntaxErrors?: boolean;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export interface SymbolOutlineInput {
|
|
@@ -43,64 +50,118 @@ export interface PreviewOutlineInput {
|
|
|
43
50
|
maxBytes?: number;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
interface OutlineBudget {
|
|
54
|
+
output: string[];
|
|
55
|
+
rows: OutlineRow[];
|
|
56
|
+
servedHashes: string[];
|
|
57
|
+
maxRows: number;
|
|
58
|
+
maxBytes: number;
|
|
59
|
+
bytes: number;
|
|
60
|
+
truncated: boolean;
|
|
61
|
+
truncation?: "rows" | "bytes";
|
|
62
|
+
skipped: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function startBudget(header: string, maxRows: number, maxBytes: number): OutlineBudget {
|
|
66
|
+
return { output: [header], rows: [], servedHashes: [], maxRows, maxBytes, bytes: Buffer.byteLength(header, "utf-8") + 1, truncated: false, skipped: 0 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function pushLine(budget: OutlineBudget, text: string): boolean {
|
|
70
|
+
const size = Buffer.byteLength(text, "utf-8") + 1;
|
|
71
|
+
if (budget.bytes + size > budget.maxBytes) return false;
|
|
72
|
+
budget.bytes += size;
|
|
73
|
+
budget.output.push(text);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function markTruncated(budget: OutlineBudget, cause: "rows" | "bytes"): void {
|
|
78
|
+
budget.truncated = true;
|
|
79
|
+
if (budget.truncation === undefined) budget.truncation = cause;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isComplete(budget: OutlineBudget): boolean {
|
|
83
|
+
return !budget.truncated && budget.skipped === 0 && budget.servedHashes.length === budget.rows.length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type EmitOutcome = "emitted" | "skipped" | "stopped";
|
|
87
|
+
|
|
88
|
+
function emitRow(budget: OutlineBudget, hashes: string[], lines: string[], line: number, text: string): EmitOutcome {
|
|
89
|
+
const anchor = hashes[line - 1];
|
|
90
|
+
if (anchor === undefined) {
|
|
91
|
+
budget.skipped += 1;
|
|
92
|
+
return "skipped";
|
|
93
|
+
}
|
|
94
|
+
if (budget.rows.length >= budget.maxRows) {
|
|
95
|
+
markTruncated(budget, "rows");
|
|
96
|
+
return "stopped";
|
|
97
|
+
}
|
|
98
|
+
if (!pushLine(budget, `${anchor}${HASH_SEP}${text}`)) {
|
|
99
|
+
markTruncated(budget, "bytes");
|
|
100
|
+
return "stopped";
|
|
101
|
+
}
|
|
102
|
+
budget.rows.push({ line, anchor, text });
|
|
103
|
+
if (text === (lines[line - 1] ?? "")) budget.servedHashes.push(anchor);
|
|
104
|
+
return "emitted";
|
|
48
105
|
}
|
|
49
106
|
|
|
50
|
-
function
|
|
107
|
+
function countSymbolRows(
|
|
108
|
+
symbols: OutlineSymbol[],
|
|
109
|
+
depth: number,
|
|
110
|
+
maxDepth: number,
|
|
111
|
+
hashes: string[],
|
|
112
|
+
seenLines: Set<number>,
|
|
113
|
+
): number {
|
|
51
114
|
let total = 0;
|
|
52
115
|
for (const symbol of symbols) {
|
|
53
|
-
|
|
54
|
-
if (
|
|
116
|
+
const children = symbol.children ?? [];
|
|
117
|
+
if (seenLines.has(symbol.startLine)) {
|
|
118
|
+
if (children.length > 0 && depth < maxDepth) total += countSymbolRows(children, depth + 1, maxDepth, hashes, seenLines);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
seenLines.add(symbol.startLine);
|
|
122
|
+
if (hashes[symbol.startLine - 1] !== undefined) total += 1;
|
|
123
|
+
if (children.length === 0 || depth >= maxDepth) continue;
|
|
124
|
+
total += countSymbolRows(children, depth + 1, maxDepth, hashes, seenLines);
|
|
55
125
|
}
|
|
56
126
|
return total;
|
|
57
127
|
}
|
|
58
128
|
|
|
59
|
-
function symbolLabel(symbol: OutlineSymbol, depth: number): string {
|
|
60
|
-
const indent = " ".repeat(depth);
|
|
61
|
-
const name = symbol.name.length > 0 ? ` ${symbol.name}` : "";
|
|
62
|
-
const detail = symbol.detail !== undefined && symbol.detail.length > 0
|
|
63
|
-
? ` ${symbol.detail.length > MAX_DETAIL_CHARS ? `${symbol.detail.slice(0, MAX_DETAIL_CHARS - 3)}...` : symbol.detail}`
|
|
64
|
-
: "";
|
|
65
|
-
const children = symbol.children !== undefined && symbol.children.length > 0 ? ` (${symbol.children.length} children)` : "";
|
|
66
|
-
const span = symbol.endLine - symbol.startLine + 1;
|
|
67
|
-
return `${indent}${symbol.type}${name}${detail}${children}${span > 1 ? ` [limit ${span}]` : ""}`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
129
|
export function renderSymbolOutline(input: SymbolOutlineInput): OutlineRender {
|
|
71
|
-
const maxRows = input.maxRows ?? DEFAULT_MAX_ROWS;
|
|
72
|
-
const maxDepth = input.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
73
|
-
const maxBytes = input.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
74
130
|
const header = `=== ${input.displayPath}${input.languageName !== undefined ? ` (${input.languageName})` : ""} — ${input.totalLines} lines ===`;
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
let truncated = false;
|
|
79
|
-
|
|
80
|
-
const addRow = (line: number, text: string): boolean => {
|
|
81
|
-
const anchor = input.hashes[line - 1];
|
|
82
|
-
if (anchor === undefined) return true;
|
|
83
|
-
const rendered = `${anchor}│${text}`;
|
|
84
|
-
const size = Buffer.byteLength(rendered, "utf-8") + 1;
|
|
85
|
-
if (rows.length >= maxRows || bytes + size > maxBytes) {
|
|
86
|
-
truncated = true;
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
bytes += size;
|
|
90
|
-
output.push(rendered);
|
|
91
|
-
rows.push({ line, anchor, text });
|
|
92
|
-
return true;
|
|
93
|
-
};
|
|
131
|
+
const budget = startBudget(header, input.maxRows ?? DEFAULT_MAX_ROWS, input.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
132
|
+
const maxDepth = input.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
133
|
+
const seenLines = new Set<number>();
|
|
94
134
|
|
|
95
135
|
const walk = (symbols: OutlineSymbol[], depth: number): boolean => {
|
|
96
136
|
for (const symbol of symbols) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
137
|
+
if (seenLines.has(symbol.startLine)) {
|
|
138
|
+
const children = symbol.children ?? [];
|
|
139
|
+
if (children.length > 0) {
|
|
140
|
+
if (depth < maxDepth) {
|
|
141
|
+
if (!walk(children, depth + 1)) return false;
|
|
142
|
+
} else if (!pushLine(budget, `[${children.length} nested]`)) {
|
|
143
|
+
markTruncated(budget, "bytes");
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
seenLines.add(symbol.startLine);
|
|
150
|
+
const text = clipLine(input.lines[symbol.startLine - 1] ?? "", PREVIEW_LINE_CHARS);
|
|
151
|
+
const outcome = emitRow(budget, input.hashes, input.lines, symbol.startLine, text);
|
|
152
|
+
if (outcome === "stopped") return false;
|
|
153
|
+
const span = symbol.endLine - symbol.startLine + 1;
|
|
154
|
+
if (outcome === "emitted" && span > 1 && !pushLine(budget, `[limit ${span}]`)) {
|
|
155
|
+
markTruncated(budget, "bytes");
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
100
158
|
const children = symbol.children ?? [];
|
|
101
159
|
if (children.length === 0) continue;
|
|
102
160
|
if (depth >= maxDepth) {
|
|
103
|
-
|
|
161
|
+
if (!pushLine(budget, `[${children.length} nested]`)) {
|
|
162
|
+
markTruncated(budget, "bytes");
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
104
165
|
continue;
|
|
105
166
|
}
|
|
106
167
|
if (!walk(children, depth + 1)) return false;
|
|
@@ -109,62 +170,75 @@ export function renderSymbolOutline(input: SymbolOutlineInput): OutlineRender {
|
|
|
109
170
|
};
|
|
110
171
|
|
|
111
172
|
walk(input.symbols, 0);
|
|
112
|
-
if (truncated) {
|
|
113
|
-
|
|
173
|
+
if (budget.truncated) {
|
|
174
|
+
const remaining = countSymbolRows(input.symbols, 0, maxDepth, input.hashes, new Set<number>()) - budget.rows.length;
|
|
175
|
+
const more = remaining > 0 ? ` (${remaining} more ${remaining === 1 ? "symbol" : "symbols"})` : "";
|
|
176
|
+
const cause = budget.truncation === "rows" ? "row cap" : "byte budget";
|
|
177
|
+
if (budget.rows.length === 0) {
|
|
178
|
+
budget.output.push(more.length > 0
|
|
179
|
+
? `...${more}; outline truncated by the ${cause} before the first row.`
|
|
180
|
+
: `... (outline truncated by the ${cause} before the first row.)`);
|
|
181
|
+
} else if (remaining > 0) {
|
|
182
|
+
budget.output.push(`...${more}; read from the last shown anchor to continue.`);
|
|
183
|
+
} else {
|
|
184
|
+
budget.output.push(`... (outline truncated by the ${cause}; read from the last shown anchor to continue.)`);
|
|
185
|
+
}
|
|
114
186
|
}
|
|
115
|
-
return { text: output.join("\n"), rows, servedHashes:
|
|
187
|
+
return { text: budget.output.join("\n"), rows: budget.rows, servedHashes: budget.servedHashes, truncated: budget.truncated, complete: isComplete(budget) };
|
|
116
188
|
}
|
|
117
189
|
|
|
118
190
|
export function renderPreviewOutline(input: PreviewOutlineInput): OutlineRender {
|
|
119
|
-
const headCount = input.headLines ?? PREVIEW_HEAD_LINES;
|
|
120
|
-
const tailCount = input.tailLines ?? PREVIEW_TAIL_LINES;
|
|
121
|
-
const maxBytes = input.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
122
191
|
const header = `=== ${input.displayPath} — ${input.lines.length} lines ===`;
|
|
123
|
-
const
|
|
124
|
-
const output: string[] = [header];
|
|
125
|
-
let bytes = Buffer.byteLength(header, "utf-8") + 1;
|
|
126
|
-
let truncated = false;
|
|
192
|
+
const budget = startBudget(header, Number.POSITIVE_INFINITY, input.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
127
193
|
if (input.lines.length === 0) {
|
|
128
194
|
const anchor = input.hashes[0];
|
|
129
195
|
if (anchor !== undefined) {
|
|
130
|
-
|
|
131
|
-
|
|
196
|
+
const marker = `${anchor}${HASH_SEP}`;
|
|
197
|
+
const note = "[File is empty. Use replace to insert content.]";
|
|
198
|
+
if (pushLine(budget, marker)) {
|
|
199
|
+
budget.rows.push({ line: 1, anchor, text: "" });
|
|
200
|
+
budget.servedHashes.push(anchor);
|
|
201
|
+
if (!pushLine(budget, note)) markTruncated(budget, "bytes");
|
|
202
|
+
} else {
|
|
203
|
+
markTruncated(budget, "bytes");
|
|
204
|
+
}
|
|
132
205
|
}
|
|
133
|
-
return {
|
|
206
|
+
return {
|
|
207
|
+
text: budget.output.join("\n"),
|
|
208
|
+
rows: budget.rows,
|
|
209
|
+
servedHashes: budget.servedHashes,
|
|
210
|
+
truncated: budget.truncated,
|
|
211
|
+
complete: isComplete(budget),
|
|
212
|
+
};
|
|
134
213
|
}
|
|
135
214
|
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (line === undefined || anchor === undefined) return true;
|
|
140
|
-
const text = clipLine(line, PREVIEW_LINE_CHARS);
|
|
141
|
-
const rendered = `${anchor}│${text}`;
|
|
142
|
-
const size = Buffer.byteLength(rendered, "utf-8") + 1;
|
|
143
|
-
if (bytes + size > maxBytes) {
|
|
144
|
-
truncated = true;
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
147
|
-
bytes += size;
|
|
148
|
-
output.push(rendered);
|
|
149
|
-
rows.push({ line: index + 1, anchor, text });
|
|
150
|
-
return true;
|
|
151
|
-
};
|
|
215
|
+
const head = Math.min(input.headLines ?? PREVIEW_HEAD_LINES, input.lines.length);
|
|
216
|
+
const tail = Math.min(input.tailLines ?? PREVIEW_TAIL_LINES, input.lines.length - head);
|
|
217
|
+
const attempt = (index: number): EmitOutcome => emitRow(budget, input.hashes, input.lines, index + 1, clipLine(input.lines[index] ?? "", PREVIEW_LINE_CHARS));
|
|
152
218
|
|
|
153
|
-
const head = Math.min(headCount, input.lines.length);
|
|
154
|
-
const tail = Math.min(tailCount, input.lines.length - head);
|
|
155
219
|
for (let index = 0; index < head; index += 1) {
|
|
156
|
-
if (
|
|
220
|
+
if (attempt(index) === "stopped") break;
|
|
157
221
|
}
|
|
158
|
-
|
|
159
|
-
|
|
222
|
+
let middleIndex: number | undefined;
|
|
223
|
+
if (!budget.truncated && head + tail < input.lines.length) {
|
|
224
|
+
if (pushLine(budget, formatMoreLines(input.lines.length - head - tail))) middleIndex = budget.output.length - 1;
|
|
225
|
+
else markTruncated(budget, "bytes");
|
|
160
226
|
}
|
|
161
|
-
if (!truncated) {
|
|
227
|
+
if (!budget.truncated) {
|
|
162
228
|
for (let index = input.lines.length - tail; index < input.lines.length; index += 1) {
|
|
163
|
-
if (
|
|
229
|
+
if (attempt(index) === "stopped") break;
|
|
164
230
|
}
|
|
165
231
|
}
|
|
166
|
-
if (truncated) {
|
|
167
|
-
|
|
232
|
+
if (budget.truncated) {
|
|
233
|
+
const remaining = input.lines.length - budget.rows.length;
|
|
234
|
+
if (middleIndex !== undefined) budget.output.splice(middleIndex, 1);
|
|
235
|
+
if (remaining > 0) budget.output.push(formatMoreLines(remaining));
|
|
168
236
|
}
|
|
169
|
-
return {
|
|
237
|
+
return {
|
|
238
|
+
text: budget.output.join("\n"),
|
|
239
|
+
rows: budget.rows,
|
|
240
|
+
servedHashes: budget.servedHashes,
|
|
241
|
+
truncated: budget.truncated,
|
|
242
|
+
complete: head + tail >= input.lines.length && isComplete(budget),
|
|
243
|
+
};
|
|
170
244
|
}
|
|
@@ -9,6 +9,10 @@ These files are copied from [pi-codebase-reader](https://github.com/HanzCEO/pi-c
|
|
|
9
9
|
- `parsers/manager.ts` extracts anonymous default exports (`export default function`/`class`), function expressions, top-level variable declarators, and nested `describe`/`test`/`it` calls, so entry-point, config, data, and test files outline instead of falling back to a preview.
|
|
10
10
|
- `parsers/manager.ts` adds PHP support: `parsers/php.ts` outlines classes, interfaces, traits, enums, methods, properties, constants, top-level functions, and assigned closures.
|
|
11
11
|
- `parsers/manager.ts` adds Blade support: `parsers/blade.ts` outlines `@section`/`@push`/`@once`/`@php` blocks, named directives such as `@extends` and `@include`, and `<x-…>`/`<livewire:…>` components.
|
|
12
|
+
- `parsers/java.ts` reads the field-based tree-sitter-java grammar shipped in `wasm/` (method, constructor, field, constant, enum, record, and annotation-element nodes), so annotated members keep their names; every member anchors on its declaration line rather than on its annotation.
|
|
13
|
+
- `parsers/php.ts` anchors a declaration on its first line after its attributes, so a member with `#[Test]` starts on the declaration instead of the attribute.
|
|
14
|
+
- `types.ts` and every parser drop the upstream `detail` string: outlines render the first line of each symbol's declaration, so the parameters, inheritance, and qualifier text was computed and discarded.
|
|
15
|
+
- `types.ts` keeps `SymbolInfo.name` and `SymbolInfo.type`: the extractors use them while building symbol trees and the upstream contract carries them, even though outlines render a real file line for each symbol.
|
|
12
16
|
|
|
13
17
|
The `wasm/` grammars come from the MIT-licensed `tree-sitter-javascript`, `tree-sitter-typescript`, `tree-sitter-python`, `tree-sitter-go`, `tree-sitter-rust`, `tree-sitter-solidity`, and `tree-sitter-java` packages, plus pi-codebase-reader's vendored Sass and SCSS grammars, the VS Code WebAssembly build of `tree-sitter-php`, and `tree-sitter-blade` built from v0.12.3; see `wasm/LICENSES.md` for the per-grammar copyright notices. Comments in these files are upstream prose and are exempt from this repository's no-comments rule.
|
|
14
18
|
|
|
@@ -62,8 +62,13 @@ function bodyStart(node: Node): number {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
function blockSymbol(node: Node, source: string): SymbolInfo | null {
|
|
65
|
+
const directive = node.namedChildren[0];
|
|
66
|
+
if (directive?.type === "directive_start" && (node.type === "conditional" || node.type === "loop")) {
|
|
67
|
+
const [startLine, endLine] = rangeOf(node);
|
|
68
|
+
return { name: parameterName(node, source), type: textOf(directive, source).slice(1), startLine, endLine };
|
|
69
|
+
}
|
|
65
70
|
const label = BLOCK_LABELS[node.type];
|
|
66
|
-
if (label === undefined) return null;
|
|
71
|
+
if (label === undefined || (node.type === "php_statement" && directive?.type !== "directive_start")) return null;
|
|
67
72
|
const [startLine, endLine] = rangeOf(node);
|
|
68
73
|
return { name: parameterName(node, source), type: label, startLine, endLine };
|
|
69
74
|
}
|
|
@@ -76,7 +81,7 @@ function directiveSymbol(node: Node, parameter: Node | null, source: string): Sy
|
|
|
76
81
|
const raw = textOf(parameter, source);
|
|
77
82
|
const quoted = unquote(raw);
|
|
78
83
|
return quoted === undefined
|
|
79
|
-
? { name: "", type: label, startLine, endLine
|
|
84
|
+
? { name: "", type: label, startLine, endLine }
|
|
80
85
|
: { name: quoted, type: label, startLine, endLine };
|
|
81
86
|
}
|
|
82
87
|
|
|
@@ -21,6 +21,7 @@ export interface ParseFileResult {
|
|
|
21
21
|
symbols: SymbolInfo[];
|
|
22
22
|
language: string | null;
|
|
23
23
|
languageName: string;
|
|
24
|
+
hasError?: boolean;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -38,8 +39,8 @@ export async function parseSourceFile(
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
try {
|
|
41
|
-
const symbols = await parseCode(lang, content);
|
|
42
|
-
return { symbols, language: lang, languageName: languageLabel(lang) };
|
|
42
|
+
const { symbols, hasError } = await parseCode(lang, content);
|
|
43
|
+
return { symbols, language: lang, languageName: languageLabel(lang), hasError };
|
|
43
44
|
} catch (err) {
|
|
44
45
|
console.warn(
|
|
45
46
|
`[codebase-reader] Parse failed for ${filePath}:`,
|