pi-supernova 0.9.1 → 0.10.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 +191 -14
- package/index.js +30 -9
- package/package.json +1 -1
- package/src/adapters/bash.js +40 -0
- package/src/adapters/read.js +24 -3
- package/src/bridge/host-bridge.js +50 -7
- package/src/bridge/invoke.js +6 -1
- package/src/bridge/trace.js +4 -1
- package/src/context/evidence.js +10 -1
- package/src/context/snap.js +11 -2
- package/src/contract/bash.js +43 -11
- package/src/contract/read.js +19 -5
- package/src/fs/background.js +304 -0
- package/src/fs/commit.js +6 -0
- package/src/fs/file-io.js +17 -2
- package/src/fs/process-tree.js +92 -0
- package/src/fs/read-window.js +18 -1
- package/src/fs/vfs.js +17 -4
- package/src/fs/workspace.js +51 -46
- package/src/output/bottleneck.js +14 -1
- package/src/runtime/guest-api.js +36 -20
- package/src/runtime/reference.js +11 -14
- package/src/shared/syntax-context.js +28 -7
package/src/fs/workspace.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {remapReadError} from "./file-io.js";
|
|
2
|
+
import { retireProcessTree } from "./process-tree.js";
|
|
2
3
|
import * as fs from "node:fs/promises";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { spawn } from "node:child_process";
|
|
@@ -45,7 +46,7 @@ async function realpathNearest(target) {
|
|
|
45
46
|
try {
|
|
46
47
|
return await fs.realpath(probe);
|
|
47
48
|
} catch (err) {
|
|
48
|
-
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") remapReadError(err, target);
|
|
49
|
+
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") await remapReadError(err, target);
|
|
49
50
|
const parent = path.dirname(probe);
|
|
50
51
|
|
|
51
52
|
if (parent === probe) throw err;
|
|
@@ -137,21 +138,22 @@ function commandTimeoutMs(options) {
|
|
|
137
138
|
return Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout)));
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
function
|
|
141
|
-
return
|
|
142
|
-
cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
|
|
143
|
-
});
|
|
144
|
-
}
|
|
141
|
+
export function commandSpawnError(error, command) {
|
|
142
|
+
if (error?.code === "EACCES" || error?.code === "EPERM") return new Error("cannot execute " + command + ": permission denied (is it executable?)");
|
|
145
143
|
|
|
146
|
-
|
|
147
|
-
if (!child.pid) return;
|
|
144
|
+
if (error?.code === "ENOENT") return new Error("command not found: " + command);
|
|
148
145
|
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
146
|
+
if (error?.code === "ENOTDIR") return new Error("cannot run " + command + ": the working directory is not a directory");
|
|
147
|
+
|
|
148
|
+
return error;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function spawnCommand(argv, options, cwd = options.cwd) {
|
|
152
|
+
try {
|
|
153
|
+
return spawn(argv[0], argv.slice(1), {
|
|
154
|
+
cwd, env: options.env ?? process.env, stdio: options.stdio ?? ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", windowsHide:true,
|
|
155
|
+
});
|
|
156
|
+
} catch (error) { throw commandSpawnError(error,argv[0]); }
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
function failCommand(state, error) {
|
|
@@ -171,11 +173,29 @@ function failCommand(state, error) {
|
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
function terminateCommand(state, error) {
|
|
174
|
-
if (state.settled
|
|
175
|
-
state.terminationError
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
if (state.settled) return;
|
|
177
|
+
state.terminationError ??= error;
|
|
178
|
+
|
|
179
|
+
if (state.stopping) return;
|
|
180
|
+
clearTimeout(state.timer);
|
|
181
|
+
state.stopping = retireProcessTree(state).then(()=>{
|
|
182
|
+
if (state.terminationError) {
|
|
183
|
+
failCommand(state,state.terminationError);
|
|
184
|
+
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
state.settled = true;
|
|
189
|
+
state.cleanup();
|
|
190
|
+
state.resolve({ stdout:state.stdout, stderr:state.stderr,
|
|
191
|
+
exitCode:state.exitCode ?? (128 + (constants.signals[state.signal] ?? 1)),
|
|
192
|
+
signal:state.signal, outputTruncated:state.outputTruncated });
|
|
193
|
+
}).catch(cause=>{
|
|
194
|
+
state.child.stdout.destroy();
|
|
195
|
+
state.child.stderr.destroy();
|
|
196
|
+
const prefix = state.terminationError ? state.terminationError.message + "; " : "";
|
|
197
|
+
failCommand(state,new Error(prefix + "command cleanup failed: " + cause.message));
|
|
198
|
+
});
|
|
179
199
|
}
|
|
180
200
|
|
|
181
201
|
function appendCommandOutput(state, current, chunk) {
|
|
@@ -186,33 +206,14 @@ function appendCommandOutput(state, current, chunk) {
|
|
|
186
206
|
return remaining ? current + chunk.slice(0, remaining) : current;
|
|
187
207
|
}
|
|
188
208
|
|
|
189
|
-
function onCommandClose(state, code, signal) {
|
|
190
|
-
if (state.settled) return;
|
|
191
|
-
|
|
192
|
-
if (state.terminationError) {
|
|
193
|
-
// A closed pipe alone says nothing about descendants. Only ESRCH proves
|
|
194
|
-
// the owned POSIX group is gone; otherwise retain the escalation timer.
|
|
195
|
-
if (process.platform !== "win32" && state.child.pid) {
|
|
196
|
-
try { process.kill(-state.child.pid, 0); }
|
|
197
|
-
catch (error) { if (error.code === "ESRCH") failCommand(state, state.terminationError); }
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
state.settled = true;
|
|
204
|
-
state.cleanup();
|
|
205
|
-
state.resolve({ stdout: state.stdout, stderr: state.stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated: state.outputTruncated });
|
|
206
|
-
}
|
|
207
|
-
|
|
208
209
|
function attachCommandIO(state, options, argv, timeoutMs) {
|
|
209
210
|
const { child } = state;
|
|
210
211
|
const onAbort = () => terminateCommand(state, new Error("aborted"));
|
|
211
212
|
state.cleanup = () => {
|
|
212
213
|
clearTimeout(state.timer);
|
|
213
|
-
clearTimeout(state.escalation);
|
|
214
214
|
options.signal?.removeEventListener("abort", onAbort);
|
|
215
215
|
};
|
|
216
|
+
|
|
216
217
|
state.timer = setTimeout(() => terminateCommand(state, new Error(
|
|
217
218
|
"command timed out after " + timeoutMs + "ms: " + truncateChars(options.commandLabel ?? argv.join(" "), 240, "command").text
|
|
218
219
|
+ "\nhint: Increase this bash timeoutMs and the outer supernova timeoutMs, or split the work. Sleeps and every command in a shell chain share the same limit."
|
|
@@ -221,13 +222,16 @@ function attachCommandIO(state, options, argv, timeoutMs) {
|
|
|
221
222
|
child.stderr.setEncoding("utf8");
|
|
222
223
|
child.stdout.on("data", chunk => { state.stdout = appendCommandOutput(state, state.stdout, chunk); });
|
|
223
224
|
child.stderr.on("data", chunk => { state.stderr = appendCommandOutput(state, state.stderr, chunk); });
|
|
224
|
-
child.on("error", error =>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
child.on("error", error => failCommand(state,commandSpawnError(error,argv[0])));
|
|
226
|
+
child.once("exit", (code, signal) => {
|
|
227
|
+
state.processExited = true;
|
|
228
|
+
state.exitCode = code;
|
|
229
|
+
state.signal = signal;
|
|
230
|
+
// An orphan can retain the pipes forever. Begin retirement at leader exit,
|
|
231
|
+
// then wait for close and group disappearance before reporting completion.
|
|
232
|
+
terminateCommand(state);
|
|
229
233
|
});
|
|
230
|
-
child.
|
|
234
|
+
child.once("close", () => { state.processClosed = true; });
|
|
231
235
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
232
236
|
|
|
233
237
|
if (options.signal?.aborted) onAbort();
|
|
@@ -243,7 +247,8 @@ export async function runCommand(argv, options = {}) {
|
|
|
243
247
|
const child = spawnCommand(argv, options, cwd);
|
|
244
248
|
attachCommandIO({
|
|
245
249
|
child, resolve, reject, stdout: "", stderr: "", settled: false,
|
|
246
|
-
outputTruncated: false, terminationError: undefined,
|
|
250
|
+
outputTruncated: false, terminationError: undefined, stopping: undefined,
|
|
251
|
+
groups:new Set(child.pid ? [child.pid] : []), killedGroups:new Set(),
|
|
247
252
|
maxOutputChars, timer: undefined, cleanup() {},
|
|
248
253
|
}, options, argv, timeoutMs);
|
|
249
254
|
});
|
package/src/output/bottleneck.js
CHANGED
|
@@ -167,6 +167,7 @@ function packageBatchItems(batch, details, maxChars) {
|
|
|
167
167
|
const itemErrors = (details.itemErrors ?? []).map(error => boundItemError(error, maxChars, batch.length));
|
|
168
168
|
let remaining = maxChars;
|
|
169
169
|
let truncated = false;
|
|
170
|
+
|
|
170
171
|
const items = batch.map((item, index) => {
|
|
171
172
|
const share = details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index));
|
|
172
173
|
const bounded = boundBatchItem(item, share);
|
|
@@ -223,14 +224,26 @@ function attachTruncation(result, truncated, batch, text, config, maxChars, capp
|
|
|
223
224
|
|
|
224
225
|
export function packageHostResult(raw, config) {
|
|
225
226
|
const details = detailsOf(raw);
|
|
227
|
+
|
|
226
228
|
if (raw && Object.hasOwn(raw, READ_VALUE)) {
|
|
227
229
|
const batch = batchFromDetails(details);
|
|
228
230
|
const result = {ok: !hostResultFailed(raw), value: raw[READ_VALUE], typed: true, cloneItems: details?.jsonMany === true, truncated: details?.outputTruncated === true};
|
|
229
231
|
attachDetails(result, details, batch);
|
|
230
|
-
|
|
232
|
+
|
|
233
|
+
if (isString(details?.sourcePath)) result.sourcePath = details.sourcePath;
|
|
234
|
+
|
|
235
|
+
if (batch) {
|
|
236
|
+
result.items = batch;
|
|
237
|
+
result.itemErrors = details.itemErrors ?? [];
|
|
238
|
+
|
|
239
|
+
if (details.sourcePaths) result.sourcePaths = details.sourcePaths;
|
|
240
|
+
}
|
|
241
|
+
|
|
231
242
|
if (details?.streamed) result.streamed = true;
|
|
243
|
+
|
|
232
244
|
return result;
|
|
233
245
|
}
|
|
246
|
+
|
|
234
247
|
const maxChars = config.maxCallResultChars ?? 65536;
|
|
235
248
|
const batch = batchFromDetails(details);
|
|
236
249
|
const text = batch ? "" : extractRawString(raw);
|
package/src/runtime/guest-api.js
CHANGED
|
@@ -15,16 +15,20 @@ function unwrapValue(res) {
|
|
|
15
15
|
return res;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
function unwrapRead(res, args) {
|
|
18
|
+
function unwrapRead(res, args, observe) {
|
|
19
19
|
const value = unwrapValue(res);
|
|
20
20
|
|
|
21
21
|
if ((args.complete === true || args.json !== undefined) && res?.truncated) throw new Error("incomplete read: complete:true or json refuses truncated host output");
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
const decoded = decodeReadItem(args, value, res);
|
|
24
|
+
observe?.(args,res);
|
|
25
|
+
|
|
26
|
+
return decoded;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
function decodeReadItem(args, value, res) {
|
|
27
30
|
if (!res?.typed) return decodeReadValue(args, value);
|
|
31
|
+
|
|
28
32
|
// JSON selectors used to cross RPC as separately encoded JSON values. Keep
|
|
29
33
|
// their mutable results independent, but put the copies in the guest heap.
|
|
30
34
|
return res.cloneItems ? value.map(item => structuredClone(item)) : value;
|
|
@@ -59,18 +63,22 @@ function missingResolvedIndex(values, paths) {
|
|
|
59
63
|
|
|
60
64
|
function settleReadItem(wave, index, res) {
|
|
61
65
|
const job = wave[index];
|
|
66
|
+
|
|
62
67
|
if (!job) throw new Error("invalid streamed read index: " + index);
|
|
63
68
|
wave[index] = null; // Release resolver closures and their potentially large values.
|
|
64
|
-
|
|
69
|
+
|
|
70
|
+
try { job.resolve(unwrapRead(res,job.args,job.observe)); }
|
|
65
71
|
catch (error) { job.reject(error); }
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
function settleInlineWave(wave,res,received) {
|
|
69
75
|
unwrapValue(res);
|
|
76
|
+
|
|
70
77
|
if (received || !Array.isArray(res.items) || res.items.length !== wave.length) throw new Error("invalid batch read response");
|
|
78
|
+
|
|
71
79
|
for (let i=0;i<wave.length;i++) {
|
|
72
80
|
const error = res.itemErrors?.[i];
|
|
73
|
-
settleReadItem(wave,i,{...res,ok:!error,value:error ?? res.items[i],items:undefined});
|
|
81
|
+
settleReadItem(wave,i,{...res,ok:!error,value:error ?? res.items[i],sourcePath:res.sourcePaths?.[i],items:undefined});
|
|
74
82
|
}
|
|
75
83
|
}
|
|
76
84
|
|
|
@@ -78,24 +86,32 @@ async function rpcReadWave(rpc, wave) {
|
|
|
78
86
|
if (wave.length === 1) {
|
|
79
87
|
const res = leanEnvelope(await rpc("call",["read",wave[0].args]));
|
|
80
88
|
settleReadItem(wave,0,res);
|
|
89
|
+
|
|
81
90
|
return;
|
|
82
91
|
}
|
|
92
|
+
|
|
83
93
|
const args = {...wave[0].args,path:wave.map(job=>job.args.path),_independent:true};
|
|
84
94
|
let received = 0, last;
|
|
95
|
+
|
|
85
96
|
const onItem = (index,res) => {
|
|
86
97
|
if (!Number.isInteger(index) || !wave[index] || last?.index === index) throw new Error("invalid streamed read response");
|
|
87
98
|
received++;
|
|
99
|
+
|
|
88
100
|
// Keep the final read pending until the host RPC/barrier itself has settled.
|
|
89
101
|
// An awaited batch must never look complete while its host call is running.
|
|
90
102
|
if (received === wave.length) last = {index,res};
|
|
91
103
|
else settleReadItem(wave,index,res);
|
|
92
104
|
};
|
|
105
|
+
|
|
93
106
|
const res = leanEnvelope(await rpc("call",["read",args],onItem));
|
|
107
|
+
|
|
94
108
|
if (res?.streamed) {
|
|
95
109
|
if (received !== wave.length || !last) throw new Error("incomplete streamed read response");
|
|
96
110
|
settleReadItem(wave,last.index,last.res);
|
|
111
|
+
|
|
97
112
|
return;
|
|
98
113
|
}
|
|
114
|
+
|
|
99
115
|
settleInlineWave(wave,res,received);
|
|
100
116
|
}
|
|
101
117
|
|
|
@@ -118,7 +134,7 @@ function enqueueCompatibleRead(readState, flushReads, args) {
|
|
|
118
134
|
if (readState.queued.length && readState.queued[0].key !== key) flushReads();
|
|
119
135
|
|
|
120
136
|
const promise = new Promise((resolve, reject) => {
|
|
121
|
-
readState.queued.push({ args, key, resolve, reject });
|
|
137
|
+
readState.queued.push({ args, key, resolve, reject, observe:readState.observe });
|
|
122
138
|
|
|
123
139
|
if (readState.queued.length === 1) queueMicrotask(flushReads);
|
|
124
140
|
});
|
|
@@ -128,12 +144,16 @@ function enqueueCompatibleRead(readState, flushReads, args) {
|
|
|
128
144
|
|
|
129
145
|
async function readManyPaths(readOne, args, paths) {
|
|
130
146
|
assertReadPaths(paths);
|
|
147
|
+
|
|
131
148
|
const values = await Promise.all(paths.map(async item => {
|
|
132
149
|
try { return await readOne({...args,path:item}); }
|
|
133
150
|
catch (error) { throwReadPathError(item,error.message); }
|
|
134
151
|
}));
|
|
152
|
+
|
|
135
153
|
const missing = args.resolve ? missingResolvedIndex(values,paths) : -1;
|
|
154
|
+
|
|
136
155
|
if (missing >= 0) throwReadPathError(paths[missing],"not_found");
|
|
156
|
+
|
|
137
157
|
return values;
|
|
138
158
|
}
|
|
139
159
|
|
|
@@ -193,12 +213,13 @@ export function buildGuestApi(rpc, batchRead) {
|
|
|
193
213
|
if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
|
|
194
214
|
const token = {};
|
|
195
215
|
checkpoint = token;
|
|
216
|
+
|
|
196
217
|
try { return await runSpeculation(fn, token, checkpointScope, drainReads, rpc); }
|
|
197
218
|
finally { checkpoint = null; }
|
|
198
219
|
}
|
|
199
220
|
|
|
200
221
|
// Coalesce already-started compatible reads without rewriting JS control flow.
|
|
201
|
-
const readState = { queued: [], waves: new Set() };
|
|
222
|
+
const readState = { queued: [], waves: new Set(), observe:noteReadPath };
|
|
202
223
|
|
|
203
224
|
function flushReads() {
|
|
204
225
|
const pending = readState.queued;
|
|
@@ -227,26 +248,21 @@ export function buildGuestApi(rpc, batchRead) {
|
|
|
227
248
|
const args = normalizeRead(gatherReadArgs(p, a, b));
|
|
228
249
|
p = args.path;
|
|
229
250
|
|
|
230
|
-
if (Array.isArray(p))
|
|
231
|
-
const values = await readManyPaths(read, args, p);
|
|
232
|
-
for (const item of p) if (isString(item)) noteReadPath({ path: item }, values);
|
|
233
|
-
|
|
234
|
-
return values;
|
|
235
|
-
}
|
|
251
|
+
if (Array.isArray(p)) return await readManyPaths(read, args, p);
|
|
236
252
|
|
|
237
|
-
|
|
238
|
-
? unwrapRead(await invoke("read", args), args)
|
|
253
|
+
return !batchRead
|
|
254
|
+
? unwrapRead(await invoke("read", args), args, noteReadPath)
|
|
239
255
|
: await enqueueCompatibleRead(readState, flushReads, args);
|
|
240
|
-
noteReadPath(args, readValue);
|
|
241
|
-
|
|
242
|
-
return readValue;
|
|
243
256
|
};
|
|
244
257
|
|
|
245
258
|
const readFiles = new Set();
|
|
246
259
|
|
|
247
|
-
function noteReadPath(args,
|
|
248
|
-
if (isString(args.path) &&
|
|
249
|
-
|
|
260
|
+
function noteReadPath(args, envelope) {
|
|
261
|
+
if (isString(args.path) && args.resolve !== true && args.query === undefined && args.evidence !== true) readFiles.add(args.path);
|
|
262
|
+
|
|
263
|
+
// A document may itself contain path/status fields. Only adapter provenance,
|
|
264
|
+
// carried separately through scalar, inline and streamed replies, names a read.
|
|
265
|
+
if (isString(envelope.sourcePath)) readFiles.add(envelope.sourcePath);
|
|
250
266
|
}
|
|
251
267
|
|
|
252
268
|
const write = async (p, content) => {
|
package/src/runtime/reference.js
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
// Standing tool description: sent on every request. No result or history compression.
|
|
2
|
-
export const REFERENCE = `JS body/async arrow
|
|
3
|
-
read(path|paths,offset=1,limit?) →
|
|
4
|
-
Text ≤64 MiB internally; complete:true requires
|
|
5
|
-
read({path,json:selector}) →
|
|
6
|
-
read("symbol or question") = read({query,resolve:true}) → view; check status; view.text is a span,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
programs:[{code?,file?,data?}] inherits source/data; entries override. mergeData:true shallow object merge (entry keys win).
|
|
14
|
-
Promise.all: ≤8 reads or disjoint-file mutations; same-file serial; bash/checkpoint barriers.
|
|
15
|
-
Fresh guests/separate commits. Sequential failure stops; prior commits stay. parallel:true for disjoint entries. ONE call for known work; split for new decisions.
|
|
2
|
+
export const REFERENCE = `JS body/async arrow; read/write/edit/bash, no fs/import/require. file: workspace JS; data: literals ≤48000 JSON chars.
|
|
3
|
+
read(path|paths,offset=1,limit?) → text/text[]; dirs→entries; PNG/JPEG/GIF/WebP ≤16/20MiB.
|
|
4
|
+
Text ≤64 MiB internally; complete:true requires whole file. Display capped: summarize or read(path,{offset:1,limit:80}); larger files via bash.
|
|
5
|
+
read({path,json:true|selector}) → JSON, no jq (16MiB input/64MiB selection). Values retain their types.
|
|
6
|
+
read("symbol or question") = read({query,resolve:true}) → view; check status; view.text is a span. read(path,{about}) windows; read({query,evidence:true}) evidence; read({path,outline:true}) declarations.
|
|
7
|
+
write/edit workspace-only; external changes need separately authorized command. write(path,text) or {path,content,append:true}; after read edit or replace:true.
|
|
8
|
+
edit(path,oldText,newText) or {path,edits:[{oldText,newText}]}; unique exact match; numbered windows. edit(view,text) or edit(view,old,new). edit(async()=>{...}) checkpoint: no bash; merge/rollback+rethrow.
|
|
9
|
+
bash(command,{cwd?,timeoutMs?}) or {command,args}; nonzero throws. Outer timeout bounds foreground; bash inherits. Shell mutations flush edits.
|
|
10
|
+
bash({command,background:true,pty?:true,timeoutMs?:1800000})→sessionId. bash({action:"list"}) or {sessionId,action:"poll"|"write"|"stop",cursor?,waitMs?,input?}→status/output/cursor/exitCode. PTY macOS/Linux; jobs end at shutdown.
|
|
11
|
+
programs:[{code?,file?,data?}] inherit defaults; mergeData:true shallow. Fresh guests/commits; sequential failure stops, prior commits stay; parallel:true disjoint work.
|
|
12
|
+
Promise.all ≤8 disjoint reads/mutations; same-file serial; bash/checkpoints barriers. Optional reads: Promise.allSettled retains successes. ONE call for known work.
|
|
16
13
|
`;
|
|
@@ -1,12 +1,26 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
|
|
1
3
|
/** Bounded source context for parse and syntax diagnostics. */
|
|
4
|
+
const JS_LINES = /\r\n|[\n\r\u2028\u2029]/;
|
|
5
|
+
|
|
6
|
+
const JSON_LINES = /\r\n|[\n\r]/;
|
|
7
|
+
|
|
8
|
+
// Source columns are UTF-16 offsets; expand controls identically before the
|
|
9
|
+
// source token and the caret. JSON strings can contain literal Unicode LS/PS.
|
|
10
|
+
const displaySource = text => text.replaceAll("\t"," ").replaceAll("\u2028","\\u2028").replaceAll("\u2029","\\u2029");
|
|
2
11
|
|
|
3
|
-
export function sourceContext(source, line, column) {
|
|
12
|
+
export function sourceContext(source, line, column, lineBreaks = JS_LINES) {
|
|
4
13
|
if (!Number.isInteger(line) || line < 1) return "";
|
|
5
|
-
const text = String(source).split(
|
|
14
|
+
const text = String(source).split(lineBreaks)[line - 1];
|
|
6
15
|
|
|
7
16
|
if (text === undefined) return "";
|
|
8
|
-
const
|
|
9
|
-
const
|
|
17
|
+
const located = Number.isInteger(column) && column >= 0;
|
|
18
|
+
const position = located ? Math.min(column, text.length) : 0;
|
|
19
|
+
const start = Math.max(0, Math.min(position - 80, text.length - 160));
|
|
20
|
+
const end = Math.min(text.length, start + 160);
|
|
21
|
+
const prefix = start > 0 ? "…" : "";
|
|
22
|
+
const shown = prefix + displaySource(text.slice(start, end)) + (end < text.length ? "…" : "");
|
|
23
|
+
const caret = located ? " ".repeat(stringWidth(prefix + displaySource(text.slice(start, position)))) + "^" : "";
|
|
10
24
|
|
|
11
25
|
return "\n " + shown + (caret ? "\n " + caret : "");
|
|
12
26
|
}
|
|
@@ -24,11 +38,12 @@ export function parsePosition(message, source) {
|
|
|
24
38
|
const offsetMatch = /at position (\d+)/.exec(String(message));
|
|
25
39
|
|
|
26
40
|
if (!offsetMatch) return null;
|
|
41
|
+
|
|
27
42
|
return offsetPosition(source, Number(offsetMatch[1]));
|
|
28
43
|
}
|
|
29
44
|
|
|
30
45
|
function offsetPosition(source, offset) {
|
|
31
|
-
const lines = String(source).slice(0, offset).split(
|
|
46
|
+
const lines = String(source).slice(0, offset).split(JSON_LINES);
|
|
32
47
|
|
|
33
48
|
return { line: lines.length, column: lines.at(-1).length };
|
|
34
49
|
}
|
|
@@ -37,6 +52,7 @@ function offsetPosition(source, offset) {
|
|
|
37
52
|
// offsets. JSON.parse remains the authority; cap extra work at 65,536 characters.
|
|
38
53
|
// eslint-disable-next-line no-control-regex -- RFC 8259 excludes unescaped control characters from strings.
|
|
39
54
|
const JSON_TOKEN = /("(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[\da-fA-F]{4}))*")|(-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|(true|false|null)|[{}[\]:,]/y;
|
|
55
|
+
|
|
40
56
|
const JSON_NEXT = {
|
|
41
57
|
end: { eof: [] },
|
|
42
58
|
value: { string: [], number: [], literal: [], "{": ["object"], "[": ["array"] },
|
|
@@ -67,9 +83,12 @@ function invalidJsonOffset(source) {
|
|
|
67
83
|
while (stack.length) {
|
|
68
84
|
const token = jsonTokenAt(source, offset);
|
|
69
85
|
let state = stack.pop();
|
|
86
|
+
|
|
70
87
|
if (state === "array" && token.kind !== "]") { stack.push("arrayNext"); state = "value"; }
|
|
88
|
+
|
|
71
89
|
if (state === "object" && token.kind !== "}") state = "key";
|
|
72
90
|
const next = JSON_NEXT[state][token.kind];
|
|
91
|
+
|
|
73
92
|
if (!next) return token.start;
|
|
74
93
|
stack.push(...next);
|
|
75
94
|
offset = token.end;
|
|
@@ -80,10 +99,12 @@ function invalidJsonOffset(source) {
|
|
|
80
99
|
|
|
81
100
|
export function jsonErrorContext(message, source) {
|
|
82
101
|
const native = parsePosition(message, source);
|
|
83
|
-
|
|
102
|
+
|
|
103
|
+
if (native) return sourceContext(source, native.line, native.column, JSON_LINES);
|
|
84
104
|
const offset = invalidJsonOffset(source);
|
|
105
|
+
|
|
85
106
|
if (offset === null) return "";
|
|
86
107
|
const { line, column } = offsetPosition(source, offset);
|
|
87
108
|
|
|
88
|
-
return " (near line " + line + " column " + (column + 1) + ")" + sourceContext(source, line, column);
|
|
109
|
+
return " (near line " + line + " column " + (column + 1) + ")" + sourceContext(source, line, column, JSON_LINES);
|
|
89
110
|
}
|