onejs-sl 0.1.5 → 0.1.7
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/CHANGELOG.md +13 -0
- package/README.md +21 -7
- package/package.json +1 -1
- package/src/index.ts +2 -2
- package/src/lang/README.md +13 -0
- package/src/lang/check.ts +108 -52
- package/src/lang/index.ts +37 -1
- package/src/lang/lower.ts +73 -25
- package/src/lang/parser.ts +83 -28
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.7
|
|
4
|
+
|
|
5
|
+
`diagnose` finds a mistake inside a refused call's arguments and on both sides of an operator, found by the Play editor's first run in a real browser. Nothing a program compiles to changes.
|
|
6
|
+
|
|
7
|
+
- `fract(uv * wrap)` reports both `fract` and `wrap`
|
|
8
|
+
|
|
9
|
+
## 0.1.6
|
|
10
|
+
|
|
11
|
+
`diagnose(source)` returns every error in a file, for an editor checking as it is typed (`Specs/SL_NEXT.md` 5). Nothing a program compiles to changes.
|
|
12
|
+
|
|
13
|
+
- `diagnose` finds a mistake in each statement and declaration, in source order, and never throws one
|
|
14
|
+
- A refused local is still declared, so its uses are not reported again
|
|
15
|
+
|
|
3
16
|
## 0.1.5
|
|
4
17
|
|
|
5
18
|
What an editor needs, and errors that fix themselves (`Specs/SL_NEXT.md` 5 and 9). Every program that compiled before compiles to exactly what it did, unless it names something after a keyword or a type.
|
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ only what it calls.
|
|
|
35
35
|
|
|
36
36
|
| Entry | Contents |
|
|
37
37
|
|---|---|
|
|
38
|
-
| `onejs-sl` | `parse` and `analyze`, `classify` (every token with its class, comments kept, never throws), the TypeScript form `sl`, the IR types, `SL_IR_VERSION`, `toJSON`/`fromJSON`, `SLParseError` (file, line, column, offset, length, the bare `text`, and a `fix` where one is certain) |
|
|
38
|
+
| `onejs-sl` | `parse` and `analyze`, `diagnose` (every error in a file, not just the first), `classify` (every token with its class, comments kept, never throws), the TypeScript form `sl`, the IR types, `SL_IR_VERSION`, `toJSON`/`fromJSON`, `SLParseError` (file, line, column, offset, length, the bare `text`, and a `fix` where one is certain) |
|
|
39
39
|
| `onejs-sl/core` | the same without the parser: what a game needs at run time |
|
|
40
40
|
| `onejs-sl/tables` | `BUILTINS`, `SL_HLSL`, `INPUTS`, `SL_SDF_SHAPES`, `SL_SDF_PARAMS`, `SL_KEYWORDS`, `SL_TYPES`, `TYPE_WIDTH`, `PRELUDE_NAMES`: what completion and highlighting read. `BUILTIN_PARAMS`, `SL_SDF_PARAM_NAMES` and `LIB_SIGNATURES` name every parameter, so an editor can show `lerp(x, y, s)`, and `BUILTIN_DOCS`, `INPUT_DOCS` and `PRELUDE_DOCS` give each a line for its tooltip |
|
|
41
41
|
| `onejs-sl/limits` | `vmFit(program)`: whether the VM runs it, and why not, without encoding |
|
|
@@ -159,11 +159,25 @@ and QuickJS (Magerie). Two checks hold it to that:
|
|
|
159
159
|
|
|
160
160
|
Push a tag `v<version>` matching `package.json`; `.github/workflows/publish.yml`
|
|
161
161
|
publishes through npm trusted publishing (OIDC), so no token exists anywhere.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
162
|
+
Before the tag:
|
|
163
|
+
|
|
164
|
+
1. Note the release in `CHANGELOG.md` and bump `package.json`.
|
|
165
|
+
2. Prove the corpus: a program that compiled before compiles to the same bytes,
|
|
166
|
+
unless the release says otherwise.
|
|
167
|
+
3. Run PlaySite's checks in the container, where the Play editor bundles this
|
|
168
|
+
package: `node scripts/gen-sl-parser.mjs`, then `npx vitest run`, in
|
|
169
|
+
`PlaySite/`. It is a private repo, so no workflow here can.
|
|
170
|
+
4. Push `main` and wait for CI, whose `consumers` job runs onejs-unity's
|
|
171
|
+
typecheck and tests against the commit (`consumers.yml`). `publish.yml`
|
|
172
|
+
runs the same job and publishes nothing if it fails. When a consumer has to
|
|
173
|
+
follow a change, land it here, fix the consumer, then re-run and tag.
|
|
174
|
+
|
|
175
|
+
The one exception to publishing by tag is the first version: npm attaches a
|
|
176
|
+
trusted publisher to a package that already exists, so 0.1.0 was published by
|
|
177
|
+
hand, and the workflow checks its tag and publishes nothing. In the OneJS
|
|
178
|
+
container this package is checked out at `JSModules/onejs-sl`, and
|
|
179
|
+
`onejs-unity` links it with `file:../onejs-sl` as a dev dependency beside its
|
|
180
|
+
`^` peer range.
|
|
167
181
|
|
|
168
182
|
## The one idea
|
|
169
183
|
|
|
@@ -179,7 +193,7 @@ generated HLSL after an eject, with no edit in between. Both backends exist:
|
|
|
179
193
|
and `hlsl.ts` feeds its `Editor/SLShaderGenerator.cs`. Nobody writes a manifest for the
|
|
180
194
|
second: an editor that interprets a program asks the encoded program for its
|
|
181
195
|
`hlsl` (a lazy getter, never read in Play), records it into
|
|
182
|
-
`Assets/OneJS
|
|
196
|
+
`Assets/OneJS/Recorded.sl.json`, generates the shader and
|
|
183
197
|
moves the live material onto it. `manifest()` is still there for an app that
|
|
184
198
|
would rather write its programs out at build time.
|
|
185
199
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onejs-sl",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "The OneJS shader language: a small typed language for per pixel programs, compiled to a portable IR and emitted as HLSL, WGSL, GLSL ES or a VM buffer",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* HLSL, WGSL or GLSL ES) is a separate entry, so a host bundles only what it
|
|
7
7
|
* calls:
|
|
8
8
|
*
|
|
9
|
-
* onejs-sl this: parse, classify, the TypeScript form, the IR, its JSON
|
|
9
|
+
* onejs-sl this: parse, diagnose, classify, the TypeScript form, the IR, its JSON
|
|
10
10
|
* onejs-sl/core the same without the parser, for a game at run time
|
|
11
11
|
* onejs-sl/tables builtins, opcodes, inputs, shapes: what an editor reads
|
|
12
12
|
* onejs-sl/limits whether a program fits the VM, without encoding it
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
export * from "./core"
|
|
21
21
|
export {
|
|
22
|
-
analyze, classify, parse, parseUnit, preludeFunctions, tokenize, PRELUDE_SOURCE, SLParseError,
|
|
22
|
+
analyze, classify, diagnose, parse, parseUnit, preludeFunctions, tokenize, PRELUDE_SOURCE, SLParseError,
|
|
23
23
|
} from "./lang"
|
|
24
24
|
export type {
|
|
25
25
|
Checked, Expr, FuncDecl, ParseOptions, Pos, SLClassifiedToken, SLFix, SLTokenClass, Stmt, Token, Unit,
|
package/src/lang/README.md
CHANGED
|
@@ -186,6 +186,19 @@ and `%` truncates, and neither does `int`, since float would change what
|
|
|
186
186
|
dividing it does. That is Decision 1 kept as `Specs/SL_NEXT.md` 5 A has it:
|
|
187
187
|
one spelling, and every hint a fix.
|
|
188
188
|
|
|
189
|
+
## Several errors at once
|
|
190
|
+
|
|
191
|
+
`parse` throws the first error; `diagnose(source)` returns all of them, in
|
|
192
|
+
source order, and never throws one, for an editor checking as the file is
|
|
193
|
+
typed. Each stage carries on past a mistake: the parser skips to the next
|
|
194
|
+
statement or declaration, the checker and the lowering move to the next one,
|
|
195
|
+
and a local whose line was refused is still declared, as a zero of its type, so
|
|
196
|
+
its uses are not reported again. A later stage runs only when the earlier ones
|
|
197
|
+
found nothing: names are not checked in a file that cannot be read, since those
|
|
198
|
+
errors would be about the hole. `diagnose.test.ts` holds it to the property that
|
|
199
|
+
matters, that whatever `parse` refuses a file with, `diagnose` reports at the
|
|
200
|
+
same place, over every program one token short of a real one.
|
|
201
|
+
|
|
189
202
|
## For an editor
|
|
190
203
|
|
|
191
204
|
`classify(source)` is every token with its class (keyword, type, builtin,
|
package/src/lang/check.ts
CHANGED
|
@@ -69,6 +69,8 @@ export interface Checked {
|
|
|
69
69
|
export interface CheckOptions {
|
|
70
70
|
/** Off for the prelude, which is functions only. See `parseUnit`. */
|
|
71
71
|
requireMain?: boolean
|
|
72
|
+
/** Where to collect errors instead of throwing the first. See `diagnose`. */
|
|
73
|
+
errors?: SLParseError[]
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {}): Checked {
|
|
@@ -82,6 +84,23 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
|
|
|
82
84
|
throw new SLParseError(message, file, pos, length, fix)
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
/**
|
|
88
|
+
* One declaration's or one statement's checks. Collecting, an error is
|
|
89
|
+
* recorded and the next one is checked; the name it declares is still
|
|
90
|
+
* declared, so a later use of it is not a second, false error.
|
|
91
|
+
*/
|
|
92
|
+
const attempt = (check: () => void): boolean => {
|
|
93
|
+
if (options.errors === undefined) { check(); return true }
|
|
94
|
+
try {
|
|
95
|
+
check()
|
|
96
|
+
return true
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (!(e instanceof SLParseError)) throw e
|
|
99
|
+
options.errors.push(e)
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
85
104
|
const funcs = new Map<string, FuncDecl>()
|
|
86
105
|
for (const fn of prelude) funcs.set(fn.name, fn)
|
|
87
106
|
|
|
@@ -126,11 +145,14 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
|
|
|
126
145
|
}
|
|
127
146
|
|
|
128
147
|
for (const u of unit.uniforms) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
attempt(() => {
|
|
149
|
+
const clash = valueClash(u.name)
|
|
150
|
+
if (clash !== null) fail(`"${u.name}" already names ${clash}`, u.pos, u.name.length)
|
|
151
|
+
})
|
|
152
|
+
if (!uniforms.has(u.name)) uniforms.set(u.name, u)
|
|
132
153
|
}
|
|
133
|
-
|
|
154
|
+
attempt(() => {
|
|
155
|
+
if (uniforms.size <= VM_UNIFORMS) return
|
|
134
156
|
const over = unit.uniforms[VM_UNIFORMS]!
|
|
135
157
|
fail(
|
|
136
158
|
`this file declares ${uniforms.size} uniforms and a program may hold ${VM_UNIFORMS}. ` +
|
|
@@ -139,14 +161,17 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
|
|
|
139
161
|
`a float4.`,
|
|
140
162
|
over.pos, over.name.length,
|
|
141
163
|
)
|
|
142
|
-
}
|
|
164
|
+
})
|
|
143
165
|
|
|
144
166
|
for (const t of unit.textures) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
167
|
+
attempt(() => {
|
|
168
|
+
const clash = valueClash(t.name)
|
|
169
|
+
if (clash !== null) fail(`"${t.name}" already names ${clash}`, t.pos, t.name.length)
|
|
170
|
+
})
|
|
171
|
+
if (!textures.has(t.name)) textures.set(t.name, t)
|
|
148
172
|
}
|
|
149
|
-
|
|
173
|
+
attempt(() => {
|
|
174
|
+
if (textures.size <= VM_TEXTURES) return
|
|
150
175
|
const over = unit.textures[VM_TEXTURES]!
|
|
151
176
|
fail(
|
|
152
177
|
`this file declares ${textures.size} textures and a program may sample ${VM_TEXTURES}. ` +
|
|
@@ -154,24 +179,28 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
|
|
|
154
179
|
`and its own after an eject, which is two different pictures from one file.`,
|
|
155
180
|
over.pos, over.name.length,
|
|
156
181
|
)
|
|
157
|
-
}
|
|
182
|
+
})
|
|
158
183
|
|
|
159
184
|
for (const c of unit.consts) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
185
|
+
attempt(() => {
|
|
186
|
+
const clash = valueClash(c.name)
|
|
187
|
+
if (clash !== null) fail(`"${c.name}" already names ${clash}`, c.pos, c.name.length)
|
|
188
|
+
})
|
|
189
|
+
if (!consts.has(c.name)) consts.set(c.name, c)
|
|
163
190
|
}
|
|
164
191
|
|
|
165
192
|
for (const fn of unit.funcs) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
193
|
+
attempt(() => {
|
|
194
|
+
const clash = taken(fn.name)
|
|
195
|
+
if (clash !== null) fail(`"${fn.name}" already names ${clash}`, fn.pos, fn.name.length)
|
|
196
|
+
if (unit.funcs.filter((f) => f.name === fn.name).length > 1) {
|
|
197
|
+
fail(
|
|
198
|
+
`this file declares ${fn.name} more than once. There is no overloading: a function ` +
|
|
199
|
+
`inlines, so two bodies under one name have nothing to pick between them`,
|
|
200
|
+
fn.pos, fn.name.length,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
})
|
|
175
204
|
funcs.set(fn.name, fn)
|
|
176
205
|
}
|
|
177
206
|
|
|
@@ -179,19 +208,23 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
|
|
|
179
208
|
if (main === null) {
|
|
180
209
|
if (options.requireMain ?? true) throw new Error("check() reached a unit with no main")
|
|
181
210
|
for (const fn of unit.funcs) checkFunction(fn)
|
|
182
|
-
refuseRecursion
|
|
211
|
+
attempt(refuseRecursion)
|
|
183
212
|
return { unit, funcs, uniforms, textures, consts }
|
|
184
213
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
214
|
+
attempt(() => {
|
|
215
|
+
if (main.ret !== "float4") {
|
|
216
|
+
fail(`main returns a colour, so it is declared \`float4 main()\`, not ${main.ret}`, main.pos)
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
attempt(() => {
|
|
220
|
+
if (main.params.length > 0) {
|
|
221
|
+
fail(
|
|
222
|
+
"main takes no parameters: what a program is given are the free identifiers uv, " +
|
|
223
|
+
"fragCoord, resolution, time and aspect",
|
|
224
|
+
main.params[0]!.pos,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
195
228
|
|
|
196
229
|
// MARK: bodies
|
|
197
230
|
|
|
@@ -201,7 +234,7 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
|
|
|
201
234
|
// which is not the author's mistake and not a name they can see.
|
|
202
235
|
for (const fn of [...unit.funcs, main]) checkFunction(fn)
|
|
203
236
|
|
|
204
|
-
refuseRecursion
|
|
237
|
+
attempt(refuseRecursion)
|
|
205
238
|
|
|
206
239
|
/**
|
|
207
240
|
* A function inlines, so a cycle is not slow, it is infinite.
|
|
@@ -272,9 +305,11 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
|
|
|
272
305
|
function checkFunction(fn: FuncDecl): void {
|
|
273
306
|
const params = new Set<string>()
|
|
274
307
|
for (const p of fn.params) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
308
|
+
attempt(() => {
|
|
309
|
+
const clash = valueClash(p.name)
|
|
310
|
+
if (clash !== null) fail(`"${p.name}" already names ${clash}`, p.pos, p.name.length)
|
|
311
|
+
if (params.has(p.name)) fail(`${fn.name} already has a parameter called "${p.name}"`, p.pos)
|
|
312
|
+
})
|
|
278
313
|
params.add(p.name)
|
|
279
314
|
}
|
|
280
315
|
checkBody(fn, fn.body, new Set(params), true)
|
|
@@ -291,8 +326,20 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
|
|
|
291
326
|
let returned: Stmt | null = null
|
|
292
327
|
for (const s of body) {
|
|
293
328
|
if (returned !== null) {
|
|
294
|
-
|
|
329
|
+
// Once: everything after it is the same mistake.
|
|
330
|
+
attempt(() => fail("this is after the return, so it can never run", s.pos))
|
|
331
|
+
break
|
|
295
332
|
}
|
|
333
|
+
attempt(() => checkStmt(s))
|
|
334
|
+
if (s.k === "var" || s.k === "const") scope.add(s.name)
|
|
335
|
+
// Only the outermost return ends a body; one inside an if is refused as that.
|
|
336
|
+
if (s.k === "return" && outermost) returned = s
|
|
337
|
+
}
|
|
338
|
+
if (outermost && returned === null) {
|
|
339
|
+
attempt(() => fail(`${fn.name} never returns a ${fn.ret}`, fn.pos, fn.name.length))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function checkStmt(s: Stmt): void {
|
|
296
343
|
switch (s.k) {
|
|
297
344
|
case "var":
|
|
298
345
|
case "const": {
|
|
@@ -300,7 +347,6 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
|
|
|
300
347
|
if (clash !== null) fail(`"${s.name}" already names ${clash}`, s.pos, s.name.length)
|
|
301
348
|
if (scope.has(s.name)) fail(`"${s.name}" is already declared in this body`, s.pos)
|
|
302
349
|
checkExpr(fn, s.init, scope)
|
|
303
|
-
scope.add(s.name)
|
|
304
350
|
break
|
|
305
351
|
}
|
|
306
352
|
case "assign": {
|
|
@@ -347,8 +393,11 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
|
|
|
347
393
|
const inner = new Set(scope)
|
|
348
394
|
inner.add(s.counter)
|
|
349
395
|
counters.add(s.counter)
|
|
350
|
-
|
|
351
|
-
|
|
396
|
+
try {
|
|
397
|
+
checkBody(fn, s.body, inner, false)
|
|
398
|
+
} finally {
|
|
399
|
+
counters.delete(s.counter)
|
|
400
|
+
}
|
|
352
401
|
break
|
|
353
402
|
}
|
|
354
403
|
case "return":
|
|
@@ -361,13 +410,9 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
|
|
|
361
410
|
)
|
|
362
411
|
}
|
|
363
412
|
checkExpr(fn, s.value, scope)
|
|
364
|
-
returned = s
|
|
365
413
|
break
|
|
366
414
|
}
|
|
367
415
|
}
|
|
368
|
-
if (outermost && returned === null) {
|
|
369
|
-
fail(`${fn.name} never returns a ${fn.ret}`, fn.pos, fn.name.length)
|
|
370
|
-
}
|
|
371
416
|
}
|
|
372
417
|
|
|
373
418
|
// MARK: expressions
|
|
@@ -408,20 +453,31 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
|
|
|
408
453
|
}
|
|
409
454
|
checkExpr(fn, e.obj, scope)
|
|
410
455
|
return
|
|
411
|
-
case "call":
|
|
412
|
-
checkCall(fn, e, scope)
|
|
456
|
+
case "call": {
|
|
457
|
+
if (attempt(() => checkCall(fn, e, scope))) return
|
|
458
|
+
// Only reached while collecting: the call was refused, and its
|
|
459
|
+
// arguments are separate questions, so a mistake inside one is
|
|
460
|
+
// not hidden by the call's own. `fract(uv * wrap)` is two
|
|
461
|
+
// mistakes. tex2D's first argument is the texture, which is a
|
|
462
|
+
// value only there.
|
|
463
|
+
const tex = e.callee.k === "ident" && e.callee.name === "tex2D"
|
|
464
|
+
for (const a of tex ? e.args.slice(1) : e.args) attempt(() => checkExpr(fn, a, scope))
|
|
413
465
|
return
|
|
466
|
+
}
|
|
467
|
+
// Each operand on its own, so that while collecting a mistake on one
|
|
468
|
+
// side does not hide one on the other. Without a collector, `attempt`
|
|
469
|
+
// is the plain call, and the first error throws as it always did.
|
|
414
470
|
case "unary":
|
|
415
471
|
checkExpr(fn, e.arg, scope)
|
|
416
472
|
return
|
|
417
473
|
case "binary":
|
|
418
|
-
checkExpr(fn, e.a, scope)
|
|
419
|
-
checkExpr(fn, e.b, scope)
|
|
474
|
+
attempt(() => checkExpr(fn, e.a, scope))
|
|
475
|
+
attempt(() => checkExpr(fn, e.b, scope))
|
|
420
476
|
return
|
|
421
477
|
case "cond":
|
|
422
|
-
checkExpr(fn, e.cond, scope)
|
|
423
|
-
checkExpr(fn, e.then, scope)
|
|
424
|
-
checkExpr(fn, e.else, scope)
|
|
478
|
+
attempt(() => checkExpr(fn, e.cond, scope))
|
|
479
|
+
attempt(() => checkExpr(fn, e.then, scope))
|
|
480
|
+
attempt(() => checkExpr(fn, e.else, scope))
|
|
425
481
|
return
|
|
426
482
|
}
|
|
427
483
|
}
|
package/src/lang/index.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import type { Program } from "../ir"
|
|
21
21
|
import { check, type Checked } from "./check"
|
|
22
|
+
import { SLParseError } from "./lexer"
|
|
22
23
|
import { lower } from "./lower"
|
|
23
24
|
import { parseUnit, type ParseOptions } from "./parser"
|
|
24
25
|
import { preludeFunctions } from "./prelude"
|
|
@@ -29,7 +30,7 @@ export type {
|
|
|
29
30
|
Expr, FuncDecl, Param, Stmt, TextureDecl, TypeName, UniformDecl, Unit,
|
|
30
31
|
} from "./ast"
|
|
31
32
|
export { TYPE_WIDTH } from "./ast"
|
|
32
|
-
export { SLParseError }
|
|
33
|
+
export { SLParseError }
|
|
33
34
|
export type { Pos, SLFix, Token } from "./lexer"
|
|
34
35
|
export { tokenize } from "./lexer"
|
|
35
36
|
export { classify } from "./classify"
|
|
@@ -56,3 +57,38 @@ export function analyze(source: string, options: ParseOptions = {}): Checked {
|
|
|
56
57
|
const unit = parseUnit(source, options)
|
|
57
58
|
return check(unit, preludeFunctions(), { requireMain: options.requireMain })
|
|
58
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every error in a file, in source order, for an editor checking as it is
|
|
63
|
+
* typed. Empty when the file compiles. Never throws a `SLParseError`.
|
|
64
|
+
*
|
|
65
|
+
* Errors of one kind at a time, the first kind the file has: what cannot be
|
|
66
|
+
* read, then names and shapes (`check`), then types and widths (lowering).
|
|
67
|
+
* Each stage carries on past an error, a statement or a declaration at a time;
|
|
68
|
+
* a later stage runs only on a file the earlier ones passed, since its errors
|
|
69
|
+
* about a file with a hole in it would be about the hole.
|
|
70
|
+
*/
|
|
71
|
+
export function diagnose(source: string, options: ParseOptions = {}): SLParseError[] {
|
|
72
|
+
const errors: SLParseError[] = []
|
|
73
|
+
try {
|
|
74
|
+
const unit = parseUnit(source, { ...options, errors })
|
|
75
|
+
if (errors.length === 0) {
|
|
76
|
+
const checked = check(unit, preludeFunctions(), { requireMain: options.requireMain, errors })
|
|
77
|
+
if (errors.length === 0) lower(checked, errors)
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
// The lexer's, which stops at the first character it cannot read.
|
|
81
|
+
if (!(e instanceof SLParseError)) throw e
|
|
82
|
+
errors.push(e)
|
|
83
|
+
}
|
|
84
|
+
// An error in a loop body or an inlined function is found once per copy.
|
|
85
|
+
const seen = new Set<string>()
|
|
86
|
+
return errors
|
|
87
|
+
.filter((e) => {
|
|
88
|
+
const key = `${e.offset}:${e.text}`
|
|
89
|
+
if (seen.has(key)) return false
|
|
90
|
+
seen.add(key)
|
|
91
|
+
return true
|
|
92
|
+
})
|
|
93
|
+
.sort((a, b) => a.offset - b.offset)
|
|
94
|
+
}
|
package/src/lang/lower.ts
CHANGED
|
@@ -82,7 +82,12 @@ class Scope {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
/**
|
|
86
|
+
* `errors`, when given, collects every error lowering finds and carries on past
|
|
87
|
+
* each, one statement or declaration at a time, for `diagnose`. The program it
|
|
88
|
+
* returns then is not one to draw.
|
|
89
|
+
*/
|
|
90
|
+
export function lower(checked: Checked, errors?: SLParseError[]): Program {
|
|
86
91
|
const { unit, funcs } = checked
|
|
87
92
|
const file = unit.file
|
|
88
93
|
const main = unit.main!
|
|
@@ -114,6 +119,26 @@ export function lower(checked: Checked): Program {
|
|
|
114
119
|
throw new SLParseError(message, file, pos, length)
|
|
115
120
|
}
|
|
116
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Collecting, runs one statement or declaration, recording its error and
|
|
124
|
+
* returning `fallback()` instead of throwing. The fallback declares what the
|
|
125
|
+
* broken line would have, so a later use of that name is not a second error.
|
|
126
|
+
*/
|
|
127
|
+
const recover = <T,>(step: () => T, fallback: () => T): T => {
|
|
128
|
+
if (errors === undefined) return step()
|
|
129
|
+
try {
|
|
130
|
+
return step()
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if (!(e instanceof SLParseError)) throw e
|
|
133
|
+
errors.push(e)
|
|
134
|
+
return fallback()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A zero of a width, standing in for a value whose line was refused. */
|
|
139
|
+
const blank = (width: SLType, pos: Pos): LV =>
|
|
140
|
+
width === 1 ? 0 : at(pos, () => compose(width, new Array<LV>(width).fill(0)))
|
|
141
|
+
|
|
117
142
|
// The globals every helper below reads, built inside the recording callback
|
|
118
143
|
// and held out here so a function being inlined can see them and nothing
|
|
119
144
|
// else the caller had in scope.
|
|
@@ -143,7 +168,10 @@ export function lower(checked: Checked): Program {
|
|
|
143
168
|
// declaration still takes its slot and still reaches the host.
|
|
144
169
|
for (const u of unit.uniforms) {
|
|
145
170
|
const width = TYPE_WIDTH[u.type]
|
|
146
|
-
const { components, colour } =
|
|
171
|
+
const { components, colour } = recover(
|
|
172
|
+
() => uniformDefault(u.type, u.init),
|
|
173
|
+
() => ({ ...uniformDefault(u.type, null), colour: false }),
|
|
174
|
+
)
|
|
147
175
|
// A hex default says the uniform IS a colour, so every read of it
|
|
148
176
|
// converts, exactly as a hex literal in an expression does. Without
|
|
149
177
|
// that, `#ff8040` and a uniform defaulting to `#ff8040` would be two
|
|
@@ -158,12 +186,18 @@ export function lower(checked: Checked): Program {
|
|
|
158
186
|
for (const t of unit.textures) samplers.set(t.name, at(t.pos, () => sl.texture(t.name)))
|
|
159
187
|
|
|
160
188
|
for (const c of unit.consts) {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
189
|
+
const width = TYPE_WIDTH[c.type]
|
|
190
|
+
const v = recover(() => {
|
|
191
|
+
const v = fit(lowerExpr(c.init, global), width, c.pos)
|
|
192
|
+
assertWidth(v, width, c.name, c.type, c.pos)
|
|
193
|
+
return v
|
|
194
|
+
}, () => blank(width, c.pos))
|
|
195
|
+
global.declare(c.name, { width, value: v })
|
|
164
196
|
}
|
|
165
197
|
|
|
166
|
-
const out = exec(main.body, new Scope(global))
|
|
198
|
+
const out = exec(main.body, new Scope(global), 4)
|
|
199
|
+
// What a refused line left behind is not the program's colour.
|
|
200
|
+
if (errors !== undefined && errors.length > 0) return blank(4, main.pos) as never
|
|
167
201
|
// Checked here rather than left to `sl.program`, which would refuse it
|
|
168
202
|
// in the EDSL's words ("a vec4") about a file whose types are spelled
|
|
169
203
|
// float4, and from outside the reach of a position.
|
|
@@ -280,17 +314,34 @@ export function lower(checked: Checked): Program {
|
|
|
280
314
|
|
|
281
315
|
// MARK: statements
|
|
282
316
|
|
|
283
|
-
/**
|
|
284
|
-
|
|
317
|
+
/**
|
|
318
|
+
* Runs a body. The value of its `return`, or nothing when it has none.
|
|
319
|
+
* `ret` is the width that return has, for the stand in when it is refused.
|
|
320
|
+
*/
|
|
321
|
+
function exec(body: Stmt[], scope: Scope, ret: SLType): LV | undefined {
|
|
285
322
|
for (const s of body) {
|
|
323
|
+
if (s.k === "return") {
|
|
324
|
+
returnedAt = s.pos
|
|
325
|
+
return recover(() => lowerExpr(s.value, scope), () => blank(ret, s.pos))
|
|
326
|
+
}
|
|
327
|
+
if (s.k === "var" || s.k === "const") {
|
|
328
|
+
const width = TYPE_WIDTH[s.type]
|
|
329
|
+
const v = recover(() => {
|
|
330
|
+
const v = fit(lowerExpr(s.init, scope), width, s.pos)
|
|
331
|
+
assertWidth(v, width, s.name, s.type, s.pos)
|
|
332
|
+
return v
|
|
333
|
+
}, () => blank(width, s.pos))
|
|
334
|
+
scope.declare(s.name, { width, value: v })
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
recover(() => run(s, scope, ret), () => undefined)
|
|
338
|
+
}
|
|
339
|
+
return undefined
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** An assignment, an if or a for. */
|
|
343
|
+
function run(s: Stmt, scope: Scope, ret: SLType): void {
|
|
286
344
|
switch (s.k) {
|
|
287
|
-
case "var":
|
|
288
|
-
case "const": {
|
|
289
|
-
const v = fit(lowerExpr(s.init, scope), TYPE_WIDTH[s.type], s.pos)
|
|
290
|
-
assertWidth(v, TYPE_WIDTH[s.type], s.name, s.type, s.pos)
|
|
291
|
-
scope.declare(s.name, { width: TYPE_WIDTH[s.type], value: v })
|
|
292
|
-
break
|
|
293
|
-
}
|
|
294
345
|
case "assign": {
|
|
295
346
|
// The checker allowed exactly two shapes: a local, or a
|
|
296
347
|
// swizzle of one with no component named twice.
|
|
@@ -314,15 +365,15 @@ export function lower(checked: Checked): Program {
|
|
|
314
365
|
if (typeof cond === "number") {
|
|
315
366
|
// The one case where a branch really does vanish: the
|
|
316
367
|
// other side is never lowered, so it costs nothing.
|
|
317
|
-
exec(cond >= 0.5 ? s.then : s.else, new Scope(scope))
|
|
368
|
+
exec(cond >= 0.5 ? s.then : s.else, new Scope(scope), ret)
|
|
318
369
|
break
|
|
319
370
|
}
|
|
320
371
|
const locals = scope.locals()
|
|
321
372
|
const before = locals.map((b) => b.value)
|
|
322
|
-
exec(s.then, new Scope(scope))
|
|
373
|
+
exec(s.then, new Scope(scope), ret)
|
|
323
374
|
const whenTrue = locals.map((b) => b.value)
|
|
324
375
|
locals.forEach((b, i) => { b.value = before[i]! })
|
|
325
|
-
exec(s.else, new Scope(scope))
|
|
376
|
+
exec(s.else, new Scope(scope), ret)
|
|
326
377
|
const whenFalse = locals.map((b) => b.value)
|
|
327
378
|
locals.forEach((b, i) => {
|
|
328
379
|
const t = whenTrue[i]!
|
|
@@ -358,17 +409,14 @@ export function lower(checked: Checked): Program {
|
|
|
358
409
|
for (let i = from; s.inclusive ? i <= to : i < to; i += step) {
|
|
359
410
|
const inner = new Scope(scope)
|
|
360
411
|
inner.declare(s.counter, { width: 1, value: i })
|
|
361
|
-
exec(s.body, inner)
|
|
412
|
+
exec(s.body, inner, ret)
|
|
362
413
|
}
|
|
363
414
|
}))
|
|
364
415
|
break
|
|
365
416
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
return lowerExpr(s.value, scope)
|
|
417
|
+
default:
|
|
418
|
+
break
|
|
369
419
|
}
|
|
370
|
-
}
|
|
371
|
-
return undefined
|
|
372
420
|
}
|
|
373
421
|
|
|
374
422
|
function constantBound(e: Expr, what: string, scope: Scope): number {
|
|
@@ -609,7 +657,7 @@ export function lower(checked: Checked): Program {
|
|
|
609
657
|
}
|
|
610
658
|
scope.declare(p.name, { width: TYPE_WIDTH[p.type], value: v })
|
|
611
659
|
})
|
|
612
|
-
const out = exec(fn.body, scope)
|
|
660
|
+
const out = exec(fn.body, scope, TYPE_WIDTH[fn.ret])
|
|
613
661
|
if (out === undefined) fail(`${fn.name} never returns a value`, e.pos)
|
|
614
662
|
const got = typeof out === "number" ? 1 : out.width
|
|
615
663
|
if (got !== TYPE_WIDTH[fn.ret]) {
|
package/src/lang/parser.ts
CHANGED
|
@@ -67,6 +67,8 @@ class Parser {
|
|
|
67
67
|
private readonly tokens: Token[],
|
|
68
68
|
private readonly file: string,
|
|
69
69
|
private readonly requireMain: boolean,
|
|
70
|
+
/** Collects errors and carries on, for `diagnose`; without it the first error throws. */
|
|
71
|
+
private readonly errors?: SLParseError[],
|
|
70
72
|
) {}
|
|
71
73
|
|
|
72
74
|
// MARK: token plumbing
|
|
@@ -77,10 +79,62 @@ class Parser {
|
|
|
77
79
|
private eat(text: string): boolean { if (this.at(text)) { this.i++; return true } return false }
|
|
78
80
|
|
|
79
81
|
private fail(message: string, at: Token = this.peek(), fix?: SLFix): never {
|
|
82
|
+
throw this.error(message, at, fix)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private error(message: string, at: Token, fix?: SLFix): SLParseError {
|
|
80
86
|
// The end of the file is no character, so an error there marks the last
|
|
81
87
|
// token instead of one past it, where an editor has nothing to underline.
|
|
82
88
|
if (at.kind === "eof" && this.tokens.length > 1) at = this.tokens[this.tokens.length - 2]!
|
|
83
|
-
|
|
89
|
+
return new SLParseError(message, this.file, at as Pos, Math.max(1, at.text.length), fix)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** An error about something already read in full, so there is nothing to skip to carry on. */
|
|
93
|
+
private report(message: string, at: Token): void {
|
|
94
|
+
const e = this.error(message, at)
|
|
95
|
+
if (this.errors === undefined) throw e
|
|
96
|
+
this.errors.push(e)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// MARK: recovery
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Runs one statement's or one declaration's parse. Collecting, an error is
|
|
103
|
+
* recorded and the tokens are skipped to where the next one starts, so a
|
|
104
|
+
* second mistake further down the file is found too.
|
|
105
|
+
*/
|
|
106
|
+
private recover<T>(parse: () => T, level: "statement" | "declaration"): T | null {
|
|
107
|
+
if (this.errors === undefined) return parse()
|
|
108
|
+
const start = this.i
|
|
109
|
+
try {
|
|
110
|
+
return parse()
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (!(e instanceof SLParseError)) throw e
|
|
113
|
+
this.errors.push(e)
|
|
114
|
+
this.skip(level)
|
|
115
|
+
// A parse that failed on its first token and a skip that stopped there
|
|
116
|
+
// would try the same token forever.
|
|
117
|
+
if (this.i === start && this.peek().kind !== "eof" && !this.at("}")) this.i++
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Past the end of the broken statement: through its `;`, or through the
|
|
124
|
+
* block it opened, or up to the `}` of the body it is in. A declaration
|
|
125
|
+
* ends at a `;` or at the `}` of its own body.
|
|
126
|
+
*/
|
|
127
|
+
private skip(level: "statement" | "declaration"): void {
|
|
128
|
+
let depth = 0
|
|
129
|
+
for (;;) {
|
|
130
|
+
const t = this.peek()
|
|
131
|
+
if (t.kind === "eof") return
|
|
132
|
+
if (t.text === "}" && depth === 0 && level === "statement") return
|
|
133
|
+
this.i++
|
|
134
|
+
if (t.text === "{") depth++
|
|
135
|
+
else if (t.text === "}") { depth--; if (depth <= 0) return }
|
|
136
|
+
else if (t.text === ";" && depth === 0) return
|
|
137
|
+
}
|
|
84
138
|
}
|
|
85
139
|
|
|
86
140
|
private expect(text: string, what: string): Token {
|
|
@@ -127,34 +181,11 @@ class Parser {
|
|
|
127
181
|
}
|
|
128
182
|
|
|
129
183
|
while (this.peek().kind !== "eof") {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (t.text === "uniform") { unit.uniforms.push(this.parseUniform()); continue }
|
|
133
|
-
if (t.text === "texture2D") { unit.textures.push(this.parseTexture()); continue }
|
|
134
|
-
if (t.text === "const") {
|
|
135
|
-
const c = this.parseConst()
|
|
136
|
-
unit.consts.push(c)
|
|
137
|
-
continue
|
|
138
|
-
}
|
|
139
|
-
if (t.kind === "ident") {
|
|
140
|
-
const fn = this.parseFunction()
|
|
141
|
-
if (fn.name === "main") {
|
|
142
|
-
if (unit.main !== null) {
|
|
143
|
-
this.fail("this file already declares main; a .sl file is exactly one fragment function", t)
|
|
144
|
-
}
|
|
145
|
-
unit.main = fn
|
|
146
|
-
} else {
|
|
147
|
-
unit.funcs.push(fn)
|
|
148
|
-
}
|
|
149
|
-
continue
|
|
150
|
-
}
|
|
151
|
-
this.fail(
|
|
152
|
-
`expected a declaration (uniform, texture2D, const, or a function), got ${describe(t)}`, t,
|
|
153
|
-
)
|
|
184
|
+
this.recover(() => this.parseDeclaration(unit), "declaration")
|
|
154
185
|
}
|
|
155
186
|
|
|
156
187
|
if (unit.main === null && this.requireMain) {
|
|
157
|
-
this.
|
|
188
|
+
this.report(
|
|
158
189
|
"this file declares no main. A .sl file is one fragment function: add " +
|
|
159
190
|
"`float4 main() { ... }`",
|
|
160
191
|
this.peek(),
|
|
@@ -163,6 +194,24 @@ class Parser {
|
|
|
163
194
|
return unit
|
|
164
195
|
}
|
|
165
196
|
|
|
197
|
+
private parseDeclaration(unit: Unit): void {
|
|
198
|
+
const t = this.peek()
|
|
199
|
+
|
|
200
|
+
if (t.text === "uniform") { unit.uniforms.push(this.parseUniform()); return }
|
|
201
|
+
if (t.text === "texture2D") { unit.textures.push(this.parseTexture()); return }
|
|
202
|
+
if (t.text === "const") { unit.consts.push(this.parseConst()); return }
|
|
203
|
+
if (t.kind === "ident") {
|
|
204
|
+
const fn = this.parseFunction()
|
|
205
|
+
if (fn.name !== "main") unit.funcs.push(fn)
|
|
206
|
+
else if (unit.main === null) unit.main = fn
|
|
207
|
+
else this.report("this file already declares main; a .sl file is exactly one fragment function", t)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
this.fail(
|
|
211
|
+
`expected a declaration (uniform, texture2D, const, or a function), got ${describe(t)}`, t,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
166
215
|
private parseUniform(): UniformDecl {
|
|
167
216
|
const kw = this.next()
|
|
168
217
|
const type = this.expectType("a type after uniform")
|
|
@@ -230,7 +279,8 @@ class Parser {
|
|
|
230
279
|
const out: Stmt[] = []
|
|
231
280
|
while (!this.at("}")) {
|
|
232
281
|
if (this.peek().kind === "eof") this.fail("this body is never closed", this.peek())
|
|
233
|
-
|
|
282
|
+
const s = this.recover(() => this.parseStmt(), "statement")
|
|
283
|
+
if (s !== null) out.push(s)
|
|
234
284
|
}
|
|
235
285
|
this.next()
|
|
236
286
|
return out
|
|
@@ -476,6 +526,11 @@ function describe(t: Token): string {
|
|
|
476
526
|
|
|
477
527
|
export interface ParseOptions {
|
|
478
528
|
file?: string
|
|
529
|
+
/**
|
|
530
|
+
* Where to collect errors instead of throwing the first: the parse carries
|
|
531
|
+
* on past each one and returns what it could read. What `diagnose` uses.
|
|
532
|
+
*/
|
|
533
|
+
errors?: SLParseError[]
|
|
479
534
|
/**
|
|
480
535
|
* Off for the prelude, which is a library of functions and has no main.
|
|
481
536
|
* Nothing else should turn it off: a `.sl` file without a main renders
|
|
@@ -486,5 +541,5 @@ export interface ParseOptions {
|
|
|
486
541
|
|
|
487
542
|
export function parseUnit(source: string, options: ParseOptions = {}): Unit {
|
|
488
543
|
const file = options.file ?? "program.sl"
|
|
489
|
-
return new Parser(tokenize(source, file), file, options.requireMain ?? true).parseUnit()
|
|
544
|
+
return new Parser(tokenize(source, file), file, options.requireMain ?? true, options.errors).parseUnit()
|
|
490
545
|
}
|