@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/src/compiler.js CHANGED
@@ -20,6 +20,10 @@ import {
20
20
 
21
21
  import { transformMatchExpressions } from './match-parser.js'
22
22
  import { transformTypeAnnotations } from './type-annotations.js'
23
+ import { transformPurityModifiers } from './purity.js'
24
+ import { transformNamedPipelines } from './named-pipelines.js'
25
+ import { transformContracts, findContracts } from './contracts.js'
26
+ import { transformTypeDeclarations, findTypeDeclarations } from './type-declarations.js'
23
27
  import {
24
28
  transformEffectDeclarations,
25
29
  transformHandleBlocks,
@@ -47,8 +51,16 @@ export function compile(source, options = {}) {
47
51
  * Optional type annotations, stripped and re-expressed as JSDoc before
48
52
  * anything else touches `fn` declarations.
49
53
  */
54
+ result = transformNamedPipelines(result)
55
+ result = transformPurityModifiers(result)
56
+
57
+ const usesContracts = findContracts(result).length > 0
58
+ result = transformContracts(result)
50
59
  result = transformTypeAnnotations(result)
51
60
 
61
+ const usesTypeDeclarations = findTypeDeclarations(result).length > 0
62
+ result = transformTypeDeclarations(result)
63
+
52
64
  /*
53
65
  * Jojo functions.
54
66
  */
@@ -98,6 +110,12 @@ export function compile(source, options = {}) {
98
110
  if (usesHandleBlocks && !pipelineStdlibFunctions.includes('withHandlers')) {
99
111
  pipelineStdlibFunctions.push('withHandlers')
100
112
  }
113
+ if (usesContracts && !pipelineStdlibFunctions.includes('ContractError')) {
114
+ pipelineStdlibFunctions.push('ContractError')
115
+ }
116
+ if (usesTypeDeclarations && !pipelineStdlibFunctions.includes('defineType')) {
117
+ pipelineStdlibFunctions.push('defineType')
118
+ }
101
119
 
102
120
  /*
103
121
  * `jojo profile` compiles with `{ profile: true }`, which wraps every
@@ -127,17 +145,13 @@ export function compile(source, options = {}) {
127
145
  * pipeline stage), the auto-imported names are merged into it instead
128
146
  * of creating a second, colliding import declaration.
129
147
  */
130
- if (pipelineStdlibFunctions.length) {
148
+ if (pipelineStdlibFunctions.length || options.runtimeImport) {
131
149
  result = mergeRuntimeImport(result, pipelineStdlibFunctions, runtimeImport)
132
150
  }
133
151
 
134
152
  return result.endsWith('\n') ? result : result + '\n'
135
153
  }
136
154
 
137
- function escapeRegExp(value) {
138
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
139
- }
140
-
141
155
  function mergeRuntimeImport(source, stdlibFunctions, runtimeImport) {
142
156
  const pattern = new RegExp(
143
157
  `import\\s*\\{([^}]*)\\}\\s*from\\s*(["'])([^"']+\\/runtime)\\2`
@@ -145,6 +159,7 @@ function mergeRuntimeImport(source, stdlibFunctions, runtimeImport) {
145
159
  const existing = source.match(pattern)
146
160
 
147
161
  if (!existing) {
162
+ if (!stdlibFunctions.length) return source
148
163
  return `import { ${stdlibFunctions.join(', ')} } from "${runtimeImport}"\n` + source
149
164
  }
150
165
 
@@ -185,43 +200,27 @@ export function compileDirectory(inputDir, outDir = null, options = {}) {
185
200
  ? path.resolve(outDir)
186
201
  : path.join(root, 'dist')
187
202
 
188
- const result = []
189
-
190
- function walk(dir) {
191
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
192
- if (
193
- entry.name === 'node_modules' ||
194
- entry.name === 'dist' ||
195
- entry.name.startsWith('.')
196
- ) {
197
- continue
198
- }
199
-
200
- const full = path.join(dir, entry.name)
203
+ return findJojoFiles(root).map(full => {
204
+ const rel = path.relative(root, full).slice(0, -'.jojo'.length) + '.js'
205
+ const out = path.join(target, rel)
201
206
 
202
- if (entry.isDirectory()) {
203
- walk(full)
204
- continue
205
- }
207
+ fs.mkdirSync(path.dirname(out), { recursive: true })
208
+ fs.writeFileSync(out, compile(fs.readFileSync(full, 'utf8'), options), 'utf8')
206
209
 
207
- if (!entry.name.endsWith('.jojo')) continue
208
-
209
- const rel =
210
- path.relative(root, full).slice(0, -'.jojo'.length) + '.js'
210
+ return { input: full, output: out }
211
+ })
212
+ }
211
213
 
212
- const out = path.join(target, rel)
214
+ export function findJojoFiles(root) {
215
+ const files = []
213
216
 
214
- fs.mkdirSync(path.dirname(out), { recursive: true })
215
- fs.writeFileSync(
216
- out,
217
- compile(fs.readFileSync(full, 'utf8'), options),
218
- 'utf8'
219
- )
217
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
218
+ if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) continue
220
219
 
221
- result.push({ input: full, output: out })
222
- }
220
+ const full = path.join(root, entry.name)
221
+ if (entry.isDirectory()) files.push(...findJojoFiles(full))
222
+ else if (entry.name.endsWith('.jojo')) files.push(full)
223
223
  }
224
224
 
225
- walk(root)
226
- return result
225
+ return files
227
226
  }
@@ -0,0 +1,124 @@
1
+ import { maskNonCode } from './lexer.js'
2
+
3
+ const FUNCTION_HEAD = /^[ \t]*(?:export[ \t]+)?(?:(?:pure|effectful)[ \t]+)?(async[ \t]+)?(?:(?:pure|effectful)[ \t]+)?fn[ \t]+([A-Za-z_$][\w$]*)[ \t]*\(/gm
4
+ const CLAUSE = /^([ \t]*)(requires|ensures)[ \t]+(.+?)[ \t]*$/
5
+
6
+ function lineAt(source, offset) {
7
+ return source.slice(0, offset).split('\n').length
8
+ }
9
+
10
+ function matchingClose(code, openIndex, open, close) {
11
+ let depth = 0
12
+ for (let i = openIndex; i < code.length; i++) {
13
+ if (code[i] === open) depth++
14
+ else if (code[i] === close && --depth === 0) return i
15
+ }
16
+ return -1
17
+ }
18
+
19
+ function parameterNames(params) {
20
+ return params.split(',')
21
+ .map(param => param.trim().match(/^(?:\.\.\.)?([A-Za-z_$][\w$]*)/)?.[1])
22
+ .filter(Boolean)
23
+ }
24
+
25
+ /*
26
+ * Contract clauses sit on their own lines between a block function's
27
+ * parameter list and its opening `{`:
28
+ *
29
+ * fn divide(a, b)
30
+ * requires b != 0
31
+ * ensures result * b == a
32
+ * {
33
+ */
34
+ export function findContracts(source) {
35
+ const code = maskNonCode(source)
36
+ const regex = new RegExp(FUNCTION_HEAD.source, FUNCTION_HEAD.flags)
37
+ const contracts = []
38
+ let match
39
+
40
+ while ((match = regex.exec(code))) {
41
+ const paramsOpen = match.index + match[0].length - 1
42
+ const paramsClose = matchingClose(code, paramsOpen, '(', ')')
43
+ if (paramsClose === -1) continue
44
+
45
+ const headEnd = code.indexOf('\n', paramsClose)
46
+ if (headEnd === -1 || /[{=]/.test(code.slice(paramsClose + 1, headEnd))) continue
47
+
48
+ const clauses = []
49
+ let position = headEnd + 1
50
+ let bodyOpen = -1
51
+
52
+ while (position < code.length) {
53
+ const lineEnd = code.indexOf('\n', position) === -1 ? code.length : code.indexOf('\n', position)
54
+ const line = source.slice(position, lineEnd)
55
+ const clause = code.slice(position, lineEnd).match(CLAUSE)
56
+
57
+ if (clause) {
58
+ clauses.push({
59
+ kind: clause[2],
60
+ condition: line.slice(clause[1].length + clause[2].length).trim(),
61
+ line: lineAt(source, position),
62
+ start: position,
63
+ end: lineEnd
64
+ })
65
+ } else if (line.trim().startsWith('{')) {
66
+ bodyOpen = position + line.indexOf('{')
67
+ break
68
+ } else {
69
+ break
70
+ }
71
+ position = lineEnd + 1
72
+ }
73
+
74
+ if (!clauses.length || bodyOpen === -1) continue
75
+
76
+ const toEntry = ({ condition, line }) => ({ condition, line })
77
+ contracts.push({
78
+ name: match[2],
79
+ async: Boolean(match[1]),
80
+ params: parameterNames(source.slice(paramsOpen + 1, paramsClose)),
81
+ line: lineAt(source, match.index + match[0].search(/\S/)),
82
+ requires: clauses.filter(clause => clause.kind === 'requires').map(toEntry),
83
+ ensures: clauses.filter(clause => clause.kind === 'ensures').map(toEntry),
84
+ clauses,
85
+ bodyOpen,
86
+ bodyClose: matchingClose(code, bodyOpen, '{', '}')
87
+ })
88
+ }
89
+
90
+ return contracts
91
+ }
92
+
93
+ function check(contract, { kind, condition }) {
94
+ const error = `new ContractError(${JSON.stringify(contract.name)}, "${kind}", ${JSON.stringify(condition)})`
95
+ return `if (!(${condition})) throw ${error};`
96
+ }
97
+
98
+ /*
99
+ * The body runs inside an arrow function so `ensures` can inspect `result`
100
+ * while `this` and `arguments` still refer to the declared function.
101
+ */
102
+ export function transformContracts(source) {
103
+ let result = source
104
+
105
+ for (const contract of findContracts(source).reverse()) {
106
+ const requires = contract.clauses.filter(clause => clause.kind === 'requires').map(clause => check(contract, clause))
107
+ const ensures = contract.clauses.filter(clause => clause.kind === 'ensures').map(clause => check(contract, clause))
108
+
109
+ let open = `{ ${requires.join(' ')}`
110
+ let close = '}'
111
+ if (ensures.length) {
112
+ open += contract.async ? ' const result = await (async () => {' : ' const result = (() => {'
113
+ close = `})(); ${ensures.join(' ')} return result }`
114
+ }
115
+
116
+ result = result.slice(0, contract.bodyClose) + close + result.slice(contract.bodyClose + 1)
117
+ result = result.slice(0, contract.bodyOpen) + open.trimEnd() + result.slice(contract.bodyOpen + 1)
118
+ for (const clause of [...contract.clauses].reverse()) {
119
+ result = result.slice(0, clause.start) + result.slice(clause.end)
120
+ }
121
+ }
122
+
123
+ return result
124
+ }
@@ -0,0 +1,63 @@
1
+ import { explainPipelines } from './pipeline-explain.js'
2
+ import { planPipelines } from './pipeline-plan.js'
3
+ import { findEffectDeclarations, findHandleBlocks } from './effect-parser.js'
4
+ import { findPurityDeclarations } from './purity.js'
5
+ import { findContracts } from './contracts.js'
6
+ import { findTypeDeclarations } from './type-declarations.js'
7
+
8
+ const DESCRIPTION_VERSION = '1'
9
+
10
+ /*
11
+ * One self-describing JSON document for a source file, assembled from the
12
+ * same analyses as `explain`, `plan`, and `check` so they never disagree.
13
+ */
14
+ export function describeSource(source) {
15
+ const plans = planPipelines(source).pipelines
16
+
17
+ return {
18
+ language: 'jojo',
19
+ version: DESCRIPTION_VERSION,
20
+ pipelines: explainPipelines(source).map((explanation, index) => describePipeline(explanation, plans[index])),
21
+ effects: describeEffects(source),
22
+ functions: describeFunctions(source),
23
+ types: findTypeDeclarations(source).map(({ name, variants }) => ({ name, variants }))
24
+ }
25
+ }
26
+
27
+ function describePipeline(explanation, plan) {
28
+ const [source, ...stages] = explanation.stages
29
+ const effects = stages.flatMap(stage => [...stage.effects, ...stage.effectfulFunctions])
30
+
31
+ return {
32
+ name: explanation.name,
33
+ inputs: /^[A-Za-z_$][\w$]*$/.test(source.detail) ? [source.detail] : [],
34
+ effects: [...new Set(effects)],
35
+ stages: stages.map((stage, index) => {
36
+ const { type, id, ...fields } = plan.stages[index + 1]
37
+ return { id: stage.id ?? `node-${stage.index - 1}`, operation: type, ...fields }
38
+ }),
39
+ properties: explanation.properties
40
+ }
41
+ }
42
+
43
+ function describeEffects(source) {
44
+ const handled = new Set(findHandleBlocks(source).flatMap(block => block.handlerNames))
45
+ return findEffectDeclarations(source).map(({ name, params }) => ({ name, params, handled: handled.has(name) }))
46
+ }
47
+
48
+ function describeFunctions(source) {
49
+ const functions = new Map()
50
+ const entry = (name, line) => {
51
+ if (!functions.has(name)) functions.set(name, { name, line, purity: null, requires: [], ensures: [] })
52
+ return functions.get(name)
53
+ }
54
+
55
+ for (const { name, line, purity } of findPurityDeclarations(source)) entry(name, line).purity = purity
56
+ for (const { name, line, requires, ensures } of findContracts(source)) {
57
+ const described = entry(name, line)
58
+ described.requires = requires.map(clause => clause.condition)
59
+ described.ensures = ensures.map(clause => clause.condition)
60
+ }
61
+
62
+ return [...functions.values()].sort((a, b) => a.line - b.line)
63
+ }
@@ -211,11 +211,13 @@ export function transformHandleBlocks(source) {
211
211
 
212
212
  const handlerText = text(result, tokens.slice(target.handlerOpen, target.handlerClose + 1))
213
213
  const bodyText = text(result, tokens.slice(target.bodyOpen, target.bodyClose + 1))
214
- const replacement = `(await withHandlers(${handlerText}, async () => ${bodyText}))`
215
-
216
214
  const start = tokens[target.keywordIndex].start
217
215
  const end = tokens[target.bodyClose].end
218
216
 
217
+ // A statement starting with `(` would otherwise be parsed as a call on the previous line.
218
+ const startsStatement = /(^|\n)[ \t]*$/.test(result.slice(0, start))
219
+ const replacement = `${startsStatement ? ';' : ''}(await withHandlers(${handlerText}, async () => ${bodyText}))`
220
+
219
221
  result = result.slice(0, start) + replacement + result.slice(end)
220
222
  }
221
223
 
@@ -0,0 +1,105 @@
1
+ /*
2
+ * A line-based reindenter, matching the compiler's no-AST design: it only
3
+ * normalizes indentation and whitespace and never rewrites expressions.
4
+ * The VS Code extension ships an identical copy (editors/vscode/src/formatter.mjs).
5
+ *
6
+ * Every open bracket remembers the indent level of the line that opened it,
7
+ * so a line opening several brackets (`reduce((a, b) => {`) indents its
8
+ * contents once. Block comments and template literal text are left
9
+ * untouched. Like the compiler, it is not regex-literal aware.
10
+ */
11
+ const PIPELINE_BLOCK = /^(?:export\s+)?pipeline\s+[A-Za-z_$][\w$]*\s*\{$/
12
+
13
+ function continuationLevels(trimmed, insidePipelineBlock) {
14
+ if (trimmed.startsWith('|>')) return insidePipelineBlock ? 0 : 1
15
+ if (/^(requires|ensures)\b/.test(trimmed)) return 1
16
+ if (/^\|(?![|>])/.test(trimmed)) return 1
17
+ if (/^[?:](?![?:.])/.test(trimmed)) return 1
18
+ if (/^\.(?!\.)/.test(trimmed)) return 1
19
+ return 0
20
+ }
21
+
22
+ export function formatSource(text, options = {}) {
23
+ const indentUnit = options.indent ?? ' '
24
+ const stack = []
25
+ const output = []
26
+ let inBlockComment = false
27
+ let hangingDepth = null
28
+
29
+ const openBrackets = () => stack.filter(entry => entry.ch !== 'TPL')
30
+
31
+ function scanLine(line, level) {
32
+ let i = 0
33
+ while (i < line.length) {
34
+ if (inBlockComment) {
35
+ const end = line.indexOf('*/', i)
36
+ if (end === -1) return
37
+ inBlockComment = false
38
+ i = end + 2
39
+ continue
40
+ }
41
+
42
+ const ch = line[i]
43
+ if (stack.at(-1)?.ch === 'TPL') {
44
+ if (ch === '\\') i += 2
45
+ else if (ch === '`') { stack.pop(); i++ }
46
+ else if (ch === '$' && line[i + 1] === '{') { stack.push({ ch: '{', level }); i += 2 }
47
+ else i++
48
+ continue
49
+ }
50
+
51
+ const two = line.slice(i, i + 2)
52
+ if (two === '//') return
53
+ if (two === '/*') { inBlockComment = true; i += 2; continue }
54
+ if (ch === '`') { stack.push({ ch: 'TPL' }); i++; continue }
55
+
56
+ if (ch === '"' || ch === "'") {
57
+ i++
58
+ while (i < line.length && line[i] !== ch) i += line[i] === '\\' ? 2 : 1
59
+ i++
60
+ continue
61
+ }
62
+
63
+ if ('{[('.includes(ch)) stack.push({ ch, level, pipeline: ch === '{' && PIPELINE_BLOCK.test(line.trim()) })
64
+ else if ('}])'.includes(ch) && stack.length && stack.at(-1).ch !== 'TPL') stack.pop()
65
+ i++
66
+ }
67
+ }
68
+
69
+ for (const rawLine of text.split(/\r\n|\r|\n/)) {
70
+ const trimmed = rawLine.trim()
71
+
72
+ if (trimmed === '') {
73
+ output.push('')
74
+ hangingDepth = null
75
+ continue
76
+ }
77
+
78
+ if (inBlockComment || stack.at(-1)?.ch === 'TPL') {
79
+ output.push(rawLine)
80
+ scanLine(rawLine, 0)
81
+ continue
82
+ }
83
+
84
+ const open = openBrackets()
85
+ const innermost = open.at(-1)
86
+ const startsWithCloser = /^[}\])]/.test(trimmed)
87
+
88
+ let level = innermost ? innermost.level + (startsWithCloser ? 0 : 1) : 0
89
+ if (!startsWithCloser) level += continuationLevels(trimmed, innermost?.pipeline)
90
+ if (hangingDepth === open.length && !startsWithCloser) level += 1
91
+
92
+ output.push(indentUnit.repeat(level) + trimmed)
93
+
94
+ if (startsWithCloser || /\{$/.test(trimmed)) hangingDepth = null
95
+ if (/(:=|=>)$/.test(trimmed)) hangingDepth = open.length
96
+
97
+ scanLine(rawLine, level)
98
+ }
99
+
100
+ const collapsed = output.filter((line, index) => line !== '' || output[index - 1] !== '')
101
+ while (collapsed[0] === '') collapsed.shift()
102
+ while (collapsed.at(-1) === '') collapsed.pop()
103
+
104
+ return collapsed.join('\n') + '\n'
105
+ }
package/src/index.js CHANGED
@@ -25,9 +25,40 @@ export {
25
25
  renderGraphDot
26
26
  } from './pipeline-ir.js'
27
27
 
28
+ export {
29
+ explainPipelines,
30
+ renderExplanationText
31
+ } from './pipeline-explain.js'
32
+
33
+ export {
34
+ planPipelines,
35
+ planToJojo
36
+ } from './pipeline-plan.js'
37
+
28
38
  export {
29
39
  transformEffectDeclarations,
30
40
  transformHandleBlocks,
31
41
  findEffectDeclarations,
32
42
  findHandleBlocks
33
43
  } from './effect-parser.js'
44
+
45
+ export {
46
+ checkSource,
47
+ checkPlan
48
+ } from './check.js'
49
+
50
+ export { describeSource } from './describe.js'
51
+
52
+ export {
53
+ simulatePipelines,
54
+ renderSimulationText
55
+ } from './simulate.js'
56
+
57
+ export {
58
+ whyPipelines,
59
+ renderWhyText
60
+ } from './why.js'
61
+
62
+ export { formatSource } from './formatter.js'
63
+
64
+ export { parseSource } from './parse.js'