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 CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.6
4
+
5
+ `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.
6
+
7
+ - `diagnose` finds a mistake in each statement and declaration, in source order, and never throws one
8
+ - A refused local is still declared, so its uses are not reported again
9
+
10
+ ## 0.1.5
11
+
12
+ 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.
13
+
14
+ - `classify` returns every token with its class, comments included, and never throws
15
+ - `SL_KEYWORDS`, `SL_TYPES` and `TYPE_WIDTH` are exported from `onejs-sl/tables`
16
+ - `BUILTIN_DOCS`, `INPUT_DOCS` and `PRELUDE_DOCS` describe every builtin, input and prelude function in a line
17
+ - `SLParseError` carries `text`, its message alone, and `offset`
18
+ - An error carries a one click `fix` where the fix is certain: a GLSL rename or a "did you mean"
19
+ - A GLSL name used as a value, such as `gl_FragCoord`, gets the HLSL name
20
+ - `break`, `continue` and `switch` say why they are not there
21
+ - A keyword or a type cannot name a value, a function or a parameter
22
+ - Something missing at the end of a line or the file is marked on the last token before it
23
+ - Errors from inside the EDSL say `float3`, not `vec3`
24
+ - `BodyTarget.sample` documents what the body expects of a sample
25
+
3
26
  ## 0.1.4
4
27
 
5
28
  Four things that were errors now compile, the most common mistakes in cold runs of authors writing from the docs alone (`Specs/SL_NEXT.md` 2). Every program that compiled before compiles to exactly what it did.
package/README.md CHANGED
@@ -35,9 +35,9 @@ only what it calls.
35
35
 
36
36
  | Entry | Contents |
37
37
  |---|---|
38
- | `onejs-sl` | `parse` and `analyze`, the TypeScript form `sl`, the IR types, `SL_IR_VERSION`, `toJSON`/`fromJSON`, `SLParseError` (file, line, column, length) |
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
- | `onejs-sl/tables` | `BUILTINS`, `SL_HLSL`, `INPUTS`, `SL_SDF_SHAPES`, `SL_SDF_PARAMS`: 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)` |
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 |
42
42
  | `onejs-sl/vm` | `encode`, `SL_WIRE_VERSION` |
43
43
  | `onejs-sl/emit/hlsl-body` | `emitBody`: a program as a function body for a host's own frame; `emitLibrary`: the library functions it calls |
