@linxiraos/pi-utils 1.1.8 → 1.1.9
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 +2 -3
- package/README.md +18 -18
- package/package.json +57 -63
- package/src/async.ts +16 -0
- package/src/dirs.ts +15 -10
- package/src/incoming-json.ts +856 -0
- package/src/index.ts +1 -0
- package/src/json-lexer.ts +413 -0
- package/src/json-parse.ts +93 -350
- package/src/tls-fetch.ts +2 -16
- package/THIRD-PARTY-NOTICES.txt +0 -22909
- package/dist/types/abortable.d.ts +0 -32
- package/dist/types/acp/connection.d.ts +0 -118
- package/dist/types/acp/protocol.d.ts +0 -526
- package/dist/types/acp/schema.d.ts +0 -41
- package/dist/types/acp/stream.d.ts +0 -8
- package/dist/types/acp/transport.d.ts +0 -88
- package/dist/types/acp.d.ts +0 -6
- package/dist/types/ar/arj.d.ts +0 -5
- package/dist/types/ar/asar.d.ts +0 -7
- package/dist/types/ar/bytes.d.ts +0 -20
- package/dist/types/ar/cab.d.ts +0 -5
- package/dist/types/ar/checksums.d.ts +0 -10
- package/dist/types/ar/codecs/bzip2.d.ts +0 -4
- package/dist/types/ar/codecs/gzip.d.ts +0 -6
- package/dist/types/ar/codecs/lzma.d.ts +0 -6
- package/dist/types/ar/codecs/lzw.d.ts +0 -4
- package/dist/types/ar/codecs/lzx.d.ts +0 -7
- package/dist/types/ar/codecs/xz.d.ts +0 -4
- package/dist/types/ar/codecs/zstd.d.ts +0 -6
- package/dist/types/ar/cpio.d.ts +0 -7
- package/dist/types/ar/deb.d.ts +0 -5
- package/dist/types/ar/entries.d.ts +0 -22
- package/dist/types/ar/error.d.ts +0 -8
- package/dist/types/ar/index.d.ts +0 -11
- package/dist/types/ar/iso.d.ts +0 -5
- package/dist/types/ar/limits.d.ts +0 -33
- package/dist/types/ar/lzh.d.ts +0 -7
- package/dist/types/ar/open.d.ts +0 -45
- package/dist/types/ar/paths.d.ts +0 -18
- package/dist/types/ar/rar/rar4-decoder.d.ts +0 -6
- package/dist/types/ar/rar/rar5-decoder.d.ts +0 -6
- package/dist/types/ar/rar.d.ts +0 -5
- package/dist/types/ar/reader.d.ts +0 -28
- package/dist/types/ar/registry.d.ts +0 -17
- package/dist/types/ar/rpm.d.ts +0 -5
- package/dist/types/ar/sevenzip/decode.d.ts +0 -33
- package/dist/types/ar/sevenzip.d.ts +0 -5
- package/dist/types/ar/source.d.ts +0 -54
- package/dist/types/ar/tar.d.ts +0 -9
- package/dist/types/ar/types.d.ts +0 -99
- package/dist/types/ar/unix-ar.d.ts +0 -7
- package/dist/types/ar/write.d.ts +0 -10
- package/dist/types/ar/zip.d.ts +0 -10
- package/dist/types/async.d.ts +0 -22
- package/dist/types/binary.d.ts +0 -23
- package/dist/types/browsers.d.ts +0 -68
- package/dist/types/chalk.d.ts +0 -125
- package/dist/types/cli.d.ts +0 -134
- package/dist/types/color.d.ts +0 -136
- package/dist/types/dates.d.ts +0 -7
- package/dist/types/dirs.d.ts +0 -325
- package/dist/types/docx/converter.d.ts +0 -46
- package/dist/types/docx/xml.d.ts +0 -26
- package/dist/types/docx.d.ts +0 -11
- package/dist/types/dom/core.d.ts +0 -431
- package/dist/types/dom/parser.d.ts +0 -7
- package/dist/types/dom/selector.d.ts +0 -5
- package/dist/types/dom.d.ts +0 -5
- package/dist/types/env.d.ts +0 -133
- package/dist/types/fetch-retry.d.ts +0 -98
- package/dist/types/file-lock.d.ts +0 -23
- package/dist/types/format.d.ts +0 -37
- package/dist/types/frontmatter.d.ts +0 -46
- package/dist/types/fs-error.d.ts +0 -31
- package/dist/types/glob.d.ts +0 -28
- package/dist/types/headers.d.ts +0 -34
- package/dist/types/index.d.ts +0 -42
- package/dist/types/json-parse.d.ts +0 -83
- package/dist/types/json.d.ts +0 -20
- package/dist/types/logger/rotating-file.d.ts +0 -18
- package/dist/types/logger.d.ts +0 -96
- package/dist/types/loop-phase.d.ts +0 -10
- package/dist/types/lru.d.ts +0 -46
- package/dist/types/marked/core.d.ts +0 -445
- package/dist/types/marked.d.ts +0 -2
- package/dist/types/materialize-string.d.ts +0 -7
- package/dist/types/math-delimiters.d.ts +0 -45
- package/dist/types/mermaid-ascii.d.ts +0 -11
- package/dist/types/mime.d.ts +0 -29
- package/dist/types/module-timer.d.ts +0 -1
- package/dist/types/path-tree.d.ts +0 -76
- package/dist/types/path.d.ts +0 -4
- package/dist/types/peek-file.d.ts +0 -29
- package/dist/types/postmortem.d.ts +0 -178
- package/dist/types/process-name.d.ts +0 -7
- package/dist/types/procmgr.d.ts +0 -74
- package/dist/types/prompt.d.ts +0 -18
- package/dist/types/ptree.d.ts +0 -124
- package/dist/types/readability/readability.d.ts +0 -9
- package/dist/types/readability/readerable.d.ts +0 -10
- package/dist/types/readability/types.d.ts +0 -70
- package/dist/types/readability.d.ts +0 -4
- package/dist/types/ring.d.ts +0 -93
- package/dist/types/runtime-install.d.ts +0 -85
- package/dist/types/sanitize-text.d.ts +0 -29
- package/dist/types/snowflake.d.ts +0 -25
- package/dist/types/sqlite.d.ts +0 -26
- package/dist/types/stderr-guard.d.ts +0 -22
- package/dist/types/stream.d.ts +0 -75
- package/dist/types/tab-spacing.d.ts +0 -24
- package/dist/types/temp.d.ts +0 -24
- package/dist/types/template.d.ts +0 -62
- package/dist/types/timing-buffer.d.ts +0 -22
- package/dist/types/tls-fetch.d.ts +0 -37
- package/dist/types/turndown/gfm.d.ts +0 -11
- package/dist/types/turndown/html.d.ts +0 -5
- package/dist/types/turndown/service.d.ts +0 -21
- package/dist/types/turndown/types.d.ts +0 -70
- package/dist/types/turndown.d.ts +0 -4
- package/dist/types/type-guards.d.ts +0 -5
- package/dist/types/vendor/mermaid-ascii/ascii/ansi.d.ts +0 -41
- package/dist/types/vendor/mermaid-ascii/ascii/canvas.d.ts +0 -89
- package/dist/types/vendor/mermaid-ascii/ascii/class-diagram.d.ts +0 -7
- package/dist/types/vendor/mermaid-ascii/ascii/converter.d.ts +0 -12
- package/dist/types/vendor/mermaid-ascii/ascii/draw.d.ts +0 -66
- package/dist/types/vendor/mermaid-ascii/ascii/edge-bundling.d.ts +0 -48
- package/dist/types/vendor/mermaid-ascii/ascii/edge-routing.d.ts +0 -43
- package/dist/types/vendor/mermaid-ascii/ascii/er-diagram.d.ts +0 -7
- package/dist/types/vendor/mermaid-ascii/ascii/grid.d.ts +0 -56
- package/dist/types/vendor/mermaid-ascii/ascii/index.d.ts +0 -65
- package/dist/types/vendor/mermaid-ascii/ascii/multiline-utils.d.ts +0 -27
- package/dist/types/vendor/mermaid-ascii/ascii/pathfinder.d.ts +0 -17
- package/dist/types/vendor/mermaid-ascii/ascii/sequence.d.ts +0 -7
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/circle.d.ts +0 -11
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/corners.d.ts +0 -34
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts +0 -11
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts +0 -11
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/index.d.ts +0 -26
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts +0 -31
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts +0 -11
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/special.d.ts +0 -59
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts +0 -17
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/state.d.ts +0 -30
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/types.d.ts +0 -55
- package/dist/types/vendor/mermaid-ascii/ascii/types.d.ts +0 -206
- package/dist/types/vendor/mermaid-ascii/ascii/validate.d.ts +0 -51
- package/dist/types/vendor/mermaid-ascii/ascii/xychart.d.ts +0 -2
- package/dist/types/vendor/mermaid-ascii/class/parser.d.ts +0 -6
- package/dist/types/vendor/mermaid-ascii/class/types.d.ts +0 -102
- package/dist/types/vendor/mermaid-ascii/er/parser.d.ts +0 -6
- package/dist/types/vendor/mermaid-ascii/er/types.d.ts +0 -76
- package/dist/types/vendor/mermaid-ascii/index.d.ts +0 -1
- package/dist/types/vendor/mermaid-ascii/multiline-utils.d.ts +0 -9
- package/dist/types/vendor/mermaid-ascii/parser.d.ts +0 -7
- package/dist/types/vendor/mermaid-ascii/sequence/parser.d.ts +0 -6
- package/dist/types/vendor/mermaid-ascii/sequence/types.d.ts +0 -130
- package/dist/types/vendor/mermaid-ascii/text-metrics.d.ts +0 -23
- package/dist/types/vendor/mermaid-ascii/types.d.ts +0 -114
- package/dist/types/vendor/mermaid-ascii/xychart/colors.d.ts +0 -25
- package/dist/types/vendor/mermaid-ascii/xychart/parser.d.ts +0 -6
- package/dist/types/vendor/mermaid-ascii/xychart/types.d.ts +0 -145
- package/dist/types/version.d.ts +0 -18
- package/dist/types/vterm/buffer.d.ts +0 -99
- package/dist/types/vterm/query-responder.d.ts +0 -24
- package/dist/types/vterm/terminal.d.ts +0 -44
- package/dist/types/vterm.d.ts +0 -9
- package/dist/types/which.d.ts +0 -37
- package/dist/types/worker-host.d.ts +0 -51
- package/dist/types/xml.d.ts +0 -31
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed cursors over a JSON document while its text is still arriving.
|
|
3
|
+
*
|
|
4
|
+
* {@link IncomingDoc.channel} returns a push-side {@link IncomingFeed} and a
|
|
5
|
+
* read-side {@link IncomingDoc}. The producer appends text fragments, then
|
|
6
|
+
* explicitly calls {@link IncomingFeed.finish} or {@link IncomingFeed.abort};
|
|
7
|
+
* a feed that is never closed leaves every pending pull waiting forever.
|
|
8
|
+
* There is one shared append-only buffer; cursors are cheap, immutable
|
|
9
|
+
* path handles over it, and every pull is an ordinary promise that
|
|
10
|
+
* re-scans the buffer whenever the feed changes. There are no snapshots,
|
|
11
|
+
* per-field events, or fan-out channels.
|
|
12
|
+
*
|
|
13
|
+
* A scalar completes at its closing quote/delimiter, and a container
|
|
14
|
+
* completes only when its closing delimiter arrives. Finished-but-truncated
|
|
15
|
+
* input rejects with kind `incomplete`; abandoned input rejects with
|
|
16
|
+
* `aborted`. String chunks contain only decoded text whose meaning is
|
|
17
|
+
* stable, so an escape or Unicode escape may span any number of fragments.
|
|
18
|
+
*
|
|
19
|
+
* Pulling an {@link IncomingObject.key} makes that key required: a missing
|
|
20
|
+
* or mistyped value is a structured {@link IncomingJsonError}. Object members
|
|
21
|
+
* never pulled are skipped without validation. {@link IncomingDoc.whole} is
|
|
22
|
+
* the explicit whole-document pull and runs only after successful input
|
|
23
|
+
* completion.
|
|
24
|
+
*
|
|
25
|
+
* Object cursors bind the first occurrence of a duplicate key, whereas
|
|
26
|
+
* complete-value pulls (`value()`, `collect()`, `whole()`) go through the
|
|
27
|
+
* final parser (`parseJsonWithRepair`), whose objects are last-write-wins.
|
|
28
|
+
*
|
|
29
|
+
* Mid-stream cursors tolerate incomplete tokens but read double-quoted
|
|
30
|
+
* strings with the final parser's strict closing rule: an unescaped inner
|
|
31
|
+
* `"` can never swallow a sibling key or value. A pulled scalar completes
|
|
32
|
+
* only once a value terminator follows it, like numbers, so structural
|
|
33
|
+
* garbage after a value surfaces as `incomplete` rather than a silently
|
|
34
|
+
* misparsed pull. Single-quote recovery (`'it's'`) is shared with the final
|
|
35
|
+
* parser and passes both.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const { feed, doc } = IncomingDoc.channel();
|
|
40
|
+
* const args = doc.root().object();
|
|
41
|
+
* const content = args.key("content").string();
|
|
42
|
+
* feed.push('{"path":"a.ts","content":"hel');
|
|
43
|
+
* await content.nextChunk(); // "hel"
|
|
44
|
+
* feed.push('lo"}');
|
|
45
|
+
* feed.finish();
|
|
46
|
+
* await content.nextChunk(); // "lo"
|
|
47
|
+
* await content.nextChunk(); // undefined
|
|
48
|
+
* await args.key("path").value<string>(); // "a.ts"
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { Serial } from "./async";
|
|
53
|
+
import {
|
|
54
|
+
COLON,
|
|
55
|
+
COMMA,
|
|
56
|
+
isNumberStart,
|
|
57
|
+
JsonLexer,
|
|
58
|
+
LBRACE,
|
|
59
|
+
LBRACKET,
|
|
60
|
+
QUOTE,
|
|
61
|
+
RBRACE,
|
|
62
|
+
RBRACKET,
|
|
63
|
+
SQUOTE,
|
|
64
|
+
} from "./json-lexer";
|
|
65
|
+
import { parseJsonWithRepair } from "./json-parse";
|
|
66
|
+
|
|
67
|
+
/** Maximum container nesting before a scan reports the value as pending forever. */
|
|
68
|
+
const MAX_DEPTH = 128;
|
|
69
|
+
|
|
70
|
+
/** Location component in a pulled JSON path: an object member name or an array index. */
|
|
71
|
+
export type PullPathSegment = string | number;
|
|
72
|
+
|
|
73
|
+
/** JSON shape observed by a started pull. */
|
|
74
|
+
export type IncomingValueKind = "null" | "boolean" | "number" | "string" | "array" | "object";
|
|
75
|
+
|
|
76
|
+
/** Why a pull could not produce the requested shape. */
|
|
77
|
+
export type PullIssueKind =
|
|
78
|
+
/** The requested member was absent when its container completed. */
|
|
79
|
+
| "missing"
|
|
80
|
+
/** The producer finished before the pulled value's closing token. */
|
|
81
|
+
| "incomplete"
|
|
82
|
+
/** The producer abandoned the input before the pull completed. */
|
|
83
|
+
| "aborted"
|
|
84
|
+
/** A complete pulled value could not be parsed. */
|
|
85
|
+
| "malformed"
|
|
86
|
+
/** A value was present with a different JSON shape (see `found`). */
|
|
87
|
+
| "mismatch";
|
|
88
|
+
|
|
89
|
+
/** Structured failure while awaiting an incoming JSON value. */
|
|
90
|
+
export class IncomingJsonError extends Error {
|
|
91
|
+
/** Full key/index path pulled by the consumer. */
|
|
92
|
+
readonly path: readonly PullPathSegment[];
|
|
93
|
+
/** Shape requested by the typed cursor. */
|
|
94
|
+
readonly expected: string;
|
|
95
|
+
/** Why the pull could not produce that shape. */
|
|
96
|
+
readonly kind: PullIssueKind;
|
|
97
|
+
/** Shape observed in the input when `kind` is `mismatch`. */
|
|
98
|
+
readonly found?: string;
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
path: readonly PullPathSegment[],
|
|
102
|
+
expected: string,
|
|
103
|
+
kind: PullIssueKind,
|
|
104
|
+
options?: { found?: string; cause?: unknown },
|
|
105
|
+
) {
|
|
106
|
+
const at = path.length === 0 ? "$" : `$${path.map(s => (typeof s === "number" ? `[${s}]` : `.${s}`)).join("")}`;
|
|
107
|
+
const why = kind === "mismatch" ? `found ${options?.found}` : kind;
|
|
108
|
+
super(`invalid JSON pull at ${at}: expected ${expected} (${why})`, { cause: options?.cause });
|
|
109
|
+
this.name = "IncomingJsonError";
|
|
110
|
+
this.path = path;
|
|
111
|
+
this.expected = expected;
|
|
112
|
+
this.kind = kind;
|
|
113
|
+
this.found = options?.found;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type Shape =
|
|
118
|
+
| { kind: "null" | "array" | "object" }
|
|
119
|
+
| { kind: "boolean"; value: boolean }
|
|
120
|
+
| { kind: "number"; value: number }
|
|
121
|
+
| { kind: "string"; value: string; stableLen: number };
|
|
122
|
+
|
|
123
|
+
interface Located {
|
|
124
|
+
tag: "located";
|
|
125
|
+
/** Source offset of the value's first char. */
|
|
126
|
+
start: number;
|
|
127
|
+
/** Source offset just past the value's closing token; `undefined` while it is still open. */
|
|
128
|
+
end: number | undefined;
|
|
129
|
+
shape: Shape;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type Probe =
|
|
133
|
+
| Located
|
|
134
|
+
/** The selected value has not started, or a token on the way to it is truncated. */
|
|
135
|
+
| { tag: "pending" }
|
|
136
|
+
/** The selected member's container closed without it. */
|
|
137
|
+
| { tag: "missing" }
|
|
138
|
+
/** A container on the path has a different shape than the path segment demands. */
|
|
139
|
+
| { tag: "mismatch"; expected: string; found: string };
|
|
140
|
+
|
|
141
|
+
const PENDING: Probe = { tag: "pending" };
|
|
142
|
+
const MISSING: Probe = { tag: "missing" };
|
|
143
|
+
|
|
144
|
+
/** Readiness predicate a pull waits for once its value has been located. */
|
|
145
|
+
type Ready = (located: Located) => boolean;
|
|
146
|
+
|
|
147
|
+
const STARTED: Ready = () => true;
|
|
148
|
+
const COMPLETE: Ready = located => located.end !== undefined;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One member reached by a selecting scan: where it starts, and the index of
|
|
152
|
+
* its container value once a scan has descended into it.
|
|
153
|
+
*/
|
|
154
|
+
interface MemberSlot {
|
|
155
|
+
/** Source offset of the member's first char (key for objects, value for arrays). */
|
|
156
|
+
offset: number;
|
|
157
|
+
child?: ContainerIndex;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Member index of one container on a pulled path, so repeated pulls resume
|
|
162
|
+
* where the previous scan stopped instead of re-lexing from the container's
|
|
163
|
+
* opening delimiter. `frontier` is the source offset just after the opening
|
|
164
|
+
* delimiter or the comma following the last fully scanned member — recorded
|
|
165
|
+
* before skipping whitespace, so an unterminated comment at the buffer edge
|
|
166
|
+
* is re-read once its tail arrives. Every member starting before the frontier
|
|
167
|
+
* is recorded in `keys` (first occurrence wins) or `elements`; `tail` is the
|
|
168
|
+
* array element at `elements.length`, which may still be open.
|
|
169
|
+
*/
|
|
170
|
+
class ContainerIndex {
|
|
171
|
+
frontier: number;
|
|
172
|
+
readonly keys = new Map<string, MemberSlot>();
|
|
173
|
+
readonly elements: MemberSlot[] = [];
|
|
174
|
+
tail?: MemberSlot;
|
|
175
|
+
|
|
176
|
+
constructor(frontier: number) {
|
|
177
|
+
this.frontier = frontier;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Advance the frontier to the cursor when a frontier scan moved past it. */
|
|
181
|
+
advance(lex: JsonLexer): void {
|
|
182
|
+
if (lex.pos > this.frontier) this.frontier = lex.pos;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Append-only buffer, terminal state, and change notification shared by one feed and its cursors. */
|
|
187
|
+
class Shared {
|
|
188
|
+
text = "";
|
|
189
|
+
end: "open" | "finished" | "aborted" = "open";
|
|
190
|
+
readonly #root: MemberSlot = { offset: 0 };
|
|
191
|
+
#changed = Promise.withResolvers<void>();
|
|
192
|
+
|
|
193
|
+
/** Resolves once the buffer or terminal state changes after this call. */
|
|
194
|
+
get changed(): Promise<void> {
|
|
195
|
+
return this.#changed.promise;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
notify(): void {
|
|
199
|
+
const changed = this.#changed;
|
|
200
|
+
this.#changed = Promise.withResolvers<void>();
|
|
201
|
+
changed.resolve();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Await input completion; rejects with `aborted` when the feed was abandoned. */
|
|
205
|
+
async finished(): Promise<void> {
|
|
206
|
+
while (this.end === "open") await this.changed;
|
|
207
|
+
if (this.end === "aborted") throw new IncomingJsonError([], "document", "aborted");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Await the value at `path` until `ready` accepts it. Resolves `undefined`
|
|
212
|
+
* when the value's container completed without it.
|
|
213
|
+
*/
|
|
214
|
+
async pull(path: readonly PullPathSegment[], expected: string, ready: Ready): Promise<Located | undefined> {
|
|
215
|
+
for (;;) {
|
|
216
|
+
const probe = this.#locate(path);
|
|
217
|
+
switch (probe.tag) {
|
|
218
|
+
case "located":
|
|
219
|
+
if (ready(probe)) return probe;
|
|
220
|
+
break;
|
|
221
|
+
case "missing":
|
|
222
|
+
return undefined;
|
|
223
|
+
case "mismatch":
|
|
224
|
+
throw new IncomingJsonError(path, probe.expected, "mismatch", { found: probe.found });
|
|
225
|
+
}
|
|
226
|
+
if (this.end === "finished") throw new IncomingJsonError(path, expected, "incomplete");
|
|
227
|
+
if (this.end === "aborted") throw new IncomingJsonError(path, expected, "aborted");
|
|
228
|
+
await this.changed;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#locate(path: readonly PullPathSegment[]): Probe {
|
|
233
|
+
const lex = new JsonLexer(this.text, "incoming");
|
|
234
|
+
lex.ws();
|
|
235
|
+
return selectValue(lex, path, 0, this.end === "finished", 0, this.#root);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function mismatch(expected: string, found: string): Probe {
|
|
240
|
+
return { tag: "mismatch", expected, found };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function located(start: number, end: number | undefined, shape: Shape): Located {
|
|
244
|
+
return { tag: "located", start, end, shape };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Descend into the value at the cursor following `path[at..]`. `slot` is the
|
|
249
|
+
* member holding this value; its container index is created on first descent
|
|
250
|
+
* and lets later pulls resume the member scan instead of restarting it.
|
|
251
|
+
*/
|
|
252
|
+
function selectValue(
|
|
253
|
+
lex: JsonLexer,
|
|
254
|
+
path: readonly PullPathSegment[],
|
|
255
|
+
at: number,
|
|
256
|
+
ended: boolean,
|
|
257
|
+
depth: number,
|
|
258
|
+
slot: MemberSlot,
|
|
259
|
+
): Probe {
|
|
260
|
+
const c = lex.peek();
|
|
261
|
+
if (Number.isNaN(c)) return PENDING;
|
|
262
|
+
if (at >= path.length) return scanValue(lex, ended, depth);
|
|
263
|
+
const segment = path[at];
|
|
264
|
+
if (c !== (typeof segment === "string" ? LBRACE : LBRACKET)) {
|
|
265
|
+
return mismatch(typeof segment === "string" ? "object" : "array", charName(c));
|
|
266
|
+
}
|
|
267
|
+
if (depth >= MAX_DEPTH) return PENDING;
|
|
268
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: parser state machine relies on the inline memoized slot
|
|
269
|
+
const index = (slot.child ??= new ContainerIndex(lex.pos + 1));
|
|
270
|
+
if (typeof segment === "string") {
|
|
271
|
+
lex.pos = index.keys.get(segment)?.offset ?? index.frontier;
|
|
272
|
+
return selectKey(lex, segment, path, at + 1, ended, depth, index);
|
|
273
|
+
}
|
|
274
|
+
const known = index.elements[segment];
|
|
275
|
+
lex.pos = known?.offset ?? index.frontier;
|
|
276
|
+
return selectIndex(lex, segment, path, at + 1, ended, depth, known ? segment : index.elements.length, index);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Scan object members from the cursor (positioned at a member start or the
|
|
281
|
+
* closing brace) until `wanted` is found, then descend along `path[at..]`.
|
|
282
|
+
* Binds the first occurrence.
|
|
283
|
+
*/
|
|
284
|
+
function selectKey(
|
|
285
|
+
lex: JsonLexer,
|
|
286
|
+
wanted: string,
|
|
287
|
+
path: readonly PullPathSegment[],
|
|
288
|
+
at: number,
|
|
289
|
+
ended: boolean,
|
|
290
|
+
depth: number,
|
|
291
|
+
index: ContainerIndex,
|
|
292
|
+
): Probe {
|
|
293
|
+
if (depth >= MAX_DEPTH) return PENDING;
|
|
294
|
+
for (;;) {
|
|
295
|
+
index.advance(lex);
|
|
296
|
+
lex.ws();
|
|
297
|
+
const c = lex.peek();
|
|
298
|
+
if (Number.isNaN(c)) return PENDING;
|
|
299
|
+
if (c === RBRACE) {
|
|
300
|
+
lex.pos++;
|
|
301
|
+
return MISSING;
|
|
302
|
+
}
|
|
303
|
+
if (c === COMMA) {
|
|
304
|
+
lex.pos++;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const memberStart = lex.pos;
|
|
308
|
+
let key: string;
|
|
309
|
+
if (c === QUOTE || c === SQUOTE) {
|
|
310
|
+
const progress = lex.string(c);
|
|
311
|
+
if (!progress.complete) return PENDING;
|
|
312
|
+
key = progress.value;
|
|
313
|
+
} else {
|
|
314
|
+
key = lex.unquotedKey();
|
|
315
|
+
if (key.length === 0) return PENDING;
|
|
316
|
+
}
|
|
317
|
+
lex.ws();
|
|
318
|
+
if (lex.peek() !== COLON) return PENDING;
|
|
319
|
+
// The colon fixes the key's spelling; only now is the member stable.
|
|
320
|
+
let slot = index.keys.get(key);
|
|
321
|
+
if (slot === undefined) {
|
|
322
|
+
slot = { offset: memberStart };
|
|
323
|
+
index.keys.set(key, slot);
|
|
324
|
+
}
|
|
325
|
+
lex.pos++;
|
|
326
|
+
lex.ws();
|
|
327
|
+
if (lex.atEnd) return PENDING;
|
|
328
|
+
if (key === wanted) return selectValue(lex, path, at, ended, depth + 1, slot);
|
|
329
|
+
const skipped = scanValue(lex, ended, depth + 1);
|
|
330
|
+
if (skipped.tag !== "located" || skipped.end === undefined) return PENDING;
|
|
331
|
+
lex.ws();
|
|
332
|
+
const d = lex.peek();
|
|
333
|
+
if (d === COMMA) {
|
|
334
|
+
lex.pos++;
|
|
335
|
+
} else if (d === RBRACE) {
|
|
336
|
+
lex.pos++;
|
|
337
|
+
return MISSING;
|
|
338
|
+
} else {
|
|
339
|
+
return PENDING;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Scan array elements from the cursor (positioned at element `at` or the
|
|
346
|
+
* closing bracket) until `wanted` is reached, then descend along `path[at..]`.
|
|
347
|
+
*/
|
|
348
|
+
function selectIndex(
|
|
349
|
+
lex: JsonLexer,
|
|
350
|
+
wanted: number,
|
|
351
|
+
path: readonly PullPathSegment[],
|
|
352
|
+
at: number,
|
|
353
|
+
ended: boolean,
|
|
354
|
+
depth: number,
|
|
355
|
+
element: number,
|
|
356
|
+
index: ContainerIndex,
|
|
357
|
+
): Probe {
|
|
358
|
+
if (depth >= MAX_DEPTH) return PENDING;
|
|
359
|
+
for (;;) {
|
|
360
|
+
index.advance(lex);
|
|
361
|
+
lex.ws();
|
|
362
|
+
const c = lex.peek();
|
|
363
|
+
if (Number.isNaN(c)) return PENDING;
|
|
364
|
+
if (c === RBRACKET) {
|
|
365
|
+
lex.pos++;
|
|
366
|
+
return MISSING;
|
|
367
|
+
}
|
|
368
|
+
if (c === COMMA) {
|
|
369
|
+
lex.pos++;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: parser state machine relies on the inline memoized slot
|
|
373
|
+
const slot = index.elements[element] ?? (index.tail ??= { offset: lex.pos });
|
|
374
|
+
if (element === wanted) return selectValue(lex, path, at, ended, depth + 1, slot);
|
|
375
|
+
const skipped = scanValue(lex, ended, depth + 1);
|
|
376
|
+
if (skipped.tag !== "located" || skipped.end === undefined) return PENDING;
|
|
377
|
+
if (element === index.elements.length) {
|
|
378
|
+
index.elements.push(slot);
|
|
379
|
+
index.tail = undefined;
|
|
380
|
+
}
|
|
381
|
+
element++;
|
|
382
|
+
lex.ws();
|
|
383
|
+
const d = lex.peek();
|
|
384
|
+
if (d === COMMA) {
|
|
385
|
+
lex.pos++;
|
|
386
|
+
} else if (d === RBRACKET) {
|
|
387
|
+
lex.pos++;
|
|
388
|
+
return MISSING;
|
|
389
|
+
} else {
|
|
390
|
+
return PENDING;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Locate the value at the cursor and determine whether it is complete. */
|
|
396
|
+
function scanValue(lex: JsonLexer, ended: boolean, depth: number): Probe {
|
|
397
|
+
const start = lex.pos;
|
|
398
|
+
const c = lex.peek();
|
|
399
|
+
if (Number.isNaN(c)) return PENDING;
|
|
400
|
+
if (c === LBRACE) return scanContainer(lex, ended, depth, start, "object");
|
|
401
|
+
if (c === LBRACKET) return scanContainer(lex, ended, depth, start, "array");
|
|
402
|
+
if (c === QUOTE || c === SQUOTE) {
|
|
403
|
+
const progress = lex.string(c);
|
|
404
|
+
const end = lex.pos;
|
|
405
|
+
// Like numbers and keywords, a string is complete only once a value
|
|
406
|
+
// terminator follows (or the input ended): an edge close may still be
|
|
407
|
+
// reopened by later fragments via single-quote recovery.
|
|
408
|
+
const complete = progress.complete && scalarComplete(lex, ended);
|
|
409
|
+
return located(start, complete ? end : undefined, {
|
|
410
|
+
kind: "string",
|
|
411
|
+
value: progress.value,
|
|
412
|
+
stableLen: progress.stableLen,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
if (isNumberStart(c)) {
|
|
416
|
+
const value = lex.number();
|
|
417
|
+
if (value === undefined) return PENDING;
|
|
418
|
+
const end = lex.pos;
|
|
419
|
+
return located(start, scalarComplete(lex, ended) ? end : undefined, { kind: "number", value });
|
|
420
|
+
}
|
|
421
|
+
const keyword = lex.keyword();
|
|
422
|
+
if (keyword !== undefined) {
|
|
423
|
+
const end = lex.pos;
|
|
424
|
+
const complete = scalarComplete(lex, ended);
|
|
425
|
+
return located(
|
|
426
|
+
start,
|
|
427
|
+
complete ? end : undefined,
|
|
428
|
+
keyword === null ? { kind: "null" } : { kind: "boolean", value: keyword },
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
const word = lex.bareword();
|
|
432
|
+
if (word === undefined) return PENDING;
|
|
433
|
+
const end = lex.pos;
|
|
434
|
+
return located(start, scalarComplete(lex, ended) ? end : undefined, {
|
|
435
|
+
kind: "string",
|
|
436
|
+
value: word,
|
|
437
|
+
stableLen: word.length,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** A scalar is complete once a value terminator follows it, or the finished input ends. */
|
|
442
|
+
function scalarComplete(lex: JsonLexer, ended: boolean): boolean {
|
|
443
|
+
lex.ws();
|
|
444
|
+
const c = lex.peek();
|
|
445
|
+
return c === COMMA || c === RBRACE || c === RBRACKET || (ended && lex.atEnd);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function scanContainer(lex: JsonLexer, ended: boolean, depth: number, start: number, kind: "object" | "array"): Probe {
|
|
449
|
+
if (depth >= MAX_DEPTH) return PENDING;
|
|
450
|
+
const close = kind === "object" ? RBRACE : RBRACKET;
|
|
451
|
+
const open = located(start, undefined, { kind });
|
|
452
|
+
lex.pos++;
|
|
453
|
+
for (;;) {
|
|
454
|
+
lex.ws();
|
|
455
|
+
const c = lex.peek();
|
|
456
|
+
if (Number.isNaN(c)) return open;
|
|
457
|
+
if (c === close) {
|
|
458
|
+
lex.pos++;
|
|
459
|
+
return located(start, lex.pos, { kind });
|
|
460
|
+
}
|
|
461
|
+
if (c === COMMA) {
|
|
462
|
+
lex.pos++;
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (kind === "object") {
|
|
466
|
+
if (c === QUOTE || c === SQUOTE) {
|
|
467
|
+
if (!lex.string(c).complete) return open;
|
|
468
|
+
} else if (lex.unquotedKey().length === 0) {
|
|
469
|
+
return open;
|
|
470
|
+
}
|
|
471
|
+
lex.ws();
|
|
472
|
+
if (lex.peek() !== COLON) return open;
|
|
473
|
+
lex.pos++;
|
|
474
|
+
lex.ws();
|
|
475
|
+
}
|
|
476
|
+
const value = scanValue(lex, ended, depth + 1);
|
|
477
|
+
if (value.tag !== "located" || value.end === undefined) return open;
|
|
478
|
+
lex.ws();
|
|
479
|
+
const d = lex.peek();
|
|
480
|
+
if (d === COMMA) {
|
|
481
|
+
lex.pos++;
|
|
482
|
+
} else if (d === close) {
|
|
483
|
+
lex.pos++;
|
|
484
|
+
return located(start, lex.pos, { kind });
|
|
485
|
+
} else {
|
|
486
|
+
return open;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Shape name implied by a value's first char, for mismatch reports. */
|
|
492
|
+
function charName(c: number): string {
|
|
493
|
+
if (c === LBRACE) return "object";
|
|
494
|
+
if (c === LBRACKET) return "array";
|
|
495
|
+
if (c === QUOTE || c === SQUOTE) return "string";
|
|
496
|
+
if (isNumberStart(c)) return "number";
|
|
497
|
+
if ((c | 0x20) === 0x74 /* t */ || (c | 0x20) === 0x66 /* f */) return "boolean";
|
|
498
|
+
if ((c | 0x20) === 0x6e /* n */) return "null";
|
|
499
|
+
return "value";
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Push side of an {@link IncomingDoc} channel. Call {@link finish} to mark
|
|
504
|
+
* the document complete or {@link abort} to abandon it; both are idempotent
|
|
505
|
+
* and every pending pull settles on the first one.
|
|
506
|
+
*/
|
|
507
|
+
export class IncomingFeed {
|
|
508
|
+
readonly #shared: Shared;
|
|
509
|
+
|
|
510
|
+
constructor(shared: Shared) {
|
|
511
|
+
this.#shared = shared;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Append one text fragment and wake every pending cursor. Throws once the feed is closed. */
|
|
515
|
+
push(fragment: string): void {
|
|
516
|
+
if (this.#shared.end !== "open") throw new Error("incoming JSON feed is already closed");
|
|
517
|
+
this.#shared.text += fragment;
|
|
518
|
+
this.#shared.notify();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Mark the input complete. */
|
|
522
|
+
finish(): void {
|
|
523
|
+
this.#close("finished");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Abandon the input; pending and future pulls reject with kind `aborted`. */
|
|
527
|
+
abort(): void {
|
|
528
|
+
this.#close("aborted");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
#close(end: "finished" | "aborted"): void {
|
|
532
|
+
if (this.#shared.end !== "open") return;
|
|
533
|
+
this.#shared.end = end;
|
|
534
|
+
this.#shared.notify();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Read side of one growing JSON document. */
|
|
539
|
+
export class IncomingDoc {
|
|
540
|
+
readonly #shared: Shared;
|
|
541
|
+
|
|
542
|
+
constructor(shared: Shared) {
|
|
543
|
+
this.#shared = shared;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Create a push feed and its read-side document. */
|
|
547
|
+
static channel(): { feed: IncomingFeed; doc: IncomingDoc } {
|
|
548
|
+
const shared = new Shared();
|
|
549
|
+
return { feed: new IncomingFeed(shared), doc: new IncomingDoc(shared) };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Text received so far. */
|
|
553
|
+
get text(): string {
|
|
554
|
+
return this.#shared.text;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Await explicit input completion; rejects with kind `aborted` if the feed was abandoned. */
|
|
558
|
+
finished(): Promise<void> {
|
|
559
|
+
return this.#shared.finished();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Parse the entire finished document with the final tolerant parser.
|
|
564
|
+
* Waits for {@link IncomingFeed.finish}; aborted input is not decoded and a
|
|
565
|
+
* document that fails the final parse rejects with kind `malformed`.
|
|
566
|
+
*/
|
|
567
|
+
async whole<T = unknown>(): Promise<T> {
|
|
568
|
+
await this.#shared.finished();
|
|
569
|
+
try {
|
|
570
|
+
return parseJsonWithRepair<T>(this.#shared.text);
|
|
571
|
+
} catch (cause) {
|
|
572
|
+
throw new IncomingJsonError([], "document", "malformed", { cause });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Cursor for the root JSON value. */
|
|
577
|
+
root(): IncomingJson {
|
|
578
|
+
return new IncomingJson(this.#shared, []);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Await the value at `path` until `ready` accepts it; a missing member rejects with kind `missing`. */
|
|
583
|
+
async function pull(
|
|
584
|
+
shared: Shared,
|
|
585
|
+
path: readonly PullPathSegment[],
|
|
586
|
+
expected: string,
|
|
587
|
+
ready: Ready,
|
|
588
|
+
): Promise<Located> {
|
|
589
|
+
const found = await shared.pull(path, expected, ready);
|
|
590
|
+
if (found === undefined) throw new IncomingJsonError(path, expected, "missing");
|
|
591
|
+
return found;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Await completion and materialize the value at `path`; containers go through the final tolerant parser. */
|
|
595
|
+
async function materialize<T>(shared: Shared, path: readonly PullPathSegment[], expected: string): Promise<T> {
|
|
596
|
+
const found = await pull(shared, path, expected, COMPLETE);
|
|
597
|
+
const { shape } = found;
|
|
598
|
+
switch (shape.kind) {
|
|
599
|
+
case "null":
|
|
600
|
+
return null as T;
|
|
601
|
+
case "boolean":
|
|
602
|
+
case "number":
|
|
603
|
+
case "string":
|
|
604
|
+
return shape.value as T;
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
return parseJsonWithRepair<T>(shared.text.slice(found.start, found.end));
|
|
608
|
+
} catch (cause) {
|
|
609
|
+
throw new IncomingJsonError(path, expected, "malformed", { cause });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function mismatchError(path: readonly PullPathSegment[], expected: string, found: string): IncomingJsonError {
|
|
614
|
+
return new IncomingJsonError(path, expected, "mismatch", { found });
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Cursor for one JSON value in the incoming document, addressed by path. */
|
|
618
|
+
export class IncomingJson {
|
|
619
|
+
readonly #shared: Shared;
|
|
620
|
+
readonly path: readonly PullPathSegment[];
|
|
621
|
+
|
|
622
|
+
constructor(shared: Shared, path: readonly PullPathSegment[]) {
|
|
623
|
+
this.#shared = shared;
|
|
624
|
+
this.path = path;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Await the value's first token and report its JSON shape. */
|
|
628
|
+
async kind(): Promise<IncomingValueKind> {
|
|
629
|
+
return (await pull(this.#shared, this.path, "value", STARTED)).shape.kind;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Await and parse the complete value. Containers go through the final tolerant parser. */
|
|
633
|
+
value<T = unknown>(): Promise<T> {
|
|
634
|
+
return materialize<T>(this.#shared, this.path, "value");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Await a complete number. */
|
|
638
|
+
number(): Promise<number> {
|
|
639
|
+
return this.#scalar("number");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Await a complete boolean. */
|
|
643
|
+
boolean(): Promise<boolean> {
|
|
644
|
+
return this.#scalar("boolean");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Await a complete `null`. */
|
|
648
|
+
null(): Promise<null> {
|
|
649
|
+
return this.#scalar("null");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** View this value as an incremental decoded string. */
|
|
653
|
+
string(): IncomingString {
|
|
654
|
+
return new IncomingString(this.#shared, this.path);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** View this value as an array of element cursors. */
|
|
658
|
+
array(): IncomingArray {
|
|
659
|
+
return new IncomingArray(this.#shared, this.path);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** View this value as an object with keyed cursors. */
|
|
663
|
+
object(): IncomingObject {
|
|
664
|
+
return new IncomingObject(this.#shared, this.path);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async #scalar<T>(expected: IncomingValueKind): Promise<T> {
|
|
668
|
+
const found = await pull(this.#shared, this.path, expected, COMPLETE);
|
|
669
|
+
if (found.shape.kind !== expected) throw mismatchError(this.path, expected, found.shape.kind);
|
|
670
|
+
return materialize<T>(this.#shared, this.path, expected);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Decoded-string chunk readiness: stable output past `emitted`, or the closing quote. */
|
|
675
|
+
function chunkReady(emitted: number): Ready {
|
|
676
|
+
return located =>
|
|
677
|
+
located.shape.kind !== "string" || located.shape.stableLen !== emitted || located.end !== undefined;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Line readiness: a newline in the stable output past `emitted`, or the closing quote. */
|
|
681
|
+
function lineReady(emitted: number): Ready {
|
|
682
|
+
return located =>
|
|
683
|
+
located.shape.kind !== "string" ||
|
|
684
|
+
located.shape.stableLen < emitted ||
|
|
685
|
+
located.end !== undefined ||
|
|
686
|
+
located.shape.value.slice(emitted, located.shape.stableLen).includes("\n");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Incremental decoded string consumer. Chunks are emitted in order without
|
|
691
|
+
* overlap and are always prefixes of the final decoded string;
|
|
692
|
+
* {@link text} returns the complete string independently of whether chunks
|
|
693
|
+
* were consumed. Async iteration yields chunks. Concurrent `nextChunk` /
|
|
694
|
+
* `nextLine` calls are served in call order, like a stream reader: a call
|
|
695
|
+
* whose result is abandoned still consumes its chunk.
|
|
696
|
+
*/
|
|
697
|
+
export class IncomingString implements AsyncIterable<string> {
|
|
698
|
+
readonly #shared: Shared;
|
|
699
|
+
readonly #path: readonly PullPathSegment[];
|
|
700
|
+
readonly #serial = new Serial();
|
|
701
|
+
#emitted = 0;
|
|
702
|
+
#done = false;
|
|
703
|
+
|
|
704
|
+
constructor(shared: Shared, path: readonly PullPathSegment[]) {
|
|
705
|
+
this.#shared = shared;
|
|
706
|
+
this.#path = path;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Await the next stable decoded chunk, or `undefined` after the closing quote. */
|
|
710
|
+
nextChunk(): Promise<string | undefined> {
|
|
711
|
+
return this.#serial.run(() => this.#nextChunk());
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Await the next complete decoded line, retaining its trailing newline.
|
|
716
|
+
* A final unterminated line is returned once the closing quote arrives.
|
|
717
|
+
*/
|
|
718
|
+
nextLine(): Promise<string | undefined> {
|
|
719
|
+
return this.#serial.run(() => this.#nextLine());
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async #nextChunk(): Promise<string | undefined> {
|
|
723
|
+
if (this.#done) return undefined;
|
|
724
|
+
const found = await pull(this.#shared, this.#path, "string", chunkReady(this.#emitted));
|
|
725
|
+
const { stableLen, value } = this.#string(found);
|
|
726
|
+
if (stableLen > this.#emitted) {
|
|
727
|
+
const chunk = value.slice(this.#emitted, stableLen);
|
|
728
|
+
this.#emitted = stableLen;
|
|
729
|
+
return chunk;
|
|
730
|
+
}
|
|
731
|
+
this.#done = true;
|
|
732
|
+
return undefined;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async #nextLine(): Promise<string | undefined> {
|
|
736
|
+
if (this.#done) return undefined;
|
|
737
|
+
const found = await pull(this.#shared, this.#path, "string", lineReady(this.#emitted));
|
|
738
|
+
const { stableLen, value } = this.#string(found);
|
|
739
|
+
const newline = value.indexOf("\n", this.#emitted);
|
|
740
|
+
const upper =
|
|
741
|
+
newline >= 0 && newline < stableLen ? newline + 1 : found.end === undefined ? this.#emitted : stableLen;
|
|
742
|
+
if (upper > this.#emitted) {
|
|
743
|
+
const line = value.slice(this.#emitted, upper);
|
|
744
|
+
this.#emitted = upper;
|
|
745
|
+
return line;
|
|
746
|
+
}
|
|
747
|
+
this.#done = true;
|
|
748
|
+
return undefined;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Iterate complete decoded lines. */
|
|
752
|
+
async *lines(): AsyncGenerator<string, void, undefined> {
|
|
753
|
+
for (let line = await this.nextLine(); line !== undefined; line = await this.nextLine()) yield line;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<string, void, undefined> {
|
|
757
|
+
for (let chunk = await this.nextChunk(); chunk !== undefined; chunk = await this.nextChunk()) yield chunk;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Await the closing quote and return the complete decoded string. */
|
|
761
|
+
async text(): Promise<string> {
|
|
762
|
+
return this.#string(await pull(this.#shared, this.#path, "string", COMPLETE)).value;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
#string(found: Located): { value: string; stableLen: number } {
|
|
766
|
+
const { shape } = found;
|
|
767
|
+
if (shape.kind !== "string") throw mismatchError(this.#path, "string", shape.kind);
|
|
768
|
+
if (shape.stableLen < this.#emitted) {
|
|
769
|
+
throw new IncomingJsonError(this.#path, "valid string offset", "malformed");
|
|
770
|
+
}
|
|
771
|
+
return shape;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Linear cursor over elements of an incoming array. Async iteration yields
|
|
777
|
+
* element cursors. Concurrent `next` calls are served in call order.
|
|
778
|
+
*/
|
|
779
|
+
export class IncomingArray implements AsyncIterable<IncomingJson> {
|
|
780
|
+
readonly #shared: Shared;
|
|
781
|
+
readonly #path: readonly PullPathSegment[];
|
|
782
|
+
readonly #serial = new Serial();
|
|
783
|
+
#index = 0;
|
|
784
|
+
#started = false;
|
|
785
|
+
|
|
786
|
+
constructor(shared: Shared, path: readonly PullPathSegment[]) {
|
|
787
|
+
this.#shared = shared;
|
|
788
|
+
this.#path = path;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Await the start of the next element; `undefined` only after the closing bracket. */
|
|
792
|
+
next(): Promise<IncomingJson | undefined> {
|
|
793
|
+
return this.#serial.run(() => this.#advance());
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async #advance(): Promise<IncomingJson | undefined> {
|
|
797
|
+
if (!this.#started) {
|
|
798
|
+
const root = await pull(this.#shared, this.#path, "array", STARTED);
|
|
799
|
+
if (root.shape.kind !== "array") throw mismatchError(this.#path, "array", root.shape.kind);
|
|
800
|
+
this.#started = true;
|
|
801
|
+
}
|
|
802
|
+
const path = [...this.#path, this.#index];
|
|
803
|
+
if ((await this.#shared.pull(path, "value", STARTED)) === undefined) return undefined;
|
|
804
|
+
this.#index++;
|
|
805
|
+
return new IncomingJson(this.#shared, path);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<IncomingJson, void, undefined> {
|
|
809
|
+
for (let element = await this.next(); element !== undefined; element = await this.next()) yield element;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Await the closing bracket and collect the fully parsed elements. */
|
|
813
|
+
async collect<T = unknown>(): Promise<T[]> {
|
|
814
|
+
const values = await materialize<unknown>(this.#shared, this.#path, "array");
|
|
815
|
+
if (!Array.isArray(values)) throw mismatchError(this.#path, "array", valueName(values));
|
|
816
|
+
return values as T[];
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Keyed cursor and final collection for an incoming object. */
|
|
821
|
+
export class IncomingObject {
|
|
822
|
+
readonly #shared: Shared;
|
|
823
|
+
readonly #path: readonly PullPathSegment[];
|
|
824
|
+
|
|
825
|
+
constructor(shared: Shared, path: readonly PullPathSegment[]) {
|
|
826
|
+
this.#shared = shared;
|
|
827
|
+
this.#path = path;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Cursor bound to the first occurrence of `name`. Pulling it makes the
|
|
832
|
+
* key required: a missing member rejects with kind `missing`.
|
|
833
|
+
*/
|
|
834
|
+
key(name: string): IncomingJson {
|
|
835
|
+
return new IncomingJson(this.#shared, [...this.#path, name]);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Await the closing brace and collect the object through the final parser,
|
|
840
|
+
* whose duplicate keys are last-write-wins unlike {@link key}.
|
|
841
|
+
*/
|
|
842
|
+
async collect<T = Record<string, unknown>>(): Promise<T> {
|
|
843
|
+
const value = await materialize<unknown>(this.#shared, this.#path, "object");
|
|
844
|
+
const found = valueName(value);
|
|
845
|
+
if (found !== "object") throw mismatchError(this.#path, "object", found);
|
|
846
|
+
return value as T;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** JSON shape name of a materialized value, for mismatch reports. */
|
|
851
|
+
function valueName(value: unknown): IncomingValueKind {
|
|
852
|
+
if (value === null) return "null";
|
|
853
|
+
if (Array.isArray(value)) return "array";
|
|
854
|
+
const type = typeof value;
|
|
855
|
+
return type === "boolean" || type === "number" || type === "string" ? type : "object";
|
|
856
|
+
}
|