@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/README.md +14 -0
- package/dist/transduce.d.ts +1 -1
- package/dist/transduce.js +1 -1
- package/package.json +3 -1
- package/src/capability.ts +52 -0
- package/src/datum.ts +407 -0
- package/src/error.ts +198 -0
- package/src/event.ts +134 -0
- package/src/guard.ts +112 -0
- package/src/json.ts +82 -0
- package/src/limits.ts +165 -0
- package/src/lines.ts +1247 -0
- package/src/matcher.ts +287 -0
- package/src/parser-source.ts +225 -0
- package/src/route.ts +275 -0
- package/src/rule-events.ts +661 -0
- package/src/scan.ts +83 -0
- package/src/selector.ts +191 -0
- package/src/sink.ts +199 -0
- package/src/source.ts +135 -0
- package/src/table-from-json.ts +296 -0
- package/src/table.ts +213 -0
- package/src/transduce.ts +72 -0
- package/src/tsconfig.json +16 -0
- package/src/value.ts +93 -0
package/src/route.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/* Copyright (c) 2026 tabnas, MIT License */
|
|
2
|
+
|
|
3
|
+
// Captures: recognize selected scopes and deliver them, complete, in
|
|
4
|
+
// source order.
|
|
5
|
+
//
|
|
6
|
+
// A `Router` is a `Sink` that feeds every event through one `Matcher` for
|
|
7
|
+
// all of its `CaptureSpec`s and hands each completed match to a
|
|
8
|
+
// `RouteSink`. A `materialize` capture is built into a `Datum` under a
|
|
9
|
+
// byte budget and delivered when its value completes; an `observe` capture
|
|
10
|
+
// delivers only the path, at the value's end, and retains nothing.
|
|
11
|
+
// Materialized captures may not nest or coincide: the router holds at most
|
|
12
|
+
// one value at a time, which is what makes its retention one selected
|
|
13
|
+
// scope rather than a document. That is checked at construction,
|
|
14
|
+
// conservatively, with `Selector.mayOverlap`, and again at run time so a
|
|
15
|
+
// stream the check could not foresee still fails rather than mixing two
|
|
16
|
+
// values.
|
|
17
|
+
|
|
18
|
+
import { Datum, DatumBuilder, Duplicates } from './datum'
|
|
19
|
+
import { Fail } from './error'
|
|
20
|
+
import { JsonEvent } from './event'
|
|
21
|
+
import { Limits, Metrics } from './limits'
|
|
22
|
+
import { CaptureId, Matcher } from './matcher'
|
|
23
|
+
import { Path, Selector } from './selector'
|
|
24
|
+
import { Flow, Sink } from './sink'
|
|
25
|
+
|
|
26
|
+
// What a capture keeps of its match: build the value and deliver it whole
|
|
27
|
+
// (`materialize`), or deliver only that the value occurred, and where,
|
|
28
|
+
// when it ends (`observe`).
|
|
29
|
+
export type CaptureMode = 'materialize' | 'observe'
|
|
30
|
+
|
|
31
|
+
// The byte budget one materialized capture may not exceed, named after the
|
|
32
|
+
// `Limits` field the failure reports.
|
|
33
|
+
export type Budget = { bytes: number; name: string }
|
|
34
|
+
|
|
35
|
+
// One capture: a tag for the consumer, the selector to match, the mode,
|
|
36
|
+
// and for a `materialize` capture an optional budget (else the router's
|
|
37
|
+
// `max_capture_bytes`).
|
|
38
|
+
export class CaptureSpec {
|
|
39
|
+
readonly tag: string
|
|
40
|
+
readonly selector: Selector
|
|
41
|
+
readonly mode: CaptureMode
|
|
42
|
+
budget: Budget | null = null
|
|
43
|
+
|
|
44
|
+
constructor(tag: string, selector: Selector, mode: CaptureMode) {
|
|
45
|
+
this.tag = tag
|
|
46
|
+
this.selector = selector
|
|
47
|
+
this.mode = mode
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static materialize(tag: string, selector: Selector): CaptureSpec {
|
|
51
|
+
return new CaptureSpec(tag, selector, 'materialize')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static observe(tag: string, selector: Selector): CaptureSpec {
|
|
55
|
+
return new CaptureSpec(tag, selector, 'observe')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// This spec with its own budget, whose failure names `name`.
|
|
59
|
+
withBudget(bytes: number, name: string): CaptureSpec {
|
|
60
|
+
this.budget = { bytes, name }
|
|
61
|
+
return this
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// One completed match: the spec's position in the router's list, its tag,
|
|
66
|
+
// the concrete path, and the value for a `materialize` capture (`null`
|
|
67
|
+
// for `observe`).
|
|
68
|
+
export type Selected = {
|
|
69
|
+
id: CaptureId
|
|
70
|
+
tag: string
|
|
71
|
+
path: Path
|
|
72
|
+
value: Datum | null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The consumer of a router's matches.
|
|
76
|
+
export interface RouteSink {
|
|
77
|
+
// A capture's value is beginning. Nothing has been retained for it yet,
|
|
78
|
+
// so a consumer that knows the value is out of order can refuse it here
|
|
79
|
+
// at no cost (throw a `Fail`); the router adds the path to a failure
|
|
80
|
+
// that has none.
|
|
81
|
+
began?(id: CaptureId, tag: string): void
|
|
82
|
+
|
|
83
|
+
selected(selected: Selected): Flow
|
|
84
|
+
|
|
85
|
+
// The document ended, validated. Exactly once, after the last match.
|
|
86
|
+
end(): Flow
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A route sink that records every match.
|
|
90
|
+
export class SelectedRecorder implements RouteSink {
|
|
91
|
+
readonly selections: Selected[] = []
|
|
92
|
+
ended = false
|
|
93
|
+
|
|
94
|
+
selected(selected: Selected): Flow {
|
|
95
|
+
this.selections.push(selected)
|
|
96
|
+
return 'continue'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
end(): Flow {
|
|
100
|
+
this.ended = true
|
|
101
|
+
return 'continue'
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A route sink made of a function; `end` continues.
|
|
106
|
+
export class FnRoute implements RouteSink {
|
|
107
|
+
private fn: (selected: Selected) => Flow | void
|
|
108
|
+
|
|
109
|
+
constructor(fn: (selected: Selected) => Flow | void) {
|
|
110
|
+
this.fn = fn
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
selected(selected: Selected): Flow {
|
|
114
|
+
return this.fn(selected) || 'continue'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
end(): Flow {
|
|
118
|
+
return 'continue'
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The one materialization in progress.
|
|
123
|
+
type Active = {
|
|
124
|
+
id: CaptureId
|
|
125
|
+
builder: DatumBuilder
|
|
126
|
+
// The depth its value began at.
|
|
127
|
+
depth: number
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Recognizes every capture in one pass and delivers completed matches.
|
|
131
|
+
export class Router<D extends RouteSink> implements Sink {
|
|
132
|
+
readonly specs: readonly CaptureSpec[]
|
|
133
|
+
readonly downstream: D
|
|
134
|
+
private matcher: Matcher
|
|
135
|
+
private active: Active | null = null
|
|
136
|
+
// Observed values in progress: `[capture, depth]`, innermost last.
|
|
137
|
+
private observing: [CaptureId, number][] = []
|
|
138
|
+
private maxDepth: number
|
|
139
|
+
private maxCaptureBytes: number
|
|
140
|
+
private duplicates: Duplicates
|
|
141
|
+
// For the capture accounting only (`captured_bytes` and its high-water
|
|
142
|
+
// marks). The source counts events, keys and scalars.
|
|
143
|
+
private metrics: Metrics
|
|
144
|
+
private isEnded = false
|
|
145
|
+
|
|
146
|
+
// Build a router; two specs that may overlap are refused unless both
|
|
147
|
+
// observe (`CAPTURE_OVERLAP_UNSUPPORTED`).
|
|
148
|
+
constructor(
|
|
149
|
+
specs: readonly CaptureSpec[],
|
|
150
|
+
limits: Limits,
|
|
151
|
+
duplicates: Duplicates,
|
|
152
|
+
metrics: Metrics,
|
|
153
|
+
downstream: D,
|
|
154
|
+
) {
|
|
155
|
+
for (let i = 0; i < specs.length; i++) {
|
|
156
|
+
for (let j = i + 1; j < specs.length; j++) {
|
|
157
|
+
const a = specs[i]
|
|
158
|
+
const b = specs[j]
|
|
159
|
+
const bothObserve = 'observe' === a.mode && 'observe' === b.mode
|
|
160
|
+
if (!bothObserve && a.selector.mayOverlap(b.selector)) {
|
|
161
|
+
throw new Fail(
|
|
162
|
+
'CAPTURE_OVERLAP_UNSUPPORTED',
|
|
163
|
+
`captures ${JSON.stringify(a.tag)} (${a.selector}) and ` +
|
|
164
|
+
`${JSON.stringify(b.tag)} (${b.selector}) may select overlapping scopes; ` +
|
|
165
|
+
`only observed captures may overlap`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
this.specs = specs.slice()
|
|
171
|
+
this.matcher = new Matcher(specs.map((s) => s.selector))
|
|
172
|
+
this.downstream = downstream
|
|
173
|
+
this.maxDepth = limits.max_depth
|
|
174
|
+
this.maxCaptureBytes = limits.max_capture_bytes
|
|
175
|
+
this.duplicates = duplicates
|
|
176
|
+
this.metrics = metrics
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Whether `end` has been delivered.
|
|
180
|
+
ended(): boolean {
|
|
181
|
+
return this.isEnded
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Start the capture `id` at the value beginning at `depth`.
|
|
185
|
+
private begin(id: CaptureId, depth: number): void {
|
|
186
|
+
const spec = this.specs[id]
|
|
187
|
+
if (this.downstream.began) {
|
|
188
|
+
try {
|
|
189
|
+
this.downstream.began(id, spec.tag)
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (err instanceof Fail && null == err.path) {
|
|
192
|
+
err.path = this.matcher.path(depth).toString()
|
|
193
|
+
}
|
|
194
|
+
throw err
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if ('observe' === spec.mode) {
|
|
198
|
+
this.observing.push([id, depth])
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
if (null !== this.active) {
|
|
202
|
+
const path = this.matcher.path(depth).toString()
|
|
203
|
+
throw new Fail(
|
|
204
|
+
'CAPTURE_OVERLAP_UNSUPPORTED',
|
|
205
|
+
`capture ${JSON.stringify(spec.tag)} began at ${path} while capture ` +
|
|
206
|
+
`${JSON.stringify(this.specs[this.active.id].tag)} was still being materialized`,
|
|
207
|
+
).atPath(path)
|
|
208
|
+
}
|
|
209
|
+
const budget = spec.budget ?? { bytes: this.maxCaptureBytes, name: 'max_capture_bytes' }
|
|
210
|
+
this.active = {
|
|
211
|
+
id,
|
|
212
|
+
builder: new DatumBuilder(budget.bytes, budget.name, this.duplicates),
|
|
213
|
+
depth,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private deliver(id: CaptureId, path: Path, value: Datum | null): Flow {
|
|
218
|
+
return this.downstream.selected({ id, tag: this.specs[id].tag, path, value })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
event(ev: JsonEvent): Flow {
|
|
222
|
+
const hit = this.matcher.event(ev)
|
|
223
|
+
if ('start' === hit.kind && hit.depth + 1 > this.maxDepth) {
|
|
224
|
+
const path = this.matcher.path(hit.depth).toString()
|
|
225
|
+
throw Fail.limit(
|
|
226
|
+
'max_depth',
|
|
227
|
+
this.maxDepth,
|
|
228
|
+
`a container at ${path} is nested deeper than ${this.maxDepth}`,
|
|
229
|
+
).atPath(path)
|
|
230
|
+
}
|
|
231
|
+
for (let k = 0; k < hit.begins; k++) {
|
|
232
|
+
this.begin(this.matcher.begins()[k], hit.depth)
|
|
233
|
+
}
|
|
234
|
+
const active = this.active
|
|
235
|
+
if (null !== active) {
|
|
236
|
+
try {
|
|
237
|
+
active.builder.event(ev)
|
|
238
|
+
} catch (err) {
|
|
239
|
+
// The builder reports no position; the value's path is spelled
|
|
240
|
+
// here, only now that something went wrong.
|
|
241
|
+
if (err instanceof Fail) err.atPath(this.matcher.path(active.depth).toString())
|
|
242
|
+
throw err
|
|
243
|
+
}
|
|
244
|
+
if (active.builder.finished()) {
|
|
245
|
+
const bytes = active.builder.bytes()
|
|
246
|
+
const value = active.builder.take() as Datum
|
|
247
|
+
this.active = null
|
|
248
|
+
const path = this.matcher.path(hit.depth)
|
|
249
|
+
this.metrics.capture(bytes)
|
|
250
|
+
let flow: Flow
|
|
251
|
+
try {
|
|
252
|
+
flow = this.deliver(active.id, path, value)
|
|
253
|
+
} finally {
|
|
254
|
+
this.metrics.release(bytes)
|
|
255
|
+
}
|
|
256
|
+
if ('stop' === flow) return 'stop'
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if ('scalar' === hit.kind || 'close' === hit.kind) {
|
|
260
|
+
while (0 < this.observing.length) {
|
|
261
|
+
const [id, depth] = this.observing[this.observing.length - 1]
|
|
262
|
+
if (depth !== hit.depth) break
|
|
263
|
+
this.observing.pop()
|
|
264
|
+
if ('stop' === this.deliver(id, this.matcher.path(hit.depth), null)) {
|
|
265
|
+
return 'stop'
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if ('end' === hit.kind) {
|
|
270
|
+
this.isEnded = true
|
|
271
|
+
return this.downstream.end()
|
|
272
|
+
}
|
|
273
|
+
return 'continue'
|
|
274
|
+
}
|
|
275
|
+
}
|