@@ -56,7 +56,11 @@ intrinsic at its exact type, since Metal overloads where HLSL converts). The `Bo
56
56
  for each input, the float4 holding a uniform slot, a texture sample, whether
57
57
  `toLinear` is real (`colour: "linear"`) or the identity (`"gamma"`), and
58
58
  optionally a local to assign the result to. It returns the uniform and texture
59
- slots the body uses and the library functions it calls. OneJS's Unity shader is
59
+ slots the body uses and the library functions it calls. The sample's contract
60
+ is on `BodyTarget.sample`: straight alpha in and out, rgb in the space `colour`
61
+ names (an sRGB texture decoded before filtering when it is `linear`), and
62
+ filtering and wrapping left to the host, which OneJS's hosts take from the
63
+ bound texture's own settings. OneJS's Unity shader is
60
64
  one frame over it (`hlsl.ts`); Magerie's compute kernel is another, and its
61
65
  target is in `src/body.test.ts` so an opcode cannot change without the text
62
66
  Magerie compiles changing in front of a test.
@@ -175,7 +179,7 @@ generated HLSL after an eject, with no edit in between. Both backends exist:
175
179
  and `hlsl.ts` feeds its `Editor/SLShaderGenerator.cs`. Nobody writes a manifest for the
176
180
  second: an editor that interprets a program asks the encoded program for its
177
181
  `hlsl` (a lazy getter, never read in Play), records it into
178
- `Assets/OneJS.Generated/Shaders/Recorded.sl.json`, generates the shader and
182
+ `Assets/OneJS/Recorded.sl.json`, generates the shader and
179
183
  moves the live material onto it. `manifest()` is still there for an app that
180
184
  would rather write its programs out at build time.
181
185
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onejs-sl",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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/body.ts CHANGED
@@ -31,12 +31,30 @@ export interface BodyTarget {
31
31
  inputs: Record<InputName, string>
32
32
  /** The float4 holding a uniform slot. The body swizzles it down to the uniform's width. */
33
33
  uniform: (slot: number, name: string) => string
34
- /** A float4 sample of the texture in `slot` at the float2 expression `uv`, straight alpha. */
34
+ /**
35
+ * A float4 sample of the texture in `slot` at the float2 expression `uv`.
36
+ *
37
+ * The contract the body is written against, which is what OneJS's own hosts
38
+ * do with the GPU's sampler:
39
+ *
40
+ * - **Straight alpha**, not premultiplied, in and out: the body never
41
+ * multiplies or divides by alpha, and its result is straight too.
42
+ * - **In the space `colour` names.** With `linear`, rgb is linear light, so
43
+ * an sRGB texture is decoded before it is filtered, as an sRGB sampler
44
+ * does; with `gamma`, rgb is as stored.
45
+ * - **Filtered and wrapped by the host.** The body does neither. OneJS's
46
+ * hosts, the generated shader, the VM and the web backends alike, use the
47
+ * bound texture's own filter and wrap modes, so a host that filters by
48
+ * hand draws the same picture by following the texture's settings.
49
+ *
50
+ * What becomes of the result is the host's: OneJS writes it to the target
51
+ * as it is, and a host that composites premultiplied multiplies on write.
52
+ */
35
53
  sample: (slot: number, uv: string) => string
36
54
  /**
37
55
  * `linear`: `toLinear` calls `sl_toLinear`, for a host whose target holds
38
56
  * linear light. `gamma`: `toLinear` is the identity, decided here, so hex
39
- * colours and ramps stay as written.
57
+ * colours and ramps stay as written. `sample` reads in the same space.
40
58
  */
41
59
  colour: "gamma" | "linear"
42
60
  /** Assign the result to this local instead of returning it. */
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, 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,6 +19,8 @@
19
19
  */
20
20
  export * from "./core"
21
21
  export {
22
- analyze, parse, parseUnit, preludeFunctions, tokenize, PRELUDE_SOURCE, SLParseError,
22
+ analyze, classify, diagnose, parse, parseUnit, preludeFunctions, tokenize, PRELUDE_SOURCE, SLParseError,
23
+ } from "./lang"
24
+ export type {
25
+ Checked, Expr, FuncDecl, ParseOptions, Pos, SLClassifiedToken, SLFix, SLTokenClass, Stmt, Token, Unit,
23
26
  } from "./lang"
24
- export type { Checked, Expr, FuncDecl, ParseOptions, Pos, Stmt, Token, Unit } from "./lang"
@@ -168,11 +168,49 @@ lowering it changes recorded behaviour rather than parser behaviour.
168
168
 
169
169
  ## Errors
170
170
 
171
- Every one carries file, line and column, and an error that starts inside the
172
- EDSL keeps the EDSL's wording. `"z" is component 3 of a vec2, which has 2` is
173
- already the right sentence; what it lacked was a place. `errors.test.ts` checks
174
- the positions as strictly as the words, because a marker under the wrong
175
- character sends the reader to look at something that is fine.
171
+ Every one carries file, line, column, offset and length, and `text`, the
172
+ message without its tag and its `file:line:col: `, for an editor that puts the
173
+ words in a marker of its own. An error that starts inside the EDSL keeps the
174
+ EDSL's wording, with its type names put in the file's: `"z" is component 3 of
175
+ a float2, which has 2` was already the right sentence; what it lacked was a
176
+ place. `errors.test.ts` checks the positions as strictly as the words, because
177
+ a marker under the wrong character sends the reader to look at something that
178
+ is fine. Something missing at the end of a line or the file marks the last
179
+ token before it, where there is a character to underline.
180
+
181
+ Where the fix is certain, the error carries it as `fix`: a title and the text
182
+ for the marked range, one click in an editor. A GLSL spelling that means the
183
+ same in HLSL (`mix`, `fract`, `vec3`, `gl_FragCoord`, `iTime`, two argument
184
+ `atan`) and a "did you mean" both have one. `mod` does not, since GLSL's floors
185
+ and `%` truncates, and neither does `int`, since float would change what
186
+ dividing it does. That is Decision 1 kept as `Specs/SL_NEXT.md` 5 A has it:
187
+ one spelling, and every hint a fix.
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
+
202
+ ## For an editor
203
+
204
+ `classify(source)` is every token with its class (keyword, type, builtin,
205
+ input, prelude, number, hex, comment, punct, member, ident, or invalid), built
206
+ on the parser's own scanner and word lists, so the highlighting is the parser's.
207
+ It never throws, since it runs on every keystroke. It classifies by spelling,
208
+ not scope: a local named `circle` still reads as a builtin where it is used.
209
+
210
+ `SL_KEYWORDS` and `SL_TYPES` (`words.ts`) are the lists the parser reads, and a
211
+ name may be neither. `onejs-sl/tables` carries them with a one line description
212
+ of every builtin, input and prelude function; a prelude function's is the
213
+ comment above it in `prelude-source.ts`.
176
214
 
177
215
  ## See also
178
216
 
package/src/lang/check.ts CHANGED
@@ -34,10 +34,22 @@ import { SL_GLSL_HINT } from "../ops"
34
34
  import { BUILTINS, NOT_YET } from "./builtins"
35
35
  import { SL_SDF_SHAPES } from "../shapes"
36
36
  import type { Expr, FuncDecl, Stmt, Unit } from "./ast"
37
- import { SLParseError, type Pos } from "./lexer"
37
+ import { SLParseError, type Pos, type SLFix } from "./lexer"
38
38
 
39
39
  const INPUT_NAMES = new Set(Object.keys(INPUTS))
40
40
 
41
+ /**
42
+ * The GLSL spellings whose HLSL name means the same wherever it is written, so
43
+ * the hint can be a one click fix (`Specs/SL_NEXT.md` 5, Decision 1 A). The
44
+ * rest of `SL_GLSL_HINT` gets the hint alone: `mod` floors where `%` truncates,
45
+ * `ivec2` truncates where `float2` does not, `atan` is `atan2` only with two
46
+ * arguments (handled at the call), and `textureLod` names a builtin that is not
47
+ * implemented yet.
48
+ */
49
+ const GLSL_RENAMES = new Set(["mix", "fract", "texture", "vec2", "vec3", "vec4", "gl_FragCoord", "iTime", "iResolution"])
50
+
51
+ const rename = (from: string, to: string): SLFix => ({ title: `Replace ${from} with ${to}`, replacement: to })
52
+
41
53
  export interface Checked {
42
54
  unit: Unit
43
55
  /** Every callable function by name, the file's shadowing the prelude's. */
@@ -57,6 +69,8 @@ export interface Checked {
57
69
  export interface CheckOptions {
58
70
  /** Off for the prelude, which is functions only. See `parseUnit`. */
59
71
  requireMain?: boolean
72
+ /** Where to collect errors instead of throwing the first. See `diagnose`. */
73
+ errors?: SLParseError[]
60
74
  }
61
75
 
62
76
  export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {}): Checked {
@@ -65,10 +79,27 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
65
79
  // terminating control flow when the callee's `never` comes from an explicit
66
80
  // type, so without this every `if (x.k !== "ident") fail(...)` below would
67
81
  // fail to narrow.
68
- const fail: (message: string, pos: Pos, length?: number) => never =
69
- (message, pos, length = 1) => {
70
- throw new SLParseError(message, file, pos, length)
82
+ const fail: (message: string, pos: Pos, length?: number, fix?: SLFix) => never =
83
+ (message, pos, length = 1, fix) => {
84
+ throw new SLParseError(message, file, pos, length, fix)
85
+ }
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
71
101
  }
102
+ }
72
103
 
73
104
  const funcs = new Map<string, FuncDecl>()
74
105
  for (const fn of prelude) funcs.set(fn.name, fn)
@@ -114,11 +145,14 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
114
145
  }
115
146
 
116
147
  for (const u of unit.uniforms) {
117
- const clash = valueClash(u.name)
118
- if (clash !== null) fail(`"${u.name}" already names ${clash}`, u.pos, u.name.length)
119
- 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)
120
153
  }
121
- if (uniforms.size > VM_UNIFORMS) {
154
+ attempt(() => {
155
+ if (uniforms.size <= VM_UNIFORMS) return
122
156
  const over = unit.uniforms[VM_UNIFORMS]!
123
157
  fail(
124
158
  `this file declares ${uniforms.size} uniforms and a program may hold ${VM_UNIFORMS}. ` +
@@ -127,14 +161,17 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
127
161
  `a float4.`,
128
162
  over.pos, over.name.length,
129
163
  )
130
- }
164
+ })
131
165
 
132
166
  for (const t of unit.textures) {
133
- const clash = valueClash(t.name)
134
- if (clash !== null) fail(`"${t.name}" already names ${clash}`, t.pos, t.name.length)
135
- 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)
136
172
  }
137
- if (textures.size > VM_TEXTURES) {
173
+ attempt(() => {
174
+ if (textures.size <= VM_TEXTURES) return
138
175
  const over = unit.textures[VM_TEXTURES]!
139
176
  fail(
140
177
  `this file declares ${textures.size} textures and a program may sample ${VM_TEXTURES}. ` +
@@ -142,24 +179,28 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
142
179
  `and its own after an eject, which is two different pictures from one file.`,
143
180
  over.pos, over.name.length,
144
181
  )
145
- }
182
+ })
146
183
 
147
184
  for (const c of unit.consts) {
148
- const clash = valueClash(c.name)
149
- if (clash !== null) fail(`"${c.name}" already names ${clash}`, c.pos, c.name.length)
150
- 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)
151
190
  }
152
191
 
153
192
  for (const fn of unit.funcs) {
154
- const clash = taken(fn.name)
155
- if (clash !== null) fail(`"${fn.name}" already names ${clash}`, fn.pos, fn.name.length)
156
- if (unit.funcs.filter((f) => f.name === fn.name).length > 1) {
157
- fail(
158
- `this file declares ${fn.name} more than once. There is no overloading: a function ` +
159
- `inlines, so two bodies under one name have nothing to pick between them`,
160
- fn.pos, fn.name.length,
161
- )
162
- }
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
+ })
163
204
  funcs.set(fn.name, fn)
164
205
  }
165
206
 
@@ -167,19 +208,23 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
167
208
  if (main === null) {
168
209
  if (options.requireMain ?? true) throw new Error("check() reached a unit with no main")
169
210
  for (const fn of unit.funcs) checkFunction(fn)
170
- refuseRecursion()
211
+ attempt(refuseRecursion)
171
212
  return { unit, funcs, uniforms, textures, consts }
172
213
  }
173
- if (main.ret !== "float4") {
174
- fail(`main returns a colour, so it is declared \`float4 main()\`, not ${main.ret}`, main.pos)
175
- }
176
- if (main.params.length > 0) {
177
- fail(
178
- "main takes no parameters: what a program is given are the free identifiers uv, " +
179
- "fragCoord, resolution, time and aspect",
180
- main.params[0]!.pos,
181
- )
182
- }
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
+ })
183
228
 
