pi-supernova 0.10.2 → 0.11.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 +365 -90
- package/docs/CHANGELOG.md +89 -5
- package/docs/TOKEN_COSTS.md +149 -324
- package/index.js +40 -11
- package/package.json +4 -3
- package/src/adapters/bash.js +40 -10
- package/src/adapters/edit.js +77 -23
- package/src/adapters/errors.js +4 -0
- package/src/adapters/index.js +1 -0
- package/src/adapters/list.js +6 -3
- package/src/adapters/read-focus.js +6 -0
- package/src/adapters/read-image.js +17 -9
- package/src/adapters/read-indexed.js +72 -0
- package/src/adapters/read-text.js +19 -2
- package/src/adapters/read.js +57 -13
- package/src/adapters/refs.js +17 -7
- package/src/adapters/write.js +14 -21
- package/src/bridge/host-bridge.js +152 -39
- package/src/bridge/invoke.js +3 -1
- package/src/bridge/tool-registry.js +132 -16
- package/src/context/evidence-graph.js +1 -0
- package/src/context/evidence-rank.js +1 -0
- package/src/context/evidence.js +4 -2
- package/src/context/fuzzy.js +31 -8
- package/src/context/outline.js +33 -9
- package/src/context/query.js +7 -2
- package/src/context/repo-index.js +8 -3
- package/src/context/search-files.js +20 -0
- package/src/context/search.js +25 -10
- package/src/context/snap-search.js +28 -6
- package/src/context/snap.js +8 -3
- package/src/context/source-entry.js +64 -28
- package/src/context/spans.js +1 -0
- package/src/contract/bash.js +16 -7
- package/src/contract/edit.js +26 -7
- package/src/contract/program.js +2 -1
- package/src/contract/read.js +46 -46
- package/src/fs/background.js +10 -2
- package/src/fs/check.js +844 -46
- package/src/fs/commit.js +89 -15
- package/src/fs/diff.js +12 -9
- package/src/fs/directory.js +14 -4
- package/src/fs/file-io.js +28 -7
- package/src/fs/glob.js +93 -11
- package/src/fs/json-read.js +3 -2
- package/src/fs/json-size.js +2 -1
- package/src/fs/lines.js +49 -2
- package/src/fs/patch.js +26 -10
- package/src/fs/process-tree.js +19 -1
- package/src/fs/read-window.js +36 -20
- package/src/fs/source-window.js +5 -10
- package/src/fs/text-ops.js +38 -18
- package/src/fs/vfs.js +296 -41
- package/src/fs/workspace.js +24 -6
- package/src/output/bottleneck.js +50 -25
- package/src/output/final.js +20 -0
- package/src/output/format.js +97 -36
- package/src/output/outcome.js +16 -4
- package/src/runtime/batch-input.js +17 -3
- package/src/runtime/guest-api.js +32 -7
- package/src/runtime/guest-worker.js +22 -4
- package/src/runtime/parallel.js +9 -49
- package/src/runtime/program-batch.js +53 -28
- package/src/runtime/program-file.js +2 -1
- package/src/runtime/program.js +94 -41
- package/src/runtime/reference.js +10 -9
- package/src/runtime/runtime.js +69 -18
- package/src/runtime/worker-pool.js +10 -1
- package/src/shared/decode.js +9 -8
- package/src/shared/image-worker.js +7 -0
- package/src/shared/image.js +58 -15
- package/src/shared/png.js +18 -0
- package/src/shared/result.js +30 -9
- package/src/shared/syntax-context.js +7 -1
- package/src/shared/utf8.js +11 -13
- package/src/ui/host-render.js +4 -0
- package/src/ui/omp-frame.js +2 -0
- package/src/ui/progress.js +2 -1
- package/src/ui/render-measure.js +11 -0
- package/src/ui/render.js +92 -31
- package/src/ui/trace.js +8 -5
package/src/fs/patch.js
CHANGED
|
@@ -78,8 +78,8 @@ function splitFile(text) {
|
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
function findHunkMatch(fileLines, expectedOld, nominal, hunk, oldStart) {
|
|
82
|
-
const matchAt = index => index >=
|
|
81
|
+
function findHunkMatch(fileLines, expectedOld, nominal, hunk, oldStart, floor) {
|
|
82
|
+
const matchAt = index => index >= floor && index + expectedOld.length <= fileLines.length
|
|
83
83
|
&& expectedOld.every((line, i) => fileLines[index + i].text === line);
|
|
84
84
|
|
|
85
85
|
if (matchAt(nominal)) return { index: nominal, relocated: 0 };
|
|
@@ -102,8 +102,11 @@ function findHunkMatch(fileLines, expectedOld, nominal, hunk, oldStart) {
|
|
|
102
102
|
return { index: -1, relocated: 0 };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function hunkLineText(
|
|
106
|
-
|
|
105
|
+
function hunkLineText(hunk, i) {
|
|
106
|
+
const text = hunk.lines[i].slice(1);
|
|
107
|
+
|
|
108
|
+
// Without an original newline, a trailing CR is payload, not a CRLF ending.
|
|
109
|
+
return hunk.noNewline.includes(i) ? text : text.replace(/\r$/, "");
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
function applyHunkLines(hunk, fileLines, matchIndex, ending) {
|
|
@@ -114,7 +117,7 @@ function applyHunkLines(hunk, fileLines, matchIndex, ending) {
|
|
|
114
117
|
const line = hunk.lines[i];
|
|
115
118
|
|
|
116
119
|
if (line[0] === "+") {
|
|
117
|
-
replacement.push({ text: hunkLineText(
|
|
120
|
+
replacement.push({ text: hunkLineText(hunk, i), ending: hunk.noNewline.includes(i) ? "" : ending });
|
|
118
121
|
} else {
|
|
119
122
|
const original = fileLines[oldIndex++];
|
|
120
123
|
|
|
@@ -127,23 +130,33 @@ function applyHunkLines(hunk, fileLines, matchIndex, ending) {
|
|
|
127
130
|
return replacement;
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
function applyOneHunk(hunk, h, fileLines, offsetShift, relocationShift, ending) {
|
|
131
|
-
const expectedOld = hunk.lines.
|
|
133
|
+
function applyOneHunk(hunk, h, fileLines, offsetShift, relocationShift, ending, floor) {
|
|
134
|
+
const expectedOld = hunk.lines.flatMap((line, i) => line[0] !== "+" ? [hunkLineText(hunk, i)] : []);
|
|
132
135
|
const newCount = hunk.lines.filter(line => line[0] !== "-").length;
|
|
133
136
|
|
|
134
137
|
if (expectedOld.length !== hunk.oldLength || newCount !== hunk.newLength) throw new Error("patch hunk " + (h + 1) + " length does not match its header");
|
|
135
138
|
// The new coordinate also handles BSD diff's -1,0 header at file start.
|
|
136
139
|
const nominal = hunk.oldLength === 0 ? hunk.newStart - 1 + relocationShift : hunk.oldStart - 1 + offsetShift;
|
|
137
|
-
const match = findHunkMatch(fileLines, expectedOld, nominal, h + 1, hunk.oldStart);
|
|
140
|
+
const match = findHunkMatch(fileLines, expectedOld, nominal, h + 1, hunk.oldStart, floor);
|
|
138
141
|
|
|
139
142
|
if (match.index < 0) throw new Error("patch hunk " + (h + 1) + " rejected at line " + hunk.oldStart + ": context did not match");
|
|
140
143
|
const replacement = applyHunkLines(hunk, fileLines, match.index, ending);
|
|
141
|
-
fileLines.
|
|
144
|
+
const tailEnd = fileLines.length;
|
|
145
|
+
const length = tailEnd + replacement.length - expectedOld.length;
|
|
146
|
+
|
|
147
|
+
// Valid hunks can exceed the engine's argument-count limit. Move the tail
|
|
148
|
+
// in place rather than passing every replacement line as a splice argument.
|
|
149
|
+
if (length > tailEnd) fileLines.length = length;
|
|
150
|
+
fileLines.copyWithin(match.index + replacement.length, match.index + expectedOld.length, tailEnd);
|
|
151
|
+
fileLines.length = length;
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < replacement.length; i++) fileLines[match.index + i] = replacement[i];
|
|
142
154
|
|
|
143
155
|
return {
|
|
144
156
|
relocationShift: relocationShift + match.relocated,
|
|
145
157
|
offsetShift: offsetShift + match.relocated + replacement.length - expectedOld.length,
|
|
146
158
|
relocated: match.relocated,
|
|
159
|
+
floor: match.index + replacement.length,
|
|
147
160
|
};
|
|
148
161
|
}
|
|
149
162
|
|
|
@@ -155,11 +168,14 @@ export function applyPatchToText(originalText, patchText) {
|
|
|
155
168
|
const relocations = [];
|
|
156
169
|
let offsetShift = 0;
|
|
157
170
|
let relocationShift = 0;
|
|
171
|
+
// Later hunks cannot reinterpret already-published output as original input.
|
|
172
|
+
let floor = 0;
|
|
158
173
|
|
|
159
174
|
for (let h = 0; h < hunks.length; h++) {
|
|
160
|
-
const applied = applyOneHunk(hunks[h], h, fileLines, offsetShift, relocationShift, ending);
|
|
175
|
+
const applied = applyOneHunk(hunks[h], h, fileLines, offsetShift, relocationShift, ending, floor);
|
|
161
176
|
offsetShift = applied.offsetShift;
|
|
162
177
|
relocationShift = applied.relocationShift;
|
|
178
|
+
floor = applied.floor;
|
|
163
179
|
|
|
164
180
|
if (applied.relocated !== 0) relocations.push({ hunk: h + 1, offset: applied.relocated });
|
|
165
181
|
}
|
package/src/fs/process-tree.js
CHANGED
|
@@ -54,6 +54,24 @@ function signalGroups(job, signal) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
function waitForRetirement(job) {
|
|
58
|
+
if (job.processClosed) return delay(10);
|
|
59
|
+
|
|
60
|
+
// A child normally closes its pipes just after exit. Wake on that event rather
|
|
61
|
+
// than charging every successful command a polling interval. Keep a fallback
|
|
62
|
+
// for stuck pipes; group disappearance is still checked by the caller.
|
|
63
|
+
return new Promise(resolve => {
|
|
64
|
+
const done = () => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
job.child.off("close", done);
|
|
67
|
+
resolve();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const timer = setTimeout(done, 10);
|
|
71
|
+
job.child.once("close", done);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
// Both foreground and background commands own their POSIX groups until they
|
|
58
76
|
// disappear, not merely until the leader exits or inherited output pipes close.
|
|
59
77
|
// Callers update processExited/processClosed from the real child events and may
|
|
@@ -85,7 +103,7 @@ export async function retireProcessTree(job) {
|
|
|
85
103
|
if (Date.now() >= deadline) throw new Error("owned process group or output pipes did not close" + (job.signalError ? ": " + job.signalError : ""));
|
|
86
104
|
|
|
87
105
|
if (process.platform !== "win32") signalGroups(job, "SIGKILL");
|
|
88
|
-
await
|
|
106
|
+
await waitForRetirement(job);
|
|
89
107
|
}
|
|
90
108
|
|
|
91
109
|
if (discoveryError) throw new Error("descendant discovery failed: " + discoveryError.message);
|
package/src/fs/read-window.js
CHANGED
|
@@ -16,15 +16,18 @@ function advanceLines(bytes, start, remaining) {
|
|
|
16
16
|
return { offset: start, remaining };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
async function scanWindow(file,
|
|
20
|
-
const scan = { parts: [], collected: 0, startByte: undefined, endByte: undefined };
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
async function scanWindow(file, startLine, lineCount, maxBytes, signal) {
|
|
20
|
+
const scan = { parts: [], collected: 0, startByte: undefined, endByte: undefined, readBytes: 0, eof: false };
|
|
21
|
+
// Empty windows need an emptiness probe, not a seek to the requested line.
|
|
22
|
+
let skip = lineCount === 0 ? 0 : startLine - 1, take = lineCount ?? Infinity;
|
|
23
|
+
|
|
24
|
+
// Regular virtual files can report zero, undersized or oversized stat lengths.
|
|
25
|
+
// Only the read stream establishes EOF and whether a selected window is whole.
|
|
26
|
+
for await (const chunk of fileChunks(file, signal, Infinity, lineCount === 0 ? 1 : undefined)) {
|
|
24
27
|
const head = advanceLines(chunk, 0, skip);
|
|
25
28
|
skip = head.remaining;
|
|
26
|
-
const start =
|
|
27
|
-
|
|
29
|
+
const start = scan.readBytes;
|
|
30
|
+
scan.readBytes += chunk.length;
|
|
28
31
|
|
|
29
32
|
if (skip) continue;
|
|
30
33
|
scan.startByte ??= start + head.offset;
|
|
@@ -39,9 +42,11 @@ async function scanWindow(file, stat, startLine, lineCount, maxBytes, signal) {
|
|
|
39
42
|
scan.collected += end - head.offset;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
if (take === 0
|
|
45
|
+
if (scan.collected > maxBytes || take === 0 && tail.offset < chunk.length) return scan;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
scan.eof = true;
|
|
49
|
+
|
|
45
50
|
return scan;
|
|
46
51
|
}
|
|
47
52
|
|
|
@@ -59,7 +64,7 @@ async function openReadFile(target) {
|
|
|
59
64
|
|
|
60
65
|
export function createWindowReader(vfs) {
|
|
61
66
|
return async function readWindow(target, startLine, lineCount, maxBytes, signal) {
|
|
62
|
-
const overlay = vfs.getOverlay(target);
|
|
67
|
+
const overlay = await vfs.getOverlay(target);
|
|
63
68
|
|
|
64
69
|
if (overlay !== undefined) {
|
|
65
70
|
const window = overlayWindow(overlay,startLine,lineCount);
|
|
@@ -70,22 +75,33 @@ export function createWindowReader(vfs) {
|
|
|
70
75
|
|
|
71
76
|
const file = await openReadFile(target);
|
|
72
77
|
|
|
78
|
+
let stat, result, wholeBytes;
|
|
79
|
+
|
|
73
80
|
try {
|
|
74
|
-
|
|
81
|
+
stat = await file.stat();
|
|
75
82
|
|
|
76
83
|
if (!stat.isFile()) throw new Error("read requires a regular file: " + target);
|
|
77
84
|
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
const scan = await scanWindow(file, startLine, lineCount, maxBytes, signal);
|
|
86
|
+
|
|
87
|
+
if (scan.startByte === undefined) {
|
|
88
|
+
result = { text: "", satisfied: true, whole: scan.eof && scan.readBytes === 0 };
|
|
89
|
+
} else {
|
|
90
|
+
if (scan.collected > maxBytes) return { text: "", satisfied: false, whole: false };
|
|
91
|
+
const bytes = Buffer.concat(scan.parts, scan.collected);
|
|
92
|
+
const end = scan.startByte + scan.collected;
|
|
93
|
+
const eof = scan.eof;
|
|
94
|
+
const text = eof ? decodeUtf8Strict(bytes, target) : decodeUtf8Window(bytes, target);
|
|
95
|
+
const whole = startLine === 1 && scan.startByte === 0 && eof;
|
|
96
|
+
// The digest-reuse contract requires bytes agreeing with observed size.
|
|
97
|
+
wholeBytes = whole && bytes.length === stat.size ? bytes : undefined;
|
|
98
|
+
result = { text, satisfied: end >= scan.endByte || eof, whole };
|
|
99
|
+
}
|
|
100
|
+
} finally { await file.close(); }
|
|
80
101
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const end = scan.startByte + scan.collected;
|
|
84
|
-
const eof = end >= stat.size;
|
|
85
|
-
const text = eof ? decodeUtf8Strict(bytes, target) : decodeUtf8Window(bytes);
|
|
86
|
-
await vfs.recordExpected(target, stat);
|
|
102
|
+
// An open read handle can prevent Windows from restoring a rollback backup.
|
|
103
|
+
await vfs.recordExpected(target, stat, wholeBytes);
|
|
87
104
|
|
|
88
|
-
|
|
89
|
-
} finally { await file.close(); }
|
|
105
|
+
return result;
|
|
90
106
|
};
|
|
91
107
|
}
|
package/src/fs/source-window.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { relativeSlash } from "./workspace.js";
|
|
4
|
+
import { sameFileVersion, readLimitedBytes } from "./file-io.js";
|
|
4
5
|
|
|
5
6
|
export const SOURCE_REF = /((?:\/|[A-Za-z]:[\\/])?(?:[\w.@-]+[\\/])*[\w.@-]+\.(?:m?[jt]sx?|c[jt]s|py|rs|go|java|kt|rb|php|c|cc|cpp|h|hpp|cs|swift|json|ya?ml|toml))(?::|\()(\d+)/g;
|
|
6
7
|
|
|
@@ -15,18 +16,11 @@ async function readBoundedFile(real, rootPrefix, signal) {
|
|
|
15
16
|
const stat = await handle.stat();
|
|
16
17
|
|
|
17
18
|
if (!real.startsWith(rootPrefix) || !stat.isFile() || stat.size > 1024 * 1024) return null;
|
|
18
|
-
const buffer =
|
|
19
|
-
let offset = 0;
|
|
19
|
+
const buffer = await readLimitedBytes(handle, stat, 1024 * 1024, "diagnostic input", signal);
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
21
|
+
if (!sameFileVersion(stat, await handle.stat())) return null;
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
offset += bytesRead;
|
|
26
|
-
signal?.throwIfAborted();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return buffer.subarray(0, offset).toString("utf8");
|
|
23
|
+
return buffer.toString("utf8");
|
|
30
24
|
} finally { await handle.close(); }
|
|
31
25
|
}
|
|
32
26
|
|
|
@@ -71,6 +65,7 @@ export async function sourceForReferences(cwd, commandCwd, output, signal, ledge
|
|
|
71
65
|
const key = m[1] + ":" + m[2];
|
|
72
66
|
|
|
73
67
|
if (seen.has(key)) continue;
|
|
68
|
+
|
|
74
69
|
if (seen.size >= 4) break;
|
|
75
70
|
seen.add(key);
|
|
76
71
|
const block = await sourceWindow(cwd, commandCwd, m[1], Number(m[2]), signal, ledger);
|
package/src/fs/text-ops.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { fileChunks } from "./file-io.js";
|
|
2
|
+
|
|
2
3
|
export {MAX_DIRECTORY_ENTRIES,formatDirectoryEntry,formatLsEntry} from './directory.js';
|
|
4
|
+
|
|
3
5
|
export {textResult,resultDiff} from '../shared/result.js';
|
|
6
|
+
|
|
4
7
|
import {sliceLinesRaw,lineNumberAt,formatNumberedLine,numberedPreview,lineStartIndex,lineEndIndex,lineTextRange,contentLineInfo} from './lines.js';
|
|
8
|
+
|
|
5
9
|
export * from './lines.js';
|
|
10
|
+
|
|
6
11
|
export {jsonStringLength,maxJsonStringPrefix} from './json-size.js';
|
|
12
|
+
|
|
7
13
|
import * as fs from "node:fs/promises";
|
|
8
14
|
import * as path from "node:path";
|
|
9
15
|
import { homedir } from "node:os";
|
|
@@ -27,13 +33,13 @@ export function normalizeReadWindow(params) {
|
|
|
27
33
|
const limit = readLineParam(params.limit, "limit");
|
|
28
34
|
|
|
29
35
|
if (offset !== undefined) normalized.offset = Math.max(1, offset);
|
|
36
|
+
|
|
30
37
|
if (limit !== undefined) normalized.limit = Math.max(0, limit);
|
|
31
38
|
|
|
32
39
|
return normalized;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
export function resolveReadPath(cwd, target) {
|
|
36
|
-
if (!isString(target) || !target.trim()) throw new Error("read requires path");
|
|
37
43
|
const input = assertFilesystemPath(target, "read");
|
|
38
44
|
|
|
39
45
|
return path.resolve(cwd, input === "~" ? homedir() : input.startsWith("~/") ? path.join(homedir(), input.slice(2)) : input);
|
|
@@ -42,8 +48,9 @@ export function resolveReadPath(cwd, target) {
|
|
|
42
48
|
export async function probeExistingPath(cwd, targetParam, vfs) {
|
|
43
49
|
const targetPath = resolveReadPath(cwd, targetParam);
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
const overlay = await vfs.getOverlay(targetPath);
|
|
52
|
+
|
|
53
|
+
if (overlay !== undefined) {
|
|
47
54
|
|
|
48
55
|
return { path: targetPath, directory: false, size: Buffer.byteLength(overlay, "utf8"), overlay };
|
|
49
56
|
}
|
|
@@ -55,7 +62,7 @@ export async function probeExistingPath(cwd, targetParam, vfs) {
|
|
|
55
62
|
} catch (err) {
|
|
56
63
|
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") throw err;
|
|
57
64
|
|
|
58
|
-
if (vfs.getOverlayPaths().some(file => file.startsWith(targetPath + path.sep))) return { path: targetPath, directory: true };
|
|
65
|
+
if ((await vfs.getOverlayPaths(targetPath)).some(file => file.startsWith(targetPath + path.sep))) return { path: targetPath, directory: true };
|
|
59
66
|
|
|
60
67
|
return null;
|
|
61
68
|
}
|
|
@@ -74,7 +81,7 @@ function duplicateEditError(target, content, index, second) {
|
|
|
74
81
|
return new Error("edit target is not unique in " + target + ": lines " + a + " and " + b + "; include more surrounding lines in oldText, or pass edits:[{oldText,newText},…]\n" + formatNumberedLine(a, lineAt(content, a)) + "\n" + formatNumberedLine(b, lineAt(content, b)));
|
|
75
82
|
}
|
|
76
83
|
|
|
77
|
-
/**
|
|
84
|
+
/** Show nearby exact bytes without guessing why the multi-line match failed. */
|
|
78
85
|
function nearMissPreview(content, oldText) {
|
|
79
86
|
const first = String(oldText).split("\n").find(line => line.trim().length > 0);
|
|
80
87
|
|
|
@@ -87,9 +94,10 @@ function nearMissPreview(content, oldText) {
|
|
|
87
94
|
const from = lineStartIndex(content, start);
|
|
88
95
|
const shown = content.slice(from, lineEndIndex(content, from, Math.min(5, line - start + 3))).replace(/\r?\n$/, "");
|
|
89
96
|
|
|
90
|
-
return "
|
|
97
|
+
return "oldText anchor near line " + line + "; exact bytes there:\n"
|
|
91
98
|
+ shown.split("\n").map((text, index) => formatNumberedLine(start + index, text)).join("\n");
|
|
92
99
|
}
|
|
100
|
+
|
|
93
101
|
function matchReplacement(target, content, replacement) {
|
|
94
102
|
if (!isString(replacement?.oldText) || replacement.oldText.length === 0) {
|
|
95
103
|
throw new Error("edit requires non-empty oldText; to insert, include adjacent existing text in oldText and repeat it in newText");
|
|
@@ -103,6 +111,7 @@ function matchReplacement(target, content, replacement) {
|
|
|
103
111
|
if (index < 0) {
|
|
104
112
|
throw new Error("edit target not found in " + target + ": oldText must match the file byte-for-byte\n" + (nearMissPreview(content, oldText) ?? numberedPreview(content)));
|
|
105
113
|
}
|
|
114
|
+
|
|
106
115
|
const second = content.indexOf(oldText, index + 1);
|
|
107
116
|
|
|
108
117
|
if (second >= 0) throw duplicateEditError(target, content, index, second);
|
|
@@ -110,31 +119,38 @@ function matchReplacement(target, content, replacement) {
|
|
|
110
119
|
return { ...replacement, oldText, newText, index, end: index + oldText.length };
|
|
111
120
|
}
|
|
112
121
|
|
|
113
|
-
function assertNoOverlap(target, matches) {
|
|
122
|
+
function assertNoOverlap(target, content, matches) {
|
|
114
123
|
for (let i = 1; i < matches.length; i++) {
|
|
115
|
-
if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}`);
|
|
124
|
+
if (matches[i].index < matches[i - 1].end) throw new Error(`edit targets overlap in ${target}: edits ${matches[i - 1].editNumber} and ${matches[i].editNumber} overlap at line ${lineNumberAt(content, matches[i].index)}; combine overlapping replacements or apply them sequentially against updated text`);
|
|
116
125
|
}
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
export function applyReplacements(target, content, requestedEdits) {
|
|
120
129
|
if (requestedEdits.length === 0) throw new Error("edit requires at least one replacement");
|
|
130
|
+
|
|
121
131
|
const matches = requestedEdits.map((replacement, index) => {
|
|
122
|
-
try { return matchReplacement(target, content, replacement); }
|
|
132
|
+
try { return { ...matchReplacement(target, content, replacement), editNumber: index + 1 }; }
|
|
123
133
|
catch (error) {
|
|
124
134
|
// Name the failing entry: a multi-edit miss is otherwise a guessing game.
|
|
125
135
|
throw requestedEdits.length === 1 ? error : new Error("edit " + (index + 1) + " of " + requestedEdits.length + ": " + error.message);
|
|
126
136
|
}
|
|
127
137
|
});
|
|
138
|
+
|
|
128
139
|
matches.sort((a, b) => a.index - b.index);
|
|
129
|
-
assertNoOverlap(target, matches);
|
|
130
|
-
|
|
140
|
+
assertNoOverlap(target, content, matches);
|
|
141
|
+
const parts = [];
|
|
142
|
+
let from = 0;
|
|
131
143
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
144
|
+
// All coordinates refer to the original. Assemble once instead of copying
|
|
145
|
+
// the growing document for every replacement; inserted text is never rematched.
|
|
146
|
+
for (const match of matches) {
|
|
147
|
+
parts.push(content.slice(from, match.index), match.newText);
|
|
148
|
+
from = match.end;
|
|
135
149
|
}
|
|
136
150
|
|
|
137
|
-
|
|
151
|
+
parts.push(content.slice(from));
|
|
152
|
+
|
|
153
|
+
return { updated: parts.join(""), matches };
|
|
138
154
|
}
|
|
139
155
|
|
|
140
156
|
export function shiftDiffLines(diff, delta) {
|
|
@@ -162,6 +178,7 @@ function withLineEnding(insert, ending) {
|
|
|
162
178
|
|
|
163
179
|
function viewInsertText(content, startIndex, endIndex, newText) {
|
|
164
180
|
const hasSuffix = endIndex < content.length;
|
|
181
|
+
|
|
165
182
|
// A view replaces whole source lines. Preserve the separator before following
|
|
166
183
|
// lines, but let an explicit trailing newline change a no-trailing-newline EOF.
|
|
167
184
|
if (hasSuffix) return content.slice(0, startIndex) + withLineEnding(newText, suffixSeparator(content, endIndex)) + content.slice(endIndex);
|
|
@@ -193,7 +210,9 @@ export function applyViewReplace(target, content, start, end, oldText, newText)
|
|
|
193
210
|
}
|
|
194
211
|
|
|
195
212
|
export const WRITE_DIFF_MAX_READ_BYTES = 512 * 1024;
|
|
213
|
+
|
|
196
214
|
export const WRITE_APPEND_MAX_READ_BYTES = 64 * 1024 * 1024;
|
|
215
|
+
|
|
197
216
|
export const QUICK_CHECK_MAX_CHARS = 2 * 1024 * 1024;
|
|
198
217
|
|
|
199
218
|
async function snapshotLargeFile(vfs, target, overlay, signal) {
|
|
@@ -230,14 +249,14 @@ async function existingStat(target) {
|
|
|
230
249
|
/** Prior body (or line count) for a write receipt / CAS, without always materializing huge files. */
|
|
231
250
|
export async function writeSnapshot(vfs, target, signal) {
|
|
232
251
|
const stat = await existingStat(target);
|
|
233
|
-
const overlay = vfs.getOverlay(target);
|
|
252
|
+
const overlay = await vfs.getOverlay(target);
|
|
234
253
|
const existingBytes = overlay !== undefined ? Buffer.byteLength(overlay, "utf8") : stat?.size;
|
|
235
254
|
|
|
236
255
|
if (existingBytes !== undefined && existingBytes > WRITE_DIFF_MAX_READ_BYTES) {
|
|
237
|
-
return { previous: "", removedLines: await snapshotLargeFile(vfs, target, overlay, signal)
|
|
256
|
+
return { previous: "", removedLines: await snapshotLargeFile(vfs, target, overlay, signal) };
|
|
238
257
|
}
|
|
239
258
|
|
|
240
|
-
return { previous: await snapshotSmallFile(vfs, target, overlay), removedLines: undefined
|
|
259
|
+
return { previous: await snapshotSmallFile(vfs, target, overlay), removedLines: undefined };
|
|
241
260
|
}
|
|
242
261
|
|
|
243
262
|
export async function countContentLines(target, signal) {
|
|
@@ -280,6 +299,7 @@ export function boundedEditDiff(target, original, matches) {
|
|
|
280
299
|
const nextStart = start + shift;
|
|
281
300
|
|
|
282
301
|
for (let i = 0; i < oldInfo.preview.length; i++) lines.push({ type: "remove", lineNum: start + i, newLineNum: nextStart + i, text: oldInfo.preview[i] });
|
|
302
|
+
|
|
283
303
|
for (let i = 0; i < newInfo.preview.length; i++) lines.push({ type: "add", lineNum: nextStart + i, newLineNum: nextStart + i, text: newInfo.preview[i] });
|
|
284
304
|
|
|
285
305
|
shift += newInfo.newlines - oldInfo.newlines;
|