onejs-sl 0.1.4 → 0.1.6

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/lang/lexer.ts CHANGED
@@ -32,6 +32,19 @@ export interface Token extends Pos {
32
32
  value?: number
33
33
  }
34
34
 
35
+ /**
36
+ * A replacement for the characters an error marks, offered as one click.
37
+ *
38
+ * Only where the fix is certain: `mix` to `lerp` is, but `mod` to `%` is not
39
+ * (GLSL's `mod` floors and `%` truncates), so `mod` gets the hint and no fix.
40
+ */
41
+ export interface SLFix {
42
+ /** What the editor's action says, as in "Replace mix with lerp". */
43
+ title: string
44
+ /** Text for the marked range, `offset` to `offset + length`. */
45
+ replacement: string
46
+ }
47
+
35
48
  /**
36
49
  * An error with a place in a file.
37
50
  *
@@ -43,15 +56,23 @@ export class SLParseError extends SLError {
43
56
  readonly file: string
44
57
  readonly line: number
45
58
  readonly column: number
59
+ /** Into the source, where the marked range starts. */
60
+ readonly offset: number
46
61
  readonly length: number
62
+ /** The message alone, without `message`'s tag and its `file:line:col: `. */
63
+ readonly text: string
64
+ readonly fix?: SLFix
47
65
 
48
- constructor(message: string, file: string, pos: Pos, length = 1) {
66
+ constructor(message: string, file: string, pos: Pos, length = 1, fix?: SLFix) {
49
67
  super(`${file}:${pos.line}:${pos.col}: ${message}`)
50
68
  this.name = "SLParseError"
51
69
  this.file = file
52
70
  this.line = pos.line
53
71
  this.column = pos.col
72
+ this.offset = pos.offset
54
73
  this.length = length
74
+ this.text = message
75
+ if (fix !== undefined) this.fix = fix
55
76
  }
56
77
  }
57
78
 
