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/CHANGELOG.md +23 -0
- package/README.md +8 -4
- package/package.json +1 -1
- package/src/body.ts +20 -2
- package/src/index.ts +5 -3
- package/src/lang/README.md +43 -5
- package/src/lang/check.ts +123 -60
- package/src/lang/classify.ts +70 -0
- package/src/lang/index.ts +41 -2
- package/src/lang/lexer.ts +72 -15
- package/src/lang/lower.ts +88 -26
- package/src/lang/parser.ts +128 -43
- package/src/lang/prelude-source.ts +53 -0
- package/src/lang/prelude.ts +2 -34
- package/src/lang/words.ts +23 -0
- package/src/params.ts +61 -0
- package/src/tables.ts +9 -3
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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 } =
|
|
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]) {
|
|
@@ -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
|
+
}
|
package/src/lang/parser.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
168
|
+
if (TYPE_SET.has(t.text)) return t.text as TypeName
|
|
97
169
|
const why = NOT_A_TYPE[t.text]
|
|
98
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|