@panagos/jojoscript 0.7.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.
@@ -0,0 +1,169 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import {
5
+ transformPipelines,
6
+ findPipelineStdlibFunctions
7
+ } from './pipeline-parser.js'
8
+
9
+ import {
10
+ transformMutableDeclarations,
11
+ transformConstDeclarations,
12
+ transformMultiBindingDeclarations,
13
+ transformDestructuringDeclarations,
14
+ transformFunctionDeclarations,
15
+ transformExpressionFunctions,
16
+ transformLocalImports
17
+ } from './transforms.js'
18
+
19
+ import { transformMatchExpressions } from './match-parser.js'
20
+ import { transformTypeAnnotations } from './type-annotations.js'
21
+
22
+ const JOJO_RUNTIME = 'jojoscript/runtime'
23
+
24
+ export function compile(source) {
25
+ let result = source
26
+
27
+ /*
28
+ * Optional type annotations, stripped and re-expressed as JSDoc before
29
+ * anything else touches `fn` declarations.
30
+ */
31
+ result = transformTypeAnnotations(result)
32
+
33
+ /*
34
+ * Jojo functions.
35
+ */
36
+ result = transformExpressionFunctions(result)
37
+ result = transformFunctionDeclarations(result)
38
+
39
+ /*
40
+ * `match` expressions.
41
+ */
42
+ result = transformMatchExpressions(result)
43
+
44
+ /*
45
+ * Jojo declarations.
46
+ */
47
+ result = transformMultiBindingDeclarations(result)
48
+ result = transformDestructuringDeclarations(result)
49
+ result = transformMutableDeclarations(result)
50
+ result = transformConstDeclarations(result)
51
+
52
+ /*
53
+ * Remember which pipeline stages need the Jojo runtime before
54
+ * the pipeline syntax itself is removed.
55
+ */
56
+ const pipelineStdlibFunctions =
57
+ findPipelineStdlibFunctions(result)
58
+
59
+ /*
60
+ * Pipelines.
61
+ */
62
+ result = transformPipelines(result)
63
+
64
+ /*
65
+ * ES module imports.
66
+ */
67
+ result = transformLocalImports(result)
68
+
69
+ /*
70
+ * Generated JavaScript imports the runtime as a normal package.
71
+ * This makes compiled applications portable and avoids globals. If the
72
+ * source already has a manual `jojoscript/runtime` import (e.g. for a
73
+ * stdlib function that is only ever called directly, never through a
74
+ * pipeline stage), the auto-imported names are merged into it instead
75
+ * of creating a second, colliding import declaration.
76
+ */
77
+ if (pipelineStdlibFunctions.length) {
78
+ result = mergeRuntimeImport(result, pipelineStdlibFunctions)
79
+ }
80
+
81
+ return result.endsWith('\n') ? result : result + '\n'
82
+ }
83
+
84
+ const RUNTIME_IMPORT_PATTERN = /import\s*\{([^}]*)\}\s*from\s*(["'])jojoscript\/runtime\2/
85
+
86
+ function mergeRuntimeImport(source, stdlibFunctions) {
87
+ const existing = source.match(RUNTIME_IMPORT_PATTERN)
88
+
89
+ if (!existing) {
90
+ return `import { ${stdlibFunctions.join(', ')} } from "${JOJO_RUNTIME}"\n` + source
91
+ }
92
+
93
+ const existingNames = existing[1].split(',').map(name => name.trim()).filter(Boolean)
94
+ const merged = [...new Set([...existingNames, ...stdlibFunctions])]
95
+
96
+ return source.replace(
97
+ RUNTIME_IMPORT_PATTERN,
98
+ `import { ${merged.join(', ')} } from "${JOJO_RUNTIME}"`
99
+ )
100
+ }
101
+
102
+ export function compileFile(input, outDir = null) {
103
+ const src = path.resolve(input)
104
+ const dir = outDir
105
+ ? path.resolve(outDir)
106
+ : path.join(path.dirname(src), 'dist')
107
+
108
+ fs.mkdirSync(dir, { recursive: true })
109
+
110
+ const out = path.join(
111
+ dir,
112
+ path.basename(src, path.extname(src)) + '.js'
113
+ )
114
+
115
+ fs.writeFileSync(
116
+ out,
117
+ compile(fs.readFileSync(src, 'utf8')),
118
+ 'utf8'
119
+ )
120
+
121
+ return { input: src, output: out }
122
+ }
123
+
124
+ export function compileDirectory(inputDir, outDir = null) {
125
+ const root = path.resolve(inputDir)
126
+ const target = outDir
127
+ ? path.resolve(outDir)
128
+ : path.join(root, 'dist')
129
+
130
+ const result = []
131
+
132
+ function walk(dir) {
133
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
134
+ if (
135
+ entry.name === 'node_modules' ||
136
+ entry.name === 'dist' ||
137
+ entry.name.startsWith('.')
138
+ ) {
139
+ continue
140
+ }
141
+
142
+ const full = path.join(dir, entry.name)
143
+
144
+ if (entry.isDirectory()) {
145
+ walk(full)
146
+ continue
147
+ }
148
+
149
+ if (!entry.name.endsWith('.jojo')) continue
150
+
151
+ const rel =
152
+ path.relative(root, full).slice(0, -'.jojo'.length) + '.js'
153
+
154
+ const out = path.join(target, rel)
155
+
156
+ fs.mkdirSync(path.dirname(out), { recursive: true })
157
+ fs.writeFileSync(
158
+ out,
159
+ compile(fs.readFileSync(full, 'utf8')),
160
+ 'utf8'
161
+ )
162
+
163
+ result.push({ input: full, output: out })
164
+ }
165
+ }
166
+
167
+ walk(root)
168
+ return result
169
+ }
package/src/index.js ADDED
@@ -0,0 +1,19 @@
1
+ export {
2
+ compile,
3
+ compileFile,
4
+ compileDirectory
5
+ } from './compiler.js'
6
+
7
+ export {
8
+ transformPipelines,
9
+ emitPipeline,
10
+ findPipelineStdlibFunctions
11
+ } from './pipeline-parser.js'
12
+
13
+ export {
14
+ transformMatchExpressions
15
+ } from './match-parser.js'
16
+
17
+ export {
18
+ transformTypeAnnotations
19
+ } from './type-annotations.js'
package/src/lexer.js ADDED
@@ -0,0 +1,178 @@
1
+ export function tokenize(source) {
2
+ const tokens = []
3
+ let i = 0
4
+
5
+ while (i < source.length) {
6
+ const start = i
7
+ const c = source[i]
8
+
9
+ if (/\s/.test(c)) {
10
+ i++
11
+ continue
12
+ }
13
+
14
+ if (source.startsWith('//', i)) {
15
+ i += 2
16
+ while (i < source.length && source[i] !== '\n') i++
17
+ continue
18
+ }
19
+
20
+ if (source.startsWith('/*', i)) {
21
+ i += 2
22
+ while (i < source.length && !source.startsWith('*/', i)) i++
23
+ if (i < source.length) i += 2
24
+ continue
25
+ }
26
+
27
+ if (c === '"' || c === "'" || c === '`') {
28
+ const quote = c
29
+ i++
30
+ let templateExpressionDepth = 0
31
+ while (i < source.length) {
32
+ if (source[i] === '\\') {
33
+ i += 2
34
+ continue
35
+ }
36
+ if (quote === '`' && source.startsWith('${', i)) {
37
+ templateExpressionDepth++
38
+ i += 2
39
+ continue
40
+ }
41
+ if (quote === '`' && source[i] === '}' && templateExpressionDepth > 0) {
42
+ templateExpressionDepth--
43
+ i++
44
+ continue
45
+ }
46
+ if (source[i] === quote && templateExpressionDepth === 0) {
47
+ i++
48
+ break
49
+ }
50
+ i++
51
+ }
52
+ tokens.push({ type: 'string', value: source.slice(start, i), start, end: i })
53
+ continue
54
+ }
55
+
56
+ if (/[A-Za-z_$]/.test(c)) {
57
+ i++
58
+ while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) i++
59
+ tokens.push({ type: 'identifier', value: source.slice(start, i), start, end: i })
60
+ continue
61
+ }
62
+
63
+ if (/[0-9]/.test(c)) {
64
+ i++
65
+ while (i < source.length && /[A-Za-z0-9_.$]/.test(source[i])) i++
66
+ tokens.push({ type: 'number', value: source.slice(start, i), start, end: i })
67
+ continue
68
+ }
69
+
70
+ const operators = [
71
+ '>>>=' , '===', '!==', '**=', '&&=', '||=', '??=',
72
+ '=>', '|>', '?.', '??', '==', '!=', '<=', '>=',
73
+ '&&', '||', '**', '++', '--', '+=', '-=', '*=', '/=',
74
+ '%=', '...', '<<', '>>', '>>>', '?.'
75
+ ]
76
+
77
+ const op = operators.find(value => source.startsWith(value, i))
78
+ if (op) {
79
+ tokens.push({
80
+ type: op === '|>' ? 'pipeline' : 'operator',
81
+ value: op,
82
+ start,
83
+ end: i + op.length
84
+ })
85
+ i += op.length
86
+ continue
87
+ }
88
+
89
+ tokens.push({ type: 'punctuation', value: c, start, end: i + 1 })
90
+ i++
91
+ }
92
+
93
+ return tokens
94
+ }
95
+
96
+ export function significantTokens(tokens) {
97
+ return tokens
98
+ }
99
+
100
+ /*
101
+ * Produces a same-length copy of `source` where the contents of comments
102
+ * and/or string/template literals are replaced with blank spaces (newlines
103
+ * are preserved so line-based/anchored regexes keep working).
104
+ *
105
+ * This lets regex-based transforms and character scanners check "is this
106
+ * position real code?" by comparing against the mask, without having to
107
+ * duplicate string/comment-parsing logic everywhere.
108
+ */
109
+ export function maskNonCode(source, options = {}) {
110
+ const maskStrings = options.strings !== false
111
+ const maskComments = options.comments !== false
112
+
113
+ let mask = ''
114
+ let i = 0
115
+
116
+ while (i < source.length) {
117
+ if (source.startsWith('//', i)) {
118
+ const start = i
119
+ while (i < source.length && source[i] !== '\n') i++
120
+ mask += maskComments ? blank(source, start, i) : source.slice(start, i)
121
+ continue
122
+ }
123
+
124
+ if (source.startsWith('/*', i)) {
125
+ const start = i
126
+ i += 2
127
+ while (i < source.length && !source.startsWith('*/', i)) i++
128
+ if (i < source.length) i += 2
129
+ mask += maskComments ? blank(source, start, i) : source.slice(start, i)
130
+ continue
131
+ }
132
+
133
+ const c = source[i]
134
+
135
+ if (c === '"' || c === "'" || c === '`') {
136
+ const quote = c
137
+ const start = i
138
+ i++
139
+ let templateExpressionDepth = 0
140
+ while (i < source.length) {
141
+ if (source[i] === '\\') {
142
+ i += 2
143
+ continue
144
+ }
145
+ if (quote === '`' && source.startsWith('${', i)) {
146
+ templateExpressionDepth++
147
+ i += 2
148
+ continue
149
+ }
150
+ if (quote === '`' && source[i] === '}' && templateExpressionDepth > 0) {
151
+ templateExpressionDepth--
152
+ i++
153
+ continue
154
+ }
155
+ if (source[i] === quote && templateExpressionDepth === 0) {
156
+ i++
157
+ break
158
+ }
159
+ i++
160
+ }
161
+ mask += maskStrings ? blank(source, start, i) : source.slice(start, i)
162
+ continue
163
+ }
164
+
165
+ mask += c
166
+ i++
167
+ }
168
+
169
+ return mask
170
+ }
171
+
172
+ function blank(source, start, end) {
173
+ let result = ''
174
+ for (let i = start; i < end; i++) {
175
+ result += source[i] === '\n' ? '\n' : ' '
176
+ }
177
+ return result
178
+ }
@@ -0,0 +1,215 @@
1
+ import { tokenize, significantTokens } from './lexer.js'
2
+
3
+ /*
4
+ * `match` compiles a value-equality expression construct:
5
+ *
6
+ * match status {
7
+ * "ok" => handleOk(),
8
+ * "error" => handleError(),
9
+ * _ => handleUnknown()
10
+ * }
11
+ *
12
+ * into an immediately-invoked arrow function that compares the subject
13
+ * against each pattern with `===`, in order, falling through to the
14
+ * mandatory default `_` arm:
15
+ *
16
+ * (() => {
17
+ * const __match = status;
18
+ * if (__match === ("ok")) return handleOk();
19
+ * if (__match === ("error")) return handleError();
20
+ * return handleUnknown();
21
+ * })()
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.
27
+ */
28
+
29
+ function text(source, tokens) {
30
+ if (!tokens.length) return ''
31
+ return source.slice(tokens[0].start, tokens[tokens.length - 1].end)
32
+ }
33
+
34
+ function splitTopLevelCommas(tokens) {
35
+ const result = []
36
+ let start = 0
37
+ let depth = 0
38
+
39
+ for (let i = 0; i < tokens.length; i++) {
40
+ const value = tokens[i].value
41
+ if (value === '(' || value === '[' || value === '{') depth++
42
+ else if (value === ')' || value === ']' || value === '}') depth--
43
+ else if (value === ',' && depth === 0) {
44
+ result.push(tokens.slice(start, i))
45
+ start = i + 1
46
+ }
47
+ }
48
+
49
+ if (start < tokens.length) result.push(tokens.slice(start))
50
+ return result
51
+ }
52
+
53
+ /*
54
+ * Distinguishes the `match` keyword from ordinary identifiers named
55
+ * "match" (e.g. `string.match(regex)`, `const match = ...`, a `match`
56
+ * loop variable, or a `match:` object key).
57
+ */
58
+ function isMatchKeyword(tokens, index) {
59
+ const token = tokens[index]
60
+ if (token.type !== 'identifier' || token.value !== 'match') return false
61
+
62
+ const prev = tokens[index - 1]
63
+ if (prev && (prev.value === '.' || prev.value === 'function' || prev.value === 'class')) {
64
+ return false
65
+ }
66
+
67
+ const next = tokens[index + 1]
68
+ if (!next) return false
69
+ if (['(', ':', '=', '.', ',', ')', ']', '}'].includes(next.value)) return false
70
+
71
+ return true
72
+ }
73
+
74
+ /*
75
+ * Scans forward from a candidate `match` keyword looking for the subject
76
+ * expression followed by a top-level `{`. Bounded defensively: if the
77
+ * scan leaves the enclosing expression (unbalanced closing bracket) or
78
+ * runs too far without finding `{`, this is not a match construct.
79
+ */
80
+ function findMatchBlock(source, tokens, index) {
81
+ let depth = 0
82
+ let braceOpen = -1
83
+
84
+ const limit = Math.min(tokens.length, index + 60)
85
+
86
+ for (let i = index + 1; i < limit; i++) {
87
+ const value = tokens[i].value
88
+ const prevToken = tokens[i - 1]
89
+
90
+ if (prevToken) {
91
+ const gap = source.slice(prevToken.end, tokens[i].start)
92
+ if (depth === 0 && (gap.match(/\n/g) || []).length > 1) return null
93
+ }
94
+
95
+ if (value === '(' || value === '[') { depth++; continue }
96
+ if (value === ')' || value === ']') {
97
+ depth--
98
+ if (depth < 0) return null
99
+ continue
100
+ }
101
+ if (value === ';' && depth === 0) return null
102
+ if (value === '{' && depth === 0) { braceOpen = i; break }
103
+ }
104
+
105
+ if (braceOpen === -1) return null
106
+
107
+ const subjectTokens = tokens.slice(index + 1, braceOpen)
108
+ if (!subjectTokens.length) return null
109
+
110
+ let braceDepth = 0
111
+ let braceClose = -1
112
+
113
+ for (let j = braceOpen; j < tokens.length; j++) {
114
+ if (tokens[j].value === '{') braceDepth++
115
+ else if (tokens[j].value === '}') {
116
+ braceDepth--
117
+ if (braceDepth === 0) { braceClose = j; break }
118
+ }
119
+ }
120
+
121
+ if (braceClose === -1) {
122
+ throw new SyntaxError(`Unclosed match block:\n${text(source, tokens.slice(index, braceOpen + 1))}`)
123
+ }
124
+
125
+ return { subjectTokens, braceOpen, braceClose }
126
+ }
127
+
128
+ function parseArms(source, tokens) {
129
+ const groups = splitTopLevelCommas(tokens).filter(group => group.length)
130
+
131
+ if (!groups.length) {
132
+ throw new SyntaxError('match requires at least one arm')
133
+ }
134
+
135
+ return groups.map(group => {
136
+ let depth = 0
137
+ let arrowIndex = -1
138
+
139
+ for (let i = 0; i < group.length; i++) {
140
+ const value = group[i].value
141
+ if (value === '(' || value === '[' || value === '{') depth++
142
+ else if (value === ')' || value === ']' || value === '}') depth--
143
+ else if (value === '=>' && depth === 0) { arrowIndex = i; break }
144
+ }
145
+
146
+ if (arrowIndex === -1) {
147
+ throw new SyntaxError(`Invalid match arm, expected \`pattern => expression\`:\n${text(source, group)}`)
148
+ }
149
+
150
+ const pattern = text(source, group.slice(0, arrowIndex)).trim()
151
+ const expression = text(source, group.slice(arrowIndex + 1)).trim()
152
+
153
+ if (!pattern || !expression) {
154
+ throw new SyntaxError(`Invalid match arm, expected \`pattern => expression\`:\n${text(source, group)}`)
155
+ }
156
+
157
+ return { pattern, expression }
158
+ })
159
+ }
160
+
161
+ function emitMatch(subject, arms) {
162
+ const defaultIndex = arms.findIndex(arm => arm.pattern === '_')
163
+
164
+ if (defaultIndex === -1) {
165
+ throw new SyntaxError('match requires a default `_` arm')
166
+ }
167
+
168
+ if (defaultIndex !== arms.length - 1) {
169
+ throw new SyntaxError('the default `_` arm must be the last arm in match')
170
+ }
171
+
172
+ const lines = arms.map(arm =>
173
+ arm.pattern === '_'
174
+ ? ` return ${arm.expression};`
175
+ : ` if (__match === (${arm.pattern})) return ${arm.expression};`
176
+ )
177
+
178
+ return `(() => {\n const __match = ${subject};\n${lines.join('\n')}\n})()`
179
+ }
180
+
181
+ export function transformMatchExpressions(source) {
182
+ let result = source
183
+
184
+ for (let iteration = 0; iteration < 1000; iteration++) {
185
+ const tokens = significantTokens(tokenize(result))
186
+
187
+ let matchIndex = -1
188
+ let block = null
189
+
190
+ for (let i = tokens.length - 1; i >= 0; i--) {
191
+ if (!isMatchKeyword(tokens, i)) continue
192
+
193
+ const candidate = findMatchBlock(result, tokens, i)
194
+ if (candidate) {
195
+ matchIndex = i
196
+ block = candidate
197
+ break
198
+ }
199
+ }
200
+
201
+ if (matchIndex === -1) return result
202
+
203
+ const subject = text(result, block.subjectTokens).trim()
204
+ const bodyTokens = tokens.slice(block.braceOpen + 1, block.braceClose)
205
+ const arms = parseArms(result, bodyTokens)
206
+ const replacement = emitMatch(subject, arms)
207
+
208
+ const start = tokens[matchIndex].start
209
+ const end = tokens[block.braceClose].end
210
+
211
+ result = result.slice(0, start) + replacement + result.slice(end)
212
+ }
213
+
214
+ throw new SyntaxError('match transformation exceeded 1000 iterations')
215
+ }