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
package/src/ls.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
|
|
5
|
+
import { readdir, stat } from "node:fs/promises";
|
|
6
|
+
import { resolveToCwd } from "./path-utils.js";
|
|
7
|
+
import { buildReadseekError } from "./readseek-value.js";
|
|
8
|
+
import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
|
|
9
|
+
import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderToolLabel, summaryLine } from "./tui-render-utils.js";
|
|
10
|
+
|
|
11
|
+
const MAX_BYTES = 50 * 1024; // 50 KB
|
|
12
|
+
const DEFAULT_LIMIT = 500;
|
|
13
|
+
|
|
14
|
+
const LS_PROMPT_METADATA = defineToolPromptMetadata({
|
|
15
|
+
promptUrl: new URL("../prompts/ls.md", import.meta.url),
|
|
16
|
+
promptSnippet: "List one directory with directories first and dotfiles included",
|
|
17
|
+
promptGuidelines: [
|
|
18
|
+
"Use ls to inspect one directory; use find for recursive discovery.",
|
|
19
|
+
"Use ls glob to narrow a single-directory listing.",
|
|
20
|
+
"Use read, not ls, for file contents.",
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const LS_READSEEK = {
|
|
25
|
+
callable: true,
|
|
26
|
+
enabled: true,
|
|
27
|
+
policy: "read-only" as const,
|
|
28
|
+
readOnly: true,
|
|
29
|
+
pythonName: "ls",
|
|
30
|
+
defaultExposure: "safe-by-default" as const,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface LsEntry {
|
|
34
|
+
name: string;
|
|
35
|
+
type: "file" | "dir";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LsReadseekValue {
|
|
39
|
+
tool: "ls";
|
|
40
|
+
path: string;
|
|
41
|
+
totalEntries: number;
|
|
42
|
+
truncated: boolean;
|
|
43
|
+
entries: LsEntry[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sortEntries(entries: LsEntry[]): LsEntry[] {
|
|
47
|
+
const dirs = entries.filter((e) => e.type === "dir");
|
|
48
|
+
const files = entries.filter((e) => e.type === "file");
|
|
49
|
+
const cmp = (a: LsEntry, b: LsEntry) => {
|
|
50
|
+
const lower = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
51
|
+
return lower !== 0 ? lower : a.name.localeCompare(b.name);
|
|
52
|
+
};
|
|
53
|
+
dirs.sort(cmp);
|
|
54
|
+
files.sort(cmp);
|
|
55
|
+
return [...dirs, ...files];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatOutput(entries: LsEntry[], totalCount: number, truncated: boolean): string {
|
|
59
|
+
const lines: string[] = [];
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
lines.push(e.type === "dir" ? `${e.name}/` : e.name);
|
|
62
|
+
}
|
|
63
|
+
if (truncated) {
|
|
64
|
+
const remaining = totalCount - entries.length;
|
|
65
|
+
lines.push(`[… ${remaining} more entries — use glob to narrow results]`);
|
|
66
|
+
}
|
|
67
|
+
if (entries.length === 0 && !truncated) {
|
|
68
|
+
return "(empty directory)";
|
|
69
|
+
}
|
|
70
|
+
let text = lines.join("\n");
|
|
71
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
72
|
+
if (bytes > MAX_BYTES) {
|
|
73
|
+
text = Buffer.from(text, "utf8").subarray(0, MAX_BYTES).toString("utf8") + "\n[… truncated at 50 KB]";
|
|
74
|
+
}
|
|
75
|
+
return text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateGlobBalance(glob: string): string | null {
|
|
79
|
+
let brackets = 0;
|
|
80
|
+
let braces = 0;
|
|
81
|
+
for (let i = 0; i < glob.length; i++) {
|
|
82
|
+
const ch = glob[i];
|
|
83
|
+
if (ch === "\\") {
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (ch === "[") brackets++;
|
|
88
|
+
else if (ch === "]") {
|
|
89
|
+
if (brackets === 0) return "Unmatched ']'.";
|
|
90
|
+
brackets--;
|
|
91
|
+
} else if (ch === "{") braces++;
|
|
92
|
+
else if (ch === "}") {
|
|
93
|
+
if (braces === 0) return "Unmatched '}'.";
|
|
94
|
+
braces--;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (brackets !== 0) return "Unterminated character class.";
|
|
98
|
+
if (braces !== 0) return "Unterminated brace expansion.";
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function registerLsTool(pi: ExtensionAPI) {
|
|
103
|
+
const tool: Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof LS_READSEEK } = {
|
|
104
|
+
name: "ls",
|
|
105
|
+
label: "ls",
|
|
106
|
+
description: LS_PROMPT_METADATA.description,
|
|
107
|
+
promptSnippet: LS_PROMPT_METADATA.promptSnippet,
|
|
108
|
+
promptGuidelines: LS_PROMPT_METADATA.promptGuidelines,
|
|
109
|
+
ptc: LS_READSEEK,
|
|
110
|
+
parameters: Type.Object({
|
|
111
|
+
path: Type.Optional(Type.String({ description: "Directory path" })),
|
|
112
|
+
limit: Type.Optional(
|
|
113
|
+
Type.Union(
|
|
114
|
+
[Type.Number(), Type.String()],
|
|
115
|
+
{ description: "Max entries" },
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
glob: Type.Optional(Type.String({ description: "Glob filter" })),
|
|
119
|
+
}),
|
|
120
|
+
async execute(
|
|
121
|
+
_toolCallId: string,
|
|
122
|
+
params: { path?: string; limit?: number | string; glob?: string },
|
|
123
|
+
_signal: AbortSignal | undefined,
|
|
124
|
+
_onUpdate: any,
|
|
125
|
+
ctx: any,
|
|
126
|
+
) {
|
|
127
|
+
const cwd: string = ctx?.cwd ?? process.cwd();
|
|
128
|
+
const targetPath = params.path ? resolveToCwd(params.path, cwd) : cwd;
|
|
129
|
+
const limitCoerced = coerceObviousBase10Int(params.limit, "limit");
|
|
130
|
+
if (!limitCoerced.ok) {
|
|
131
|
+
return {
|
|
132
|
+
content: [{ type: "text" as const, text: limitCoerced.message }],
|
|
133
|
+
isError: true,
|
|
134
|
+
details: {
|
|
135
|
+
readseekValue: {
|
|
136
|
+
tool: "ls" as const,
|
|
137
|
+
ok: false,
|
|
138
|
+
path: params.path ?? targetPath,
|
|
139
|
+
error: buildReadseekError("invalid-limit", limitCoerced.message),
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (limitCoerced.value !== undefined && limitCoerced.value < 1) {
|
|
145
|
+
const message = `Invalid limit: expected a positive integer, received ${limitCoerced.value}.`;
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: "text" as const, text: message }],
|
|
148
|
+
isError: true,
|
|
149
|
+
details: {
|
|
150
|
+
readseekValue: {
|
|
151
|
+
tool: "ls" as const,
|
|
152
|
+
ok: false,
|
|
153
|
+
path: params.path ?? targetPath,
|
|
154
|
+
error: buildReadseekError("invalid-limit", message),
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const limit = limitCoerced.value ?? DEFAULT_LIMIT;
|
|
160
|
+
|
|
161
|
+
// Check if path exists and is a directory
|
|
162
|
+
let pathStat;
|
|
163
|
+
try {
|
|
164
|
+
pathStat = await stat(targetPath);
|
|
165
|
+
} catch (err: any) {
|
|
166
|
+
const target = params.path ?? targetPath;
|
|
167
|
+
const code =
|
|
168
|
+
err?.code === "EACCES" || err?.code === "EPERM"
|
|
169
|
+
? "permission-denied"
|
|
170
|
+
: err?.code === "ENOENT"
|
|
171
|
+
? "path-not-found"
|
|
172
|
+
: "fs-error";
|
|
173
|
+
const message =
|
|
174
|
+
code === "permission-denied"
|
|
175
|
+
? `Error: permission denied for path '${target}'`
|
|
176
|
+
: code === "path-not-found"
|
|
177
|
+
? `Error: path '${target}' does not exist`
|
|
178
|
+
: `Error: could not access path '${target}': ${err?.message ?? String(err)}`;
|
|
179
|
+
return {
|
|
180
|
+
content: [{ type: "text" as const, text: message }],
|
|
181
|
+
isError: true,
|
|
182
|
+
details: {
|
|
183
|
+
readseekValue: {
|
|
184
|
+
tool: "ls",
|
|
185
|
+
ok: false,
|
|
186
|
+
path: target,
|
|
187
|
+
error: buildReadseekError(code, message, undefined, code === "fs-error"
|
|
188
|
+
? { fsCode: err?.code, fsMessage: err?.message }
|
|
189
|
+
: undefined),
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (!pathStat.isDirectory()) {
|
|
195
|
+
const message = `Error: '${params.path ?? targetPath}' is a file, not a directory. Use read to inspect files.`;
|
|
196
|
+
return {
|
|
197
|
+
content: [{ type: "text" as const, text: message }],
|
|
198
|
+
isError: true,
|
|
199
|
+
details: {
|
|
200
|
+
readseekValue: {
|
|
201
|
+
tool: "ls",
|
|
202
|
+
ok: false,
|
|
203
|
+
path: params.path ?? targetPath,
|
|
204
|
+
error: buildReadseekError(
|
|
205
|
+
"path-not-directory",
|
|
206
|
+
message,
|
|
207
|
+
`Use read(${JSON.stringify(params.path ?? targetPath)}) to inspect files.`,
|
|
208
|
+
),
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Read directory
|
|
215
|
+
const dirents = await readdir(targetPath, { withFileTypes: true });
|
|
216
|
+
let allEntries: LsEntry[] = dirents.map((d) => ({
|
|
217
|
+
name: d.name,
|
|
218
|
+
type: d.isDirectory() ? ("dir" as const) : ("file" as const),
|
|
219
|
+
}));
|
|
220
|
+
|
|
221
|
+
// Apply glob filter
|
|
222
|
+
if (params.glob) {
|
|
223
|
+
const balanceError = validateGlobBalance(params.glob);
|
|
224
|
+
if (balanceError) {
|
|
225
|
+
const message = `Invalid glob ${JSON.stringify(params.glob)}: ${balanceError}`;
|
|
226
|
+
return {
|
|
227
|
+
content: [{ type: "text" as const, text: message }],
|
|
228
|
+
isError: true,
|
|
229
|
+
details: {
|
|
230
|
+
readseekValue: {
|
|
231
|
+
tool: "ls" as const,
|
|
232
|
+
ok: false,
|
|
233
|
+
path: params.path ?? targetPath,
|
|
234
|
+
error: buildReadseekError("invalid-params-combo", message),
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const picomatch = (await import("picomatch" as any)).default;
|
|
240
|
+
const isMatch = picomatch(params.glob);
|
|
241
|
+
allEntries = allEntries.filter((e) => isMatch(e.name));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Sort: dirs first, then files, each group alpha case-insensitive
|
|
245
|
+
const sorted = sortEntries(allEntries);
|
|
246
|
+
const totalCount = sorted.length;
|
|
247
|
+
const truncated = totalCount > limit;
|
|
248
|
+
const displayed = truncated ? sorted.slice(0, limit) : sorted;
|
|
249
|
+
|
|
250
|
+
const text = formatOutput(displayed, totalCount, truncated);
|
|
251
|
+
const readseekValue: LsReadseekValue = {
|
|
252
|
+
tool: "ls",
|
|
253
|
+
path: targetPath,
|
|
254
|
+
totalEntries: totalCount,
|
|
255
|
+
truncated,
|
|
256
|
+
entries: displayed,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
content: [{ type: "text" as const, text }],
|
|
261
|
+
details: { readseekValue },
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
renderCall(args: any, theme: any, context: any = {}) {
|
|
266
|
+
const { path } = args as { path?: string };
|
|
267
|
+
const cwd = context.cwd ?? process.cwd();
|
|
268
|
+
const displayPath = path ?? ".";
|
|
269
|
+
const linkedPath = linkToolPath(theme.fg("muted", displayPath), displayPath, cwd);
|
|
270
|
+
return new Text(clampLineToWidth(`${renderToolLabel(theme, "ls")} ${linkedPath}`, context.width), 0, 0);
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
renderResult(result: any, options: any, theme: any, context: any = {}) {
|
|
274
|
+
const expanded = isRendererExpanded(options, context);
|
|
275
|
+
const width = context.width ?? options?.width;
|
|
276
|
+
const output = result.content[0]?.type === "text" ? (result.content[0] as { type: "text"; text: string }).text : "";
|
|
277
|
+
if (result.isError || context.isError) {
|
|
278
|
+
const firstLine = output.split("\n")[0] || "error";
|
|
279
|
+
const body = expanded && output ? output : firstLine;
|
|
280
|
+
return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
|
|
281
|
+
}
|
|
282
|
+
const readseekValue = result.details?.readseekValue as { totalEntries?: number } | undefined;
|
|
283
|
+
const total = readseekValue?.totalEntries ?? output.split("\n").filter(Boolean).length;
|
|
284
|
+
if (total === 0) return new Text(summaryLine("no entries"), 0, 0);
|
|
285
|
+
let text = summaryLine(`${total} ${total === 1 ? "entry" : "entries"} returned`, { hidden: !!output && !expanded });
|
|
286
|
+
if (expanded && output) text += `\n${output}`;
|
|
287
|
+
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
pi.registerTool(tool);
|
|
292
|
+
return tool;
|
|
293
|
+
}
|
package/src/map-cache.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import type { FileMap } from "./readseek/types.js";
|
|
3
|
+
import { generateMap, generateMapWithIdentity, READSEEK_MAPPER_IDENTITY } from "./readseek/mapper.js";
|
|
4
|
+
import {
|
|
5
|
+
computeKey,
|
|
6
|
+
contentHashFor64k,
|
|
7
|
+
readCached,
|
|
8
|
+
writeCached,
|
|
9
|
+
persistenceEnabled,
|
|
10
|
+
} from "./persistent-map-cache.js";
|
|
11
|
+
interface CacheEntry {
|
|
12
|
+
mtimeMs: number;
|
|
13
|
+
contentHash: string;
|
|
14
|
+
map: FileMap | null;
|
|
15
|
+
}
|
|
16
|
+
export const MAP_CACHE_MAX_SIZE = 500;
|
|
17
|
+
|
|
18
|
+
interface MapCacheGlobalState {
|
|
19
|
+
cache: Map<string, CacheEntry>;
|
|
20
|
+
maxSize: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MAP_CACHE_STATE_KEY = Symbol.for("pi-readseek.mapCacheState.v1");
|
|
24
|
+
|
|
25
|
+
function getMapCacheState(): MapCacheGlobalState {
|
|
26
|
+
const globalObject = globalThis as any;
|
|
27
|
+
globalObject[MAP_CACHE_STATE_KEY] ??= {
|
|
28
|
+
cache: new Map<string, CacheEntry>(),
|
|
29
|
+
maxSize: MAP_CACHE_MAX_SIZE,
|
|
30
|
+
} satisfies MapCacheGlobalState;
|
|
31
|
+
return globalObject[MAP_CACHE_STATE_KEY] as MapCacheGlobalState;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rememberInMemory(absPath: string, entry: CacheEntry): void {
|
|
35
|
+
const state = getMapCacheState();
|
|
36
|
+
if (state.cache.has(absPath)) state.cache.delete(absPath);
|
|
37
|
+
state.cache.set(absPath, entry);
|
|
38
|
+
if (state.cache.size > state.maxSize) {
|
|
39
|
+
const oldestKey = state.cache.keys().next().value;
|
|
40
|
+
if (oldestKey !== undefined) state.cache.delete(oldestKey);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function stableContentHash(
|
|
45
|
+
absPath: string,
|
|
46
|
+
mtimeMs: number,
|
|
47
|
+
expectedHash: string,
|
|
48
|
+
): Promise<string | null> {
|
|
49
|
+
if (!expectedHash) return null;
|
|
50
|
+
const currentStat = await stat(absPath);
|
|
51
|
+
if (currentStat.mtimeMs !== mtimeMs) return null;
|
|
52
|
+
const currentHash = await contentHashFor64k(absPath);
|
|
53
|
+
if (!currentHash || currentHash !== expectedHash) return null;
|
|
54
|
+
return currentHash;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get or generate a structural file map, with mtime-based caching.
|
|
59
|
+
* Returns null on any failure — never throws.
|
|
60
|
+
*/
|
|
61
|
+
export async function getOrGenerateMap(absPath: string): Promise<FileMap | null> {
|
|
62
|
+
try {
|
|
63
|
+
const fileStat = await stat(absPath);
|
|
64
|
+
const { mtimeMs } = fileStat;
|
|
65
|
+
const state = getMapCacheState();
|
|
66
|
+
const cached = state.cache.get(absPath);
|
|
67
|
+
if (cached && cached.mtimeMs === mtimeMs) {
|
|
68
|
+
const currentHash = await contentHashFor64k(absPath);
|
|
69
|
+
if (currentHash && currentHash === cached.contentHash) {
|
|
70
|
+
state.cache.delete(absPath);
|
|
71
|
+
state.cache.set(absPath, cached);
|
|
72
|
+
return cached.map;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!persistenceEnabled()) {
|
|
76
|
+
const map = await generateMap(absPath);
|
|
77
|
+
const hash = await contentHashFor64k(absPath);
|
|
78
|
+
rememberInMemory(absPath, { mtimeMs, contentHash: hash, map });
|
|
79
|
+
return map;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let preContentHash = "";
|
|
83
|
+
try {
|
|
84
|
+
preContentHash = await contentHashFor64k(absPath);
|
|
85
|
+
if (preContentHash) {
|
|
86
|
+
const key = computeKey(
|
|
87
|
+
absPath,
|
|
88
|
+
mtimeMs,
|
|
89
|
+
preContentHash,
|
|
90
|
+
READSEEK_MAPPER_IDENTITY.mapperName,
|
|
91
|
+
READSEEK_MAPPER_IDENTITY.mapperVersion,
|
|
92
|
+
);
|
|
93
|
+
const fromDisk = await readCached(key);
|
|
94
|
+
if (fromDisk) {
|
|
95
|
+
rememberInMemory(absPath, { mtimeMs, contentHash: preContentHash, map: fromDisk });
|
|
96
|
+
return fromDisk;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// fall through to regeneration on a disk-cache miss
|
|
101
|
+
}
|
|
102
|
+
const { map, mapperName, mapperVersion } = await generateMapWithIdentity(absPath);
|
|
103
|
+
const persistentIdentity = { mapperName, mapperVersion };
|
|
104
|
+
let stableHash: string | null = null;
|
|
105
|
+
let shouldRemember = true;
|
|
106
|
+
if (preContentHash) {
|
|
107
|
+
try {
|
|
108
|
+
stableHash = await stableContentHash(absPath, mtimeMs, preContentHash);
|
|
109
|
+
shouldRemember = stableHash !== null;
|
|
110
|
+
} catch {
|
|
111
|
+
shouldRemember = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (shouldRemember) {
|
|
115
|
+
const hash = stableHash ?? preContentHash ?? "";
|
|
116
|
+
rememberInMemory(absPath, { mtimeMs, contentHash: hash, map });
|
|
117
|
+
}
|
|
118
|
+
if (map && stableHash) {
|
|
119
|
+
try {
|
|
120
|
+
const key = computeKey(
|
|
121
|
+
absPath,
|
|
122
|
+
mtimeMs,
|
|
123
|
+
stableHash,
|
|
124
|
+
persistentIdentity.mapperName,
|
|
125
|
+
persistentIdentity.mapperVersion,
|
|
126
|
+
);
|
|
127
|
+
await writeCached(key, map);
|
|
128
|
+
} catch {
|
|
129
|
+
// never fail the caller on a cache-write miss
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return map;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export function setMapCacheMaxSize(size: number): void {
|
|
138
|
+
getMapCacheState().maxSize = size;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Clear the map cache. Exported for testing.
|
|
143
|
+
*/
|
|
144
|
+
export function clearMapCache(): void {
|
|
145
|
+
const state = getMapCacheState();
|
|
146
|
+
state.cache.clear();
|
|
147
|
+
state.maxSize = MAP_CACHE_MAX_SIZE;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function __getInMemoryMapCacheSizeForTest(): number {
|
|
151
|
+
return getMapCacheState().cache.size;
|
|
152
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as os from "os";
|
|
2
|
+
import { isAbsolute, resolve as resolvePath } from "path";
|
|
3
|
+
|
|
4
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
5
|
+
|
|
6
|
+
function normalizeUnicodeSpaces(str: string): string {
|
|
7
|
+
return str.replace(UNICODE_SPACES, " ");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeAtPrefix(filePath: string): string {
|
|
11
|
+
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function expandPath(filePath: string): string {
|
|
15
|
+
const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath));
|
|
16
|
+
if (normalized === "~") return os.homedir();
|
|
17
|
+
if (normalized.startsWith("~/")) return os.homedir() + normalized.slice(1);
|
|
18
|
+
return normalized;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveToCwd(filePath: string, cwd: string): string {
|
|
22
|
+
const expanded = expandPath(filePath);
|
|
23
|
+
return isAbsolute(expanded) ? expanded : resolvePath(cwd, expanded);
|
|
24
|
+
}
|