@@ -79,14 +100,37 @@ const isIdentStart = (c: string) => c === "_" || (c >= "a" && c <= "z") || (c >=
79
100
  const isIdentPart = (c: string) => isIdentStart(c) || isDigit(c)
80
101
 
81
102
  export function tokenize(source: string, file: string): Token[] {
82
- const out: Token[] = []
103
+ return lex(source, file, false)
104
+ }
105
+
106
+ /** A token as `classify` sees it: comments kept, and whatever cannot be read marked rather than thrown. */
107
+ export interface LexedToken extends Omit<Token, "kind"> {
108
+ kind: TokenKind | "comment" | "invalid"
109
+ /** Characters it covers, which for `1.0f` is one more than its text. */
110
+ length: number
111
+ }
112
+
113
+ /**
114
+ * The scanner behind `tokenize` and `classify`.
115
+ *
116
+ * With `keep` off it is `tokenize`: comments dropped, the first unreadable
117
+ * character thrown. With it on, nothing throws and nothing is dropped, because
118
+ * a highlighter runs on every keystroke over text that is half typed.
119
+ */
120
+ export function lex(source: string, file: string, keep: true): LexedToken[]
121
+ export function lex(source: string, file: string, keep: false): Token[]
122
+ export function lex(source: string, file: string, keep: boolean): Token[] | LexedToken[] {
123
+ const out: LexedToken[] = []
83
124
  let i = 0
84
125
  let line = 1
85
126
  let lineStart = 0
86
127
  const here = (): Pos => ({ line, col: i - lineStart + 1, offset: i })
87
- const fail = (message: string, at: Pos = here(), length = 1): never => {
88
- throw new SLParseError(message, file, at, length)
128
+ /** Throws, or with `keep` records the span as invalid and lets the caller skip it. */
129
+ const fail = (message: string, at: Pos = here(), length = 1): void => {
130
+ if (!keep) throw new SLParseError(message, file, at, length)
131
+ out.push({ kind: "invalid", text: source.slice(at.offset, at.offset + length), length, ...at })
89
132
  }
133
+ const push = (t: Omit<LexedToken, "length">, end: number) => { out.push({ ...t, length: end - t.offset }) }
90
134
 
91
135
  while (i < source.length) {
92
136
  const c = source[i]!
@@ -95,18 +139,25 @@ export function tokenize(source: string, file: string): Token[] {
95
139
  if (c === " " || c === "\t" || c === "\r") { i++; continue }
96
140
 
97
141
  if (c === "/" && source[i + 1] === "/") {
142
+ const start = here()
98
143
  while (i < source.length && source[i] !== "\n") i++
144
+ if (keep) push({ kind: "comment", text: source.slice(start.offset, i), ...start }, i)
99
145
  continue
100
146
  }
101
147
  if (c === "/" && source[i + 1] === "*") {
102
148
  const open = here()
103
149
  i += 2
104
150
  for (;;) {
105
- if (i >= source.length) fail("this block comment is never closed", open, 2)
151
+ if (i >= source.length) {
152
+ // Highlighted to the end as the comment it will become once closed.
153
+ if (!keep) fail("this block comment is never closed", open, 2)
154
+ break
155
+ }
106
156
  if (source[i] === "*" && source[i + 1] === "/") { i += 2; break }
107
157
  if (source[i] === "\n") { line++; lineStart = i + 1 }
108
158
  i++
109
159
  }
160
+ if (keep) push({ kind: "comment", text: source.slice(open.offset, i), ...open }, i)
110
161
  continue
111
162
  }
112
163
 
@@ -122,11 +173,11 @@ export function tokenize(source: string, file: string): Token[] {
122
173
  `function, which inlines.`,
123
174
  start, body.length + 1,
124
175
  )
125
- }
126
- if (body.length === 0 || ![...body].every(isHex)) {
176
+ } else if (body.length === 0 || ![...body].every(isHex)) {
127
177
  fail(`"#${body}" is not a colour; use #rgb, #rrggbb or #rrggbbaa`, start, body.length + 1)
178
+ } else {
179
+ push({ kind: "hex", text: "#" + body, ...start }, j)
128
180
  }
129
- out.push({ kind: "hex", text: "#" + body, ...start })
130
181
  i = j
131
182
  continue
132
183
  }
@@ -150,7 +201,7 @@ export function tokenize(source: string, file: string): Token[] {
150
201
  }
151
202
  const value = Number(text)
152
203
  if (!Number.isFinite(value)) fail(`"${text}" is not a number`, start, text.length)
153
- out.push({ kind: "number", text, value, ...start })
204
+ else push({ kind: "number", text, value, ...start }, j)
154
205
  i = j
155
206
  continue
156
207
  }
@@ -159,18 +210,24 @@ export function tokenize(source: string, file: string): Token[] {
159
210
  const start = here()
160
211
  let j = i
161
212
  while (j < source.length && isIdentPart(source[j]!)) j++
162
- out.push({ kind: "ident", text: source.slice(i, j), ...start })
213
+ push({ kind: "ident", text: source.slice(i, j), ...start }, j)
163
214
  i = j
164
215
  continue
165
216
  }
166
217
 
167
218
  const start = here()
168
219
  const p = PUNCT.find((op) => source.startsWith(op, i))
169
- if (p === undefined) fail(`"${c}" means nothing here`, start)
170
- out.push({ kind: "punct", text: p!, ...start })
171
- i += p!.length
220
+ if (p === undefined) {
221
+ fail(`"${c}" means nothing here`, start)
222
+ i++
223
+ continue
224
+ }
225
+ push({ kind: "punct", text: p, ...start }, i + p.length)
226
+ i += p.length
172
227
  }
173
228
 
174
- out.push({ kind: "eof", text: "", line, col: i - lineStart + 1, offset: i })
175
- return out
229
+ out.push({ kind: "eof", text: "", line, col: i - lineStart + 1, offset: i, length: 0 })
230
+ if (keep) return out
231
+ // `tokenize`'s tokens are the ones it always returned, with no length.
232
+ return out.map(({ length: _, ...t }) => t as Token)
176
233
  }
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!
@@ -106,7 +111,7 @@ export function lower(checked: Checked): Program {
106
111
  const located = (e: unknown, pos: Pos): unknown => {
107
112
  if (e instanceof SLParseError) return e
108
113
  const raw = e instanceof Error ? e.message : String(e)
109
- return new SLParseError(raw.replace(/^\[onejs sl] /, ""), file, pos)
114
+ return new SLParseError(inFileWords(raw.replace(/^\[onejs sl] /, "")), file, pos)
110
115
  }
111
116
 
112
117
  const fail: (message: string, pos: Pos, length?: number) => never =
@@ -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]) {
@@ -676,3 +724,17 @@ function widthType(w: SLType): TypeName {
676
724
  return (["float", "float2", "float3", "float4"] as const)[w - 1]!
677
725
  }
678
726
 
727
+ /**
728
+ * An EDSL message in the words of a `.sl` file.
729
+ *
730
+ * The EDSL names its types after its constructors, `sl.vec3`, so "cannot
731
+ * combine a vec3 with a vec2" is right for a TypeScript author and wrong for
732
+ * one who wrote float3 (`Specs/SL_NEXT.md` 5). One rewrite here, rather than a
733
+ * second set of messages, keeps both surfaces saying the same thing. A quoted
734
+ * name is the author's own and left as written.
735
+ */
736
+ function inFileWords(message: string): string {
737
+ return message.split(/("[^"]*")/).map((part, i) =>
738
+ i % 2 === 1 ? part : part.replace(/\bsl\.vec([234])\(/g, "float$1(").replace(/\bvec([234])\b/g, "float$1"),
739
+ ).join("")
740
+ }
@@ -12,13 +12,11 @@
12
12
  */
13
13
 
14
14
  import {
15
- TYPE_WIDTH,
16
15
  type AssignOp, type BinaryOp, type Expr, type FuncDecl, type Param, type Stmt,
17
16
  type TextureDecl, type TypeName, type UniformDecl, type Unit,
18
17
  } from "./ast"
19
- import { SLParseError, tokenize, type Pos, type Token } from "./lexer"
20
-
21
- const TYPE_NAMES = new Set(Object.keys(TYPE_WIDTH))
18
+ import { SLParseError, tokenize, type Pos, type SLFix, type Token } from "./lexer"
19
+ import { KEYWORD_SET, TYPE_SET } from "./words"
22
20
 
23
21
  /**
24
22
  * Type spellings that exist in HLSL or GLSL and not here, each with the reason.
@@ -45,6 +43,11 @@ const NOT_A_TYPE: Record<string, string> = {
45
43
  struct: "there are no structs",
46
44
  }
47
45
 
46
+ /** The spellings above whose replacement is certain, so the error can offer it as a fix. */
47
+ const TYPE_FIX: Record<string, TypeName> = {
48
+ vec2: "float2", vec3: "float3", vec4: "float4", half: "float", double: "float", fixed: "float",
49
+ }
50
+
48
51
  /** Binding power per binary operator, HLSL's. Higher binds tighter. */
49
52
  const BINDING: Record<string, number> = {
50
53
  "||": 1,
@@ -64,6 +67,8 @@ class Parser {
64
67
  private readonly tokens: Token[],
65
68
  private readonly file: string,
66
69
  private readonly requireMain: boolean,
70
+ /** Collects errors and carries on, for `diagnose`; without it the first error throws. */
71
+ private readonly errors?: SLParseError[],
67
72
  ) {}
68
73
 
69
74
  // MARK: token plumbing
@@ -73,14 +78,73 @@ class Parser {
73
78
  private at(text: string): boolean { const t = this.peek(); return t.kind !== "eof" && t.text === text }
74
79
  private eat(text: string): boolean { if (this.at(text)) { this.i++; return true } return false }
75
80
 
76
- private fail(message: string, at: Token = this.peek()): never {
77
- throw new SLParseError(message, this.file, at as Pos, Math.max(1, at.text.length))
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 {
86
+ // The end of the file is no character, so an error there marks the last
87
+ // token instead of one past it, where an editor has nothing to underline.
88
+ if (at.kind === "eof" && this.tokens.length > 1) at = this.tokens[this.tokens.length - 2]!
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
+ }
78
138
  }
79
139
 
80
140
  private expect(text: string, what: string): Token {
81
141
  if (!this.at(text)) {
82
142
  const got = this.peek()
83
- this.fail(`expected "${text}" ${what}, got ${describe(got)}`, got)
143
+ // Something missing at the end of a line is missing after the last
144
+ // token on it, which is where the author will look, not at the next line.
145
+ const prev = this.tokens[this.i - 1]
146
+ const at = prev !== undefined && got.line > prev.line ? prev : got
147
+ this.fail(`expected "${text}" ${what}, got ${describe(got)}`, at)
84
148
  }
85
149
  return this.next()
86
150
  }
@@ -91,11 +155,21 @@ class Parser {
91
155
  return this.next()
92
156
  }
93
157
 
158
+ /** A name being declared, which may not be a word the parser reads as syntax. */
159
+ private expectName(what: string): Token {
160
+ const t = this.expectIdent(what)
161
+ if (KEYWORD_SET.has(t.text)) this.fail(`"${t.text}" is a keyword, so it cannot be ${what}`, t)
162
+ if (TYPE_SET.has(t.text)) this.fail(`"${t.text}" is a type, so it cannot be ${what}`, t)
163
+ return t
164
+ }
165
+
94
166
  private expectType(what: string): TypeName {
95
167
  const t = this.expectIdent(what)
96
- if (TYPE_NAMES.has(t.text)) return t.text as TypeName
168
+ if (TYPE_SET.has(t.text)) return t.text as TypeName
97
169
  const why = NOT_A_TYPE[t.text]
98
- if (why !== undefined) this.fail(`"${t.text}" is not a type here: ${why}`, t)
170
+ const to = TYPE_FIX[t.text]
171
+ const fix = to === undefined ? undefined : { title: `Replace ${t.text} with ${to}`, replacement: to }
172
+ if (why !== undefined) this.fail(`"${t.text}" is not a type here: ${why}`, t, fix)
99
173
  this.fail(`"${t.text}" is not a type; the types are float, float2, float3 and float4`, t)
100
174
  }
101
175
 
@@ -107,34 +181,11 @@ class Parser {
107
181
  }
108
182
 
109
183
  while (this.peek().kind !== "eof") {
110
- const t = this.peek()
111
-
112
- if (t.text === "uniform") { unit.uniforms.push(this.parseUniform()); continue }
113
- if (t.text === "texture2D") { unit.textures.push(this.parseTexture()); continue }
114
- if (t.text === "const") {
115
- const c = this.parseConst()
116
- unit.consts.push(c)
117
- continue
118
- }
119
- if (t.kind === "ident") {
120
- const fn = this.parseFunction()
121
- if (fn.name === "main") {
122
- if (unit.main !== null) {
123
- this.fail("this file already declares main; a .sl file is exactly one fragment function", t)
124
- }
125
- unit.main = fn
126
- } else {
127
- unit.funcs.push(fn)
128
- }
129
- continue
130
- }
131
- this.fail(
132
- `expected a declaration (uniform, texture2D, const, or a function), got ${describe(t)}`, t,
133
- )
184
+ this.recover(() => this.parseDeclaration(unit), "declaration")
134
185
  }
135
186
 
136
187
  if (unit.main === null && this.requireMain) {
137
- this.fail(
188
+ this.report(
138
189
  "this file declares no main. A .sl file is one fragment function: add " +
139
190
  "`float4 main() { ... }`",
140
191
  this.peek(),
@@ -143,10 +194,28 @@ class Parser {
143
194
  return unit
144
195
  }
145
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
+
146
215
  private parseUniform(): UniformDecl {
147
216
  const kw = this.next()
148
217
  const type = this.expectType("a type after uniform")
149
- const name = this.expectIdent("a uniform name")
218
+ const name = this.expectName("a uniform name")
150
219
  let init: Expr | null = null
151
220
  if (this.eat("=")) init = this.parseExpr()
152
221
  this.expect(";", "after a uniform declaration")
@@ -155,7 +224,7 @@ class Parser {
155
224
 
156
225
  private parseTexture(): TextureDecl {
157
226
  const kw = this.next()
158
- const name = this.expectIdent("a texture name")
227
+ const name = this.expectName("a texture name")
159
228
  if (this.at("=")) {
160
229
  this.fail(
161
230
  "a texture has no default; the host binds it by name through the textures prop",
@@ -169,7 +238,7 @@ class Parser {
169
238
  private parseConst(): Extract<Stmt, { k: "const" }> {
170
239
  const kw = this.next()
171
240
  const type = this.expectType("a type after const")
172
- const name = this.expectIdent("a name")
241
+ const name = this.expectName("a const's name")
173
242
  this.expect("=", "after a const's name; a const has to have a value")
174
243
  const init = this.parseExpr()
175
244
  this.expect(";", "after a const")
@@ -179,7 +248,7 @@ class Parser {
179
248
  private parseFunction(): FuncDecl {
180
249
  const start = this.peek()
181
250
  const ret = this.expectType("a return type")
182
- const name = this.expectIdent("a function name")
251
+ const name = this.expectName("a function name")
183
252
  if (!this.at("(")) {
184
253
  this.fail(
185
254
  `expected "(" after ${name.text}. Only uniforms, textures, consts and functions live ` +
@@ -193,7 +262,7 @@ class Parser {
193
262
  for (;;) {
194
263
  const p = this.peek()
195
264
  const ptype = this.expectType("a parameter type")
196
- const pname = this.expectIdent("a parameter name")
265
+ const pname = this.expectName("a parameter name")
197
266
  params.push({ type: ptype, name: pname.text, pos: p })
198
267
  if (!this.eat(",")) break
199
268
  }
@@ -210,7 +279,8 @@ class Parser {
210
279
  const out: Stmt[] = []
211
280
  while (!this.at("}")) {
212
281
  if (this.peek().kind === "eof") this.fail("this body is never closed", this.peek())
213
- out.push(this.parseStmt())
282
+ const s = this.recover(() => this.parseStmt(), "statement")
283
+ if (s !== null) out.push(s)
214
284
  }
215
285
  this.next()
216
286
  return out
@@ -238,6 +308,16 @@ class Parser {
238
308
  if (t.text === "discard") {
239
309
  this.fail("there is no discard; return a colour with alpha 0 instead", t)
240
310
  }
311
+ if (t.text === "break" || t.text === "continue") {
312
+ this.fail(
313
+ `there is no ${t.text}: a for loop unrolls, so every iteration runs. Put the rest of ` +
314
+ `the body under an if instead`,
315
+ t,
316
+ )
317
+ }
318
+ if (t.text === "switch") {
319
+ this.fail("there is no switch; write it as an if and else if", t)
320
+ }
241
321
  if (t.text === "return") {
242
322
  this.next()
243
323
  const value = this.parseExpr()
@@ -253,7 +333,7 @@ class Parser {
253
333
  // statement may be is the left of an assignment.
254
334
  if (t.kind === "ident" && this.peek(1).kind === "ident") {
255
335
  const type = this.expectType("a type")
256
- const name = this.expectIdent("a name")
336
+ const name = this.expectName("a local's name")
257
337
  if (this.at("(")) {
258
338
  this.fail(
259
339
  "a function is declared at the top level of the file, not inside another function",
@@ -318,7 +398,7 @@ class Parser {
318
398
  )
319
399
  }
320
400
  this.next()
321
- const counter = this.expectIdent("a counter name")
401
+ const counter = this.expectName("a counter name")
322
402
  this.expect("=", "after the counter")
323
403
  const from = this.parseExpr()
324
404
  this.expect(";", "after the counter's start")
@@ -446,6 +526,11 @@ function describe(t: Token): string {
446
526
 
447
527
  export interface ParseOptions {
448
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[]
449
534
  /**
450
535
  * Off for the prelude, which is a library of functions and has no main.
451
536
  * Nothing else should turn it off: a `.sl` file without a main renders
@@ -456,5 +541,5 @@ export interface ParseOptions {
456
541
 
457
542
  export function parseUnit(source: string, options: ParseOptions = {}): Unit {
458
543
  const file = options.file ?? "program.sl"
459
- 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()
460
545
  }