@panagos/jojoscript 0.9.3 → 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.
- package/README.md +848 -25
- package/bin/jojo.js +257 -23
- package/package.json +2 -2
- package/src/check.js +253 -0
- package/src/compiler.js +36 -37
- package/src/contracts.js +124 -0
- package/src/describe.js +63 -0
- package/src/effect-parser.js +4 -2
- package/src/formatter.js +105 -0
- package/src/index.js +31 -0
- package/src/match-parser.js +212 -14
- package/src/named-pipelines.js +87 -0
- package/src/parse.js +124 -0
- package/src/pipeline-explain.js +132 -0
- package/src/pipeline-ir.js +9 -7
- package/src/pipeline-parser.js +24 -2
- package/src/pipeline-plan.js +203 -0
- package/src/purity.js +101 -0
- package/src/runtime/a2a.js +322 -0
- package/src/runtime/checkpoint-store.js +26 -5
- package/src/runtime/checkpoint.js +3 -2
- package/src/runtime/effects.js +136 -2
- package/src/runtime/errors.js +14 -0
- package/src/runtime/index.js +15 -3
- package/src/runtime/mcp.js +237 -35
- package/src/runtime/serialization.js +97 -0
- package/src/runtime/tagged-unions.js +29 -0
- package/src/simulate.js +156 -0
- package/src/type-declarations.js +83 -0
- package/src/why.js +146 -0
package/src/match-parser.js
CHANGED
|
@@ -20,10 +20,11 @@ import { tokenize, significantTokens } from './lexer.js'
|
|
|
20
20
|
* return handleUnknown();
|
|
21
21
|
* })()
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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 =
|
|
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
|
|
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 {
|
|
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(
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
+
}
|
package/src/pipeline-ir.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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: [
|
|
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: [
|
|
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(
|
|
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
|
|