184
229
  // MARK: bodies
185
230
 
@@ -189,7 +234,7 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
189
234
  // which is not the author's mistake and not a name they can see.
190
235
  for (const fn of [...unit.funcs, main]) checkFunction(fn)
191
236
 
192
- refuseRecursion()
237
+ attempt(refuseRecursion)
193
238
 
194
239
  /**
195
240
  * A function inlines, so a cycle is not slow, it is infinite.
@@ -260,9 +305,11 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
260
305
  function checkFunction(fn: FuncDecl): void {
261
306
  const params = new Set<string>()
262
307
  for (const p of fn.params) {
263
- const clash = valueClash(p.name)
264
- if (clash !== null) fail(`"${p.name}" already names ${clash}`, p.pos, p.name.length)
265
- 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
+ })
266
313
  params.add(p.name)
267
314
  }
268
315
  checkBody(fn, fn.body, new Set(params), true)
@@ -279,8 +326,20 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
279
326
  let returned: Stmt | null = null
280
327
  for (const s of body) {
281
328
  if (returned !== null) {
282
- 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
283
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 {
284
343
  switch (s.k) {
285
344
  case "var":
286
345
  case "const": {
@@ -288,7 +347,6 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
288
347
  if (clash !== null) fail(`"${s.name}" already names ${clash}`, s.pos, s.name.length)
289
348
  if (scope.has(s.name)) fail(`"${s.name}" is already declared in this body`, s.pos)
290
349
  checkExpr(fn, s.init, scope)
291
- scope.add(s.name)
292
350
  break
293
351
  }
294
352
  case "assign": {
@@ -316,9 +374,7 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
316
374
  target.pos, n.length,
317
375
  )
318
376
  }
319
- if (!scope.has(n)) {
320
- fail(unknown(n, scope), target.pos, n.length)
321
- }
377
+ if (!scope.has(n)) unknown(n, scope, target.pos)
322
378
  checkExpr(fn, s.value, scope)
323
379
  break
324
380
  }
@@ -337,8 +393,11 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
337
393
  const inner = new Set(scope)
338
394
  inner.add(s.counter)
339
395
  counters.add(s.counter)
340
- checkBody(fn, s.body, inner, false)
341
- counters.delete(s.counter)
396
+ try {
397
+ checkBody(fn, s.body, inner, false)
398
+ } finally {
399
+ counters.delete(s.counter)
400
+ }
342
401
  break
343
402
  }
344
403
  case "return":
@@ -351,13 +410,9 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
351
410
  )
352
411
  }
353
412
  checkExpr(fn, s.value, scope)
354
- returned = s
355
413
  break
356
414
  }
357
415
  }
358
- if (outermost && returned === null) {
359
- fail(`${fn.name} never returns a ${fn.ret}`, fn.pos, fn.name.length)
360
- }
361
416
  }
362
417
 
363
418
  // MARK: expressions
@@ -382,7 +437,8 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
382
437
  if (funcs.has(e.name) || BUILTINS[e.name] !== undefined) {
383
438
  fail(`${e.name} is a function; call it, as in \`${e.name}(...)\``, e.pos, e.name.length)
384
439
  }
385
- fail(unknown(e.name, scope), e.pos, e.name.length)
440
+ glsl(e.name, e.pos)
441
+ unknown(e.name, scope, e.pos)
386
442
  break
387
443
  }
388
444
  case "member":
@@ -493,10 +549,16 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
493
549
  callee.pos, n.length,
494
550
  )
495
551
  }
496
- if (SL_GLSL_HINT[n] !== undefined) {
497
- fail(`${n} is GLSL; this is HLSL, so write ${SL_GLSL_HINT[n]}`, callee.pos, n.length)
498
- }
499
- fail(unknown(n, scope), callee.pos, n.length)
552
+ glsl(n, callee.pos, e.args.length)
553
+ unknown(n, scope, callee.pos)
554
+ }
555
+
556
+ /** Refuses a GLSL spelling by its HLSL name, with the fix where the rename is certain. */
557
+ function glsl(n: string, pos: Pos, args?: number): void {
558
+ const hint = SL_GLSL_HINT[n]
559
+ if (hint === undefined) return
560
+ const certain = GLSL_RENAMES.has(n) || (n === "atan" && args === 2)
561
+ fail(`${n} is GLSL; this is HLSL, so write ${hint}`, pos, n.length, certain ? rename(n, hint) : undefined)
500
562
  }
