pi-readseek 0.1.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/LICENSE +22 -0
- package/README.md +41 -0
- package/index.ts +142 -0
- package/package.json +73 -0
- package/prompts/edit.md +113 -0
- package/prompts/find.md +19 -0
- package/prompts/grep.md +26 -0
- package/prompts/ls.md +11 -0
- package/prompts/read.md +33 -0
- package/prompts/sg.md +25 -0
- package/prompts/write.md +46 -0
- package/src/binary-detect.ts +22 -0
- package/src/binary-resolution.ts +77 -0
- package/src/coerce-obvious-int.ts +39 -0
- package/src/context-application.ts +70 -0
- package/src/context-hygiene.ts +503 -0
- package/src/diff-data.ts +303 -0
- package/src/doom-loop-suggestions.ts +42 -0
- package/src/doom-loop.ts +216 -0
- package/src/edit-classify.ts +190 -0
- package/src/edit-diff.ts +354 -0
- package/src/edit-output.ts +107 -0
- package/src/edit-render-helpers.ts +141 -0
- package/src/edit-syntax-validate.ts +120 -0
- package/src/edit.ts +725 -0
- package/src/find-parsers.ts +89 -0
- package/src/find-stat.ts +36 -0
- package/src/find.ts +613 -0
- package/src/grep-budget.ts +79 -0
- package/src/grep-output.ts +197 -0
- package/src/grep-render-helpers.ts +77 -0
- package/src/grep-symbol-scope.ts +197 -0
- package/src/grep.ts +792 -0
- package/src/hashline.ts +747 -0
- package/src/ls.ts +293 -0
- package/src/map-cache.ts +152 -0
- package/src/path-utils.ts +24 -0
- package/src/pending-diff-preview.ts +269 -0
- package/src/persistent-map-cache.ts +251 -0
- package/src/read-local-bundle.ts +87 -0
- package/src/read-output.ts +212 -0
- package/src/read-render-helpers.ts +104 -0
- package/src/read.ts +748 -0
- package/src/readseek/constants.ts +21 -0
- package/src/readseek/enums.ts +38 -0
- package/src/readseek/formatter.ts +431 -0
- package/src/readseek/language-detect.ts +29 -0
- package/src/readseek/mapper.ts +69 -0
- package/src/readseek/parser-errors.ts +22 -0
- package/src/readseek/parser-loader.ts +83 -0
- package/src/readseek/symbol-error-format.ts +18 -0
- package/src/readseek/symbol-lookup.ts +294 -0
- package/src/readseek/types.ts +79 -0
- package/src/readseek-client.ts +343 -0
- package/src/readseek-error-codes.ts +54 -0
- package/src/readseek-settings.ts +287 -0
- package/src/readseek-value.ts +144 -0
- package/src/replace-symbol.ts +74 -0
- package/src/runtime.ts +3 -0
- package/src/sg-output.ts +88 -0
- package/src/sg.ts +308 -0
- package/src/syntax-validate-mode.ts +25 -0
- package/src/tool-prompt-metadata.ts +76 -0
- package/src/tui-diff-component.ts +86 -0
- package/src/tui-diff-renderer.ts +92 -0
- package/src/tui-render-utils.ts +129 -0
- package/src/write.ts +532 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { ReadseekLine, ReadseekWarning } from "./readseek-value.js";
|
|
2
|
+
import {
|
|
3
|
+
formatSize,
|
|
4
|
+
truncateHead,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { resolveGrepOutputBudget } from "./grep-budget.js";
|
|
7
|
+
import {
|
|
8
|
+
buildContextHygieneMetadata,
|
|
9
|
+
buildFileResource,
|
|
10
|
+
buildSymbolResource,
|
|
11
|
+
type ContextHygieneMetadata,
|
|
12
|
+
type ContextHygieneRehydrateDescriptor,
|
|
13
|
+
type ContextHygieneResource,
|
|
14
|
+
} from "./context-hygiene.js";
|
|
15
|
+
|
|
16
|
+
export interface GrepOutputRecord extends ReadseekLine {
|
|
17
|
+
path: string;
|
|
18
|
+
kind: "match" | "context";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GrepOutputReadseekRecord {
|
|
22
|
+
path: string;
|
|
23
|
+
line: number;
|
|
24
|
+
anchor: string;
|
|
25
|
+
kind: "match" | "context";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type GrepOutputEntry =
|
|
29
|
+
| { kind: "match" | "context"; line: ReadseekLine }
|
|
30
|
+
| { kind: "separator"; text: string };
|
|
31
|
+
|
|
32
|
+
export interface GrepOutputScopeSymbol {
|
|
33
|
+
name: string;
|
|
34
|
+
kind: string;
|
|
35
|
+
startLine: number;
|
|
36
|
+
endLine: number;
|
|
37
|
+
parentName?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GrepScopeWarning extends ReadseekWarning {
|
|
41
|
+
path?: string;
|
|
42
|
+
line?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GrepOutputGroup {
|
|
46
|
+
displayPath: string;
|
|
47
|
+
absolutePath: string;
|
|
48
|
+
matchCount: number;
|
|
49
|
+
entries: GrepOutputEntry[];
|
|
50
|
+
scope?: {
|
|
51
|
+
mode: "symbol";
|
|
52
|
+
symbol: GrepOutputScopeSymbol;
|
|
53
|
+
matchLines: number[];
|
|
54
|
+
contextLines?: number;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface BuildGrepOutputInput {
|
|
59
|
+
summary: boolean;
|
|
60
|
+
totalMatches: number;
|
|
61
|
+
groups: GrepOutputGroup[];
|
|
62
|
+
limit?: number;
|
|
63
|
+
records: GrepOutputRecord[];
|
|
64
|
+
scopeMode?: "symbol";
|
|
65
|
+
scopeWarnings?: GrepScopeWarning[];
|
|
66
|
+
passthroughLines?: string[];
|
|
67
|
+
rehydrate?: ContextHygieneRehydrateDescriptor | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface GrepOutputResult {
|
|
71
|
+
text: string;
|
|
72
|
+
readseekValue: {
|
|
73
|
+
tool: "grep";
|
|
74
|
+
summary: boolean;
|
|
75
|
+
totalMatches: number;
|
|
76
|
+
records: GrepOutputReadseekRecord[];
|
|
77
|
+
scopes?: {
|
|
78
|
+
mode: "symbol";
|
|
79
|
+
groups: Array<{
|
|
80
|
+
path: string;
|
|
81
|
+
displayPath: string;
|
|
82
|
+
symbol: GrepOutputScopeSymbol;
|
|
83
|
+
matchCount: number;
|
|
84
|
+
matchLines: number[];
|
|
85
|
+
lineAnchors: string[];
|
|
86
|
+
}>;
|
|
87
|
+
warnings: GrepScopeWarning[];
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
contextHygiene: ContextHygieneMetadata;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function renderEntry(displayPath: string, entry: GrepOutputEntry): string {
|
|
94
|
+
if (entry.kind === "separator") return entry.text;
|
|
95
|
+
const marker = entry.kind === "match" ? ">>" : " ";
|
|
96
|
+
return `${displayPath}:${marker}${entry.line.anchor}|${entry.line.display}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderGroupHeader(group: GrepOutputGroup): string {
|
|
100
|
+
if (!group.scope) {
|
|
101
|
+
return `--- ${group.displayPath} (${group.matchCount} matches) ---`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const parent = group.scope.symbol.parentName ? ` in ${group.scope.symbol.parentName}` : "";
|
|
105
|
+
const suffix = group.scope.contextLines !== undefined ? `, scoped to ±${group.scope.contextLines} lines` : "";
|
|
106
|
+
return `--- ${group.displayPath} :: ${group.scope.symbol.kind} ${group.scope.symbol.name}${parent} (${group.scope.symbol.startLine}-${group.scope.symbol.endLine}, ${group.matchCount} matches${suffix}) ---`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildScopeMetadata(groups: GrepOutputGroup[], warnings: GrepScopeWarning[]) {
|
|
110
|
+
return {
|
|
111
|
+
mode: "symbol" as const,
|
|
112
|
+
groups: groups
|
|
113
|
+
.filter((group) => group.scope)
|
|
114
|
+
.map((group) => ({
|
|
115
|
+
path: group.absolutePath,
|
|
116
|
+
displayPath: group.displayPath,
|
|
117
|
+
symbol: group.scope!.symbol,
|
|
118
|
+
matchCount: group.matchCount,
|
|
119
|
+
matchLines: [...group.scope!.matchLines],
|
|
120
|
+
lineAnchors: group.entries.flatMap((entry) => (entry.kind === "separator" ? [] : [entry.line.anchor])),
|
|
121
|
+
})),
|
|
122
|
+
warnings: [...warnings],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function buildGrepOutput(input: BuildGrepOutputInput): GrepOutputResult {
|
|
127
|
+
const fileCount = new Set(input.groups.map((group) => group.absolutePath)).size;
|
|
128
|
+
const header = `[${input.totalMatches} matches in ${fileCount} files]`;
|
|
129
|
+
let text: string;
|
|
130
|
+
if (input.summary) {
|
|
131
|
+
const fileLines = [...input.groups]
|
|
132
|
+
.sort((a, b) => b.matchCount - a.matchCount)
|
|
133
|
+
.map((group) => `${group.absolutePath}: ${group.matchCount} matches`);
|
|
134
|
+
text = [header, ...fileLines].join("\n");
|
|
135
|
+
} else {
|
|
136
|
+
const blocks: string[] = [header];
|
|
137
|
+
for (const group of input.groups) {
|
|
138
|
+
blocks.push(renderGroupHeader(group));
|
|
139
|
+
for (const entry of group.entries) {
|
|
140
|
+
blocks.push(renderEntry(group.displayPath, entry));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
text = blocks.join("\n");
|
|
144
|
+
}
|
|
145
|
+
if ((input.passthroughLines?.length ?? 0) > 0) {
|
|
146
|
+
text += `\n\n${input.passthroughLines!.join("\n")}`;
|
|
147
|
+
}
|
|
148
|
+
if (input.limit !== undefined && input.totalMatches === input.limit) {
|
|
149
|
+
text += `\n\n[Results truncated at ${input.limit} matches — refine pattern or increase limit]`;
|
|
150
|
+
}
|
|
151
|
+
if (!input.summary && input.scopeMode === "symbol" && (input.scopeWarnings?.length ?? 0) > 0) {
|
|
152
|
+
text = `${input.scopeWarnings!.map((warning) => warning.message).join("\n\n")}\n\n${text}`;
|
|
153
|
+
}
|
|
154
|
+
const budget = resolveGrepOutputBudget();
|
|
155
|
+
const truncated = truncateHead(text, {
|
|
156
|
+
maxLines: budget.maxLines,
|
|
157
|
+
maxBytes: budget.maxBytes,
|
|
158
|
+
});
|
|
159
|
+
if (truncated.truncated) {
|
|
160
|
+
text = `${truncated.content}\n\n[Output truncated: showing ${truncated.outputLines} of ${truncated.totalLines} lines (${formatSize(truncated.outputBytes)} of ${formatSize(truncated.totalBytes)}). Refine pattern or increase limit.]`;
|
|
161
|
+
}
|
|
162
|
+
const readseekValue: GrepOutputResult["readseekValue"] = {
|
|
163
|
+
tool: "grep",
|
|
164
|
+
summary: input.summary,
|
|
165
|
+
totalMatches: input.totalMatches,
|
|
166
|
+
records: input.records.map((record) => ({
|
|
167
|
+
path: record.path,
|
|
168
|
+
line: record.line,
|
|
169
|
+
anchor: record.anchor,
|
|
170
|
+
kind: record.kind,
|
|
171
|
+
})),
|
|
172
|
+
};
|
|
173
|
+
if (!input.summary && input.scopeMode === "symbol") {
|
|
174
|
+
readseekValue.scopes = buildScopeMetadata(input.groups, input.scopeWarnings ?? []);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const contextHygieneResources: ContextHygieneResource[] = [];
|
|
178
|
+
for (const record of input.records) {
|
|
179
|
+
contextHygieneResources.push(buildFileResource(record.path));
|
|
180
|
+
}
|
|
181
|
+
for (const group of input.groups) {
|
|
182
|
+
if (!group.scope) continue;
|
|
183
|
+
contextHygieneResources.push(buildFileResource(group.absolutePath));
|
|
184
|
+
contextHygieneResources.push(buildSymbolResource(group.absolutePath, group.scope.symbol.name, group.scope.symbol.kind));
|
|
185
|
+
}
|
|
186
|
+
const contextHygiene = buildContextHygieneMetadata({
|
|
187
|
+
tool: "grep",
|
|
188
|
+
classification: "search-context",
|
|
189
|
+
resources: contextHygieneResources,
|
|
190
|
+
rehydrate: input.rehydrate ?? undefined,
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
text,
|
|
194
|
+
readseekValue,
|
|
195
|
+
contextHygiene,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export interface GrepCallTextResult {
|
|
2
|
+
pattern: string;
|
|
3
|
+
suffix: string | undefined;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function formatGrepCallText(
|
|
7
|
+
args: Record<string, unknown> | undefined,
|
|
8
|
+
): GrepCallTextResult {
|
|
9
|
+
const pattern = typeof args?.pattern === "string" ? args.pattern : "";
|
|
10
|
+
|
|
11
|
+
const parts: string[] = [];
|
|
12
|
+
if (typeof args?.path === "string" && args.path !== ".") {
|
|
13
|
+
parts.push(args.path as string);
|
|
14
|
+
}
|
|
15
|
+
if (typeof args?.glob === "string") {
|
|
16
|
+
parts.push(args.glob as string);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
pattern,
|
|
21
|
+
suffix: parts.length > 0 ? parts.join(" ") : undefined,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const GREP_TRUNCATION_THRESHOLD = 50;
|
|
26
|
+
|
|
27
|
+
export interface GrepResultTextInput {
|
|
28
|
+
totalMatches: number;
|
|
29
|
+
summary: boolean;
|
|
30
|
+
records: unknown[];
|
|
31
|
+
fileCount: number;
|
|
32
|
+
hasBinaryWarning?: boolean;
|
|
33
|
+
isError?: boolean;
|
|
34
|
+
errorText?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GrepResultTextOutput {
|
|
38
|
+
summary: string;
|
|
39
|
+
badges: string[];
|
|
40
|
+
noMatches: boolean;
|
|
41
|
+
truncated: boolean;
|
|
42
|
+
errorText: string | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatGrepResultText(input: GrepResultTextInput): GrepResultTextOutput {
|
|
46
|
+
// Error case
|
|
47
|
+
if (input.isError && input.errorText) {
|
|
48
|
+
return {
|
|
49
|
+
summary: "",
|
|
50
|
+
badges: [],
|
|
51
|
+
noMatches: false,
|
|
52
|
+
truncated: false,
|
|
53
|
+
errorText: input.errorText,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { totalMatches, summary, fileCount } = input;
|
|
58
|
+
const noMatches = totalMatches === 0;
|
|
59
|
+
const truncated = totalMatches >= GREP_TRUNCATION_THRESHOLD;
|
|
60
|
+
|
|
61
|
+
const matchWord = totalMatches === 1 ? "match" : "matches";
|
|
62
|
+
const fileWord = fileCount === 1 ? "file" : "files";
|
|
63
|
+
const summaryText = noMatches ? "" : `\u2713 ${totalMatches} ${matchWord} in ${fileCount} ${fileWord}`;
|
|
64
|
+
|
|
65
|
+
const badges: string[] = [];
|
|
66
|
+
if (truncated) badges.push("10/file cap");
|
|
67
|
+
if (summary) badges.push("summary");
|
|
68
|
+
if (input.hasBinaryWarning) badges.push("\u26a0 binary");
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
summary: summaryText,
|
|
72
|
+
badges,
|
|
73
|
+
noMatches,
|
|
74
|
+
truncated,
|
|
75
|
+
errorText: undefined,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { buildReadseekLine } from "./readseek-value.js";
|
|
2
|
+
import type { GrepOutputEntry, GrepOutputGroup, GrepOutputScopeSymbol, GrepScopeWarning } from "./grep-output.js";
|
|
3
|
+
import type { FileMap, FileSymbol } from "./readseek/types.js";
|
|
4
|
+
|
|
5
|
+
interface FlatSymbol extends GrepOutputScopeSymbol {
|
|
6
|
+
rangeSize: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function flattenSymbols(symbols: FileSymbol[], parentName?: string): FlatSymbol[] {
|
|
10
|
+
const flattened: FlatSymbol[] = [];
|
|
11
|
+
for (const symbol of symbols) {
|
|
12
|
+
flattened.push({
|
|
13
|
+
name: symbol.name,
|
|
14
|
+
kind: symbol.kind,
|
|
15
|
+
startLine: symbol.startLine,
|
|
16
|
+
endLine: symbol.endLine,
|
|
17
|
+
parentName,
|
|
18
|
+
rangeSize: symbol.endLine - symbol.startLine,
|
|
19
|
+
});
|
|
20
|
+
if (symbol.children?.length) flattened.push(...flattenSymbols(symbol.children, symbol.name));
|
|
21
|
+
}
|
|
22
|
+
return flattened;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function findEnclosingSymbol(map: FileMap, lineNumber: number): GrepOutputScopeSymbol | null {
|
|
26
|
+
const candidates = flattenSymbols(map.symbols)
|
|
27
|
+
.filter((s) => lineNumber >= s.startLine && lineNumber <= s.endLine)
|
|
28
|
+
.sort((a, b) => {
|
|
29
|
+
if (a.rangeSize !== b.rangeSize) return a.rangeSize - b.rangeSize;
|
|
30
|
+
if (a.startLine !== b.startLine) return a.startLine - b.startLine;
|
|
31
|
+
return a.name.localeCompare(b.name);
|
|
32
|
+
});
|
|
33
|
+
if (!candidates.length) return null;
|
|
34
|
+
const { rangeSize: _rangeSize, ...symbol } = candidates[0];
|
|
35
|
+
return symbol;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function firstLineNumber(group: GrepOutputGroup): number {
|
|
39
|
+
const first = group.entries.find((e) => e.kind !== "separator");
|
|
40
|
+
return first ? first.line.line : Number.MAX_SAFE_INTEGER;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildSymbolEntries(
|
|
44
|
+
fileLines: string[],
|
|
45
|
+
symbol: GrepOutputScopeSymbol,
|
|
46
|
+
matchLines: Set<number>,
|
|
47
|
+
scopeContext: number | undefined,
|
|
48
|
+
): GrepOutputEntry[] {
|
|
49
|
+
if (scopeContext === undefined) {
|
|
50
|
+
const entries: GrepOutputEntry[] = [];
|
|
51
|
+
for (let lineNumber = symbol.startLine; lineNumber <= symbol.endLine; lineNumber++) {
|
|
52
|
+
const built = buildReadseekLine(lineNumber, fileLines[lineNumber - 1] ?? "");
|
|
53
|
+
entries.push({ kind: matchLines.has(lineNumber) ? "match" : "context", line: built });
|
|
54
|
+
}
|
|
55
|
+
return entries;
|
|
56
|
+
}
|
|
57
|
+
// Windowed path: ±scopeContext lines, clipped, merged, with '--' separators between non-overlapping ranges.
|
|
58
|
+
const ranges = [...matchLines].sort((a, b) => a - b).map((ln) => ({
|
|
59
|
+
startLine: Math.max(symbol.startLine, ln - scopeContext),
|
|
60
|
+
endLine: Math.min(symbol.endLine, ln + scopeContext),
|
|
61
|
+
}));
|
|
62
|
+
const merged: Array<{ startLine: number; endLine: number }> = [];
|
|
63
|
+
for (const r of ranges) {
|
|
64
|
+
const last = merged[merged.length - 1];
|
|
65
|
+
if (last && r.startLine <= last.endLine + 1) {
|
|
66
|
+
last.endLine = Math.max(last.endLine, r.endLine);
|
|
67
|
+
} else {
|
|
68
|
+
merged.push({ ...r });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const entries: GrepOutputEntry[] = [];
|
|
72
|
+
for (let i = 0; i < merged.length; i++) {
|
|
73
|
+
if (i > 0) entries.push({ kind: "separator", text: "--" });
|
|
74
|
+
const range = merged[i];
|
|
75
|
+
for (let ln = range.startLine; ln <= range.endLine; ln++) {
|
|
76
|
+
const built = buildReadseekLine(ln, fileLines[ln - 1] ?? "");
|
|
77
|
+
entries.push({ kind: matchLines.has(ln) ? "match" : "context", line: built });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return entries;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildFallbackEntries(fileLines: string[], matchLines: number[], contextLines: number): GrepOutputEntry[] {
|
|
84
|
+
const lineMap = new Map<number, GrepOutputEntry>();
|
|
85
|
+
for (const matchLine of matchLines) {
|
|
86
|
+
const start = Math.max(1, matchLine - contextLines);
|
|
87
|
+
const end = Math.min(fileLines.length, matchLine + contextLines);
|
|
88
|
+
for (let lineNumber = start; lineNumber <= end; lineNumber++) {
|
|
89
|
+
const built = buildReadseekLine(lineNumber, fileLines[lineNumber - 1] ?? "");
|
|
90
|
+
const candidate: GrepOutputEntry = { kind: lineNumber === matchLine ? "match" : "context", line: built };
|
|
91
|
+
const existing = lineMap.get(lineNumber);
|
|
92
|
+
if (!existing || (existing.kind === "context" && candidate.kind === "match")) {
|
|
93
|
+
lineMap.set(lineNumber, candidate);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const ordered = [...lineMap.entries()].sort(([a], [b]) => a - b);
|
|
98
|
+
const entries: GrepOutputEntry[] = [];
|
|
99
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
100
|
+
if (i > 0 && ordered[i][0] > ordered[i - 1][0] + 1) entries.push({ kind: "separator", text: "--" });
|
|
101
|
+
entries.push(ordered[i][1]);
|
|
102
|
+
}
|
|
103
|
+
return entries;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function scopeGrepGroupsToSymbols(input: {
|
|
107
|
+
groups: GrepOutputGroup[];
|
|
108
|
+
fileLinesByPath: Map<string, string[]>;
|
|
109
|
+
fileMapsByPath: Map<string, FileMap | null>;
|
|
110
|
+
contextLines: number;
|
|
111
|
+
scopeContext?: number;
|
|
112
|
+
}): { groups: GrepOutputGroup[]; warnings: GrepScopeWarning[] } {
|
|
113
|
+
const warnings: GrepScopeWarning[] = [];
|
|
114
|
+
const rendered: Array<{ order: number; group: GrepOutputGroup }> = [];
|
|
115
|
+
|
|
116
|
+
for (const group of input.groups) {
|
|
117
|
+
const fileLines = input.fileLinesByPath.get(group.absolutePath);
|
|
118
|
+
const fileMap = input.fileMapsByPath.get(group.absolutePath) ?? null;
|
|
119
|
+
|
|
120
|
+
if (!fileLines || !fileMap) {
|
|
121
|
+
warnings.push({
|
|
122
|
+
code: "unmappable-file",
|
|
123
|
+
message: `[Warning: symbol scoping unavailable for ${group.absolutePath} — showing normal grep lines for this file]`,
|
|
124
|
+
path: group.absolutePath,
|
|
125
|
+
});
|
|
126
|
+
rendered.push({ order: firstLineNumber(group), group });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const symbolBuckets = new Map<string, { symbol: GrepOutputScopeSymbol; matchLines: Set<number> }>();
|
|
131
|
+
const fallbackMatchLines: number[] = [];
|
|
132
|
+
|
|
133
|
+
for (const entry of group.entries) {
|
|
134
|
+
if (entry.kind !== "match") continue;
|
|
135
|
+
const symbol = findEnclosingSymbol(fileMap, entry.line.line);
|
|
136
|
+
if (!symbol) {
|
|
137
|
+
fallbackMatchLines.push(entry.line.line);
|
|
138
|
+
warnings.push({
|
|
139
|
+
code: "no-enclosing-symbol",
|
|
140
|
+
message: `[Warning: no enclosing symbol for ${group.absolutePath}:${entry.line.line} — showing normal grep lines for this match]`,
|
|
141
|
+
path: group.absolutePath,
|
|
142
|
+
line: entry.line.line,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const key = `${symbol.startLine}:${symbol.endLine}:${symbol.parentName ?? ""}:${symbol.name}`;
|
|
148
|
+
const bucket = symbolBuckets.get(key) ?? { symbol, matchLines: new Set<number>() };
|
|
149
|
+
bucket.matchLines.add(entry.line.line);
|
|
150
|
+
symbolBuckets.set(key, bucket);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const scopedGroups = [...symbolBuckets.values()]
|
|
154
|
+
.sort((a, b) => {
|
|
155
|
+
if (a.symbol.startLine !== b.symbol.startLine) return a.symbol.startLine - b.symbol.startLine;
|
|
156
|
+
return a.symbol.name.localeCompare(b.symbol.name);
|
|
157
|
+
})
|
|
158
|
+
.map(({ symbol, matchLines }) => ({
|
|
159
|
+
displayPath: group.displayPath,
|
|
160
|
+
absolutePath: group.absolutePath,
|
|
161
|
+
matchCount: matchLines.size,
|
|
162
|
+
scope: {
|
|
163
|
+
mode: "symbol" as const,
|
|
164
|
+
symbol,
|
|
165
|
+
matchLines: [...matchLines].sort((a, b) => a - b),
|
|
166
|
+
...(input.scopeContext !== undefined ? { contextLines: input.scopeContext } : {}),
|
|
167
|
+
},
|
|
168
|
+
entries: buildSymbolEntries(fileLines, symbol, matchLines, input.scopeContext),
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
for (const scopedGroup of scopedGroups) rendered.push({ order: scopedGroup.scope!.symbol.startLine, group: scopedGroup });
|
|
172
|
+
|
|
173
|
+
if (fallbackMatchLines.length > 0) {
|
|
174
|
+
rendered.push({
|
|
175
|
+
order: Math.min(...fallbackMatchLines),
|
|
176
|
+
group: {
|
|
177
|
+
displayPath: group.displayPath,
|
|
178
|
+
absolutePath: group.absolutePath,
|
|
179
|
+
matchCount: fallbackMatchLines.length,
|
|
180
|
+
entries: buildFallbackEntries(fileLines, fallbackMatchLines, input.contextLines),
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (scopedGroups.length === 0 && fallbackMatchLines.length === 0) {
|
|
186
|
+
rendered.push({ order: firstLineNumber(group), group });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
rendered.sort((a, b) => {
|
|
191
|
+
if (a.order !== b.order) return a.order - b.order;
|
|
192
|
+
if (a.group.absolutePath !== b.group.absolutePath) return a.group.absolutePath.localeCompare(b.group.absolutePath);
|
|
193
|
+
return a.group.displayPath.localeCompare(b.group.displayPath);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return { groups: rendered.map((item) => item.group), warnings };
|
|
197
|
+
}
|