@tabnas/transduce 0.1.0 → 0.1.1

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/src/matcher.ts ADDED
@@ -0,0 +1,287 @@
1
+ /* Copyright (c) 2026 tabnas, MIT License */
2
+
3
+ // Shared-prefix matching of many selectors in one pass.
4
+ //
5
+ // The router has to recognize every capture's selector over one event
6
+ // stream, and a document can be hundreds of megabytes, so the matcher
7
+ // never spells out a path per event: the selectors are compiled into one
8
+ // trie of steps, and the matcher walks the document with a stack of open
9
+ // containers (a level per container: its kind, how many values it has
10
+ // started, the current member's key) and, per level, the trie nodes that
11
+ // named that container, kept on one flat stack.
12
+ //
13
+ // The matcher also validates the protocol as it goes, because it holds the
14
+ // state that makes a malformed stream visible (a key outside an object, an
15
+ // array ending an object, a value after the root). Every stage downstream
16
+ // of it can therefore trust the sequence. A concrete `Path` is built only
17
+ // on request, for a delivered match or a failure.
18
+
19
+ import { Fail } from './error'
20
+ import { JsonEvent } from './event'
21
+ import { Path, Segment, Selector } from './selector'
22
+
23
+ // Which selector matched: its position in the list given to the matcher.
24
+ export type CaptureId = number
25
+
26
+ const ROOT = 0
27
+
28
+ // One trie node: the selectors' next steps below one position.
29
+ type Node = {
30
+ properties: Map<string, number>
31
+ indexes: Map<number, number>
32
+ eachIndex: number
33
+ eachMember: number
34
+ // The selectors that end here, in id order.
35
+ terminals: CaptureId[]
36
+ }
37
+
38
+ function newNode(): Node {
39
+ return {
40
+ properties: new Map(),
41
+ indexes: new Map(),
42
+ eachIndex: -1,
43
+ eachMember: -1,
44
+ terminals: [],
45
+ }
46
+ }
47
+
48
+ // One open container.
49
+ type Level = {
50
+ array: boolean
51
+ // Values started in this container so far; the current or last one is
52
+ // at index `started - 1`.
53
+ started: number
54
+ // The current member's key, for objects.
55
+ key: string
56
+ // Between a key and its value.
57
+ inValue: boolean
58
+ // The trie nodes that named this container: `nodeStack[start..end]`.
59
+ start: number
60
+ end: number
61
+ }
62
+
63
+ // What one event did to the document's structure: a member name (`key`),
64
+ // a container began (`start`), a whole scalar (`scalar`), a container
65
+ // completed (`close`), the document completed (`end`).
66
+ export type HitKind = 'key' | 'start' | 'scalar' | 'close' | 'end'
67
+
68
+ // The matcher's answer for one event.
69
+ export type Hit = {
70
+ kind: HitKind
71
+ // How many captures begin with this event: their ids are
72
+ // `Matcher.begins()`, valid until the next event.
73
+ begins: number
74
+ // For `start` and `scalar`, the number of containers enclosing the
75
+ // value; for `close`, the number enclosing the container that closed.
76
+ depth: number
77
+ }
78
+
79
+ // Recognizes every selector of a set over one event stream.
80
+ export class Matcher {
81
+ private nodes: Node[] = [newNode()]
82
+ private levels: Level[] = []
83
+ private open = 0
84
+ private nodeStack: number[] = []
85
+ private begun: CaptureId[] = []
86
+ private rootDone = false
87
+ private isEnded = false
88
+
89
+ // Compile the selectors; their positions are the capture ids.
90
+ constructor(selectors: readonly Selector[]) {
91
+ selectors.forEach((selector, id) => {
92
+ let at = ROOT
93
+ for (const step of selector.steps) {
94
+ const node = this.nodes[at]
95
+ let next: number | undefined
96
+ switch (step.type) {
97
+ case 'property':
98
+ next = node.properties.get(step.name)
99
+ break
100
+ case 'index':
101
+ next = node.indexes.get(step.index)
102
+ break
103
+ case 'each_index':
104
+ next = node.eachIndex < 0 ? undefined : node.eachIndex
105
+ break
106
+ case 'each_member':
107
+ next = node.eachMember < 0 ? undefined : node.eachMember
108
+ break
109
+ }
110
+ if (undefined === next) {
111
+ next = this.nodes.length
112
+ this.nodes.push(newNode())
113
+ switch (step.type) {
114
+ case 'property':
115
+ node.properties.set(step.name, next)
116
+ break
117
+ case 'index':
118
+ node.indexes.set(step.index, next)
119
+ break
120
+ case 'each_index':
121
+ node.eachIndex = next
122
+ break
123
+ case 'each_member':
124
+ node.eachMember = next
125
+ break
126
+ }
127
+ }
128
+ at = next
129
+ }
130
+ this.nodes[at].terminals.push(id)
131
+ })
132
+ }
133
+
134
+ // Open containers right now.
135
+ depth(): number {
136
+ return this.open
137
+ }
138
+
139
+ // Whether `end` has been seen.
140
+ ended(): boolean {
141
+ return this.isEnded
142
+ }
143
+
144
+ // The concrete path of the value at `depth` enclosing containers. Asked
145
+ // at a `start` or `scalar` hit it names the value; asked at a `close`
146
+ // hit it names the container that closed.
147
+ path(depth: number): Path {
148
+ const segments: Segment[] = []
149
+ const n = Math.min(depth, this.open)
150
+ for (let i = 0; i < n; i++) {
151
+ const level = this.levels[i]
152
+ segments.push(level.array ? Math.max(0, level.started - 1) : level.key)
153
+ }
154
+ return new Path(segments)
155
+ }
156
+
157
+ // The captures that began with the last event, in id order.
158
+ begins(): readonly CaptureId[] {
159
+ return this.begun
160
+ }
161
+
162
+ // One event. A malformed sequence is a `PROTOCOL_ORDER_ERROR`.
163
+ event(ev: JsonEvent): Hit {
164
+ if (this.isEnded) {
165
+ throw Fail.protocol('an event arrived after the document ended')
166
+ }
167
+ switch (ev.type) {
168
+ case 'key': {
169
+ const level = 0 < this.open ? this.levels[this.open - 1] : undefined
170
+ if (undefined === level || level.array) {
171
+ throw Fail.protocol('a key arrived outside an object')
172
+ }
173
+ if (level.inValue) {
174
+ throw Fail.protocol('a key arrived where a value was expected')
175
+ }
176
+ level.key = ev.key
177
+ level.inValue = true
178
+ this.begun.length = 0
179
+ return { kind: 'key', begins: 0, depth: this.open }
180
+ }
181
+ case 'object_start':
182
+ case 'array_start': {
183
+ const depth = this.open
184
+ const start = this.beginValue()
185
+ const end = this.nodeStack.length
186
+ const array = 'array_start' === ev.type
187
+ const level: Level = {
188
+ array,
189
+ started: 0,
190
+ key: '',
191
+ inValue: false,
192
+ start,
193
+ end,
194
+ }
195
+ if (depth === this.levels.length) this.levels.push(level)
196
+ else this.levels[depth] = level
197
+ this.open++
198
+ return { kind: 'start', begins: this.begun.length, depth }
199
+ }
200
+ case 'object_end':
201
+ case 'array_end': {
202
+ const wantArray = 'array_end' === ev.type
203
+ const level = 0 < this.open ? this.levels[this.open - 1] : undefined
204
+ if (undefined === level) {
205
+ throw Fail.protocol('a container ended that had not started')
206
+ }
207
+ if (level.array !== wantArray) {
208
+ throw Fail.protocol(
209
+ wantArray ? 'an array ended inside an object' : 'an object ended inside an array',
210
+ )
211
+ }
212
+ if (level.inValue) {
213
+ throw Fail.protocol('an object ended after a key without its value')
214
+ }
215
+ this.open--
216
+ this.nodeStack.length = level.start
217
+ this.completeValue()
218
+ this.begun.length = 0
219
+ return { kind: 'close', begins: 0, depth: this.open }
220
+ }
221
+ case 'end':
222
+ if (0 < this.open) {
223
+ throw Fail.protocol('the document ended inside a container')
224
+ }
225
+ if (!this.rootDone) {
226
+ throw Fail.protocol('the document ended before its root value')
227
+ }
228
+ this.isEnded = true
229
+ this.begun.length = 0
230
+ return { kind: 'end', begins: 0, depth: 0 }
231
+ default: {
232
+ const depth = this.open
233
+ const start = this.beginValue()
234
+ this.nodeStack.length = start
235
+ this.completeValue()
236
+ return { kind: 'scalar', begins: this.begun.length, depth }
237
+ }
238
+ }
239
+ }
240
+
241
+ // A value begins at the current position: check that one is allowed
242
+ // here, push the trie nodes naming it onto the node stack, and fill
243
+ // `begun`. Returns where on the node stack the value's nodes start.
244
+ private beginValue(): number {
245
+ let base: number
246
+ if (0 === this.open) {
247
+ if (this.rootDone) throw Fail.protocol('a second root value arrived')
248
+ this.nodeStack.length = 0
249
+ this.nodeStack.push(ROOT)
250
+ base = 0
251
+ } else {
252
+ const level = this.levels[this.open - 1]
253
+ if (!level.array && !level.inValue) {
254
+ throw Fail.protocol('a value arrived inside an object without a key')
255
+ }
256
+ const index = level.started
257
+ level.started++
258
+ this.nodeStack.length = level.end
259
+ for (let at = level.start; at < level.end; at++) {
260
+ const node = this.nodes[this.nodeStack[at]]
261
+ if (level.array) {
262
+ if (0 <= node.eachIndex) this.nodeStack.push(node.eachIndex)
263
+ const c = node.indexes.get(index)
264
+ if (undefined !== c) this.nodeStack.push(c)
265
+ } else {
266
+ if (0 <= node.eachMember) this.nodeStack.push(node.eachMember)
267
+ const c = node.properties.get(level.key)
268
+ if (undefined !== c) this.nodeStack.push(c)
269
+ }
270
+ }
271
+ base = level.end
272
+ }
273
+ this.begun.length = 0
274
+ for (let i = base; i < this.nodeStack.length; i++) {
275
+ const terminals = this.nodes[this.nodeStack[i]].terminals
276
+ for (const t of terminals) this.begun.push(t)
277
+ }
278
+ if (this.begun.length > 1) this.begun.sort((a, b) => a - b)
279
+ return base
280
+ }
281
+
282
+ // The value at the current position is complete.
283
+ private completeValue(): void {
284
+ if (0 === this.open) this.rootDone = true
285
+ else this.levels[this.open - 1].inValue = false
286
+ }
287
+ }
@@ -0,0 +1,225 @@
1
+ /* Copyright (c) 2026 tabnas, MIT License */
2
+
3
+ // `ParserSource`: `JsonEvents/1` from a tabnas parse of one text.
4
+ //
5
+ // Two modes. `materialize` parses, then walks the value: always sound, and
6
+ // retains the whole value. `incremental` installs the rule-event adapter
7
+ // so events leave the parse as containers open and entries land, and
8
+ // optionally prunes streamed array elements from the engine's tree. It is
9
+ // sound for the grammars `capability.incremental` lists, which the
10
+ // differential suite verifies in this runtime, and for no other: an
11
+ // imperative grammar's rule events give a well-formed stream of the wrong
12
+ // shape or a malformed one, and the run would still return. So the
13
+ // incremental path is gated on the list, by the grammar's name, which the
14
+ // source cannot learn from the `Tabnas` and so must be told (`grammar`);
15
+ // without a listed name it fails with `STREAMABILITY_UNKNOWN` before the
16
+ // parse, having emitted nothing. `unverified()` lifts the gate for the
17
+ // differential suite that maintains the list.
18
+ //
19
+ // The parser is the source's: the source subscribes to its rule events and
20
+ // sets its parse budget (the engine has no way to take either back), so a
21
+ // parser is given to one source and not reused afterwards, and a source
22
+ // runs once (a second run is `STREAM_REUSED`).
23
+ //
24
+ // Failure mapping, in this order: a sink failure is thrown as it was; a
25
+ // sink that stopped is `'stop'`; a parse cancelled through the caller's
26
+ // `AbortFlag` is `ABORTED`; any other engine error is `INPUT_INVALID`
27
+ // with the engine's code and position; a grammar's own guard cancelling
28
+ // the parse is `INPUT_INVALID` too, with a message that names the
29
+ // grammar's guard. An incremental parse that returned without one rule
30
+ // event the adapter could turn into a value (YAML's empty document is
31
+ // `null`) has its value walked instead.
32
+
33
+ import { isIncremental } from './capability'
34
+ import { Fail } from './error'
35
+ import { Ev } from './event'
36
+ import { Guarded } from './guard'
37
+ import { AbortFlag, Limits, Metrics } from './limits'
38
+ import { Adapter, notStreamable, prepare } from './rule-events'
39
+ import { Flow, Sink } from './sink'
40
+ import { Prune, Source, SourceMode, engineFailure, walkValue } from './source'
41
+
42
+ // A tabnas parser applied to one text, as a source.
43
+ export class ParserSource implements Source {
44
+ private parser: any
45
+ private text: string
46
+ private sourceMode: SourceMode = SourceMode.materialize()
47
+ private sourceLimits: Limits = Limits.default()
48
+ private abortFlag: AbortFlag = new AbortFlag()
49
+ private sourceMetrics: Metrics = new Metrics()
50
+ private grammarName: string | null = null
51
+ private isUnverified = false
52
+ private used = false
53
+
54
+ // A source in materialize mode with default limits, its own abort flag
55
+ // and fresh metrics; the builder methods change each.
56
+ constructor(parser: any, text: string) {
57
+ this.parser = parser
58
+ this.text = text
59
+ }
60
+
61
+ mode(mode: SourceMode): this {
62
+ this.sourceMode = mode
63
+ return this
64
+ }
65
+
66
+ // The grammar the parser implements, by its package's name (`json` for
67
+ // `@tabnas/json`). Incremental mode runs only for a name
68
+ // `capability.incremental` lists.
69
+ grammar(name: string): this {
70
+ this.grammarName = name
71
+ return this
72
+ }
73
+
74
+ // Run incremental mode whatever the verified list says. This exists for
75
+ // the differential suite that maintains the list and for nothing else.
76
+ unverified(): this {
77
+ this.isUnverified = true
78
+ return this
79
+ }
80
+
81
+ limits(limits: Limits): this {
82
+ this.sourceLimits = limits
83
+ return this
84
+ }
85
+
86
+ abort(abort: AbortFlag): this {
87
+ this.abortFlag = abort
88
+ return this
89
+ }
90
+
91
+ metrics(metrics: Metrics): this {
92
+ this.sourceMetrics = metrics
93
+ return this
94
+ }
95
+
96
+ // Why the incremental path may not run, when it may not.
97
+ private gate(): Fail | null {
98
+ if (this.isUnverified) return null
99
+ const name = this.grammarName
100
+ if (null !== name && isIncremental(name)) return null
101
+ if (null !== name) {
102
+ return new Fail(
103
+ 'STREAMABILITY_UNKNOWN',
104
+ `grammar ${JSON.stringify(name)} is not in capability.incremental: the differential ` +
105
+ 'suite has not verified that its rule events stream as the walk does; run it with ' +
106
+ 'SourceMode.materialize',
107
+ )
108
+ }
109
+ return new Fail(
110
+ 'STREAMABILITY_UNKNOWN',
111
+ "SourceMode.incremental needs the grammar's name (ParserSource.grammar) to check " +
112
+ 'capability.incremental; without one, run SourceMode.materialize',
113
+ )
114
+ }
115
+
116
+ // Run in the configured mode.
117
+ run(sink: Sink): Flow {
118
+ return this.runWithValue(sink).flow
119
+ }
120
+
121
+ // `run`, also handing back the value the engine returned. In materialize
122
+ // mode that is the grammar's value. In incremental mode it is the
123
+ // engine's tree AFTER pruning, which is neither the grammar's value nor
124
+ // the run's result (the events are): it exists so a test can measure
125
+ // what pruning left in the tree, and nothing else should read it.
126
+ runWithValue(sink: Sink): { flow: Flow; value: unknown } {
127
+ if (this.used) {
128
+ throw new Fail(
129
+ 'STREAM_REUSED',
130
+ 'a ParserSource runs once: its parser carries the run\'s subscriber and budget; ' +
131
+ 'make a new source, with a new parser, for another run',
132
+ )
133
+ }
134
+ this.used = true
135
+ const mode = this.sourceMode
136
+ if ('materialize' === mode.type) {
137
+ const guarded = new Guarded(sink, this.sourceLimits, this.abortFlag, this.sourceMetrics)
138
+ try {
139
+ return materialize(this.parser, this.text, this.abortFlag, guarded)
140
+ } finally {
141
+ guarded.flush()
142
+ }
143
+ }
144
+ const refused = this.gate()
145
+ if (null !== refused) throw refused
146
+ return incremental(
147
+ this.parser,
148
+ this.text,
149
+ this.sourceLimits,
150
+ this.abortFlag,
151
+ this.sourceMetrics,
152
+ mode.prune,
153
+ sink,
154
+ )
155
+ }
156
+ }
157
+
158
+ // Parse, then walk; the grammar's value comes back beside the outcome.
159
+ function materialize<S extends Sink>(
160
+ parser: any,
161
+ text: string,
162
+ abort: AbortFlag,
163
+ guarded: Guarded<S>,
164
+ ): { flow: Flow; value: unknown } {
165
+ prepare(parser, () => !abort.isAborted())
166
+ let value: unknown
167
+ try {
168
+ value = parser.parse(text)
169
+ } catch (err) {
170
+ throw engineFailure(err, abort)
171
+ }
172
+ let flow = walkValue(value, guarded)
173
+ if ('continue' === flow) flow = guarded.event(Ev.end)
174
+ return { flow, value }
175
+ }
176
+
177
+ function incremental<S extends Sink>(
178
+ parser: any,
179
+ text: string,
180
+ limits: Limits,
181
+ abort: AbortFlag,
182
+ metrics: Metrics,
183
+ prune: Prune,
184
+ sink: S,
185
+ ): { flow: Flow; value: unknown } {
186
+ const stop = new AbortFlag()
187
+ const adapter = new Adapter(sink, limits, abort, metrics, prune, stop)
188
+ prepare(
189
+ parser,
190
+ () => !abort.isAborted() && !stop.isAborted(),
191
+ (rule, done) => adapter.onDone(rule, done),
192
+ )
193
+ let parsed: { ok: true; value: unknown } | { ok: false; error: unknown }
194
+ try {
195
+ parsed = { ok: true, value: parser.parse(text) }
196
+ } catch (error) {
197
+ parsed = { ok: false, error }
198
+ }
199
+ let outcome: { flow: Flow } | { error: unknown } = { flow: 'continue' }
200
+ try {
201
+ if ('running' === adapter.status.type) {
202
+ if (parsed.ok && adapter.complete()) {
203
+ outcome = { flow: adapter.send(Ev.end) }
204
+ } else if (parsed.ok && adapter.idle()) {
205
+ let flow = adapter.walkWhole(parsed.value)
206
+ if ('continue' === flow) flow = adapter.send(Ev.end)
207
+ outcome = { flow }
208
+ } else if (parsed.ok) {
209
+ outcome = { error: notStreamable() }
210
+ } else {
211
+ outcome = { error: engineFailure(parsed.error, abort) }
212
+ }
213
+ }
214
+ } catch (error) {
215
+ outcome = { error }
216
+ } finally {
217
+ adapter.sink.flush()
218
+ }
219
+ const value = parsed.ok ? parsed.value : undefined
220
+ const status = adapter.status
221
+ if ('failed' === status.type) throw status.error
222
+ if ('stopped' === status.type) return { flow: 'stop', value }
223
+ if ('error' in outcome) throw outcome.error
224
+ return { flow: outcome.flow, value }
225
+ }