501
563
 
502
564
  function arity(e: Extract<Expr, { k: "call" }>, n: string, min: number, max: number): void {
@@ -505,13 +567,14 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
505
567
  fail(`${n} takes ${want} argument${max === 1 ? "" : "s"}, got ${e.args.length}`, e.pos)
506
568
  }
507
569
 
508
- /** "unknown x" plus the nearest thing that is spelled almost like it. */
509
- function unknown(n: string, scope: Set<string>): string {
570
+ /** Refuses a name nothing declares, offering the nearest one spelled almost like it. */
571
+ function unknown(n: string, scope: Set<string>, pos: Pos): never {
510
572
  const near = nearest(n, [
511
573
  ...scope, ...INPUT_NAMES, ...uniforms.keys(), ...textures.keys(), ...consts.keys(),
512
574
  ...funcs.keys(), ...Object.keys(BUILTINS),
513
575
  ])
514
- return near === null ? `"${n}" is not declared` : `"${n}" is not declared; did you mean ${near}?`
576
+ if (near === null) fail(`"${n}" is not declared`, pos, n.length)
577
+ fail(`"${n}" is not declared; did you mean ${near}?`, pos, n.length, rename(n, near))
515
578
  }
516
579
 
517
580
  return { unit, funcs, uniforms, textures, consts }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Every token of a file with what it is, for an editor's highlighting.
3
+ *
4
+ * `Specs/SL_NEXT.md` 9. Built on the parser's own scanner and the parser's own
5
+ * word lists, so what highlights as a keyword is exactly what parses as one,
6
+ * and a builtin is exactly what `BUILTINS` holds. It never throws: it runs on
7
+ * every keystroke over half typed text, and a character it cannot read comes
8
+ * back as `invalid` rather than ending the pass.
9
+ *
10
+ * Classification is by spelling and position alone, not by scope. A local
11
+ * that takes a builtin's name (`float circle = ...`) still reads `builtin`
12
+ * where it is used; telling the two apart needs `analyze`, and an editor that
13
+ * wants that should ask it.
14
+ */
15
+
16
+ import { INPUTS } from "../ir"
17
+ import { BUILTINS } from "./builtins"
18
+ import { lex } from "./lexer"
19
+ import { PRELUDE_NAMES } from "./prelude-source"
20
+ import { KEYWORD_SET, TYPE_SET } from "./words"
21
+
22
+ export type SLTokenClass =
23
+ | "keyword" | "type" | "builtin" | "input" | "prelude"
24
+ | "number" | "hex" | "comment" | "punct"
25
+ /** A component after a dot, as in `c.rgb`. */
26
+ | "member"
27
+ /** Any other name: a uniform, a local, a function the file declares. */
28
+ | "ident"
29
+ /** A character or `#word` the language cannot read, and would refuse. */
30
+ | "invalid"
31
+
32
+ export interface SLClassifiedToken {
33
+ kind: SLTokenClass
34
+ text: string
35
+ /** 1 based. */
36
+ line: number
37
+ /** 1 based. */
38
+ col: number
39
+ offset: number
40
+ length: number
41
+ }
42
+
43
+ const INPUT_NAMES: ReadonlySet<string> = new Set(Object.keys(INPUTS))
44
+
45
+ /** The tokens of `source`, comments included, in order, each with its class. */
46
+ export function classify(source: string): SLClassifiedToken[] {
47
+ const tokens = lex(source, "", true)
48
+ const out: SLClassifiedToken[] = []
49
+ // The last token that was not a comment, which decides what a name after a dot is.
50
+ let before = ""
51
+ let beforeThat = ""
52
+ for (const t of tokens) {
53
+ if (t.kind === "eof") break
54
+ let kind: SLTokenClass
55
+ if (t.kind === "ident") {
56
+ if (before === ".") kind = beforeThat === "sdf" ? "builtin" : "member"
57
+ else if (KEYWORD_SET.has(t.text)) kind = "keyword"
58
+ else if (TYPE_SET.has(t.text)) kind = "type"
59
+ else if (INPUT_NAMES.has(t.text)) kind = "input"
60
+ else if (BUILTINS[t.text] !== undefined) kind = "builtin"
61
+ else if (PRELUDE_NAMES.has(t.text)) kind = "prelude"
62
+ else kind = "ident"
63
+ } else {
64
+ kind = t.kind
65
+ }
66
+ out.push({ kind, text: t.text, line: t.line, col: t.col, offset: t.offset, length: t.length })
67
+ if (t.kind !== "comment") { beforeThat = before; before = t.text }
68
+ }
69
+ return out
70
+ }
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,9 +30,12 @@ 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 type { Pos, Token } from "./lexer"
33
+ export { SLParseError }
34
+ export type { Pos, SLFix, Token } from "./lexer"
34
35
  export { tokenize } from "./lexer"
36
+ export { classify } from "./classify"
37
+ export type { SLClassifiedToken, SLTokenClass } from "./classify"
38
+ export { SL_KEYWORDS, SL_TYPES } from "./words"
35
39
  export { parseUnit } from "./parser"
36
40
  export { PRELUDE_SOURCE, preludeFunctions } from "./prelude"
37
41
  export { BUILTINS, NOT_YET } from "./builtins"
@@ -53,3 +57,38 @@ export function analyze(source: string, options: ParseOptions = {}): Checked {
53
57
  const unit = parseUnit(source, options)
54
58
  return check(unit, preludeFunctions(), { requireMain: options.requireMain })
55
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
+ }