@panagos/jojoscript 0.10.1 → 1.0.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.
@@ -20,10 +20,11 @@ import { tokenize, significantTokens } from './lexer.js'
20
20
  * return handleUnknown();
21
21
  * })()
22
22
  *
23
- * This is deliberately a value-equality match, not structural pattern
24
- * matching: patterns are plain expressions compared with `===`, and arms
25
- * are single expressions (no statement blocks). The default `_` arm is
26
- * required and must be last.
23
+ * Arms are single expressions (no statement blocks), separated by commas or
24
+ * newlines. Object patterns such as `{ status: "ok", value }` match
25
+ * structurally; any other pattern is compared with `===`. A value-equality
26
+ * match requires a final `_` arm, while a structural match without one
27
+ * throws when nothing matches.
27
28
  */
28
29
 
29
30
  function text(source, tokens) {
@@ -125,8 +126,157 @@ function findMatchBlock(source, tokens, index) {
125
126
  return { subjectTokens, braceOpen, braceClose }
126
127
  }
127
128
 
129
+ function isNewlineBetween(source, previous, next) {
130
+ return source.slice(previous.end, next.start).includes('\n')
131
+ }
132
+
133
+ function hasTopLevelArrowBeforeNewline(source, tokens, start) {
134
+ let depth = 0
135
+ for (let i = start; i < tokens.length; i++) {
136
+ if (i > start && depth === 0 && isNewlineBetween(source, tokens[i - 1], tokens[i])) return false
137
+ const value = tokens[i].value
138
+ if (value === '(' || value === '[' || value === '{') depth++
139
+ else if (value === ')' || value === ']' || value === '}') depth--
140
+ else if (value === '=>' && depth === 0) return true
141
+ }
142
+ return false
143
+ }
144
+
145
+ /*
146
+ * A new line starts a new arm only once the current arm has its `=>` and the
147
+ * new line has its own top-level `=>`, so multi-line arm expressions still work.
148
+ */
149
+ function splitArms(source, tokens) {
150
+ const result = []
151
+ let start = 0
152
+ let depth = 0
153
+ let seenArrow = false
154
+
155
+ for (let i = 0; i < tokens.length; i++) {
156
+ const value = tokens[i].value
157
+
158
+ if (
159
+ depth === 0 && seenArrow && i > start &&
160
+ tokens[i - 1].value !== '=>' &&
161
+ isNewlineBetween(source, tokens[i - 1], tokens[i]) &&
162
+ hasTopLevelArrowBeforeNewline(source, tokens, i)
163
+ ) {
164
+ result.push(tokens.slice(start, i))
165
+ start = i
166
+ seenArrow = false
167
+ }
168
+
169
+ if (value === '(' || value === '[' || value === '{') depth++
170
+ else if (value === ')' || value === ']' || value === '}') depth--
171
+ else if (value === '=>' && depth === 0) seenArrow = true
172
+ else if (value === ',' && depth === 0) {
173
+ result.push(tokens.slice(start, i))
174
+ start = i + 1
175
+ seenArrow = false
176
+ }
177
+ }
178
+
179
+ if (start < tokens.length) result.push(tokens.slice(start))
180
+ return result
181
+ }
182
+
183
+ const LITERAL_IDENTIFIERS = new Set(['true', 'false', 'null', 'undefined'])
184
+
185
+ function isBindingName(tokens) {
186
+ return tokens.length === 1 &&
187
+ tokens[0].type === 'identifier' &&
188
+ !LITERAL_IDENTIFIERS.has(tokens[0].value)
189
+ }
190
+
191
+ function matchingClose(tokens, openIndex) {
192
+ let depth = 0
193
+ for (let i = openIndex; i < tokens.length; i++) {
194
+ const value = tokens[i].value
195
+ if (value === '(' || value === '[' || value === '{') depth++
196
+ else if (value === ')' || value === ']' || value === '}') {
197
+ depth--
198
+ if (depth === 0) return i
199
+ }
200
+ }
201
+ return -1
202
+ }
203
+
204
+ function isObjectPattern(tokens) {
205
+ return tokens[0]?.value === '{' && matchingClose(tokens, 0) === tokens.length - 1
206
+ }
207
+
208
+ function isConstructorPattern(tokens) {
209
+ return tokens[0]?.type === 'identifier' &&
210
+ /^[A-Z]/.test(tokens[0].value) &&
211
+ tokens[1]?.value === '(' &&
212
+ matchingClose(tokens, 1) === tokens.length - 1
213
+ }
214
+
215
+ function isStructuralPattern(tokens) {
216
+ return isObjectPattern(tokens) || isConstructorPattern(tokens)
217
+ }
218
+
219
+ // Fields bind by position through the constructor's `fields`, so values decoded from JSON still match.
220
+ function compileConstructorPattern(source, tokens, accessor) {
221
+ const variant = tokens[0].value
222
+ const conditions = [
223
+ `${accessor} !== null && typeof ${accessor} === "object"`,
224
+ `${accessor}.tag === ${JSON.stringify(variant)}`
225
+ ]
226
+ const bindings = []
227
+
228
+ splitTopLevelCommas(tokens.slice(2, -1)).filter(group => group.length).forEach((argument, index) => {
229
+ const pattern = compilePattern(source, argument, `${accessor}[${variant}.fields[${index}]]`, true)
230
+ conditions.push(...pattern.conditions)
231
+ bindings.push(...pattern.bindings)
232
+ })
233
+
234
+ return { conditions, bindings }
235
+ }
236
+
237
+ /*
238
+ * Inside an object pattern a bare name binds the field, while literals and
239
+ * other expressions are compared with `===`.
240
+ */
241
+ function compilePattern(source, tokens, accessor, nested) {
242
+ if (tokens.length === 1 && tokens[0].value === '_') return { conditions: [], bindings: [] }
243
+
244
+ if (isObjectPattern(tokens)) return compileObjectPattern(source, tokens, accessor)
245
+ if (isConstructorPattern(tokens)) return compileConstructorPattern(source, tokens, accessor)
246
+
247
+ if (nested && isBindingName(tokens)) {
248
+ return { conditions: [], bindings: [[tokens[0].value, accessor]] }
249
+ }
250
+
251
+ return { conditions: [`${accessor} === (${text(source, tokens)})`], bindings: [] }
252
+ }
253
+
254
+ function compileObjectPattern(source, tokens, accessor) {
255
+ const conditions = [`${accessor} !== null && typeof ${accessor} === "object"`]
256
+ const bindings = []
257
+
258
+ for (const field of splitTopLevelCommas(tokens.slice(1, -1)).filter(group => group.length)) {
259
+ const colon = field.findIndex(token => token.value === ':')
260
+ const keyTokens = colon === -1 ? field : field.slice(0, colon)
261
+ const valueTokens = colon === -1 ? field : field.slice(colon + 1)
262
+
263
+ if (keyTokens.length !== 1 || !['identifier', 'string'].includes(keyTokens[0].type) || !valueTokens.length) {
264
+ throw new SyntaxError(`Invalid match pattern field: ${text(source, field)}`)
265
+ }
266
+
267
+ const key = keyTokens[0].type === 'string' ? keyTokens[0].value : JSON.stringify(keyTokens[0].value)
268
+ const fieldAccessor = `${accessor}[${key}]`
269
+ const pattern = compilePattern(source, valueTokens, fieldAccessor, true)
270
+
271
+ conditions.push(`${key} in ${accessor}`, ...pattern.conditions)
272
+ bindings.push(...pattern.bindings)
273
+ }
274
+
275
+ return { conditions, bindings }
276
+ }
277
+
128
278
  function parseArms(source, tokens) {
129
- const groups = splitTopLevelCommas(tokens).filter(group => group.length)
279
+ const groups = splitArms(source, tokens).filter(group => group.length)
130
280
 
131
281
  if (!groups.length) {
132
282
  throw new SyntaxError('match requires at least one arm')
@@ -147,33 +297,81 @@ function parseArms(source, tokens) {
147
297
  throw new SyntaxError(`Invalid match arm, expected \`pattern => expression\`:\n${text(source, group)}`)
148
298
  }
149
299
 
150
- const pattern = text(source, group.slice(0, arrowIndex)).trim()
300
+ const patternTokens = group.slice(0, arrowIndex)
301
+ const pattern = text(source, patternTokens).trim()
151
302
  const expression = text(source, group.slice(arrowIndex + 1)).trim()
152
303
 
153
304
  if (!pattern || !expression) {
154
305
  throw new SyntaxError(`Invalid match arm, expected \`pattern => expression\`:\n${text(source, group)}`)
155
306
  }
156
307
 
157
- return { pattern, expression }
308
+ return {
309
+ pattern,
310
+ expression,
311
+ structural: isStructuralPattern(patternTokens),
312
+ variant: isConstructorPattern(patternTokens)
313
+ ? {
314
+ name: patternTokens[0].value,
315
+ arity: splitTopLevelCommas(patternTokens.slice(2, -1)).filter(argument => argument.length).length,
316
+ line: lineAt(source, patternTokens[0].start)
317
+ }
318
+ : null,
319
+ compiled: compilePattern(source, patternTokens, '__match', false)
320
+ }
158
321
  })
159
322
  }
