@oh-my-pi/pi-utils 18.1.4 → 18.1.6
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/CHANGELOG.md +17 -0
- package/dist/types/async.d.ts +13 -3
- package/dist/types/incoming-json.d.ts +232 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/json-lexer.d.ts +116 -0
- package/dist/types/vterm/query-responder.d.ts +24 -0
- package/dist/types/vterm.d.ts +1 -0
- package/package.json +2 -2
- package/src/async.ts +25 -4
- package/src/incoming-json.ts +854 -0
- package/src/index.ts +1 -0
- package/src/json-lexer.ts +413 -0
- package/src/json-parse.ts +93 -350
- package/src/vterm/query-responder.ts +76 -0
- package/src/vterm.ts +1 -0
package/src/json-parse.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import {
|
|
2
|
+
BACKSLASH,
|
|
3
|
+
COLON,
|
|
4
|
+
COMMA,
|
|
5
|
+
isHexDigit,
|
|
6
|
+
isNumberStart,
|
|
7
|
+
isWhitespace,
|
|
8
|
+
JsonLexer,
|
|
9
|
+
LBRACE,
|
|
10
|
+
LBRACKET,
|
|
11
|
+
QUOTE,
|
|
12
|
+
RBRACE,
|
|
13
|
+
RBRACKET,
|
|
14
|
+
SQUOTE,
|
|
15
|
+
VALID_ESCAPE_CHAR,
|
|
16
|
+
} from "./json-lexer";
|
|
5
17
|
|
|
6
|
-
|
|
7
|
-
const VALID_ESCAPE_CHAR = new Uint8Array(128);
|
|
8
|
-
for (const ch of '"\\/bfnrtu') VALID_ESCAPE_CHAR[ch.charCodeAt(0)] = 1;
|
|
18
|
+
const U = 0x75;
|
|
9
19
|
|
|
10
20
|
const CONTROL_ESCAPES: readonly string[] = (() => {
|
|
11
21
|
const e: string[] = [];
|
|
@@ -20,47 +30,6 @@ const CONTROL_ESCAPES: readonly string[] = (() => {
|
|
|
20
30
|
return e;
|
|
21
31
|
})();
|
|
22
32
|
|
|
23
|
-
const HEX4_RE = /^[0-9a-fA-F]{4}$/;
|
|
24
|
-
|
|
25
|
-
function isHexDigit(cp: number): boolean {
|
|
26
|
-
return (cp >= 0x30 && cp <= 0x39) || ((cp | 0x20) >= 0x61 && (cp | 0x20) <= 0x66);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function isWhitespace(cp: number): boolean {
|
|
30
|
-
return cp === 0x20 || cp === 0x09 || cp === 0x0a || cp === 0x0d;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function isIdentChar(cp: number): boolean {
|
|
34
|
-
return (
|
|
35
|
-
(cp >= 0x30 && cp <= 0x39) ||
|
|
36
|
-
((cp | 0x20) >= 0x61 && (cp | 0x20) <= 0x7a) ||
|
|
37
|
-
cp === 0x5f /* _ */ ||
|
|
38
|
-
cp === 0x24 /* $ */
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Bareword literals: standard JSON plus Python `True`/`False`/`None`. */
|
|
43
|
-
const KEYWORDS: readonly (readonly [string, unknown])[] = [
|
|
44
|
-
["true", true],
|
|
45
|
-
["false", false],
|
|
46
|
-
["null", null],
|
|
47
|
-
["True", true],
|
|
48
|
-
["False", false],
|
|
49
|
-
["None", null],
|
|
50
|
-
];
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* JS-only atoms never recovered as bareword strings — a tool must not execute
|
|
54
|
-
* with a non-finite or undefined argument masquerading as a string.
|
|
55
|
-
*/
|
|
56
|
-
const NON_RECOVERABLE_BAREWORDS: Record<string, true> = {
|
|
57
|
-
NaN: true,
|
|
58
|
-
Infinity: true,
|
|
59
|
-
"-Infinity": true,
|
|
60
|
-
"+Infinity": true,
|
|
61
|
-
undefined: true,
|
|
62
|
-
};
|
|
63
|
-
|
|
64
33
|
/**
|
|
65
34
|
* Sentinel returned by partial-mode value parsing when an atomic value
|
|
66
35
|
* (number / keyword) is incomplete at the streaming edge, so the enclosing
|
|
@@ -160,139 +129,109 @@ export function repairJson(json: string): string {
|
|
|
160
129
|
}
|
|
161
130
|
|
|
162
131
|
/**
|
|
163
|
-
* Recursive-descent
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* - single-quoted strings and unquoted object keys (JSON5);
|
|
168
|
-
* - trailing / stray commas, and `//` + block comments;
|
|
169
|
-
* - Python literals `True` / `False` / `None` and JS `NaN` / `Infinity`;
|
|
170
|
-
* - raw control characters and invalid `\x` escapes inside strings (kept literally);
|
|
171
|
-
* - unescaped quotes inside strings — a quote only closes a string when followed
|
|
172
|
-
* by a value terminator, recovering apostrophes such as `'it's'`;
|
|
173
|
-
* - unquoted string values in object/array value position (strict mode only) —
|
|
174
|
-
* an unrecognized bareword such as `{"paths": packages/foo/*}` is recovered as
|
|
175
|
-
* a string up to the next `,` / `}` / `]` / newline.
|
|
132
|
+
* Recursive-descent tree builder over the tolerant {@link JsonLexer} grammar
|
|
133
|
+
* (single quotes, unquoted keys, comments, Python literals, stray commas,
|
|
134
|
+
* literal invalid escapes, apostrophe recovery, bareword values).
|
|
176
135
|
*
|
|
177
|
-
* In `partial` mode an unterminated string/object/array
|
|
178
|
-
* end-of-input) is auto-closed with whatever was parsed
|
|
179
|
-
*
|
|
180
|
-
*
|
|
136
|
+
* In `partial` mode (lexer mode `streaming`) an unterminated string/object/array
|
|
137
|
+
* (or a value cut off at end-of-input) is auto-closed with whatever was parsed
|
|
138
|
+
* so far, and an incomplete trailing atom rolls the enclosing container back
|
|
139
|
+
* to its last valid prefix. In strict mode, end-of-input mid-value and trailing
|
|
140
|
+
* garbage both throw, so a final parse never silently accepts a half-formed
|
|
141
|
+
* tool call.
|
|
181
142
|
*/
|
|
182
143
|
class RelaxedJson {
|
|
183
|
-
readonly #
|
|
184
|
-
readonly #n: number;
|
|
144
|
+
readonly #lex: JsonLexer;
|
|
185
145
|
readonly #partial: boolean;
|
|
186
|
-
#i = 0;
|
|
187
146
|
|
|
188
147
|
constructor(source: string, partial: boolean) {
|
|
189
|
-
this.#
|
|
190
|
-
this.#n = source.length;
|
|
148
|
+
this.#lex = new JsonLexer(source, partial ? "streaming" : "strict");
|
|
191
149
|
this.#partial = partial;
|
|
192
150
|
}
|
|
193
151
|
|
|
194
152
|
parse(): unknown {
|
|
195
|
-
this.#
|
|
196
|
-
|
|
153
|
+
const lex = this.#lex;
|
|
154
|
+
lex.ws();
|
|
155
|
+
if (lex.atEnd) {
|
|
197
156
|
if (this.#partial) return undefined;
|
|
198
157
|
throw new SyntaxError("Unexpected end of JSON input");
|
|
199
158
|
}
|
|
200
159
|
const value = this.#value(false);
|
|
201
160
|
if (value === INCOMPLETE) return undefined;
|
|
202
|
-
|
|
203
|
-
if (!this.#partial &&
|
|
204
|
-
throw new SyntaxError(`Unexpected trailing characters at position ${
|
|
161
|
+
lex.ws();
|
|
162
|
+
if (!this.#partial && !lex.atEnd) {
|
|
163
|
+
throw new SyntaxError(`Unexpected trailing characters at position ${lex.pos}`);
|
|
205
164
|
}
|
|
206
165
|
return value;
|
|
207
166
|
}
|
|
208
167
|
|
|
209
|
-
#ws(): void {
|
|
210
|
-
const s = this.#s;
|
|
211
|
-
for (;;) {
|
|
212
|
-
while (this.#i < this.#n && isWhitespace(s.charCodeAt(this.#i))) this.#i++;
|
|
213
|
-
if (this.#i + 1 < this.#n && s.charCodeAt(this.#i) === 0x2f /* / */) {
|
|
214
|
-
const next = s.charCodeAt(this.#i + 1);
|
|
215
|
-
if (next === 0x2f /* / line comment */) {
|
|
216
|
-
this.#i += 2;
|
|
217
|
-
while (this.#i < this.#n && s.charCodeAt(this.#i) !== 0x0a) this.#i++;
|
|
218
|
-
continue;
|
|
219
|
-
}
|
|
220
|
-
if (next === 0x2a /* * block comment */) {
|
|
221
|
-
this.#i += 2;
|
|
222
|
-
while (
|
|
223
|
-
this.#i + 1 < this.#n &&
|
|
224
|
-
!(s.charCodeAt(this.#i) === 0x2a && s.charCodeAt(this.#i + 1) === 0x2f)
|
|
225
|
-
) {
|
|
226
|
-
this.#i++;
|
|
227
|
-
}
|
|
228
|
-
this.#i = Math.min(this.#i + 2, this.#n);
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
break;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
168
|
#value(allowBareword: boolean): unknown {
|
|
237
|
-
const
|
|
238
|
-
const c =
|
|
239
|
-
if (c ===
|
|
240
|
-
if (c ===
|
|
241
|
-
if (c ===
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
169
|
+
const lex = this.#lex;
|
|
170
|
+
const c = lex.peek();
|
|
171
|
+
if (c === LBRACE) return this.#object();
|
|
172
|
+
if (c === LBRACKET) return this.#array();
|
|
173
|
+
if (c === QUOTE || c === SQUOTE) return lex.string(c).value;
|
|
174
|
+
// JS-only NaN / Infinity are deliberately not accepted: a tool must not
|
|
175
|
+
// execute with a non-finite numeric arg; they fail the lexer's finite
|
|
176
|
+
// guard (strict throw / partial rollback) like other bad tokens.
|
|
177
|
+
if (isNumberStart(c)) return lex.number() ?? INCOMPLETE;
|
|
178
|
+
const keyword = lex.keyword();
|
|
179
|
+
if (keyword !== undefined) return keyword;
|
|
180
|
+
if (this.#partial) {
|
|
181
|
+
// Incomplete / unrecognized atomic token at the streaming edge — signal the
|
|
182
|
+
// caller to roll back to the last valid prefix instead of committing junk.
|
|
183
|
+
lex.pos = lex.src.length;
|
|
184
|
+
return INCOMPLETE;
|
|
248
185
|
}
|
|
249
|
-
|
|
186
|
+
if (allowBareword) return lex.bareword();
|
|
187
|
+
throw new SyntaxError(`Unexpected token at position ${lex.pos}`);
|
|
250
188
|
}
|
|
251
189
|
|
|
252
190
|
#object(): Record<string, unknown> {
|
|
253
|
-
|
|
191
|
+
const lex = this.#lex;
|
|
192
|
+
lex.pos++; // consume {
|
|
254
193
|
const out: Record<string, unknown> = {};
|
|
255
194
|
for (;;) {
|
|
256
|
-
|
|
257
|
-
if (
|
|
195
|
+
lex.ws();
|
|
196
|
+
if (lex.atEnd) {
|
|
258
197
|
if (this.#partial) return out;
|
|
259
198
|
throw new SyntaxError("Unterminated object");
|
|
260
199
|
}
|
|
261
|
-
const c =
|
|
262
|
-
if (c ===
|
|
263
|
-
|
|
200
|
+
const c = lex.peek();
|
|
201
|
+
if (c === RBRACE) {
|
|
202
|
+
lex.pos++;
|
|
264
203
|
return out;
|
|
265
204
|
}
|
|
266
|
-
if (c ===
|
|
205
|
+
if (c === COMMA) {
|
|
267
206
|
// Tolerate leading / doubled / trailing commas.
|
|
268
|
-
|
|
207
|
+
lex.pos++;
|
|
269
208
|
continue;
|
|
270
209
|
}
|
|
271
210
|
const key = this.#key();
|
|
272
|
-
|
|
273
|
-
if (
|
|
274
|
-
|
|
211
|
+
lex.ws();
|
|
212
|
+
if (lex.peek() === COLON) {
|
|
213
|
+
lex.pos++;
|
|
275
214
|
} else if (this.#partial) {
|
|
276
215
|
return out;
|
|
277
216
|
} else {
|
|
278
217
|
throw new SyntaxError("Expected ':' in object");
|
|
279
218
|
}
|
|
280
|
-
|
|
281
|
-
if (
|
|
219
|
+
lex.ws();
|
|
220
|
+
if (lex.atEnd) {
|
|
282
221
|
if (this.#partial) return out;
|
|
283
222
|
throw new SyntaxError("Expected value after ':'");
|
|
284
223
|
}
|
|
285
224
|
const value = this.#value(true);
|
|
286
225
|
if (value === INCOMPLETE) return out;
|
|
287
226
|
out[key] = value;
|
|
288
|
-
|
|
289
|
-
const d =
|
|
290
|
-
if (d ===
|
|
291
|
-
|
|
227
|
+
lex.ws();
|
|
228
|
+
const d = lex.peek();
|
|
229
|
+
if (d === COMMA) {
|
|
230
|
+
lex.pos++;
|
|
292
231
|
continue;
|
|
293
232
|
}
|
|
294
|
-
if (d ===
|
|
295
|
-
|
|
233
|
+
if (d === RBRACE) {
|
|
234
|
+
lex.pos++;
|
|
296
235
|
return out;
|
|
297
236
|
}
|
|
298
237
|
if (this.#partial) return out;
|
|
@@ -301,34 +240,35 @@ class RelaxedJson {
|
|
|
301
240
|
}
|
|
302
241
|
|
|
303
242
|
#array(): unknown[] {
|
|
304
|
-
|
|
243
|
+
const lex = this.#lex;
|
|
244
|
+
lex.pos++; // consume [
|
|
305
245
|
const out: unknown[] = [];
|
|
306
246
|
for (;;) {
|
|
307
|
-
|
|
308
|
-
if (
|
|
247
|
+
lex.ws();
|
|
248
|
+
if (lex.atEnd) {
|
|
309
249
|
if (this.#partial) return out;
|
|
310
250
|
throw new SyntaxError("Unterminated array");
|
|
311
251
|
}
|
|
312
|
-
const c =
|
|
313
|
-
if (c ===
|
|
314
|
-
|
|
252
|
+
const c = lex.peek();
|
|
253
|
+
if (c === RBRACKET) {
|
|
254
|
+
lex.pos++;
|
|
315
255
|
return out;
|
|
316
256
|
}
|
|
317
|
-
if (c ===
|
|
318
|
-
|
|
257
|
+
if (c === COMMA) {
|
|
258
|
+
lex.pos++;
|
|
319
259
|
continue;
|
|
320
260
|
}
|
|
321
261
|
const value = this.#value(true);
|
|
322
262
|
if (value === INCOMPLETE) return out;
|
|
323
263
|
out.push(value);
|
|
324
|
-
|
|
325
|
-
const d =
|
|
326
|
-
if (d ===
|
|
327
|
-
|
|
264
|
+
lex.ws();
|
|
265
|
+
const d = lex.peek();
|
|
266
|
+
if (d === COMMA) {
|
|
267
|
+
lex.pos++;
|
|
328
268
|
continue;
|
|
329
269
|
}
|
|
330
|
-
if (d ===
|
|
331
|
-
|
|
270
|
+
if (d === RBRACKET) {
|
|
271
|
+
lex.pos++;
|
|
332
272
|
return out;
|
|
333
273
|
}
|
|
334
274
|
if (this.#partial) return out;
|
|
@@ -337,209 +277,12 @@ class RelaxedJson {
|
|
|
337
277
|
}
|
|
338
278
|
|
|
339
279
|
#key(): string {
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if (ch === ":" || ch === "," || ch === "}" || isWhitespace(this.#s.charCodeAt(this.#i))) break;
|
|
347
|
-
this.#i++;
|
|
348
|
-
}
|
|
349
|
-
if (this.#i === start) {
|
|
350
|
-
if (this.#partial) return "";
|
|
351
|
-
throw new SyntaxError("Expected object key");
|
|
352
|
-
}
|
|
353
|
-
return this.#s.slice(start, this.#i);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
#string(quote: number): string {
|
|
357
|
-
const s = this.#s;
|
|
358
|
-
const n = this.#n;
|
|
359
|
-
let i = this.#i + 1; // skip opening quote
|
|
360
|
-
let out = "";
|
|
361
|
-
let runStart = i;
|
|
362
|
-
while (i < n) {
|
|
363
|
-
const cc = s.charCodeAt(i);
|
|
364
|
-
if (cc !== BACKSLASH && cc !== quote) {
|
|
365
|
-
i++;
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
if (cc === quote) {
|
|
369
|
-
// Apostrophe / inner-quote recovery (a quote that isn't followed by a
|
|
370
|
-
// value terminator is literal) is safe for single quotes and in partial
|
|
371
|
-
// mode. For double quotes in strict mode, close on the first unescaped
|
|
372
|
-
// quote like standard JSON so malformed structure fails loudly instead
|
|
373
|
-
// of silently swallowing commas/colons into one string.
|
|
374
|
-
const lenient = quote === SQUOTE || this.#partial;
|
|
375
|
-
if (!lenient || this.#closesString(i + 1)) {
|
|
376
|
-
out += s.slice(runStart, i);
|
|
377
|
-
this.#i = i + 1;
|
|
378
|
-
return out;
|
|
379
|
-
}
|
|
380
|
-
// Unescaped inner quote (e.g. apostrophe in `'it's'`) — keep it literal.
|
|
381
|
-
i++;
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
// Backslash escape.
|
|
385
|
-
out += s.slice(runStart, i);
|
|
386
|
-
i++;
|
|
387
|
-
if (i >= n) {
|
|
388
|
-
out += "\\";
|
|
389
|
-
runStart = i;
|
|
390
|
-
break;
|
|
391
|
-
}
|
|
392
|
-
const esc = s.charCodeAt(i);
|
|
393
|
-
switch (esc) {
|
|
394
|
-
case QUOTE:
|
|
395
|
-
out += '"';
|
|
396
|
-
break;
|
|
397
|
-
case SQUOTE:
|
|
398
|
-
out += "'";
|
|
399
|
-
break;
|
|
400
|
-
case BACKSLASH:
|
|
401
|
-
out += "\\";
|
|
402
|
-
break;
|
|
403
|
-
case 0x2f:
|
|
404
|
-
out += "/";
|
|
405
|
-
break;
|
|
406
|
-
case 0x62:
|
|
407
|
-
out += "\b";
|
|
408
|
-
break;
|
|
409
|
-
case 0x66:
|
|
410
|
-
out += "\f";
|
|
411
|
-
break;
|
|
412
|
-
case 0x6e:
|
|
413
|
-
out += "\n";
|
|
414
|
-
break;
|
|
415
|
-
case 0x72:
|
|
416
|
-
out += "\r";
|
|
417
|
-
break;
|
|
418
|
-
case 0x74:
|
|
419
|
-
out += "\t";
|
|
420
|
-
break;
|
|
421
|
-
case U: {
|
|
422
|
-
const hex = s.slice(i + 1, i + 5);
|
|
423
|
-
if (HEX4_RE.test(hex)) {
|
|
424
|
-
out += String.fromCharCode(parseInt(hex, 16));
|
|
425
|
-
i += 4;
|
|
426
|
-
} else {
|
|
427
|
-
out += "\\u"; // invalid \u — keep literal
|
|
428
|
-
}
|
|
429
|
-
break;
|
|
430
|
-
}
|
|
431
|
-
default:
|
|
432
|
-
out += `\\${s[i]}`; // invalid escape — keep backslash literal
|
|
433
|
-
}
|
|
434
|
-
i++;
|
|
435
|
-
runStart = i;
|
|
436
|
-
}
|
|
437
|
-
out += s.slice(runStart, i);
|
|
438
|
-
if (this.#partial) {
|
|
439
|
-
this.#i = i;
|
|
440
|
-
return out;
|
|
441
|
-
}
|
|
442
|
-
throw new SyntaxError("Unterminated string");
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/** A quote closes a string only when the next non-space char ends a value. */
|
|
446
|
-
#closesString(from: number): boolean {
|
|
447
|
-
const s = this.#s;
|
|
448
|
-
let k = from;
|
|
449
|
-
while (k < this.#n && isWhitespace(s.charCodeAt(k))) k++;
|
|
450
|
-
if (k >= this.#n) return true;
|
|
451
|
-
const c = s[k];
|
|
452
|
-
return c === "," || c === "}" || c === "]" || c === ":";
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
#number(): unknown {
|
|
456
|
-
const s = this.#s;
|
|
457
|
-
const start = this.#i;
|
|
458
|
-
while (this.#i < this.#n) {
|
|
459
|
-
const ch = s[this.#i];
|
|
460
|
-
if (
|
|
461
|
-
(ch >= "0" && ch <= "9") ||
|
|
462
|
-
ch === "-" ||
|
|
463
|
-
ch === "+" ||
|
|
464
|
-
ch === "." ||
|
|
465
|
-
ch === "e" ||
|
|
466
|
-
ch === "E" ||
|
|
467
|
-
ch === "x" ||
|
|
468
|
-
ch === "X" ||
|
|
469
|
-
(ch >= "a" && ch <= "f") ||
|
|
470
|
-
(ch >= "A" && ch <= "F")
|
|
471
|
-
) {
|
|
472
|
-
this.#i++;
|
|
473
|
-
} else {
|
|
474
|
-
break;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
const token = s.slice(start, this.#i);
|
|
478
|
-
const num = Number(token);
|
|
479
|
-
if (Number.isNaN(num)) {
|
|
480
|
-
if (this.#partial) return INCOMPLETE;
|
|
481
|
-
throw new SyntaxError(`Invalid number: ${token}`);
|
|
482
|
-
}
|
|
483
|
-
return num;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
#keyword(allowBareword: boolean): unknown {
|
|
487
|
-
const s = this.#s;
|
|
488
|
-
const i = this.#i;
|
|
489
|
-
for (const [word, value] of KEYWORDS) {
|
|
490
|
-
// Require a non-identifier boundary so `Truex` / `nullish` are not misread
|
|
491
|
-
// as the keyword followed by junk.
|
|
492
|
-
if (s.startsWith(word, i) && !isIdentChar(s.charCodeAt(i + word.length))) {
|
|
493
|
-
this.#i += word.length;
|
|
494
|
-
return value;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
if (this.#partial) {
|
|
498
|
-
// Incomplete / unrecognized atomic token at the streaming edge — signal the
|
|
499
|
-
// caller to roll back to the last valid prefix instead of committing junk.
|
|
500
|
-
this.#i = this.#n;
|
|
501
|
-
return INCOMPLETE;
|
|
502
|
-
}
|
|
503
|
-
if (allowBareword) return this.#bareword();
|
|
504
|
-
throw new SyntaxError(`Unexpected token at position ${this.#i}`);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* Strict-mode recovery of an unquoted string value, e.g.
|
|
509
|
-
* `{"paths": packages/foo/*}`: consume until `,` / `}` / `]` / newline and
|
|
510
|
-
* trim trailing whitespace. Recovery still throws — so a final parse never
|
|
511
|
-
* accepts a half-formed or non-finite argument — when the token:
|
|
512
|
-
* - hits end-of-input before a delimiter (truncated value);
|
|
513
|
-
* - contains a `"`, `{`, `[`, or a key-like `:` — this parser accepts
|
|
514
|
-
* unquoted keys, so a missed comma (`{"a": foo "b": 1}`, `{a: foo b: 1}`)
|
|
515
|
-
* would otherwise silently swallow the following field. A colon followed
|
|
516
|
-
* by `/` or `\` stays literal so URL and Windows-path values recover;
|
|
517
|
-
* - is a non-finite atom ({@link NON_RECOVERABLE_BAREWORDS}).
|
|
518
|
-
*/
|
|
519
|
-
#bareword(): string {
|
|
520
|
-
const s = this.#s;
|
|
521
|
-
const start = this.#i;
|
|
522
|
-
let i = start;
|
|
523
|
-
while (i < this.#n) {
|
|
524
|
-
const cc = s.charCodeAt(i);
|
|
525
|
-
if (cc === 0x2c /* , */ || cc === 0x7d /* } */ || cc === 0x5d /* ] */ || cc === 0x0a || cc === 0x0d) break;
|
|
526
|
-
if (
|
|
527
|
-
cc === QUOTE ||
|
|
528
|
-
cc === 0x7b /* { */ ||
|
|
529
|
-
cc === 0x5b /* [ */ ||
|
|
530
|
-
(cc === 0x3a /* : */ && s.charCodeAt(i + 1) !== 0x2f /* / */ && s.charCodeAt(i + 1) !== 0x5c) /* \ */
|
|
531
|
-
) {
|
|
532
|
-
throw new SyntaxError(`Unexpected token at position ${start}`);
|
|
533
|
-
}
|
|
534
|
-
i++;
|
|
535
|
-
}
|
|
536
|
-
if (i >= this.#n) throw new SyntaxError(`Unexpected token at position ${start}`);
|
|
537
|
-
let end = i;
|
|
538
|
-
while (end > start && isWhitespace(s.charCodeAt(end - 1))) end--;
|
|
539
|
-
const word = s.slice(start, end);
|
|
540
|
-
if (NON_RECOVERABLE_BAREWORDS[word]) throw new SyntaxError(`Unexpected token at position ${start}`);
|
|
541
|
-
this.#i = i;
|
|
542
|
-
return word;
|
|
280
|
+
const lex = this.#lex;
|
|
281
|
+
const c = lex.peek();
|
|
282
|
+
if (c === QUOTE || c === SQUOTE) return lex.string(c).value;
|
|
283
|
+
const key = lex.unquotedKey();
|
|
284
|
+
if (key.length === 0 && !this.#partial) throw new SyntaxError("Expected object key");
|
|
285
|
+
return key;
|
|
543
286
|
}
|
|
544
287
|
}
|
|
545
288
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answers terminal capability queries emitted by programs on a headless PTY.
|
|
3
|
+
*
|
|
4
|
+
* A PTY that advertises `TERM=xterm-256color` but has no terminal behind it
|
|
5
|
+
* leaves capability probes unanswered: a program writes a query escape to
|
|
6
|
+
* stdout and blocks on stdin until the reply arrives (or its timeout expires,
|
|
7
|
+
* seconds later). This scanner watches raw PTY output for the standard queries
|
|
8
|
+
* and returns the bytes a real xterm-class terminal would send back, so the
|
|
9
|
+
* caller can write them into the PTY. It keeps no screen state — cursor
|
|
10
|
+
* position reports are answered with the home position — which makes it cheap
|
|
11
|
+
* enough to run on every byte of a long-lived supervised process. Use the full
|
|
12
|
+
* {@link Terminal} when the caller also renders the output.
|
|
13
|
+
*
|
|
14
|
+
* Queries can straddle chunk boundaries, so an unfinished trailing escape is
|
|
15
|
+
* carried into the next {@link feed}.
|
|
16
|
+
*/
|
|
17
|
+
export class TerminalQueryResponder {
|
|
18
|
+
/** Trailing bytes that may be the start of an unfinished query escape. */
|
|
19
|
+
#residual = "";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Feed one raw PTY output chunk. Returns the reply bytes to write back into
|
|
23
|
+
* the PTY, or an empty string when the chunk held no answerable query.
|
|
24
|
+
*/
|
|
25
|
+
feed(chunk: string): string {
|
|
26
|
+
const buffer = this.#residual + chunk;
|
|
27
|
+
let replies = "";
|
|
28
|
+
let lastEnd = 0;
|
|
29
|
+
QUERY.lastIndex = 0;
|
|
30
|
+
for (let match = QUERY.exec(buffer); match !== null; match = QUERY.exec(buffer)) {
|
|
31
|
+
lastEnd = match.index + match[0].length;
|
|
32
|
+
replies += replyFor(match);
|
|
33
|
+
}
|
|
34
|
+
// Keep only a short unmatched trailing escape: a query split across
|
|
35
|
+
// chunks completes on the next feed, while a long tail is ordinary output
|
|
36
|
+
// that can never become a query.
|
|
37
|
+
const tailEscape = buffer.lastIndexOf("\x1b");
|
|
38
|
+
this.#residual =
|
|
39
|
+
tailEscape >= lastEnd && buffer.length - tailEscape <= MAX_PARTIAL_QUERY ? buffer.slice(tailEscape) : "";
|
|
40
|
+
return replies;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Longest query escape we answer, bounding the cross-chunk residual. */
|
|
45
|
+
const MAX_PARTIAL_QUERY = 32;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* CSI DSR/DA queries (final byte `n` or `c`) and OSC 10/11 color queries. Only
|
|
49
|
+
* forms with canned answers are matched; everything else stays plain output.
|
|
50
|
+
*/
|
|
51
|
+
const QUERY = /\x1b\[([?>=]?)([0-9;]*)([nc])|\x1b\](10|11);\?(\x07|\x1b\\)/gu;
|
|
52
|
+
|
|
53
|
+
/** Reply a real xterm-class terminal would send for one matched query. */
|
|
54
|
+
function replyFor(match: RegExpExecArray): string {
|
|
55
|
+
const final = match[3];
|
|
56
|
+
if (final !== undefined) {
|
|
57
|
+
const intermediate = match[1];
|
|
58
|
+
const params = match[2] ?? "";
|
|
59
|
+
if (final === "c") {
|
|
60
|
+
if (intermediate === ">") return "\x1b[>0;10;1c"; // secondary DA: VT100-class, firmware 10
|
|
61
|
+
if (intermediate === "" || intermediate === "0") return "\x1b[?1;2c"; // primary DA: VT100 with AVO
|
|
62
|
+
return ""; // tertiary (`=`) DA has no widely expected reply
|
|
63
|
+
}
|
|
64
|
+
if (intermediate !== "") return ""; // private DSR forms (DECXCPR, appearance) stay unanswered
|
|
65
|
+
const selector = params.split(";", 1)[0];
|
|
66
|
+
if (selector === "6") return "\x1b[1;1R"; // cursor position: home, there is no screen
|
|
67
|
+
if (selector === "5") return "\x1b[0n"; // device status: OK
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
// OSC color queries: neutral colors, terminated the way the request was.
|
|
71
|
+
const selector = match[4];
|
|
72
|
+
const terminator = match[5] ?? "\x07";
|
|
73
|
+
if (selector === "10") return `\x1b]10;rgb:ffff/ffff/ffff${terminator}`; // foreground
|
|
74
|
+
if (selector === "11") return `\x1b]11;rgb:0000/0000/0000${terminator}`; // background
|
|
75
|
+
return "";
|
|
76
|
+
}
|