@tabnas/transduce 0.1.0 → 0.1.2

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/scan.ts ADDED
@@ -0,0 +1,83 @@
1
+ /* Copyright (c) 2026 tabnas, MIT License */
2
+
3
+ // `scan-emit`: declarative state evolution over a stream.
4
+ //
5
+ // A pure step function takes the state and one item and returns the next
6
+ // state with the items to emit; a finish function turns the final state
7
+ // into the closing items. The operator owns the state and the iteration;
8
+ // the functions own nothing. This is what the DSL's `scan-emit` lowers to,
9
+ // and the table transducer is one instance of it.
10
+
11
+ import { Fail } from './error'
12
+ import { Flow } from './sink'
13
+
14
+ // The result of one step: the next state and what to emit for it.
15
+ export type Transition<S, O> = { state: S; outputs: O[] }
16
+
17
+ export const Transition = Object.freeze({
18
+ of<S, O>(state: S, outputs: O[]): Transition<S, O> {
19
+ return { state, outputs }
20
+ },
21
+ // A step that emits nothing.
22
+ stay<S, O>(state: S): Transition<S, O> {
23
+ return { state, outputs: [] }
24
+ },
25
+ // A step that emits one item.
26
+ emit<S, O>(state: S, output: O): Transition<S, O> {
27
+ return { state, outputs: [output] }
28
+ },
29
+ })
30
+
31
+ // The operator. Feed items with `item`, then call `finish` exactly once
32
+ // when the input completed successfully; never call it after a failure or
33
+ // a cancellation. A step or the finish throws a `Fail` to fail the run; a
34
+ // step that failed leaves the operator without a state, so a later item or
35
+ // `finish` is a `PROTOCOL_ORDER_ERROR`.
36
+ export class ScanEmit<S, I, O> {
37
+ private state: { value: S } | null
38
+ private step: (state: S, item: I) => Transition<S, O>
39
+ private finisher: ((state: S) => O[]) | null
40
+ private out: (output: O) => Flow
41
+
42
+ constructor(
43
+ initial: S,
44
+ step: (state: S, item: I) => Transition<S, O>,
45
+ finish: (state: S) => O[],
46
+ out: (output: O) => Flow,
47
+ ) {
48
+ this.state = { value: initial }
49
+ this.step = step
50
+ this.finisher = finish
51
+ this.out = out
52
+ }
53
+
54
+ // One input item. Its outputs go downstream before this returns.
55
+ item(item: I): Flow {
56
+ const state = this.state
57
+ if (null === state) {
58
+ throw Fail.protocol('scan-emit received an item after it finished')
59
+ }
60
+ this.state = null
61
+ const transition = this.step(state.value, item)
62
+ this.state = { value: transition.state }
63
+ for (const o of transition.outputs) {
64
+ if ('stop' === this.out(o)) return 'stop'
65
+ }
66
+ return 'continue'
67
+ }
68
+
69
+ // The input completed: emit the closing items.
70
+ finish(): Flow {
71
+ const state = this.state
72
+ const finish = this.finisher
73
+ if (null === state || null === finish) {
74
+ throw Fail.protocol('scan-emit finished twice')
75
+ }
76
+ this.state = null
77
+ this.finisher = null
78
+ for (const o of finish(state.value)) {
79
+ if ('stop' === this.out(o)) return 'stop'
80
+ }
81
+ return 'continue'
82
+ }
83
+ }
@@ -0,0 +1,191 @@
1
+ /* Copyright (c) 2026 tabnas, MIT License */
2
+
3
+ // Selectors: reusable descriptions of where in a document to look.
4
+ //
5
+ // A selector is data, never code: it is built from constructors or from
6
+ // validated path segments (`Selector.fromSegments`), and a matcher
7
+ // interprets it. A concrete `Path` names one location; a `Selector` may
8
+ // name many (`each_index`, `each_member`). Both print in jq syntax, which
9
+ // is what the rest of the fleet prints and accepts.
10
+
11
+ import { jsonString } from './json'
12
+
13
+ // One step of a concrete path: a string is a member's key, a non-negative
14
+ // integer an element's index.
15
+ export type Segment = string | number
16
+
17
+ // One key as jq writes it: bare when it is an ASCII identifier, a JSON
18
+ // string otherwise.
19
+ export function keyText(key: string): string {
20
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) ? '.' + key : '.' + jsonString(key)
21
+ }
22
+
23
+ // A concrete location in a document.
24
+ export class Path {
25
+ readonly segments: Segment[]
26
+
27
+ constructor(segments: Segment[] = []) {
28
+ this.segments = segments
29
+ }
30
+
31
+ static root(): Path {
32
+ return new Path([])
33
+ }
34
+
35
+ push(seg: Segment): void {
36
+ this.segments.push(seg)
37
+ }
38
+
39
+ pop(): Segment | undefined {
40
+ return this.segments.pop()
41
+ }
42
+
43
+ depth(): number {
44
+ return this.segments.length
45
+ }
46
+
47
+ clone(): Path {
48
+ return new Path(this.segments.slice())
49
+ }
50
+
51
+ // jq syntax: `.a[0]."b-c"`, and `.` for the root.
52
+ toString(): string {
53
+ return pathText(this.segments)
54
+ }
55
+ }
56
+
57
+ // Segments in jq syntax.
58
+ export function pathText(segments: readonly Segment[]): string {
59
+ if (0 === segments.length) return '.'
60
+ let out = ''
61
+ for (const seg of segments) {
62
+ out += 'number' === typeof seg ? `[${seg}]` : keyText(seg)
63
+ }
64
+ return out
65
+ }
66
+
67
+ // One step of a selector.
68
+ export type Step =
69
+ // The member with this name, inside an object.
70
+ | { readonly type: 'property'; readonly name: string }
71
+ // The element at this position, inside an array.
72
+ | { readonly type: 'index'; readonly index: number }
73
+ // Every element of an array.
74
+ | { readonly type: 'each_index' }
75
+ // Every member value of an object.
76
+ | { readonly type: 'each_member' }
77
+
78
+ const EACH_INDEX: Step = Object.freeze({ type: 'each_index' })
79
+ const EACH_MEMBER: Step = Object.freeze({ type: 'each_member' })
80
+
81
+ // A description of locations: the root, narrowed step by step. Immutable:
82
+ // every builder returns a new selector.
83
+ export class Selector {
84
+ readonly steps: readonly Step[]
85
+
86
+ constructor(steps: readonly Step[] = []) {
87
+ this.steps = Object.freeze(steps.slice())
88
+ }
89
+
90
+ // The document itself.
91
+ static root(): Selector {
92
+ return new Selector([])
93
+ }
94
+
95
+ property(name: string): Selector {
96
+ return new Selector([...this.steps, { type: 'property', name }])
97
+ }
98
+
99
+ index(index: number): Selector {
100
+ if (!Number.isInteger(index) || index < 0) {
101
+ throw new TypeError('an index step is a non-negative integer: ' + index)
102
+ }
103
+ return new Selector([...this.steps, { type: 'index', index }])
104
+ }
105
+
106
+ eachIndex(): Selector {
107
+ return new Selector([...this.steps, EACH_INDEX])
108
+ }
109
+
110
+ eachMember(): Selector {
111
+ return new Selector([...this.steps, EACH_MEMBER])
112
+ }
113
+
114
+ // `this`, then `other` below every location `this` names.
115
+ compose(other: Selector): Selector {
116
+ return new Selector([...this.steps, ...other.steps])
117
+ }
118
+
119
+ // A selector naming exactly one location: `as-path` over data.
120
+ static fromSegments(segments: readonly Segment[]): Selector {
121
+ let s = Selector.root()
122
+ for (const seg of segments) {
123
+ s = 'number' === typeof seg ? s.index(seg) : s.property(seg)
124
+ }
125
+ return s
126
+ }
127
+
128
+ isRoot(): boolean {
129
+ return 0 === this.steps.length
130
+ }
131
+
132
+ // Whether the selector can name more than one location.
133
+ isMulti(): boolean {
134
+ return this.steps.some((s) => 'each_index' === s.type || 'each_member' === s.type)
135
+ }
136
+
137
+ // Whether this selector names a location strictly inside a location
138
+ // `other` names, or the same one: the test a router uses to refuse
139
+ // overlapping captures.
140
+ mayOverlap(other: Selector): boolean {
141
+ const [short, long] =
142
+ this.steps.length <= other.steps.length ? [this, other] : [other, this]
143
+ for (let i = 0; i < short.steps.length; i++) {
144
+ if (!stepMayMatchSame(short.steps[i], long.steps[i])) return false
145
+ }
146
+ return true
147
+ }
148
+
149
+ equals(other: Selector): boolean {
150
+ return this.toString() === other.toString()
151
+ }
152
+
153
+ // jq syntax: `.response.records[*]`, `."odd key"`, `[3]`, `[]` for
154
+ // every member, `.` for the root.
155
+ toString(): string {
156
+ if (0 === this.steps.length) return '.'
157
+ let out = ''
158
+ for (const step of this.steps) {
159
+ switch (step.type) {
160
+ case 'property':
161
+ out += keyText(step.name)
162
+ break
163
+ case 'index':
164
+ out += `[${step.index}]`
165
+ break
166
+ case 'each_index':
167
+ out += '[*]'
168
+ break
169
+ case 'each_member':
170
+ out += '[]'
171
+ break
172
+ }
173
+ }
174
+ return out
175
+ }
176
+ }
177
+
178
+ function stepMayMatchSame(a: Step, b: Step): boolean {
179
+ switch (a.type) {
180
+ case 'property':
181
+ return (
182
+ ('property' === b.type && a.name === b.name) || 'each_member' === b.type
183
+ )
184
+ case 'each_member':
185
+ return 'property' === b.type || 'each_member' === b.type
186
+ case 'index':
187
+ return ('index' === b.type && a.index === b.index) || 'each_index' === b.type
188
+ case 'each_index':
189
+ return 'index' === b.type || 'each_index' === b.type
190
+ }
191
+ }
package/src/sink.ts ADDED
@@ -0,0 +1,199 @@
1
+ /* Copyright (c) 2026 tabnas, MIT License */
2
+
3
+ // The push boundary between stages.
4
+ //
5
+ // A pipeline is a chain of sinks. The source calls the first sink once per
6
+ // event, synchronously, inside the parse; each stage does its work and
7
+ // calls the next. Nothing is queued between stages, so a slow writer at the
8
+ // end slows the parser at the start: that is the backpressure, and it
9
+ // costs no buffer. A stage that must stop early (a `take`) answers
10
+ // `'stop'`, which the source turns into a cancelled parse. A stage that
11
+ // fails throws a `Fail`; the source stops the parse and the failure
12
+ // reaches the caller unchanged.
13
+
14
+ import { Fail } from './error'
15
+ import { JsonEvent } from './event'
16
+ import { Path, Segment } from './selector'
17
+
18
+ // What a stage wants next: `'continue'`, or `'stop'` (the stage has all it
19
+ // needs; the source stops the parse, releases what it holds and reports
20
+ // nothing further; not an error).
21
+ export type Flow = 'continue' | 'stop'
22
+
23
+ // A consumer of `JsonEvents/1`. `event` throws a `Fail` to fail the run.
24
+ export interface Sink {
25
+ event(ev: JsonEvent): Flow
26
+ }
27
+
28
+ // A sink that records every event, for tests and small results.
29
+ export class EventRecorder implements Sink {
30
+ readonly events: JsonEvent[] = []
31
+
32
+ event(ev: JsonEvent): Flow {
33
+ this.events.push(ev)
34
+ return 'continue'
35
+ }
36
+ }
37
+
38
+ // A sink made of a function. A function that returns nothing continues.
39
+ export class FnSink implements Sink {
40
+ private fn: (ev: JsonEvent) => Flow | void
41
+
42
+ constructor(fn: (ev: JsonEvent) => Flow | void) {
43
+ this.fn = fn
44
+ }
45
+
46
+ event(ev: JsonEvent): Flow {
47
+ return this.fn(ev) || 'continue'
48
+ }
49
+ }
50
+
51
+ // A sink that counts events and drops them: the cheapest consumer, for
52
+ // measuring a source on its own.
53
+ export class CountSink implements Sink {
54
+ events = 0
55
+
56
+ event(_ev: JsonEvent): Flow {
57
+ this.events++
58
+ return 'continue'
59
+ }
60
+ }
61
+
62
+ // Replay a recording into a sink, stopping where the sink stops.
63
+ export function replay(events: Iterable<JsonEvent>, sink: Sink): Flow {
64
+ for (const ev of events) {
65
+ if ('stop' === sink.event(ev)) return 'stop'
66
+ }
67
+ return 'continue'
68
+ }
69
+
70
+ // A container `TreeContract` has open: an object with the keys it has had
71
+ // (the last of them the member whose value is due unless `keyDue`), or an
72
+ // array with the index of the element due next.
73
+ type Open =
74
+ | { array: false; keys: Set<string>; last: string | null; keyDue: boolean }
75
+ | { array: true; next: number }
76
+
77
+ // A tree's events, held to their contract in front of a sink that takes
78
+ // them as one (a render that writes a document from them): one root value,
79
+ // and in each object a key and then its value, each key once. A value
80
+ // walked from a parsed tree keeps it by construction. A parse streamed as
81
+ // it proceeds may not: it hands on a member its grammar reads twice
82
+ // (JSON's `{"a":1,"a":2}`, whose value keeps the last) where a tree has
83
+ // one, and the rule-event adapter refuses most shapes it cannot follow but
84
+ // not every one a grammar can produce. A repeated key in one object is
85
+ // refused with `DUPLICATE_MEMBER`, and events no tree has (a value where a
86
+ // key is due, a key outside an object, a close with nothing open, a second
87
+ // root) with `STREAMABILITY_UNKNOWN`, each at the path of the object
88
+ // concerned; the event is not passed on. Each open object keeps the keys
89
+ // it has had, dropped when it closes.
90
+ export class TreeContract<S extends Sink> implements Sink {
91
+ readonly inner: S
92
+ private open: Open[] = []
93
+ private rootDone = false
94
+
95
+ constructor(inner: S) {
96
+ this.inner = inner
97
+ }
98
+
99
+ // The path of the value due next: the open containers, each by the member
100
+ // or element open in it, and in the innermost object its last key when
101
+ // that member's value is due.
102
+ private path(): Path {
103
+ const segments: Segment[] = []
104
+ const last = this.open.length - 1
105
+ this.open.forEach((open, i) => {
106
+ if (open.array) {
107
+ segments.push(open.next)
108
+ } else if ((i !== last || !open.keyDue) && null != open.last) {
109
+ // A container open inside this object is its last key's value,
110
+ // whatever `keyDue` says: the member was counted as taken when its
111
+ // value opened.
112
+ segments.push(open.last)
113
+ }
114
+ })
115
+ return new Path(segments)
116
+ }
117
+
118
+ private notATree(what: string): Fail {
119
+ return new Fail(
120
+ 'STREAMABILITY_UNKNOWN',
121
+ `the stream holds ${what}, which a tree's events never do, so it is not a tree's`,
122
+ ).atPath(this.path().toString())
123
+ }
124
+
125
+ // A value is complete: the next in its array is due, or the root is.
126
+ private closed(): void {
127
+ const top = this.open[this.open.length - 1]
128
+ if (undefined === top) this.rootDone = true
129
+ else if (top.array) top.next++
130
+ }
131
+
132
+ event(ev: JsonEvent): Flow {
133
+ const top = this.open[this.open.length - 1]
134
+ switch (ev.type) {
135
+ case 'key': {
136
+ if (undefined !== top && !top.array && top.keyDue) {
137
+ if (top.keys.has(ev.key)) {
138
+ const path = this.path()
139
+ path.push(ev.key)
140
+ throw new Fail(
141
+ 'DUPLICATE_MEMBER',
142
+ `member ${JSON.stringify(ev.key)} appears twice in one object, and a tree's ` +
143
+ `events hold each key once`,
144
+ ).atPath(path.toString())
145
+ }
146
+ top.keys.add(ev.key)
147
+ top.last = ev.key
148
+ top.keyDue = false
149
+ } else if (undefined !== top && !top.array) {
150
+ throw this.notATree('a key where a value is due')
151
+ } else {
152
+ throw this.notATree('a key outside an object')
153
+ }
154
+ break
155
+ }
156
+ case 'object_end':
157
+ if (undefined !== top && !top.array && top.keyDue) {
158
+ this.open.pop()
159
+ this.closed()
160
+ } else if (undefined !== top && !top.array) {
161
+ throw this.notATree("an object's end where a value is due")
162
+ } else {
163
+ throw this.notATree("an object's end where none is due")
164
+ }
165
+ break
166
+ case 'array_end':
167
+ if (undefined !== top && top.array) {
168
+ this.open.pop()
169
+ this.closed()
170
+ } else {
171
+ throw this.notATree("an array's end where none is due")
172
+ }
173
+ break
174
+ case 'end':
175
+ if (0 < this.open.length) {
176
+ throw this.notATree('its end inside an open container')
177
+ }
178
+ break
179
+ default: {
180
+ // A value: in an object, only once its key is in; at the root,
181
+ // only once.
182
+ if (undefined === top) {
183
+ if (this.rootDone) throw this.notATree('a second root value')
184
+ } else if (!top.array) {
185
+ if (top.keyDue) throw this.notATree('a value where a key is due')
186
+ top.keyDue = true
187
+ }
188
+ if ('object_start' === ev.type) {
189
+ this.open.push({ array: false, keys: new Set(), last: null, keyDue: true })
190
+ } else if ('array_start' === ev.type) {
191
+ this.open.push({ array: true, next: 0 })
192
+ } else {
193
+ this.closed()
194
+ }
195
+ }
196
+ }
197
+ return this.inner.event(ev)
198
+ }
199
+ }
package/src/source.ts ADDED
@@ -0,0 +1,135 @@
1
+ /* Copyright (c) 2026 tabnas, MIT License */
2
+
3
+ // Sources: where `JsonEvents/1` come from.
4
+ //
5
+ // - `ValueSource` walks a parsed engine value. Always correct, retains the
6
+ // whole value: the fallback for every grammar.
7
+ // - `ParserSource` drives a tabnas parse of one text and, in incremental
8
+ // mode, turns its rule events into source events as they happen, for the
9
+ // grammars the differential suite has verified (`capability`); in
10
+ // materialize mode it parses and walks.
11
+ // - `LinesSource` reads JSON Lines or CSV a record (or a chunk of records)
12
+ // at a time, bounding memory whatever the input's size.
13
+ //
14
+ // Every source emits through `Guarded`, which enforces the source limits,
15
+ // polls the abort flag and counts the source metrics.
16
+
17
+ import { Fail, engineCode, engineDetail } from './error'
18
+ import { Ev } from './event'
19
+ import { AbortFlag } from './limits'
20
+ import { Selector } from './selector'
21
+ import { Flow, Sink } from './sink'
22
+ import { engineKeys, engineScalar } from './value'
23
+
24
+ // Which arrays the incremental source empties as it streams them: none
25
+ // (`never`), the array whose elements the selector names (`under`: a
26
+ // trailing `[*]` names the elements; without one the selector names the
27
+ // array), or every array (`all_arrays`). Pruning alters the value the
28
+ // engine returns, which the incremental source discards; it is never
29
+ // applied in materialize mode.
30
+ export type Prune =
31
+ | { type: 'never' }
32
+ | { type: 'under'; selector: Selector }
33
+ | { type: 'all_arrays' }
34
+
35
+ export const Prune = Object.freeze({
36
+ never(): Prune {
37
+ return { type: 'never' }
38
+ },
39
+ under(selector: Selector): Prune {
40
+ return { type: 'under', selector }
41
+ },
42
+ allArrays(): Prune {
43
+ return { type: 'all_arrays' }
44
+ },
45
+ })
46
+
47
+ // How `ParserSource` produces its events: parse the whole text, then walk
48
+ // the value (`materialize`, sound for every grammar), or emit from the
49
+ // engine's rule events as the parse proceeds (`incremental`, sound for the
50
+ // grammars `capability.incremental` lists).
51
+ export type SourceMode = { type: 'materialize' } | { type: 'incremental'; prune: Prune }
52
+
53
+ export const SourceMode = Object.freeze({
54
+ materialize(): SourceMode {
55
+ return { type: 'materialize' }
56
+ },
57
+ incremental(prune: Prune = Prune.never()): SourceMode {
58
+ return { type: 'incremental', prune }
59
+ },
60
+ })
61
+
62
+ // Something that can drive a sink with one document's events. `run`
63
+ // returns `'stop'` when the sink stopped it (the document was not
64
+ // validated past that point) and throws a `Fail` on failure.
65
+ export interface Source {
66
+ run(sink: Sink): Flow
67
+ }
68
+
69
+ // The engine's cancel code: what a cancelled parse reports, whether the
70
+ // canceller is this crate's abort or a guard of the grammar's own.
71
+ const CANCEL = 'cancel'
72
+
73
+ // Map an engine error to a failure. A cancel while the caller's flag is set
74
+ // is `ABORTED`. A cancel otherwise is a guard the GRAMMAR installed, so
75
+ // the message says so instead of "parse cancelled", which would read as
76
+ // the caller's doing; it stays `INPUT_INVALID`, because the document is
77
+ // what the grammar refused. Any other error is the input's, with the
78
+ // engine's code and position. A thrown value that is not an engine error
79
+ // (a grammar's plugin threw) is the input's too, without a position.
80
+ export function engineFailure(error: unknown, abort: AbortFlag): Fail {
81
+ if (error instanceof Fail) return error
82
+ if (CANCEL !== engineCode(error)) return Fail.fromTabnas(error)
83
+ if (abort.isAborted()) return Fail.aborted()
84
+ const fail = Fail.fromTabnas(error)
85
+ fail.message =
86
+ `the grammar stopped the parse with a guard of its own (${engineCode(error)}: ` +
87
+ `${engineDetail(error)}); a grammar may refuse nesting or size below this crate's Limits`
88
+ return fail
89
+ }
90
+
91
+ // Emit one engine value's events (without `end`). `undefined` is `null`,
92
+ // as the engine serializes it; an object's members are in insertion order.
93
+ export function walkValue(value: unknown, sink: Sink): Flow {
94
+ if (Array.isArray(value)) {
95
+ if ('stop' === sink.event(Ev.arrayStart)) return 'stop'
96
+ for (const item of value) {
97
+ if ('stop' === walkValue(item, sink)) return 'stop'
98
+ }
99
+ return sink.event(Ev.arrayEnd)
100
+ }
101
+ const scalar = engineScalar(value)
102
+ if (undefined !== scalar) {
103
+ switch (scalar.type) {
104
+ case 'null':
105
+ return sink.event(Ev.null)
106
+ case 'bool':
107
+ return sink.event(Ev.bool(scalar.value))
108
+ case 'number':
109
+ return sink.event(Ev.number(scalar.value))
110
+ case 'string':
111
+ return sink.event(Ev.string(scalar.value))
112
+ }
113
+ }
114
+ if ('stop' === sink.event(Ev.objectStart)) return 'stop'
115
+ for (const k of engineKeys(value)) {
116
+ if ('stop' === sink.event(Ev.key(k))) return 'stop'
117
+ if ('stop' === walkValue((value as any)[k], sink)) return 'stop'
118
+ }
119
+ return sink.event(Ev.objectEnd)
120
+ }
121
+
122
+ // Emit a parsed engine value as events, ending with `end`. Applies no
123
+ // limits: wrap the sink in `Guarded` for those.
124
+ export class ValueSource implements Source {
125
+ readonly value: unknown
126
+
127
+ constructor(value: unknown) {
128
+ this.value = value
129
+ }
130
+
131
+ run(sink: Sink): Flow {
132
+ if ('stop' === walkValue(this.value, sink)) return 'stop'
133
+ return sink.event(Ev.end)
134
+ }
135
+ }