@panagos/jojoscript 0.7.0 → 0.8.0

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.
@@ -1,418 +1,583 @@
1
- import {
2
- tokenize,
3
- significantTokens,
4
- maskNonCode
5
- } from './lexer.js'
6
-
7
- const PIPELINE_STDLIB = new Set([
8
- 'map',
9
- 'filter',
10
- 'sort',
11
- 'unique',
12
- 'take',
13
- 'skip',
14
- 'flatMap',
15
- 'reduce',
16
- 'toArray',
17
- 'count',
18
- 'first',
19
- 'find',
20
- 'some',
21
- 'every',
22
- 'groupBy',
23
- 'partition',
24
- 'chunk',
25
- 'window',
26
- 'zip',
27
- 'zipWith',
28
- 'scan',
29
- 'sortBy',
30
- 'distinctBy',
31
- 'tap',
32
- 'mapAsync',
33
- 'filterAsync',
34
- 'toArrayAsync'
35
- ])
36
-
37
- /*
38
- * These stage names are handled at compile time instead of being resolved
39
- * to a runtime function call, so they must never be added as automatic
40
- * `jojoscript/runtime` imports.
41
- */
42
- const PIPELINE_CONTROL_STAGES = new Set(['await', 'catch', 'orElse'])
43
-
44
- function text(source, tokens) {
45
- if (!tokens.length) return ''
46
- return source.slice(tokens[0].start, tokens[tokens.length - 1].end)
47
- }
48
-
49
- function matchingToken(tokens, start, open, close) {
50
- let depth = 0
51
- for (let i = start; i < tokens.length; i++) {
52
- if (tokens[i].value === open) depth++
53
- if (tokens[i].value === close) {
54
- depth--
55
- if (depth === 0) return i
56
- }
57
- }
58
- throw new SyntaxError(`Unclosed ${open}`)
59
- }
60
-
61
- function splitArguments(tokens) {
62
- const result = []
63
- let start = 0
64
- let parens = 0
65
- let braces = 0
66
- let brackets = 0
67
-
68
- for (let i = 0; i < tokens.length; i++) {
69
- const value = tokens[i].value
70
- if (value === '(') parens++
71
- else if (value === ')') parens--
72
- else if (value === '{') braces++
73
- else if (value === '}') braces--
74
- else if (value === '[') brackets++
75
- else if (value === ']') brackets--
76
-
77
- if (
78
- value === ',' &&
79
- parens === 0 &&
80
- braces === 0 &&
81
- brackets === 0
82
- ) {
83
- result.push(tokens.slice(start, i))
84
- start = i + 1
85
- }
86
- }
87
-
88
- if (start < tokens.length) result.push(tokens.slice(start))
89
- return result
90
- }
91
-
92
- function parseStage(source, tokens) {
93
- if (!tokens.length) throw new SyntaxError('Empty pipeline stage')
94
-
95
- if (tokens.every((token, index) =>
96
- index % 2 === 0
97
- ? token.type === 'identifier'
98
- : token.value === '.'
99
- )) {
100
- const name = text(source, tokens).trim()
101
- if (/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(name)) {
102
- return { name, args: [] }
103
- }
104
- }
105
-
106
- let openIndex = -1
107
- for (let i = 0; i < tokens.length; i++) {
108
- if (tokens[i].value === '(') {
109
- openIndex = i
110
- break
111
- }
112
- }
113
-
114
- if (openIndex === -1) {
115
- throw new SyntaxError(`Invalid pipeline stage: ${text(source, tokens)}`)
116
- }
117
-
118
- const closeIndex = matchingToken(tokens, openIndex, '(', ')')
119
- if (closeIndex !== tokens.length - 1) {
120
- throw new SyntaxError(`Invalid pipeline stage: ${text(source, tokens)}`)
121
- }
122
-
123
- const name = text(source, tokens.slice(0, openIndex)).trim()
124
-
125
- if (!/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(name)) {
126
- throw new SyntaxError(`Invalid pipeline function: ${name}`)
127
- }
128
-
129
- const args = splitArguments(
130
- tokens.slice(openIndex + 1, closeIndex)
131
- ).map(argument => text(source, argument).trim())
132
-
133
- return { name, args }
134
- }
135
-
136
- function parsePipelineStatement(source, statementStart, statementEnd) {
137
- const statement = source.slice(statementStart, statementEnd)
138
- const tokens = significantTokens(tokenize(statement))
139
-
140
- const firstPipeIndex = tokens.findIndex(token => token.type === 'pipeline')
141
- if (firstPipeIndex === -1) return null
142
-
143
- let inputStartIndex = 0
144
- let parens = 0
145
- let brackets = 0
146
- let braces = 0
147
-
148
- for (let i = 0; i < firstPipeIndex; i++) {
149
- const token = tokens[i]
150
-
151
- if (token.value === '(') { parens++; continue }
152
- if (token.value === ')') { parens--; continue }
153
- if (token.value === '[') { brackets++; continue }
154
- if (token.value === ']') { brackets--; continue }
155
- if (token.value === '{') { braces++; continue }
156
- if (token.value === '}') { braces--; continue }
157
-
158
- if (parens === 0 && brackets === 0 && braces === 0) {
159
- if (token.value === '=' || token.value === '=>' || token.value === ':') {
160
- inputStartIndex = i + 1
161
- }
162
- if (token.type === 'identifier' && token.value === 'return') {
163
- inputStartIndex = i + 1
164
- }
165
- }
166
- }
167
-
168
- const inputTokens = tokens.slice(inputStartIndex, firstPipeIndex)
169
- if (!inputTokens.length) {
170
- throw new SyntaxError(`Pipeline has no input expression:\n${statement}`)
171
- }
172
-
173
- const expression = text(statement, inputTokens).trim()
174
- if (!expression) {
175
- throw new SyntaxError(`Pipeline has no input expression:\n${statement}`)
176
- }
177
-
178
- const stages = []
179
- for (let i = 0; i < tokens.length; i++) {
180
- if (tokens[i].type !== 'pipeline') continue
181
-
182
- const stageStart = i + 1
183
- let stageEnd = tokens.length
184
-
185
- for (let j = stageStart; j < tokens.length; j++) {
186
- if (tokens[j].type === 'pipeline') {
187
- stageEnd = j
188
- break
189
- }
190
- }
191
-
192
- const stageTokens = tokens.slice(stageStart, stageEnd)
193
- if (!stageTokens.length) {
194
- throw new SyntaxError(`Empty pipeline stage in:\n${statement}`)
195
- }
196
-
197
- stages.push(parseStage(statement, stageTokens))
198
- i = stageEnd - 1
199
- }
200
-
201
- const prefixTokens = tokens.slice(0, inputStartIndex)
202
- const prefix = prefixTokens.length ? text(statement, prefixTokens).trim() : ''
203
-
204
- return { expression, stages, prefix }
205
- }
206
-
207
- export function emitPipeline(pipeline) {
208
- let expression = pipeline.expression.trim()
209
- let prefix = pipeline.prefix ? `${pipeline.prefix} ` : ''
210
-
211
- for (const stage of pipeline.stages) {
212
- if (PIPELINE_CONTROL_STAGES.has(stage.name)) {
213
- expression = emitControlStage(stage, expression)
214
- continue
215
- }
216
-
217
- const placeholderIndex = stage.args.indexOf('_')
218
-
219
- if (placeholderIndex !== -1) {
220
- const args = stage.args.slice()
221
- args[placeholderIndex] = expression
222
- expression = `${stage.name}(${args.join(', ')})`
223
- } else if (stage.args.length === 0) {
224
- expression = `${stage.name}(${expression})`
225
- } else {
226
- expression = `${stage.name}(${expression}, ${stage.args.join(', ')})`
227
- }
228
- }
229
-
230
- return prefix + expression
231
- }
232
-
233
- function emitControlStage(stage, expression) {
234
- if (stage.name === 'await') {
235
- if (stage.args.length) {
236
- throw new SyntaxError('the `await` pipeline stage does not take arguments')
237
- }
238
- return `(await (${expression}))`
239
- }
240
-
241
- if (stage.name === 'catch') {
242
- if (stage.args.length !== 1) {
243
- throw new SyntaxError('the `catch` pipeline stage takes exactly one handler argument, e.g. `|> catch(handler)`')
244
- }
245
- return `(() => { try { return (${expression}) } catch (__error) { return (${stage.args[0]})(__error) } })()`
246
- }
247
-
248
- if (stage.name === 'orElse') {
249
- if (stage.args.length !== 1) {
250
- throw new SyntaxError('the `orElse` pipeline stage takes exactly one fallback argument, e.g. `|> orElse(fallback)`')
251
- }
252
- return `(() => { try { return (${expression}) } catch (__error) { return (${stage.args[0]}) } })()`
253
- }
254
-
255
- throw new SyntaxError(`Unknown pipeline control stage: ${stage.name}`)
256
- }
257
-
258
- function findStatementBounds(source, pipe) {
259
- const mask = maskNonCode(source)
260
-
261
- let start = pipe.start
262
- let parenDepth = 0
263
- let bracketDepth = 0
264
- let braceDepth = 0
265
-
266
- for (let i = pipe.start - 1; i >= 0; i--) {
267
- const c = mask[i]
268
-
269
- if (c === ')') { parenDepth++; continue }
270
- if (c === ']') { bracketDepth++; continue }
271
- if (c === '}') { braceDepth++; continue }
272
-
273
- if (c === '(') {
274
- if (parenDepth > 0) { parenDepth--; continue }
275
- start = i + 1
276
- break
277
- }
278
- if (c === '[') {
279
- if (bracketDepth > 0) { bracketDepth--; continue }
280
- start = i + 1
281
- break
282
- }
283
- if (c === '{') {
284
- if (braceDepth > 0) { braceDepth--; continue }
285
- start = i + 1
286
- break
287
- }
288
-
289
- if (parenDepth !== 0 || bracketDepth !== 0 || braceDepth !== 0) continue
290
-
291
- if (c === ';' || c === ',') {
292
- start = i + 1
293
- break
294
- }
295
-
296
- if (c === '\n') {
297
- let j = i - 1
298
- while (j >= 0 && [' ', '\t', '\r'].includes(mask[j])) j--
299
- if (j < 0 || mask[j] === '\n') {
300
- start = i + 1
301
- break
302
- }
303
- }
304
-
305
- start = i
306
- }
307
-
308
- while (
309
- start < pipe.start &&
310
- [' ', '\t', '\r', '\n'].includes(mask[start])
311
- ) start++
312
-
313
- let end = source.length
314
- parenDepth = 0
315
- bracketDepth = 0
316
- braceDepth = 0
317
-
318
- for (let i = pipe.end; i < source.length; i++) {
319
- const c = mask[i]
320
-
321
- if (c === '(') { parenDepth++; continue }
322
- if (c === ')') {
323
- if (parenDepth > 0) { parenDepth--; continue }
324
- end = i
325
- break
326
- }
327
- if (c === '[') { bracketDepth++; continue }
328
- if (c === ']') {
329
- if (bracketDepth > 0) { bracketDepth--; continue }
330
- end = i
331
- break
332
- }
333
- if (c === '{') { braceDepth++; continue }
334
- if (c === '}') {
335
- if (braceDepth > 0) { braceDepth--; continue }
336
- end = i
337
- break
338
- }
339
-
340
- if (
341
- parenDepth === 0 &&
342
- bracketDepth === 0 &&
343
- braceDepth === 0 &&
344
- (c === ';' || c === ',')
345
- ) {
346
- end = i
347
- break
348
- }
349
-
350
- if (
351
- parenDepth === 0 &&
352
- bracketDepth === 0 &&
353
- braceDepth === 0 &&
354
- c === '\n'
355
- ) {
356
- let j = i + 1
357
- while (
358
- j < source.length &&
359
- [' ', '\t', '\r'].includes(mask[j])
360
- ) j++
361
-
362
- if (mask.slice(j, j + 2) === '|>') continue
363
- end = i
364
- break
365
- }
366
- }
367
-
368
- return { start, end }
369
- }
370
-
371
- export function transformPipelines(source) {
372
- let result = source
373
-
374
- for (let iteration = 0; iteration < 1000; iteration++) {
375
- const tokens = significantTokens(tokenize(result))
376
- const pipes = tokens.filter(token => token.type === 'pipeline')
377
-
378
- if (!pipes.length) return result
379
-
380
- const pipe = pipes[pipes.length - 1]
381
- const bounds = findStatementBounds(result, pipe)
382
- const pipeline = parsePipelineStatement(result, bounds.start, bounds.end)
383
-
384
- if (!pipeline) {
385
- throw new SyntaxError(
386
- `Unable to parse pipeline:\n\n${result.slice(bounds.start, bounds.end)}`
387
- )
388
- }
389
-
390
- const replacement = emitPipeline(pipeline)
391
-
392
- result =
393
- result.slice(0, bounds.start) +
394
- replacement +
395
- result.slice(bounds.end)
396
- }
397
-
398
- throw new SyntaxError('Pipeline transformation exceeded 1000 iterations')
399
- }
400
-
401
- export function findPipelineStdlibFunctions(source) {
402
- const tokens = significantTokens(tokenize(source))
403
- const result = new Set()
404
-
405
- for (let i = 0; i < tokens.length - 1; i++) {
406
- if (
407
- tokens[i].type === 'pipeline' &&
408
- tokens[i + 1].type === 'identifier' &&
409
- PIPELINE_STDLIB.has(tokens[i + 1].value)
410
- ) {
411
- result.add(tokens[i + 1].value)
412
- }
413
- }
414
-
415
- return [...result]
416
- }
417
-
418
- export { parsePipelineStatement, findStatementBounds }
1
+ import {
2
+ tokenize,
3
+ significantTokens,
4
+ maskNonCode
5
+ } from './lexer.js'
6
+
7
+ import { computePipelineId } from './pipeline-hash.js'
8
+
9
+ const PIPELINE_STDLIB = new Set([
10
+ 'map',
11
+ 'filter',
12
+ 'sort',
13
+ 'unique',
14
+ 'take',
15
+ 'skip',
16
+ 'flatMap',
17
+ 'reduce',
18
+ 'toArray',
19
+ 'count',
20
+ 'first',
21
+ 'find',
22
+ 'some',
23
+ 'every',
24
+ 'groupBy',
25
+ 'partition',
26
+ 'chunk',
27
+ 'window',
28
+ 'zip',
29
+ 'zipWith',
30
+ 'scan',
31
+ 'sortBy',
32
+ 'distinctBy',
33
+ 'tap',
34
+ 'mapAsync',
35
+ 'filterAsync',
36
+ 'toArrayAsync',
37
+ 'checkpoint',
38
+ 'inspect',
39
+ 'parallel',
40
+ 'batch',
41
+ 'retry'
42
+ ])
43
+
44
+ /*
45
+ * These stages are "runtime-aware": the compiler auto-appends a hidden,
46
+ * literal metadata argument (pipeline id, stable node id, stage type) to
47
+ * their call so the runtime operator can identify itself for
48
+ * checkpointing/profiling/error-reporting without the user ever writing
49
+ * that plumbing by hand.
50
+ */
51
+ const RUNTIME_AWARE_STAGES = new Set(['checkpoint', 'inspect', 'parallel', 'batch'])
52
+
53
+ /*
54
+ * A `retry(...)` stage immediately following one of these fuses into that
55
+ * stage's function argument (per-item retry) instead of becoming its own
56
+ * pipeline stage. See `fuseRetryStages`.
57
+ */
58
+ const MAP_LIKE_STAGES = new Set(['map', 'mapAsync', 'filterAsync', 'tap'])
59
+
60
+ /*
61
+ * These stage names are handled at compile time instead of being resolved
62
+ * to a runtime function call, so they must never be added as automatic
63
+ * `jojoscript/runtime` imports.
64
+ */
65
+ const PIPELINE_CONTROL_STAGES = new Set(['await', 'catch', 'orElse'])
66
+
67
+ function text(source, tokens) {
68
+ if (!tokens.length) return ''
69
+ return source.slice(tokens[0].start, tokens[tokens.length - 1].end)
70
+ }
71
+
72
+ function matchingToken(tokens, start, open, close) {
73
+ let depth = 0
74
+ for (let i = start; i < tokens.length; i++) {
75
+ if (tokens[i].value === open) depth++
76
+ if (tokens[i].value === close) {
77
+ depth--
78
+ if (depth === 0) return i
79
+ }
80
+ }
81
+ throw new SyntaxError(`Unclosed ${open}`)
82
+ }
83
+
84
+ function splitArguments(tokens) {
85
+ const result = []
86
+ let start = 0
87
+ let parens = 0
88
+ let braces = 0
89
+ let brackets = 0
90
+
91
+ for (let i = 0; i < tokens.length; i++) {
92
+ const value = tokens[i].value
93
+ if (value === '(') parens++
94
+ else if (value === ')') parens--
95
+ else if (value === '{') braces++
96
+ else if (value === '}') braces--
97
+ else if (value === '[') brackets++
98
+ else if (value === ']') brackets--
99
+
100
+ if (
101
+ value === ',' &&
102
+ parens === 0 &&
103
+ braces === 0 &&
104
+ brackets === 0
105
+ ) {
106
+ result.push(tokens.slice(start, i))
107
+ start = i + 1
108
+ }
109
+ }
110
+
111
+ if (start < tokens.length) result.push(tokens.slice(start))
112
+ return result
113
+ }
114
+
115
+ function parseStage(source, tokens) {
116
+ if (!tokens.length) throw new SyntaxError('Empty pipeline stage')
117
+
118
+ if (tokens.every((token, index) =>
119
+ index % 2 === 0
120
+ ? token.type === 'identifier'
121
+ : token.value === '.'
122
+ )) {
123
+ const name = text(source, tokens).trim()
124
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(name)) {
125
+ return { name, args: [] }
126
+ }
127
+ }
128
+
129
+ let openIndex = -1
130
+ for (let i = 0; i < tokens.length; i++) {
131
+ if (tokens[i].value === '(') {
132
+ openIndex = i
133
+ break
134
+ }
135
+ }
136
+
137
+ if (openIndex === -1) {
138
+ throw new SyntaxError(`Invalid pipeline stage: ${text(source, tokens)}`)
139
+ }
140
+
141
+ const closeIndex = matchingToken(tokens, openIndex, '(', ')')
142
+ if (closeIndex !== tokens.length - 1) {
143
+ throw new SyntaxError(`Invalid pipeline stage: ${text(source, tokens)}`)
144
+ }
145
+
146
+ const name = text(source, tokens.slice(0, openIndex)).trim()
147
+
148
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(name)) {
149
+ throw new SyntaxError(`Invalid pipeline function: ${name}`)
150
+ }
151
+
152
+ const args = splitArguments(
153
+ tokens.slice(openIndex + 1, closeIndex)
154
+ ).map(argument => text(source, argument).trim())
155
+
156
+ return { name, args }
157
+ }
158
+
159
+ function parsePipelineStatement(source, statementStart, statementEnd) {
160
+ const statement = source.slice(statementStart, statementEnd)
161
+ const tokens = significantTokens(tokenize(statement))
162
+
163
+ const firstPipeIndex = tokens.findIndex(token => token.type === 'pipeline')
164
+ if (firstPipeIndex === -1) return null
165
+
166
+ let inputStartIndex = 0
167
+ let parens = 0
168
+ let brackets = 0
169
+ let braces = 0
170
+
171
+ for (let i = 0; i < firstPipeIndex; i++) {
172
+ const token = tokens[i]
173
+
174
+ if (token.value === '(') { parens++; continue }
175
+ if (token.value === ')') { parens--; continue }
176
+ if (token.value === '[') { brackets++; continue }
177
+ if (token.value === ']') { brackets--; continue }
178
+ if (token.value === '{') { braces++; continue }
179
+ if (token.value === '}') { braces--; continue }
180
+
181
+ if (parens === 0 && brackets === 0 && braces === 0) {
182
+ if (token.value === '=' || token.value === '=>' || token.value === ':') {
183
+ inputStartIndex = i + 1
184
+ }
185
+ if (token.type === 'identifier' && token.value === 'return') {
186
+ inputStartIndex = i + 1
187
+ }
188
+ }
189
+ }
190
+
191
+ const inputTokens = tokens.slice(inputStartIndex, firstPipeIndex)
192
+ if (!inputTokens.length) {
193
+ throw new SyntaxError(`Pipeline has no input expression:\n${statement}`)
194
+ }
195
+
196
+ const expression = text(statement, inputTokens).trim()
197
+ if (!expression) {
198
+ throw new SyntaxError(`Pipeline has no input expression:\n${statement}`)
199
+ }
200
+
201
+ const stages = []
202
+ for (let i = 0; i < tokens.length; i++) {
203
+ if (tokens[i].type !== 'pipeline') continue
204
+
205
+ const stageStart = i + 1
206
+ let stageEnd = tokens.length
207
+
208
+ for (let j = stageStart; j < tokens.length; j++) {
209
+ if (tokens[j].type === 'pipeline') {
210
+ stageEnd = j
211
+ break
212
+ }
213
+ }
214
+
215
+ const stageTokens = tokens.slice(stageStart, stageEnd)
216
+ if (!stageTokens.length) {
217
+ throw new SyntaxError(`Empty pipeline stage in:\n${statement}`)
218
+ }
219
+
220
+ stages.push(parseStage(statement, stageTokens))
221
+ i = stageEnd - 1
222
+ }
223
+
224
+ const prefixTokens = tokens.slice(0, inputStartIndex)
225
+ const prefix = prefixTokens.length ? text(statement, prefixTokens).trim() : ''
226
+
227
+ return { expression, stages, prefix }
228
+ }
229
+
230
+ function metaLiteral(pipelineId, nodeId, type, extra = {}) {
231
+ return JSON.stringify({ id: nodeId, pipelineId, type, ...extra })
232
+ }
233
+
234
+ /*
235
+ * A bare integer shorthand (`retry(3)`) becomes `{ attempts: 3 }`; anything
236
+ * else (an object literal, a variable holding options, ...) is passed
237
+ * through untouched and assumed to already be options-shaped at runtime.
238
+ */
239
+ function normalizeRetryOptions(args) {
240
+ if (args.length === 0) {
241
+ throw new SyntaxError('retry expects an attempt count or an options object, e.g. `retry(3)` or `retry({ attempts: 3 })`')
242
+ }
243
+ if (args.length > 1) {
244
+ throw new SyntaxError('retry takes exactly one argument: an attempt count or an options object')
245
+ }
246
+
247
+ const arg = args[0].trim()
248
+ return /^\d+$/.test(arg) ? `{ attempts: ${arg} }` : arg
249
+ }
250
+
251
+ /*
252
+ * `retry(...)` immediately following a `map`/`mapAsync`/`filterAsync`/
253
+ * `tap` stage fuses into that stage's function argument, so each *item* is
254
+ * retried independently (`map(items, retry(fn, opts, meta))`) instead of
255
+ * retry meaninglessly wrapping an already-lazy, not-yet-run generator.
256
+ * With no such preceding stage, `retry` instead wraps the whole expression
257
+ * built so far as a standalone stage (handled in the main emission loop).
258
+ */
259
+ function fuseRetryStages(stages, pipelineId) {
260
+ const result = []
261
+
262
+ for (const stage of stages) {
263
+ if (stage.name !== 'retry') {
264
+ result.push(stage)
265
+ continue
266
+ }
267
+
268
+ const options = normalizeRetryOptions(stage.args)
269
+ const previous = result[result.length - 1]
270
+
271
+ if (previous && MAP_LIKE_STAGES.has(previous.name) && previous.args.length) {
272
+ const meta = metaLiteral(pipelineId, stage.nodeId, 'retry')
273
+ const lastIndex = previous.args.length - 1
274
+ previous.args[lastIndex] = `retry(${previous.args[lastIndex]}, ${options}, ${meta})`
275
+ continue
276
+ }
277
+
278
+ result.push({ ...stage, args: [options], standaloneRetry: true })
279
+ }
280
+
281
+ return result
282
+ }
283
+
284
+ export function emitPipeline(pipeline, options = {}) {
285
+ let expression = pipeline.expression.trim()
286
+ let prefix = pipeline.prefix ? `${pipeline.prefix} ` : ''
287
+
288
+ const pipelineId = computePipelineId(pipeline)
289
+
290
+ const checkpointOrder = pipeline.stages
291
+ .filter(stage => stage.name === 'checkpoint' && stage.args.length)
292
+ .map(stage => stripQuotes(stage.args[0]))
293
+
294
+ let checkpointsSeen = 0
295
+
296
+ const stagesWithIds = pipeline.stages.map((stage, index) => ({
297
+ ...stage,
298
+ nodeId: `node-${index + 1}`
299
+ }))
300
+
301
+ const stages = fuseRetryStages(stagesWithIds, pipelineId)
302
+
303
+ if (options.profile) {
304
+ expression = `traceNode(${expression}, ${metaLiteral(pipelineId, 'node-0', 'source')})`
305
+ }
306
+
307
+ for (const stage of stages) {
308
+ if (PIPELINE_CONTROL_STAGES.has(stage.name)) {
309
+ expression = emitControlStage(stage, expression)
310
+ continue
311
+ }
312
+
313
+ if (stage.name === 'retry' && stage.standaloneRetry) {
314
+ const meta = metaLiteral(pipelineId, stage.nodeId, 'retry')
315
+ expression = `(await retry(async () => (${expression}), ${stage.args[0]}, ${meta})())`
316
+ if (options.profile) {
317
+ expression = `traceNode(${expression}, ${metaLiteral(pipelineId, stage.nodeId, 'retry')})`
318
+ }
319
+ continue
320
+ }
321
+
322
+ const placeholderIndex = stage.args.indexOf('_')
323
+
324
+ if (placeholderIndex !== -1) {
325
+ const args = stage.args.slice()
326
+ args[placeholderIndex] = expression
327
+ expression = `${stage.name}(${args.join(', ')})`
328
+ } else if (stage.args.length === 0) {
329
+ expression = `${stage.name}(${expression})`
330
+ } else {
331
+ expression = `${stage.name}(${expression}, ${stage.args.join(', ')})`
332
+ }
333
+
334
+ if (RUNTIME_AWARE_STAGES.has(stage.name)) {
335
+ const extra = stage.name === 'checkpoint'
336
+ ? { checkpointOrder, checkpointIndex: checkpointsSeen++ }
337
+ : {}
338
+ const meta = metaLiteral(pipelineId, stage.nodeId, stage.name, extra)
339
+ expression = expression.slice(0, -1) + `, ${meta})`
340
+ }
341
+
342
+ if (options.profile) {
343
+ expression = `traceNode(${expression}, ${metaLiteral(pipelineId, stage.nodeId, stage.name)})`
344
+ }
345
+ }
346
+
347
+ return prefix + expression
348
+ }
349
+
350
+ function stripQuotes(text) {
351
+ const trimmed = text.trim()
352
+ return /^["'`]/.test(trimmed) ? trimmed.slice(1, -1) : trimmed
353
+ }
354
+
355
+ function emitControlStage(stage, expression) {
356
+ if (stage.name === 'await') {
357
+ if (stage.args.length) {
358
+ throw new SyntaxError('the `await` pipeline stage does not take arguments')
359
+ }
360
+ return `(await (${expression}))`
361
+ }
362
+
363
+ if (stage.name === 'catch') {
364
+ if (stage.args.length !== 1) {
365
+ throw new SyntaxError('the `catch` pipeline stage takes exactly one handler argument, e.g. `|> catch(handler)`')
366
+ }
367
+ return `(() => { try { return (${expression}) } catch (__error) { return (${stage.args[0]})(__error) } })()`
368
+ }
369
+
370
+ if (stage.name === 'orElse') {
371
+ if (stage.args.length !== 1) {
372
+ throw new SyntaxError('the `orElse` pipeline stage takes exactly one fallback argument, e.g. `|> orElse(fallback)`')
373
+ }
374
+ return `(() => { try { return (${expression}) } catch (__error) { return (${stage.args[0]}) } })()`
375
+ }
376
+
377
+ throw new SyntaxError(`Unknown pipeline control stage: ${stage.name}`)
378
+ }
379
+
380
+ function findStatementBounds(source, pipe) {
381
+ const mask = maskNonCode(source)
382
+
383
+ let start = pipe.start
384
+ let parenDepth = 0
385
+ let bracketDepth = 0
386
+ let braceDepth = 0
387
+
388
+ for (let i = pipe.start - 1; i >= 0; i--) {
389
+ const c = mask[i]
390
+
391
+ if (c === ')') { parenDepth++; continue }
392
+ if (c === ']') { bracketDepth++; continue }
393
+ if (c === '}') { braceDepth++; continue }
394
+
395
+ if (c === '(') {
396
+ if (parenDepth > 0) { parenDepth--; continue }
397
+ start = i + 1
398
+ break
399
+ }
400
+ if (c === '[') {
401
+ if (bracketDepth > 0) { bracketDepth--; continue }
402
+ start = i + 1
403
+ break
404
+ }
405
+ if (c === '{') {
406
+ if (braceDepth > 0) { braceDepth--; continue }
407
+ start = i + 1
408
+ break
409
+ }
410
+
411
+ if (parenDepth !== 0 || bracketDepth !== 0 || braceDepth !== 0) continue
412
+
413
+ if (c === ';' || c === ',') {
414
+ start = i + 1
415
+ break
416
+ }
417
+
418
+ if (c === '\n') {
419
+ let j = i + 1
420
+ while (j < pipe.start && [' ', '\t', '\r'].includes(mask[j])) j++
421
+ if (mask.slice(j, j + 2) !== '|>' && mask[j] !== '.') {
422
+ start = i + 1
423
+ break
424
+ }
425
+ }
426
+
427
+ start = i
428
+ }
429
+
430
+ while (
431
+ start < pipe.start &&
432
+ [' ', '\t', '\r', '\n'].includes(mask[start])
433
+ ) start++
434
+
435
+ let end = source.length
436
+ parenDepth = 0
437
+ bracketDepth = 0
438
+ braceDepth = 0
439
+
440
+ for (let i = pipe.end; i < source.length; i++) {
441
+ const c = mask[i]
442
+
443
+ if (c === '(') { parenDepth++; continue }
444
+ if (c === ')') {
445
+ if (parenDepth > 0) { parenDepth--; continue }
446
+ end = i
447
+ break
448
+ }
449
+ if (c === '[') { bracketDepth++; continue }
450
+ if (c === ']') {
451
+ if (bracketDepth > 0) { bracketDepth--; continue }
452
+ end = i
453
+ break
454
+ }
455
+ if (c === '{') { braceDepth++; continue }
456
+ if (c === '}') {
457
+ if (braceDepth > 0) { braceDepth--; continue }
458
+ end = i
459
+ break
460
+ }
461
+
462
+ if (
463
+ parenDepth === 0 &&
464
+ bracketDepth === 0 &&
465
+ braceDepth === 0 &&
466
+ (c === ';' || c === ',')
467
+ ) {
468
+ end = i
469
+ break
470
+ }
471
+
472
+ if (
473
+ parenDepth === 0 &&
474
+ bracketDepth === 0 &&
475
+ braceDepth === 0 &&
476
+ c === '\n'
477
+ ) {
478
+ let j = i + 1
479
+ while (
480
+ j < source.length &&
481
+ [' ', '\t', '\r'].includes(mask[j])
482
+ ) j++
483
+
484
+ if (mask.slice(j, j + 2) === '|>') continue
485
+ end = i
486
+ break
487
+ }
488
+ }
489
+
490
+ return { start, end }
491
+ }
492
+
493
+ export function transformPipelines(source, options = {}) {
494
+ let result = source
495
+
496
+ for (let iteration = 0; iteration < 1000; iteration++) {
497
+ const tokens = significantTokens(tokenize(result))
498
+ const pipes = tokens.filter(token => token.type === 'pipeline')
499
+
500
+ if (!pipes.length) return result
501
+
502
+ const pipe = pipes[pipes.length - 1]
503
+ const bounds = findStatementBounds(result, pipe)
504
+ const pipeline = parsePipelineStatement(result, bounds.start, bounds.end)
505
+
506
+ if (!pipeline) {
507
+ throw new SyntaxError(
508
+ `Unable to parse pipeline:\n\n${result.slice(bounds.start, bounds.end)}`
509
+ )
510
+ }
511
+
512
+ const replacement = emitPipeline(pipeline, options)
513
+
514
+ result =
515
+ result.slice(0, bounds.start) +
516
+ replacement +
517
+ result.slice(bounds.end)
518
+ }
519
+
520
+ throw new SyntaxError('Pipeline transformation exceeded 1000 iterations')
521
+ }
522
+
523
+ export function findPipelineStdlibFunctions(source) {
524
+ const tokens = significantTokens(tokenize(source))
525
+ const result = new Set()
526
+
527
+ for (let i = 0; i < tokens.length - 1; i++) {
528
+ if (
529
+ tokens[i].type === 'pipeline' &&
530
+ tokens[i + 1].type === 'identifier' &&
531
+ PIPELINE_STDLIB.has(tokens[i + 1].value)
532
+ ) {
533
+ result.add(tokens[i + 1].value)
534
+ }
535
+ }
536
+
537
+ return [...result]
538
+ }
539
+
540
+ /*
541
+ * True if the source contains at least one pipeline statement. Used by the
542
+ * compiler to decide whether the `traceNode` profiling helper needs to be
543
+ * auto-imported, independent of which (if any) stdlib stages are used.
544
+ */
545
+ export function hasPipelines(source) {
546
+ const tokens = significantTokens(tokenize(source))
547
+ return tokens.some(token => token.type === 'pipeline')
548
+ }
549
+
550
+ /*
551
+ * Non-mutating forward scan collecting every pipeline statement in the
552
+ * source, used for static analysis (`jojo graph`) rather than compilation.
553
+ * Unlike `transformPipelines` (which repeatedly processes the *last* pipe
554
+ * in the whole file so nested pipelines-in-arguments resolve inside-out),
555
+ * this simply walks forward statement-by-statement, which is sufficient
556
+ * for the common case of top-level `|>` chains that graph/profile display.
557
+ */
558
+ export function findAllPipelines(source) {
559
+ const pipelines = []
560
+ let searchFrom = 0
561
+
562
+ while (searchFrom < source.length) {
563
+ const remainder = source.slice(searchFrom)
564
+ const tokens = significantTokens(tokenize(remainder))
565
+ const pipe = tokens.find(token => token.type === 'pipeline')
566
+ if (!pipe) break
567
+
568
+ const absolutePipe = { ...pipe, start: pipe.start + searchFrom, end: pipe.end + searchFrom }
569
+ const bounds = findStatementBounds(source, absolutePipe)
570
+ const pipeline = parsePipelineStatement(source, bounds.start, bounds.end)
571
+
572
+ if (pipeline) {
573
+ pipelines.push({ ...pipeline, start: bounds.start, end: bounds.end })
574
+ }
575
+
576
+ searchFrom = Math.max(bounds.end, absolutePipe.end) + 1
577
+ }
578
+
579
+ return pipelines
580
+ }
581
+
582
+ export { parsePipelineStatement, findStatementBounds }
583
+