160
323
 
324
+ function lineAt(source, offset) {
325
+ return source.slice(0, offset).split('\n').length
326
+ }
327
+
328
+ // Static view of every match expression, used by `jojo check` for exhaustiveness.
329
+ export function findMatchExpressions(source) {
330
+ const tokens = significantTokens(tokenize(source))
331
+ const matches = []
332
+
333
+ for (let i = 0; i < tokens.length; i++) {
334
+ if (!isMatchKeyword(tokens, i)) continue
335
+ const block = findMatchBlock(source, tokens, i)
336
+ if (!block) continue
337
+
338
+ const arms = parseArms(source, tokens.slice(block.braceOpen + 1, block.braceClose))
339
+ matches.push({
340
+ line: lineAt(source, tokens[i].start),
341
+ hasDefault: arms.some(arm => arm.pattern === '_'),
342
+ arms: arms.map(({ pattern, variant }) => ({ pattern, variant }))
343
+ })
344
+ }
345
+
346
+ return matches
347
+ }
348
+
349
+ function emitArm({ pattern, expression, compiled: { conditions, bindings } }) {
350
+ if (pattern === '_') return ` return ${expression};`
351
+
352
+ const condition = conditions.map(item => conditions.length > 1 ? `(${item})` : item).join(' && ')
353
+ if (!bindings.length) return ` if (${condition}) return ${expression};`
354
+
355
+ const declarations = bindings.map(([name, accessor]) => `${name} = ${accessor}`).join(', ')
356
+ return ` if (${condition}) { const ${declarations}; return ${expression}; }`
357
+ }
358
+
161
359
  function emitMatch(subject, arms) {
162
360
  const defaultIndex = arms.findIndex(arm => arm.pattern === '_')
361
+ const structural = arms.some(arm => arm.structural)
163
362
 
164
- if (defaultIndex === -1) {
363
+ if (defaultIndex === -1 && !structural) {
165
364
  throw new SyntaxError('match requires a default `_` arm')
166
365
  }
167
366
 
168
- if (defaultIndex !== arms.length - 1) {
367
+ if (defaultIndex !== -1 && defaultIndex !== arms.length - 1) {
169
368
  throw new SyntaxError('the default `_` arm must be the last arm in match')
170
369
  }
171
370
 
172
- const lines = arms.map(arm =>
173
- arm.pattern === '_'
174
- ? ` return ${arm.expression};`
175
- : ` if (__match === (${arm.pattern})) return ${arm.expression};`
176
- )
371
+ const lines = arms.map(emitArm)
372
+ if (defaultIndex === -1) {
373
+ lines.push(" throw new TypeError('No match arm matched the value');")
374
+ }
177
375
 
178
376
  return `(() => {\n const __match = ${subject};\n${lines.join('\n')}\n})()`
179
377
  }
@@ -0,0 +1,87 @@
1
+ import { maskNonCode } from './lexer.js'
2
+
3
+ const PIPELINE_HEAD = /^([ \t]*)(export[ \t]+)?pipeline[ \t]+([A-Za-z_$][\w$]*)[ \t]*\{/gm
4
+ const SOURCE_LINE = /^([ \t]*)source[ \t]+(.+?)[ \t]*$/
5
+
6
+ function lineAt(source, offset) {
7
+ return source.slice(0, offset).split('\n').length
8
+ }
9
+
10
+ function matchingBrace(code, openIndex) {
11
+ let depth = 0
12
+ for (let i = openIndex; i < code.length; i++) {
13
+ if (code[i] === '{') depth++
14
+ else if (code[i] === '}' && --depth === 0) return i
15
+ }
16
+ throw new SyntaxError('Unclosed pipeline block')
17
+ }
18
+
19
+ /*
20
+ * `pipeline name { source input |> stages }` is a named, reusable pipeline.
21
+ * An identifier source becomes the function's single input parameter.
22
+ */
23
+ export function findNamedPipelines(source) {
24
+ const code = maskNonCode(source)
25
+ const regex = new RegExp(PIPELINE_HEAD.source, PIPELINE_HEAD.flags)
26
+ const pipelines = []
27
+ let match
28
+
29
+ while ((match = regex.exec(code))) {
30
+ const [head, indent, exportPart, name] = match
31
+ const bodyOpen = match.index + head.length - 1
32
+ const bodyClose = matchingBrace(code, bodyOpen)
33
+ const lines = source.slice(bodyOpen + 1, bodyClose).split('\n')
34
+
35
+ const sourceIndex = lines.findIndex(line => line.trim())
36
+ const sourceLine = sourceIndex === -1 ? null : lines[sourceIndex].match(SOURCE_LINE)
37
+ if (!sourceLine) throw new SyntaxError(`pipeline "${name}" must start with "source <expression>"`)
38
+
39
+ const stageLines = lines.slice(sourceIndex + 1).filter(line => line.trim())
40
+ if (!stageLines.length) throw new SyntaxError(`pipeline "${name}" has no stages`)
41
+
42
+ const expression = sourceLine[2]
43
+ pipelines.push({
44
+ name,
45
+ inputs: /^[A-Za-z_$][\w$]*$/.test(expression) ? [expression] : [],
46
+ expression,
47
+ exported: Boolean(exportPart),
48
+ // Only a top-level `|> await` or an awaited source needs an async function; awaits inside stage callbacks do not.
49
+ async: /\bawait\b/.test(expression) || stageLines.some(line => /^\s*\|>\s*await\b/.test(line)),
50
+ line: lineAt(source, match.index + indent.length),
51
+ indent,
52
+ sourceIndent: sourceLine[1],
53
+ stageLines,
54
+ // The first and last entries are the rest of the `{` line and the start of the `}` line.
55
+ blankLines: Math.max(0, lines.length - 3 - stageLines.length),
56
+ start: match.index,
57
+ end: bodyClose + 1
58
+ })
59
+ }
60
+
61
+ return pipelines
62
+ }
63
+
64
+ /*
65
+ * Blank lines would split the returned pipeline into separate statements, so
66
+ * they move to the end of the block, which keeps its total line count.
67
+ */
68
+ export function transformNamedPipelines(source) {
69
+ let result = source
70
+
71
+ for (const pipeline of findNamedPipelines(source).reverse()) {
72
+ const exportPart = pipeline.exported ? 'export ' : ''
73
+ const asyncPart = pipeline.async ? 'async ' : ''
74
+ const body = [
75
+ `${pipeline.sourceIndent}return ${pipeline.expression}`,
76
+ ...pipeline.stageLines,
77
+ ...Array(pipeline.blankLines).fill('')
78
+ ].join('\n')
79
+
80
+ const replacement =
81
+ `${pipeline.indent}${exportPart}${asyncPart}fn ${pipeline.name}(${pipeline.inputs.join(', ')}) {\n${body}\n${pipeline.indent}}`
82
+
83
+ result = result.slice(0, pipeline.start) + replacement + result.slice(pipeline.end)
84
+ }
85
+
86
+ return result
87
+ }
package/src/parse.js ADDED
@@ -0,0 +1,124 @@
1
+ import { maskNonCode } from './lexer.js'
2
+ import { findAllPipelines } from './pipeline-parser.js'
3
+ import { inferPipelineName } from './pipeline-explain.js'
4
+ import { findNamedPipelines, transformNamedPipelines } from './named-pipelines.js'
5
+ import { findEffectDeclarations, findHandleBlocks } from './effect-parser.js'
6
+ import { findTypeDeclarations } from './type-declarations.js'
7
+ import { findContracts } from './contracts.js'
8
+ import { findMatchExpressions } from './match-parser.js'
9
+
10
+ const PARSE_VERSION = '1'
11
+ const IMPORT = /^[ \t]*import\s+([^'"]+?)\s+from\s+(["'])([^"']+)\2/gm
12
+ const FUNCTION = /^[ \t]*(export[ \t]+)?(?:(async)[ \t]+)?(?:(pure|effectful)[ \t]+)?(?:(async)[ \t]+)?fn[ \t]+([A-Za-z_$][\w$]*)[ \t]*\(/gm
13
+
14
+ function lineAt(source, offset) {
15
+ return source.slice(0, offset).split('\n').length
16
+ }
17
+
18
+ function splitTopLevel(text) {
19
+ const parts = []
20
+ let depth = 0
21
+ let start = 0
22
+ for (let i = 0; i < text.length; i++) {
23
+ if ('([{'.includes(text[i])) depth++
24
+ else if (')]}'.includes(text[i])) depth--
25
+ else if (text[i] === ',' && depth === 0) {
26
+ parts.push(text.slice(start, i).trim())
27
+ start = i + 1
28
+ }
29
+ }
30
+ if (text.trim()) parts.push(text.slice(start).trim())
31
+ return parts
32
+ }
33
+
34
+ function matchingParen(code, open) {
35
+ let depth = 0
36
+ for (let i = open; i < code.length; i++) {
37
+ if (code[i] === '(') depth++
38
+ else if (code[i] === ')' && --depth === 0) return i
39
+ }
40
+ return code.length
41
+ }
42
+
43
+ function parseImports(source, code) {
44
+ return [...source.matchAll(IMPORT)]
45
+ .filter(match => code.startsWith('import', match.index + match[0].search(/import/)))
46
+ .map(match => ({
47
+ kind: 'import',
48
+ line: lineAt(source, match.index),
49
+ from: match[3],
50
+ names: splitTopLevel(match[1].replace(/[{}]/g, ',')).filter(Boolean)
51
+ }))
52
+ }
53
+
54
+ function parseFunctions(source, code) {
55
+ const contracts = findContracts(source)
56
+
57
+ return [...code.matchAll(FUNCTION)].map(match => {
58
+ const [head, exportPart, asyncBefore, purity, asyncAfter, name] = match
59
+ const open = match.index + head.length - 1
60
+ const close = matchingParen(code, open)
61
+ const restOfLine = code.slice(close + 1).split('\n')[0]
62
+ const line = lineAt(source, match.index + head.search(/\S/))
63
+ const contract = contracts.find(item => item.name === name && item.line === line)
64
+
65
+ return {
66
+ kind: 'function',
67
+ line,
68
+ name,
69
+ params: splitTopLevel(source.slice(open + 1, close)),
70
+ exported: Boolean(exportPart),
71
+ async: Boolean(asyncBefore || asyncAfter),
72
+ purity: purity ?? null,
73
+ body: /^\s*(?::[^={]*)?=(?![=>])/.test(restOfLine) ? 'expression' : 'block',
74
+ requires: contract?.requires.map(clause => clause.condition) ?? [],
75
+ ensures: contract?.ensures.map(clause => clause.condition) ?? []
76
+ }
77
+ })
78
+ }
79
+
80
+ /*
81
+ * Named pipeline blocks are analyzed through their compiled `fn` form, which
82
+ * keeps every line in place, so their nodes report the `pipeline` line.
83
+ */
84
+ function parsePipelines(rawSource) {
85
+ const source = transformNamedPipelines(rawSource)
86
+ const declared = findNamedPipelines(rawSource)
87
+
88
+ return findAllPipelines(source).map(pipeline => {
89
+ const name = inferPipelineName(source, pipeline)
90
+ const startLine = lineAt(source, pipeline.start)
91
+ const block = declared.find(item => item.name === name && item.line < startLine)
92
+
93
+ return {
94
+ kind: 'pipeline',
95
+ line: block ? block.line : startLine,
96
+ name,
97
+ declared: Boolean(block),
98
+ source: pipeline.expression,
99
+ stages: pipeline.stages.map(stage => ({ type: stage.name, args: stage.args, id: stage.id ?? null }))
100
+ }
101
+ })
102
+ }
103
+
104
+ export function parseSource(source) {
105
+ const code = maskNonCode(source)
106
+
107
+ const nodes = [
108
+ ...parseImports(source, code),
109
+ ...findEffectDeclarations(source).map(effect => ({ kind: 'effect', line: lineAt(source, effect.start), name: effect.name, params: effect.params })),
110
+ ...findTypeDeclarations(source).map(type => ({
111
+ kind: 'type',
112
+ line: lineAt(source, type.start + type.indent.length),
113
+ name: type.name,
114
+ exported: type.exported,
115
+ variants: type.variants
116
+ })),
117
+ ...parseFunctions(source, code),
118
+ ...parsePipelines(source),
119
+ ...findHandleBlocks(source).map(block => ({ kind: 'handle', line: lineAt(source, block.start), handlers: block.handlerNames })),
120
+ ...findMatchExpressions(source).map(match => ({ kind: 'match', line: match.line, patterns: match.arms.map(arm => arm.pattern) }))
121
+ ]
122
+
123
+ return { language: 'jojo', version: PARSE_VERSION, nodes: nodes.sort((a, b) => a.line - b.line) }
124
+ }
@@ -0,0 +1,132 @@
1
+ import { maskNonCode } from './lexer.js'
2
+ import { findAllPipelines } from './pipeline-parser.js'
3
+ import { analyzePipelines, namesReferencedByStage } from './pipeline-ir.js'
4
+ import { findPurityDeclarations } from './purity.js'
5
+ import { transformNamedPipelines } from './named-pipelines.js'
6
+
7
+ const CONTROL_STAGES = new Set(['await', 'catch', 'orElse', 'pass'])
8
+ const MATERIALIZING_STAGES = new Set(['sort', 'sortBy', 'groupBy', 'partition'])
9
+
10
+ const ASSIGNED_NAME = /(?:^|[^\w$.])([A-Za-z_$][\w$]*)\s*(?::=|=|:)$/
11
+ const EXPRESSION_FUNCTION_NAME = /\bfn\s+([A-Za-z_$][\w$]*)\s*\([^()]*\)\s*=$/
12
+ const FUNCTION_NAME = /\b(?:fn|function)\s+([A-Za-z_$][\w$]*)\s*\(/g
13
+
14
+ /*
15
+ * A static, execution-free description of what each pipeline in `source`
16
+ * means, built on the same graph as `jojo graph`. Purity and determinism
17
+ * only reflect references to declared effects; ordinary JavaScript calls
18
+ * inside a stage are not analyzed.
19
+ */
20
+ export function explainPipelines(jojoSource) {
21
+ const source = transformNamedPipelines(jojoSource)
22
+ const pipelines = findAllPipelines(source)
23
+ const effectfulFunctionNames = findPurityDeclarations(source)
24
+ .filter(declaration => declaration.purity === 'effectful')
25
+ .map(declaration => declaration.name)
26
+
27
+ return analyzePipelines(source).map((graph, index) => {
28
+ const stages = graph.nodes
29
+ .filter(node => node.type !== 'result')
30
+ .map((node, position) => describeStage(node, position + 1, effectfulFunctionNames))
31
+
32
+ return {
33
+ name: inferPipelineName(source, pipelines[index]) ?? `pipeline-${index + 1}`,
34
+ id: graph.id,
35
+ stages,
36
+ effects: uniqueEffects(graph.effects),
37
+ properties: {
38
+ lazy: !stages.some(stage => MATERIALIZING_STAGES.has(stage.type)),
39
+ parallel: stages.some(stage => stage.type === 'parallel'),
40
+ resumable: stages.some(stage => stage.type === 'checkpoint'),
41
+ deterministic: stages.every(stage => !stage.effects.length && !stage.effectfulFunctions.length)
42
+ }
43
+ }
44
+ })
45
+ }
46
+
47
+ function describeStage(node, index, effectfulFunctionNames) {
48
+ const effects = node.metadata.effects ?? []
49
+ const effectfulFunctions = node.type === 'source'
50
+ ? []
51
+ : namesReferencedByStage({ name: node.type, args: node.args }, effectfulFunctionNames)
52
+
53
+ return {
54
+ index,
55
+ id: node.type === 'source' || /^node-\d+$/.test(node.id) ? null : node.id,
56
+ type: node.type,
57
+ detail: stageDetail(node, [...effects, ...effectfulFunctions]),
58
+ args: node.args,
59
+ effects,
60
+ effectfulFunctions
61
+ }
62
+ }
63
+
64
+ function stageDetail(node, effects) {
65
+ if (node.type === 'source') return node.label
66
+ if (CONTROL_STAGES.has(node.type)) return 'control'
67
+ if (node.type === 'parallel') return `concurrency=${node.args[0]}`
68
+ if (node.type === 'batch') return `size=${node.args[0]}`
69
+ if (node.type === 'checkpoint') return node.args[0].replace(/^(["'`])(.*)\1$/, '$2')
70
+ return effects.length ? `effectful: ${effects.join(', ')}` : 'pure'
71
+ }
72
+
73
+ function uniqueEffects(effects) {
74
+ const byName = new Map()
75
+ for (const { name, staticallyHandled } of effects) {
76
+ if (!byName.has(name)) byName.set(name, { name, staticallyHandled })
77
+ }
78
+ return [...byName.values()]
79
+ }
80
+
81
+ export function inferPipelineName(source, pipeline) {
82
+ const lead = (source.slice(0, pipeline.start) + pipeline.prefix).trimEnd()
83
+
84
+ const expressionFunction = lead.match(EXPRESSION_FUNCTION_NAME)
85
+ if (expressionFunction) return expressionFunction[1]
86
+
87
+ if (pipeline.prefix === 'return') return enclosingFunctionName(source, pipeline.start)
88
+
89
+ return lead.match(ASSIGNED_NAME)?.[1] ?? null
90
+ }
91
+
92
+ function enclosingFunctionName(source, position) {
93
+ const code = maskNonCode(source).slice(0, position)
94
+ const candidates = [...code.matchAll(FUNCTION_NAME)].reverse()
95
+
96
+ const enclosing = candidates.find(match => !bodyClosesWithin(code, match.index))
97
+ return enclosing?.[1] ?? null
98
+ }
99
+
100
+ function bodyClosesWithin(code, functionStart) {
101
+ const bodyStart = code.indexOf('{', functionStart)
102
+ if (bodyStart === -1) return true
103
+
104
+ let depth = 0
105
+ for (let i = bodyStart; i < code.length; i++) {
106
+ if (code[i] === '{') depth++
107
+ else if (code[i] === '}' && --depth === 0) return true
108
+ }
109
+ return false
110
+ }
111
+
112
+ export function renderExplanationText(explanation) {
113
+ const width = Math.max(...explanation.stages.map(stage => stage.type.length))
114
+ const effects = explanation.effects.length
115
+ ? explanation.effects.map(effect =>
116
+ ` ${effect.name} (${effect.staticallyHandled ? 'handler declared' : 'no handler in file'})`)
117
+ : [' none']
118
+
119
+ return [
120
+ `PIPELINE ${explanation.name}`,
121
+ '',
122
+ 'STAGES',
123
+ ...explanation.stages.map(stage =>
124
+ ` ${stage.index} ${stage.type.padEnd(width)} ${stage.detail}${stage.id ? ` @${stage.id}` : ''}`),
125
+ '',
126
+ 'EFFECTS',
127
+ ...effects,
128
+ '',
129
+ 'PROPERTIES',
130
+ ...Object.entries(explanation.properties).map(([name, value]) => ` ${name}: ${value}`)
131
+ ].join('\n')
132
+ }
@@ -1,6 +1,7 @@
1
1
  import { findAllPipelines } from './pipeline-parser.js'
2
2
  import { computePipelineId } from './pipeline-hash.js'
3
- import { findEffectDeclarations, findHandleBlocks } from './effect-parser.js'
3
+ import { findEffectDeclarations, findHandleBlocks } from './effect-parser.js'
4
+ import { transformNamedPipelines } from './named-pipelines.js'
4
5
 
5
6
  /*
6
7
  * Best-effort, purely textual check for whether `effectName` is referenced
@@ -10,7 +11,7 @@ import { findEffectDeclarations, findHandleBlocks } from './effect-parser.js'
10
11
  * whether the reference is actually reachable/called, only that the name
11
12
  * appears with word boundaries.
12
13
  */
13
- function effectsUsedByStage(stage, effectNames) {
14
+ export function namesReferencedByStage(stage, effectNames) {
14
15
  if (!effectNames.length) return []
15
16
  const haystack = stage.args.length ? `${stage.name} ${stage.args.join(' ')}` : stage.name
16
17
  return effectNames.filter(name => new RegExp(`\\b${name}\\b`).test(haystack))
@@ -44,14 +45,14 @@ export function buildGraph(pipeline, pipelineId = computePipelineId(pipeline), o
44
45
  })
45
46
 
46
47
  pipeline.stages.forEach((stage, index) => {
47
- const effects = effectsUsedByStage(stage, effectNames)
48
+ const effects = namesReferencedByStage(stage, effectNames)
48
49
 
49
50
  nodes.push({
50
- id: `node-${index + 1}`,
51
+ id: stage.id ?? `node-${index + 1}`,
51
52
  type: stage.name,
52
53
  label: stage.args.length ? `${stage.name}(${stage.args.join(', ')})` : stage.name,
53
54
  args: stage.args,
54
- dependencies: [`node-${index}`],
55
+ dependencies: [nodes[index].id],
55
56
  metadata: effects.length ? { effects } : {}
56
57
  })
57
58
  })
@@ -62,7 +63,7 @@ export function buildGraph(pipeline, pipelineId = computePipelineId(pipeline), o
62
63
  type: 'result',
63
64
  label: 'RESULT',
64
65
  args: [],
65
- dependencies: [`node-${pipeline.stages.length}`],
66
+ dependencies: [nodes[nodes.length - 1].id],
66
67
  metadata: {}
67
68
  })
68
69
 
@@ -92,7 +93,8 @@ export function buildGraph(pipeline, pipelineId = computePipelineId(pipeline), o
92
93
  * handler-map keys, so returned graphs can expose which effects each
93
94
  * pipeline uses and whether a handler for them is statically visible.
94
95
  */
95
- export function analyzePipelines(source) {
96
+ export function analyzePipelines(jojoSource) {
97
+ const source = transformNamedPipelines(jojoSource)
96
98
  const effectNames = findEffectDeclarations(source).map(declaration => declaration.name)
97
99
  const handledEffectNames = findHandleBlocks(source).flatMap(block => block.handlerNames)
98
100