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 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
- Note the release in `CHANGELOG.md` first. The one exception is the first version: npm
163
- attaches a trusted publisher to a package that already exists, so 0.1.0 was
164
- published by hand, and the workflow checks its tag and publishes nothing. In the OneJS container this package
165
- is checked out at `JSModules/onejs-sl`, and `onejs-unity` links it with
166
- `file:../onejs-sl` as a dev dependency beside its `^` peer range.
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.Generated/Shaders/Recorded.sl.json`, generates the shader and
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.5",
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,
@@ -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
- const clash = valueClash(u.name)
130
- if (clash !== null) fail(`"${u.name}" already names ${clash}`, u.pos, u.name.length)
131
- uniforms.set(u.name, u)
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
- if (uniforms.size > VM_UNIFORMS) {
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
- const clash = valueClash(t.name)
146
- if (clash !== null) fail(`"${t.name}" already names ${clash}`, t.pos, t.name.length)
147
- textures.set(t.name, t)
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
- if (textures.size > VM_TEXTURES) {
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
- const clash = valueClash(c.name)
161
- if (clash !== null) fail(`"${c.name}" already names ${clash}`, c.pos, c.name.length)
162
- consts.set(c.name, c)
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
- const clash = taken(fn.name)
167
- if (clash !== null) fail(`"${fn.name}" already names ${clash}`, fn.pos, fn.name.length)
168
- if (unit.funcs.filter((f) => f.name === fn.name).length > 1) {
169
- fail(
170
- `this file declares ${fn.name} more than once. There is no overloading: a function ` +
171
- `inlines, so two bodies under one name have nothing to pick between them`,
172
- fn.pos, fn.name.length,
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
- if (main.ret !== "float4") {
186
- fail(`main returns a colour, so it is declared \`float4 main()\`, not ${main.ret}`, main.pos)
187
- }
188
- if (main.params.length > 0) {
189
- fail(
190
- "main takes no parameters: what a program is given are the free identifiers uv, " +
191
- "fragCoord, resolution, time and aspect",
192
- main.params[0]!.pos,
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
- const clash = valueClash(p.name)
276
- if (clash !== null) fail(`"${p.name}" already names ${clash}`, p.pos, p.name.length)
277
- if (params.has(p.name)) fail(`${fn.name} already has a parameter called "${p.name}"`, p.pos)
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
- fail("this is after the return, so it can never run", s.pos)
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
- checkBody(fn, s.body, inner, false)
351
- counters.delete(s.counter)
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 } from "./lexer"
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
- export function lower(checked: Checked): Program {
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 } = uniformDefault(u.type, u.init)
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 v = fit(lowerExpr(c.init, global), TYPE_WIDTH[c.type], c.pos)
162
- assertWidth(v, TYPE_WIDTH[c.type], c.name, c.type, c.pos)
163
- global.declare(c.name, { width: TYPE_WIDTH[c.type], value: v })
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
- /** Runs a body. The value of its `return`, or nothing when it has none. */
284
- function exec(body: Stmt[], scope: Scope): LV | undefined {
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
- case "return":
367
- returnedAt = s.pos
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]) {
@@ -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
- throw new SLParseError(message, this.file, at as Pos, Math.max(1, at.text.length), fix)
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
- const t = this.peek()
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.fail(
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
- out.push(this.parseStmt())
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
  }