onejs-sl 0.1.3 → 0.1.5

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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5
4
+
5
+ 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.
6
+
7
+ - `classify` returns every token with its class, comments included, and never throws
8
+ - `SL_KEYWORDS`, `SL_TYPES` and `TYPE_WIDTH` are exported from `onejs-sl/tables`
9
+ - `BUILTIN_DOCS`, `INPUT_DOCS` and `PRELUDE_DOCS` describe every builtin, input and prelude function in a line
10
+ - `SLParseError` carries `text`, its message alone, and `offset`
11
+ - An error carries a one click `fix` where the fix is certain: a GLSL rename or a "did you mean"
12
+ - A GLSL name used as a value, such as `gl_FragCoord`, gets the HLSL name
13
+ - `break`, `continue` and `switch` say why they are not there
14
+ - A keyword or a type cannot name a value, a function or a parameter
15
+ - Something missing at the end of a line or the file is marked on the last token before it
16
+ - Errors from inside the EDSL say `float3`, not `vec3`
17
+ - `BodyTarget.sample` documents what the body expects of a sample
18
+
19
+ ## 0.1.4
20
+
21
+ 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.
22
+
23
+ - A swizzle can be assigned to: `p.x = 1;`, `c.rgb *= 0.5;`
24
+ - A single number fills a vector in a declaration, an assignment or a uniform default: `float3 c = 0.5;`
25
+ - A float4 goes into a float3 by dropping its fourth component: `float3 c = #ff8040;`
26
+ - A local or a uniform may take a builtin's or a prelude function's name
27
+
3
28
  ## 0.1.3
4
29
 
5
30
  `onejs-sl/tables` names every parameter, so an editor can offer `lerp(x, y, s)` rather than an argument count. Nothing a program compiles to changes.
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`, `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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onejs-sl",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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, 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, 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"
@@ -127,11 +127,25 @@ is read unconverted.
127
127
  and colours, and nothing else. It is baked into the program before anything
128
128
  runs, so it cannot name a const, which could itself name a uniform.
129
129
 
130
- **Shadowing is refused.** A local may not take the name of an input, a uniform,
131
- a texture, a const or a builtin. Allowing it would make "is this a texture?"
132
- depend on where the question is asked, for no gain in a language whose
133
- functions are six lines long. A file function may shadow a *prelude* function,
134
- which is the sanctioned way to replace one.
130
+ **Shadowing is refused between values.** A local may not take the name of an
131
+ input, a uniform, a texture or a const. Allowing it would make "is this a
132
+ texture?" depend on where the question is asked, for no gain in a language whose
133
+ functions are six lines long. A value *may* take a builtin's or a prelude
134
+ function's name (`float circle`, `uniform float turbulence`), which hides that
135
+ function wherever the value is visible, and a call there says so. A file
136
+ function may shadow a *prelude* function, which is the sanctioned way to replace
137
+ one.
138
+
139
+ **Declarations and assignments convert two ways, and only two.** A single
140
+ number fills every component (`float3 c = 0.5;`, and a uniform default such as
141
+ `uniform float2 offset = 0;`), and a float4 goes into a float3 by dropping its
142
+ fourth component, which is how a colour becomes an rgb. Every other width change
143
+ is refused with the swizzle that fixes it.
144
+
145
+ **A swizzle can be assigned to.** `p.x *= aspect;` rebuilds `p` from the
146
+ components it names and keeps the rest, so it is the same program as
147
+ `p = float2(p.x * aspect, p.y);`. A swizzle that names a component twice, or
148
+ one the local does not have, is refused.
135
149
 
136
150
  ## Not yet spellable
137
151
 
@@ -154,11 +168,36 @@ lowering it changes recorded behaviour rather than parser behaviour.
154
168
 
155
169
  ## Errors
156
170
 
157
- Every one carries file, line and column, and an error that starts inside the
158
- EDSL keeps the EDSL's wording. `"z" is component 3 of a vec2, which has 2` is
159
- already the right sentence; what it lacked was a place. `errors.test.ts` checks
160
- the positions as strictly as the words, because a marker under the wrong
161
- 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
+ ## For an editor
190
+
191
+ `classify(source)` is every token with its class (keyword, type, builtin,
192
+ input, prelude, number, hex, comment, punct, member, ident, or invalid), built
193
+ on the parser's own scanner and word lists, so the highlighting is the parser's.
194
+ It never throws, since it runs on every keystroke. It classifies by spelling,
195
+ not scope: a local named `circle` still reads as a builtin where it is used.
196
+
197
+ `SL_KEYWORDS` and `SL_TYPES` (`words.ts`) are the lists the parser reads, and a
198
+ name may be neither. `onejs-sl/tables` carries them with a one line description
199
+ of every builtin, input and prelude function; a prelude function's is the
200
+ comment above it in `prelude-source.ts`.
162
201
 
163
202
  ## See also
164
203
 
package/src/lang/check.ts CHANGED
@@ -14,11 +14,18 @@
14
14
  * whether a name exists, whether a body is shaped like a body, and whether the
15
15
  * program fits the budgets before anything is built.
16
16
  *
17
- * SHADOWING IS REFUSED. HLSL would let a local called `time` hide the input, or
18
- * a parameter called `tint` hide a uniform. Allowing it would mean every later
19
- * question about a name ("is this a texture?") depends on where it is asked
20
- * from, for no expressive gain in a language whose functions are half a dozen
21
- * lines long.
17
+ * SHADOWING IS REFUSED, between values. HLSL would let a local called `time`
18
+ * hide the input, or a parameter called `tint` hide a uniform. Allowing it would
19
+ * mean every later question about a name ("is this a texture?") depends on where
20
+ * it is asked from, for no expressive gain in a language whose functions are
21
+ * half a dozen lines long.
22
+ *
23
+ * A value MAY take the name of a builtin or a prelude function: `float circle`,
24
+ * `uniform float turbulence`. Those are the names an author reaches for first,
25
+ * and refusing them was the most common error left once the others were fixed
26
+ * (`Specs/SL_NEXT.md` 2d). Calls and values never meet in one position, so the
27
+ * only question left is a call to the name where the value is visible, and that
28
+ * is refused with the reason.
22
29
  */
23
30
 
24
31
  import { INPUTS } from "../ir"
@@ -27,10 +34,22 @@ import { SL_GLSL_HINT } from "../ops"
27
34
  import { BUILTINS, NOT_YET } from "./builtins"
28
35
  import { SL_SDF_SHAPES } from "../shapes"
29
36
  import type { Expr, FuncDecl, Stmt, Unit } from "./ast"
30
- import { SLParseError, type Pos } from "./lexer"
37
+ import { SLParseError, type Pos, type SLFix } from "./lexer"
31
38
 
32
39
  const INPUT_NAMES = new Set(Object.keys(INPUTS))
33
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
+
34
53
  export interface Checked {
35
54
  unit: Unit
36
55
  /** Every callable function by name, the file's shadowing the prelude's. */
@@ -58,9 +77,9 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
58
77
  // terminating control flow when the callee's `never` comes from an explicit
59
78
  // type, so without this every `if (x.k !== "ident") fail(...)` below would
60
79
  // fail to narrow.
61
- const fail: (message: string, pos: Pos, length?: number) => never =
62
- (message, pos, length = 1) => {
63
- throw new SLParseError(message, file, pos, length)
80
+ const fail: (message: string, pos: Pos, length?: number, fix?: SLFix) => never =
81
+ (message, pos, length = 1, fix) => {
82
+ throw new SLParseError(message, file, pos, length, fix)
64
83
  }
65
84
 
66
85
  const funcs = new Map<string, FuncDecl>()
@@ -84,8 +103,8 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
84
103
  *
85
104
  * Functions are NOT in here, because a file function shadowing a prelude
86
105
  * function of the same name is the sanctioned way to replace one
87
- * (`Specs/SL_TEXT.md` 3.8). Every other kind of declaration checks `asFunc`
88
- * as well, so only a function may take a function's name.
106
+ * (`Specs/SL_TEXT.md` 3.8). Every other kind of declaration checks
107
+ * `valueClash`, which adds the file's own functions.
89
108
  */
90
109
  const taken = (n: string): string | null => {
91
110
  if (INPUT_NAMES.has(n)) return "an input"
@@ -98,14 +117,16 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
98
117
  return null
99
118
  }
100
119
 
101
- const asFunc = (n: string): string | null => {
120
+ /** What a value may not be called: anything spoken for, except a builtin's or a prelude function's name. */
121
+ const valueClash = (n: string): string | null => {
122
+ const why = taken(n)
123
+ if (why !== null && why !== "a builtin") return why
102
124
  const fn = funcs.get(n)
103
- if (fn === undefined) return null
104
- return fn.prelude ? "a prelude function" : "a function"
125
+ return fn !== undefined && !fn.prelude ? "a function" : null
105
126
  }
106
127
 
107
128
  for (const u of unit.uniforms) {
108
- const clash = taken(u.name) ?? asFunc(u.name)
129
+ const clash = valueClash(u.name)
109
130
  if (clash !== null) fail(`"${u.name}" already names ${clash}`, u.pos, u.name.length)
110
131
  uniforms.set(u.name, u)
111
132
  }
@@ -121,7 +142,7 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
121
142
  }
122
143
 
123
144
  for (const t of unit.textures) {
124
- const clash = taken(t.name) ?? asFunc(t.name)
145
+ const clash = valueClash(t.name)
125
146
  if (clash !== null) fail(`"${t.name}" already names ${clash}`, t.pos, t.name.length)
126
147
  textures.set(t.name, t)
127
148
  }
@@ -136,7 +157,7 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
136
157
  }
137
158
 
138
159
  for (const c of unit.consts) {
139
- const clash = taken(c.name) ?? asFunc(c.name)
160
+ const clash = valueClash(c.name)
140
161
  if (clash !== null) fail(`"${c.name}" already names ${clash}`, c.pos, c.name.length)
141
162
  consts.set(c.name, c)
142
163
  }
@@ -251,7 +272,7 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
251
272
  function checkFunction(fn: FuncDecl): void {
252
273
  const params = new Set<string>()
253
274
  for (const p of fn.params) {
254
- const clash = taken(p.name) ?? asFunc(p.name)
275
+ const clash = valueClash(p.name)
255
276
  if (clash !== null) fail(`"${p.name}" already names ${clash}`, p.pos, p.name.length)
256
277
  if (params.has(p.name)) fail(`${fn.name} already has a parameter called "${p.name}"`, p.pos)
257
278
  params.add(p.name)
@@ -275,7 +296,7 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
275
296
  switch (s.k) {
276
297
  case "var":
277
298
  case "const": {
278
- const clash = taken(s.name) ?? asFunc(s.name)
299
+ const clash = valueClash(s.name)
279
300
  if (clash !== null) fail(`"${s.name}" already names ${clash}`, s.pos, s.name.length)
280
301
  if (scope.has(s.name)) fail(`"${s.name}" is already declared in this body`, s.pos)
281
302
  checkExpr(fn, s.init, scope)
@@ -283,27 +304,31 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
283
304
  break
284
305
  }
285
306
  case "assign": {
286
- if (s.target.k === "member") {
287
- fail(
288
- "a swizzle is read only; build the value you want instead, as in " +
289
- "`p = float2(1, p.y);`",
290
- s.target.pos,
291
- )
307
+ // `p.x = 1` and `c.rgb *= 0.5` write the components they name
308
+ // and keep the rest; lowering rebuilds the local from both.
309
+ let target = s.target
310
+ if (target.k === "member") {
311
+ const sw = target.name
312
+ if (!/^([xyzw]{1,4}|[rgba]{1,4})$/.test(sw)) {
313
+ fail(`${sw} is not a swizzle that can be assigned to; name components with xyzw or rgba`, target.pos, sw.length)
314
+ }
315
+ if (new Set(sw).size !== sw.length) {
316
+ fail(`${sw} names a component twice, so assigning to it would write one component two ways`, target.pos, sw.length)
317
+ }
318
+ target = target.obj
292
319
  }
293
- if (s.target.k !== "ident") fail("only a local can be assigned to", s.target.pos)
294
- const n = s.target.name
320
+ if (target.k !== "ident") fail("only a local can be assigned to", target.pos)
321
+ const n = target.name
295
322
  const why = taken(n)
296
- if (why !== null) fail(`"${n}" is ${why} and cannot be assigned to`, s.target.pos, n.length)
323
+ if (why !== null) fail(`"${n}" is ${why} and cannot be assigned to`, target.pos, n.length)
297
324
  if (counters.has(n)) {
298
325
  fail(
299
326
  `"${n}" is a loop counter. The loop unrolls and substitutes it as a ` +
300
327
  `number, so assigning to it would change nothing. Use another local`,
301
- s.target.pos, n.length,
328
+ target.pos, n.length,
302
329
  )
303
330
  }
304
- if (!scope.has(n)) {
305
- fail(unknown(n, scope), s.target.pos, n.length)
306
- }
331
+ if (!scope.has(n)) unknown(n, scope, target.pos)
307
332
  checkExpr(fn, s.value, scope)
308
333
  break
309
334
  }
@@ -313,7 +338,7 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
313
338
  checkBody(fn, s.else, new Set(scope), false)
314
339
  break
315
340
  case "for": {
316
- const clash = taken(s.counter) ?? asFunc(s.counter)
341
+ const clash = valueClash(s.counter)
317
342
  if (clash !== null) fail(`"${s.counter}" already names ${clash}`, s.pos, s.counter.length)
318
343
  if (scope.has(s.counter)) fail(`"${s.counter}" is already declared in this body`, s.pos)
319
344
  checkExpr(fn, s.from, scope)
@@ -367,7 +392,8 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
367
392
  if (funcs.has(e.name) || BUILTINS[e.name] !== undefined) {
368
393
  fail(`${e.name} is a function; call it, as in \`${e.name}(...)\``, e.pos, e.name.length)
369
394
  }
370
- fail(unknown(e.name, scope), e.pos, e.name.length)
395
+ glsl(e.name, e.pos)
396
+ unknown(e.name, scope, e.pos)
371
397
  break
372
398
  }
373
399
  case "member":
@@ -435,6 +461,16 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
435
461
  )
436
462
  }
437
463
 
464
+ // A value that took a builtin's or a prelude function's name hides it
465
+ // wherever the value is visible.
466
+ const value = scope.has(n) ? "a local" : uniforms.has(n) ? "a uniform" : consts.has(n) ? "a const" : null
467
+ if (value !== null) {
468
+ fail(`"${n}" is ${value} here, so it cannot be called. Rename it to call ${n}(...)`, callee.pos, n.length)
469
+ }
470
+ if (textures.has(n)) {
471
+ fail(`"${n}" is a texture; sample it with \`tex2D(${n}, uv)\``, callee.pos, n.length)
472
+ }
473
+
438
474
  const builtin = BUILTINS[n]
439
475
  if (builtin !== undefined) {
440
476
  if (n === "tex2D") {
@@ -468,13 +504,16 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
468
504
  callee.pos, n.length,
469
505
  )
470
506
  }
471
- if (SL_GLSL_HINT[n] !== undefined) {
472
- fail(`${n} is GLSL; this is HLSL, so write ${SL_GLSL_HINT[n]}`, callee.pos, n.length)
473
- }
474
- if (textures.has(n)) {
475
- fail(`"${n}" is a texture; sample it with \`tex2D(${n}, uv)\``, callee.pos, n.length)
476
- }
477
- fail(unknown(n, scope), callee.pos, n.length)
507
+ glsl(n, callee.pos, e.args.length)
508
+ unknown(n, scope, callee.pos)
509
+ }
510
+
511
+ /** Refuses a GLSL spelling by its HLSL name, with the fix where the rename is certain. */
512
+ function glsl(n: string, pos: Pos, args?: number): void {
513
+ const hint = SL_GLSL_HINT[n]
514
+ if (hint === undefined) return
515
+ const certain = GLSL_RENAMES.has(n) || (n === "atan" && args === 2)
516
+ fail(`${n} is GLSL; this is HLSL, so write ${hint}`, pos, n.length, certain ? rename(n, hint) : undefined)
478
517
  }
479
518
 
480
519
  function arity(e: Extract<Expr, { k: "call" }>, n: string, min: number, max: number): void {
@@ -483,13 +522,14 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
483
522
  fail(`${n} takes ${want} argument${max === 1 ? "" : "s"}, got ${e.args.length}`, e.pos)
484
523
  }
485
524
 
486
- /** "unknown x" plus the nearest thing that is spelled almost like it. */
487
- function unknown(n: string, scope: Set<string>): string {
525
+ /** Refuses a name nothing declares, offering the nearest one spelled almost like it. */
526
+ function unknown(n: string, scope: Set<string>, pos: Pos): never {
488
527
  const near = nearest(n, [
489
528
  ...scope, ...INPUT_NAMES, ...uniforms.keys(), ...textures.keys(), ...consts.keys(),
490
529
  ...funcs.keys(), ...Object.keys(BUILTINS),
491
530
  ])
492
- return near === null ? `"${n}" is not declared` : `"${n}" is not declared; did you mean ${near}?`
531
+ if (near === null) fail(`"${n}" is not declared`, pos, n.length)
532
+ fail(`"${n}" is not declared; did you mean ${near}?`, pos, n.length, rename(n, near))
493
533
  }
494
534
 
495
535
  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
@@ -30,8 +30,11 @@ export type {
30
30
  } from "./ast"
31
31
  export { TYPE_WIDTH } from "./ast"
32
32
  export { SLParseError } from "./lexer"
33
- export type { Pos, Token } from "./lexer"
33
+ export type { Pos, SLFix, Token } from "./lexer"
34
34
  export { tokenize } from "./lexer"
35
+ export { classify } from "./classify"
36
+ export type { SLClassifiedToken, SLTokenClass } from "./classify"
37
+ export { SL_KEYWORDS, SL_TYPES } from "./words"
35
38
  export { parseUnit } from "./parser"
36
39
  export { PRELUDE_SOURCE, preludeFunctions } from "./prelude"
37
40
  export { BUILTINS, NOT_YET } from "./builtins"
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
@@ -106,7 +106,7 @@ export function lower(checked: Checked): Program {
106
106
  const located = (e: unknown, pos: Pos): unknown => {
107
107
  if (e instanceof SLParseError) return e
108
108
  const raw = e instanceof Error ? e.message : String(e)
109
- return new SLParseError(raw.replace(/^\[onejs sl] /, ""), file, pos)
109
+ return new SLParseError(inFileWords(raw.replace(/^\[onejs sl] /, "")), file, pos)
110
110
  }
111
111
 
112
112
  const fail: (message: string, pos: Pos, length?: number) => never =
@@ -158,7 +158,7 @@ export function lower(checked: Checked): Program {
158
158
  for (const t of unit.textures) samplers.set(t.name, at(t.pos, () => sl.texture(t.name)))
159
159
 
160
160
  for (const c of unit.consts) {
161
- const v = lowerExpr(c.init, global)
161
+ const v = fit(lowerExpr(c.init, global), TYPE_WIDTH[c.type], c.pos)
162
162
  assertWidth(v, TYPE_WIDTH[c.type], c.name, c.type, c.pos)
163
163
  global.declare(c.name, { width: TYPE_WIDTH[c.type], value: v })
164
164
  }
@@ -227,6 +227,8 @@ export function lower(checked: Checked): Program {
227
227
  init.pos,
228
228
  )
229
229
  }
230
+ if (c.length === 1 && width > 1) return { components: new Array<number>(width).fill(c[0]!), colour: false }
231
+ if (c.length === 4 && width === 3) return { components: c.slice(0, 3), colour: false }
230
232
  if (c.length !== width) {
231
233
  fail(
232
234
  `this default has ${c.length} component${c.length === 1 ? "" : "s"} and the uniform is ` +
@@ -284,19 +286,25 @@ export function lower(checked: Checked): Program {
284
286
  switch (s.k) {
285
287
  case "var":
286
288
  case "const": {
287
- const v = lowerExpr(s.init, scope)
289
+ const v = fit(lowerExpr(s.init, scope), TYPE_WIDTH[s.type], s.pos)
288
290
  assertWidth(v, TYPE_WIDTH[s.type], s.name, s.type, s.pos)
289
291
  scope.declare(s.name, { width: TYPE_WIDTH[s.type], value: v })
290
292
  break
291
293
  }
292
294
  case "assign": {
293
- const name = (s.target as Extract<Expr, { k: "ident" }>).name
295
+ // The checker allowed exactly two shapes: a local, or a
296
+ // swizzle of one with no component named twice.
297
+ const member = s.target.k === "member" ? s.target : null
298
+ const name = ((member?.obj ?? s.target) as Extract<Expr, { k: "ident" }>).name
294
299
  const b = scope.lookup(name)!
295
300
  let v = lowerExpr(s.value, scope)
296
301
  if (s.op !== "=") {
297
302
  const op = s.op[0] as ArithOp
298
- v = at(s.pos, () => arithmetic(op, b.value, v, s.pos))
303
+ const old = member === null ? b.value : lowerExpr(member, scope)
304
+ v = at(s.pos, () => arithmetic(op, old, v, s.pos))
299
305
  }
306
+ if (member !== null) v = writeComponents(b, member, v, s.pos)
307
+ else v = fit(v, b.width, s.pos)
300
308
  assertWidth(v, b.width, name, widthType(b.width), s.pos)
301
309
  b.value = v
302
310
  break
@@ -381,13 +389,52 @@ export function lower(checked: Checked): Program {
381
389
  return typeof a !== "number" && typeof b !== "number" && a.ref === b.ref && a.width === b.width
382
390
  }
383
391
 
392
+ /**
393
+ * The two conversions a declaration or an assignment makes for the author
394
+ * (`Specs/SL_NEXT.md` 2b and 2c): a single number fills every component, as
395
+ * `float3(0.5)` would, and a float4 goes into a float3 by dropping its
396
+ * fourth, which is how a colour (a hex, a ramp, a texture sample) becomes
397
+ * an rgb. Every other width change is left for `assertWidth` to refuse.
398
+ */
399
+ function fit(v: LV, want: SLType, pos: Pos): LV {
400
+ const got = typeof v === "number" ? 1 : v.width
401
+ if (got === 1 && want > 1) return at(pos, () => compose(want, new Array<LV>(want).fill(v)))
402
+ if (got === 4 && want === 3) return at(pos, () => asVal(v).swz("xyz") as unknown as Val)
403
+ return v
404
+ }
405
+
406
+ /**
407
+ * `p.x = v`, `c.rgb *= k`: the local rebuilt from the components the
408
+ * swizzle names, taken from `v`, and the rest kept from what it held. A
409
+ * single number fills every named component.
410
+ */
411
+ function writeComponents(b: Binding, member: Extract<Expr, { k: "member" }>, v: LV, pos: Pos): LV {
412
+ const letters = member.name.replace(/[rgba]/g, (c) => "xyzw"["rgba".indexOf(c)]!)
413
+ const names = "xyzw".slice(0, b.width)
414
+ for (const c of letters) {
415
+ if (!names.includes(c)) {
416
+ fail(`${member.name} writes a component a ${widthType(b.width)} does not have`, member.pos)
417
+ }
418
+ }
419
+ const got = typeof v === "number" ? 1 : v.width
420
+ if (got !== 1 && got !== letters.length) {
421
+ fail(`${member.name} is ${letters.length} components and this is a ${widthType(got as SLType)}`, pos)
422
+ }
423
+ const parts: LV[] = [...names].map((c) => {
424
+ const j = letters.indexOf(c)
425
+ if (j < 0) return b.width === 1 ? b.value : at(pos, () => asVal(b.value).swz(c) as unknown as Val)
426
+ return got === 1 ? v : at(pos, () => asVal(v).swz("xyzw"[j]!) as unknown as Val)
427
+ })
428
+ return b.width === 1 ? parts[0]! : at(pos, () => compose(b.width, parts))
429
+ }
430
+
384
431
  function assertWidth(v: LV, want: SLType, name: string, type: TypeName, pos: Pos): void {
385
432
  const got = typeof v === "number" ? 1 : v.width
386
433
  if (got === want) return
387
434
  // A declaration is an assertion, not an input to inference
388
435
  // (`Specs/SL_TEXT.md` 3.2), so this says which half is wrong rather
389
436
  // than quietly promoting one to the other.
390
- const hint = got === 1 ? ` Wrap it in ${type}(...) to broadcast the value across ${want} components.` : ""
437
+ const hint = got > want ? ` Take the components you want with a swizzle, as in .${"xyzw".slice(0, want)}.` : ""
391
438
  fail(`${name} is declared ${type} and this is a ${widthType(got as SLType)}.${hint}`, pos)
392
439
  }
393
440
 
@@ -629,3 +676,17 @@ function widthType(w: SLType): TypeName {
629
676
  return (["float", "float2", "float3", "float4"] as const)[w - 1]!
630
677
  }
631
678
 
679
+ /**
680
+ * An EDSL message in the words of a `.sl` file.
681
+ *
682
+ * The EDSL names its types after its constructors, `sl.vec3`, so "cannot
683
+ * combine a vec3 with a vec2" is right for a TypeScript author and wrong for
684
+ * one who wrote float3 (`Specs/SL_NEXT.md` 5). One rewrite here, rather than a
685
+ * second set of messages, keeps both surfaces saying the same thing. A quoted
686
+ * name is the author's own and left as written.
687
+ */
688
+ function inFileWords(message: string): string {
689
+ return message.split(/("[^"]*")/).map((part, i) =>
690
+ i % 2 === 1 ? part : part.replace(/\bsl\.vec([234])\(/g, "float$1(").replace(/\bvec([234])\b/g, "float$1"),
691
+ ).join("")
692
+ }
@@ -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,
@@ -73,14 +76,21 @@ class Parser {
73
76
  private at(text: string): boolean { const t = this.peek(); return t.kind !== "eof" && t.text === text }
74
77
  private eat(text: string): boolean { if (this.at(text)) { this.i++; return true } return false }
75
78
 
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))
79
+ private fail(message: string, at: Token = this.peek(), fix?: SLFix): never {
80
+ // The end of the file is no character, so an error there marks the last
81
+ // token instead of one past it, where an editor has nothing to underline.
82
+ if (at.kind === "eof" && this.tokens.length > 1) at = this.tokens[this.tokens.length - 2]!
83
+ throw new SLParseError(message, this.file, at as Pos, Math.max(1, at.text.length), fix)
78
84
  }
79
85
 
80
86
  private expect(text: string, what: string): Token {
81
87
  if (!this.at(text)) {
82
88
  const got = this.peek()
83
- this.fail(`expected "${text}" ${what}, got ${describe(got)}`, got)
89
+ // Something missing at the end of a line is missing after the last
90
+ // token on it, which is where the author will look, not at the next line.
91
+ const prev = this.tokens[this.i - 1]
92
+ const at = prev !== undefined && got.line > prev.line ? prev : got
93
+ this.fail(`expected "${text}" ${what}, got ${describe(got)}`, at)
84
94
  }
85
95
  return this.next()
86
96
  }
@@ -91,11 +101,21 @@ class Parser {
91
101
  return this.next()
92
102
  }
93
103
 
104
+ /** A name being declared, which may not be a word the parser reads as syntax. */
105
+ private expectName(what: string): Token {
106
+ const t = this.expectIdent(what)
107
+ if (KEYWORD_SET.has(t.text)) this.fail(`"${t.text}" is a keyword, so it cannot be ${what}`, t)
108
+ if (TYPE_SET.has(t.text)) this.fail(`"${t.text}" is a type, so it cannot be ${what}`, t)
109
+ return t
110
+ }
111
+
94
112
  private expectType(what: string): TypeName {
95
113
  const t = this.expectIdent(what)
96
- if (TYPE_NAMES.has(t.text)) return t.text as TypeName
114
+ if (TYPE_SET.has(t.text)) return t.text as TypeName
97
115
  const why = NOT_A_TYPE[t.text]
98
- if (why !== undefined) this.fail(`"${t.text}" is not a type here: ${why}`, t)
116
+ const to = TYPE_FIX[t.text]
117
+ const fix = to === undefined ? undefined : { title: `Replace ${t.text} with ${to}`, replacement: to }
118
+ if (why !== undefined) this.fail(`"${t.text}" is not a type here: ${why}`, t, fix)
99
119
  this.fail(`"${t.text}" is not a type; the types are float, float2, float3 and float4`, t)
100
120
  }
101
121
 
@@ -146,7 +166,7 @@ class Parser {
146
166
  private parseUniform(): UniformDecl {
147
167
  const kw = this.next()
148
168
  const type = this.expectType("a type after uniform")
149
- const name = this.expectIdent("a uniform name")
169
+ const name = this.expectName("a uniform name")
150
170
  let init: Expr | null = null
151
171
  if (this.eat("=")) init = this.parseExpr()
152
172
  this.expect(";", "after a uniform declaration")
@@ -155,7 +175,7 @@ class Parser {
155
175
 
156
176
  private parseTexture(): TextureDecl {
157
177
  const kw = this.next()
158
- const name = this.expectIdent("a texture name")
178
+ const name = this.expectName("a texture name")
159
179
  if (this.at("=")) {
160
180
  this.fail(
161
181
  "a texture has no default; the host binds it by name through the textures prop",
@@ -169,7 +189,7 @@ class Parser {
169
189
  private parseConst(): Extract<Stmt, { k: "const" }> {
170
190
  const kw = this.next()
171
191
  const type = this.expectType("a type after const")
172
- const name = this.expectIdent("a name")
192
+ const name = this.expectName("a const's name")
173
193
  this.expect("=", "after a const's name; a const has to have a value")
174
194
  const init = this.parseExpr()
175
195
  this.expect(";", "after a const")
@@ -179,7 +199,7 @@ class Parser {
179
199
  private parseFunction(): FuncDecl {
180
200
  const start = this.peek()
181
201
  const ret = this.expectType("a return type")
182
- const name = this.expectIdent("a function name")
202
+ const name = this.expectName("a function name")
183
203
  if (!this.at("(")) {
184
204
  this.fail(
185
205
  `expected "(" after ${name.text}. Only uniforms, textures, consts and functions live ` +
@@ -193,7 +213,7 @@ class Parser {
193
213
  for (;;) {
194
214
  const p = this.peek()
195
215
  const ptype = this.expectType("a parameter type")
196
- const pname = this.expectIdent("a parameter name")
216
+ const pname = this.expectName("a parameter name")
197
217
  params.push({ type: ptype, name: pname.text, pos: p })
198
218
  if (!this.eat(",")) break
199
219
  }
@@ -238,6 +258,16 @@ class Parser {
238
258
  if (t.text === "discard") {
239
259
  this.fail("there is no discard; return a colour with alpha 0 instead", t)
240
260
  }
261
+ if (t.text === "break" || t.text === "continue") {
262
+ this.fail(
263
+ `there is no ${t.text}: a for loop unrolls, so every iteration runs. Put the rest of ` +
264
+ `the body under an if instead`,
265
+ t,
266
+ )
267
+ }
268
+ if (t.text === "switch") {
269
+ this.fail("there is no switch; write it as an if and else if", t)
270
+ }
241
271
  if (t.text === "return") {
242
272
  this.next()
243
273
  const value = this.parseExpr()
@@ -253,7 +283,7 @@ class Parser {
253
283
  // statement may be is the left of an assignment.
254
284
  if (t.kind === "ident" && this.peek(1).kind === "ident") {
255
285
  const type = this.expectType("a type")
256
- const name = this.expectIdent("a name")
286
+ const name = this.expectName("a local's name")
257
287
  if (this.at("(")) {
258
288
  this.fail(
259
289
  "a function is declared at the top level of the file, not inside another function",
@@ -318,7 +348,7 @@ class Parser {
318
348
  )
319
349
  }
320
350
  this.next()
321
- const counter = this.expectIdent("a counter name")
351
+ const counter = this.expectName("a counter name")
322
352
  this.expect("=", "after the counter")
323
353
  const from = this.parseExpr()
324
354
  this.expect(";", "after the counter's start")
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The prelude's source, and what an editor reads from it without parsing it.
3
+ *
4
+ * Kept out of `prelude.ts`, which parses it, so that `onejs-sl/tables` can offer
5
+ * the prelude's names and descriptions with no parser in the bundle. Each
6
+ * function's description is the comment above it, so there is one place to
7
+ * write it.
8
+ */
9
+
10
+ export const PRELUDE_SOURCE = `
11
+ // Rotate a point about the origin. Translate first if you want another centre.
12
+ float2 rotate(float2 p, float angle) {
13
+ float c = cos(angle);
14
+ float s = sin(angle);
15
+ return float2(p.x * c - p.y * s, p.x * s + p.y * c);
16
+ }
17
+
18
+ // Cartesian to polar: x is the angle in radians, y is the radius.
19
+ float2 polar(float2 p) {
20
+ return float2(atan2(p.y, p.x), length(p));
21
+ }
22
+
23
+ // Signed distance to a circle at the origin. Negative inside.
24
+ float circle(float2 p, float r) {
25
+ return length(p) - r;
26
+ }
27
+
28
+ // Signed distance to an axis aligned box of the given half extents.
29
+ float box(float2 p, float2 size) {
30
+ float2 d = abs(p) - size;
31
+ return length(max(d, 0)) + min(max(d.x, d.y), 0);
32
+ }
33
+
34
+ // Repeat a unit cell n times across the input, centred on zero in each cell.
35
+ float2 tile(float2 p, float n) {
36
+ return frac(p * n) - 0.5;
37
+ }
38
+
39
+ // Inigo Quilez's cosine palette. Four float3s in, a colour out, no texture.
40
+ float3 palette(float t, float3 a, float3 b, float3 c, float3 d) {
41
+ return a + b * cos(6.28318530718 * (c * t + d));
42
+ }
43
+ `
44
+
45
+ /** Each prelude function's name, in the order the source declares them. */
46
+ const DECLARED = /* @__PURE__ */ [...PRELUDE_SOURCE.matchAll(/^\/\/ (.+)\n\w+ (\w+)\(/gm)]
47
+
48
+ export const PRELUDE_NAMES: ReadonlySet<string> = /* @__PURE__ */ new Set(DECLARED.map((m) => m[2]!))
49
+
50
+ /** Each prelude function's one line description: the comment above it. */
51
+ export const PRELUDE_DOCS: Readonly<Record<string, string>> = /* @__PURE__ */ Object.fromEntries(
52
+ DECLARED.map((m) => [m[2]!, m[1]!]),
53
+ )
@@ -21,41 +21,9 @@
21
21
  import type { FuncDecl } from "./ast"
22
22
  import { check } from "./check"
23
23
  import { parseUnit } from "./parser"
24
+ import { PRELUDE_SOURCE } from "./prelude-source"
24
25
 
25
- export const PRELUDE_SOURCE = `
26
- // Rotate a point about the origin. Translate first if you want another centre.
27
- float2 rotate(float2 p, float angle) {
28
- float c = cos(angle);
29
- float s = sin(angle);
30
- return float2(p.x * c - p.y * s, p.x * s + p.y * c);
31
- }
32
-
33
- // Cartesian to polar: x is the angle in radians, y is the radius.
34
- float2 polar(float2 p) {
35
- return float2(atan2(p.y, p.x), length(p));
36
- }
37
-
38
- // Signed distance to a circle at the origin. Negative inside.
39
- float circle(float2 p, float r) {
40
- return length(p) - r;
41
- }
42
-
43
- // Signed distance to an axis aligned box of the given half extents.
44
- float box(float2 p, float2 size) {
45
- float2 d = abs(p) - size;
46
- return length(max(d, 0)) + min(max(d.x, d.y), 0);
47
- }
48
-
49
- // Repeat a unit cell n times across the input, centred on zero in each cell.
50
- float2 tile(float2 p, float n) {
51
- return frac(p * n) - 0.5;
52
- }
53
-
54
- // Inigo Quilez's cosine palette. Four float3s in, a colour out, no texture.
55
- float3 palette(float t, float3 a, float3 b, float3 c, float3 d) {
56
- return a + b * cos(6.28318530718 * (c * t + d));
57
- }
58
- `
26
+ export { PRELUDE_SOURCE }
59
27
 
60
28
  let cached: FuncDecl[] | null = null
61
29
 
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The words the parser reads as syntax, and the value types.
3
+ *
4
+ * One list, read by the parser (a name may not be one of these) and by
5
+ * `classify` (these highlight as keywords and types), so an editor that
6
+ * highlights with it agrees with the parser by construction rather than by a
7
+ * list it keeps in step by hand.
8
+ */
9
+
10
+ import { TYPE_WIDTH, type TypeName } from "./ast"
11
+
12
+ /**
13
+ * Declaration and statement words. `int` is here rather than among the types
14
+ * because it is only ever a for loop's counter, `for (int i = 0; i < 4; i++)`;
15
+ * a value cannot be declared as one.
16
+ */
17
+ export const SL_KEYWORDS = ["uniform", "texture2D", "const", "if", "else", "for", "return", "int"] as const
18
+
19
+ /** The types a value, a uniform or a function can have. */
20
+ export const SL_TYPES = Object.keys(TYPE_WIDTH) as readonly TypeName[]
21
+
22
+ export const KEYWORD_SET: ReadonlySet<string> = new Set(SL_KEYWORDS)
23
+ export const TYPE_SET: ReadonlySet<string> = new Set(SL_TYPES)
package/src/params.ts CHANGED
@@ -20,6 +20,7 @@ import { BUILTINS } from "./lang/builtins"
20
20
  import { LIB_FUNCTIONS, SDF_CALLS } from "./lib/table"
21
21
  import { LIB_PARAM_NAMES } from "./lib/names"
22
22
  import { SL_SDF_PARAMS, SL_SDF_SHAPES, type SlSdfKind } from "./shapes"
23
+ import type { InputName } from "./ir"
23
24
 
24
25
  /** One function of the helper library, as its source declares it. */
25
26
  export interface LibSignature {
@@ -111,3 +112,63 @@ function shapeNames(): Record<SlSdfKind, readonly string[]> {
111
112
  }
112
113
  return out
113
114
  }
115
+
116
+ /**
117
+ * One line on what each builtin does, for a completion's tooltip and the docs
118
+ * page: the other half of `BUILTIN_PARAMS`, and named in its words, so a line
119
+ * can say `s` and mean the third argument.
120
+ */
121
+ export const BUILTIN_DOCS: Readonly<Record<string, string>> = {
122
+ sin: "The sine of x, in radians.",
123
+ cos: "The cosine of x, in radians.",
124
+ tan: "The tangent of x, in radians.",
125
+ asin: "The angle whose sine is x, with x clamped to -1 to 1.",
126
+ acos: "The angle whose cosine is x, with x clamped to -1 to 1.",
127
+ exp: "e raised to the power x.",
128
+ log: "The natural logarithm of x.",
129
+ sqrt: "The square root of x.",
130
+ sign: "-1, 0 or 1, by the sign of x.",
131
+ ceil: "x rounded up to a whole number.",
132
+ round: "x rounded to the nearest whole number.",
133
+ toLinear: "A colour as written, in sRGB, converted to the space the target holds; alpha is left alone.",
134
+ abs: "x without its sign.",
135
+ floor: "x rounded down to a whole number.",
136
+ frac: "The part of x after the decimal point, 0 up to 1.",
137
+ saturate: "x clamped to 0 to 1.",
138
+ rcp: "1 / x.",
139
+ normalize: "x scaled to length 1.",
140
+ pow: "x raised to the power y.",
141
+ min: "The smaller of x and y, per component.",
142
+ max: "The larger of x and y, per component.",
143
+ clamp: "x held between min and max.",
144
+ atan2: "The angle of the point (x, y) from the positive x axis, in radians.",
145
+ length: "The length of the vector x.",
146
+ distance: "The distance between the points x and y.",
147
+ dot: "The dot product of x and y.",
148
+ cross: "The cross product of two float3s.",
149
+ reflect: "The direction i reflected off a surface with normal n.",
150
+ lerp: "Mixes x into y by s: x at 0, y at 1.",
151
+ step: "0 where x is below y, else 1.",
152
+ smoothstep: "0 below min, 1 above max, and a smooth curve between.",
153
+ remap: "v moved from the range fromMin to fromMax into the range toMin to toMax.",
154
+ hsv2rgb: "A colour from hue, saturation and value, each 0 to 1.",
155
+ luminance: "How bright a colour looks, as one number.",
156
+ noise: "Smooth value noise at p, about 0 to 1. Offset p for a different field.",
157
+ simplex: "Simplex noise at p, about 0 to 1, with no grid pattern.",
158
+ voronoi: "The distance from p to the nearest point of a jittered grid: cells.",
159
+ fbm: "Value noise layered in octaves, 1 to 4 (3 when left out): clouds and terrain.",
160
+ turbulence: "Simplex creases layered in octaves, 1 to 4: fire, smoke, marble.",
161
+ ridged: "Turbulence made bright at its creases: ridges, lightning, cracks.",
162
+ tex2D: "The colour of texture s at the uv t, with straight alpha.",
163
+ sdf: "The signed distance from p to a shape, negative inside: `sdf.circle(p, r)`.",
164
+ ramp: "t mapped through evenly spaced colour stops, from the first at 0 to the last at 1.",
165
+ }
166
+
167
+ /** One line on each input, for the same tooltips. */
168
+ export const INPUT_DOCS: Readonly<Record<InputName, string>> = {
169
+ uv: "Where this pixel is in the element, 0 to 1 on each axis, with y up.",
170
+ fragCoord: "Where this pixel is in the element, in pixels, with y up.",
171
+ resolution: "The element's size in pixels.",
172
+ time: "Seconds since the effect started.",
173
+ aspect: "The element's width divided by its height.",
174
+ }
package/src/tables.ts CHANGED
@@ -2,8 +2,10 @@
2
2
  * `onejs-sl/tables`: what completion and highlighting read.
3
3
  *
4
4
  * The same tables the checker and the emitters use, so an editor offers
5
- * exactly the builtins that compile, with the arities they take and the names
6
- * of their parameters (`params.ts`), and the helper library's signatures.
5
+ * exactly the builtins that compile, with the arities they take, the names of
6
+ * their parameters and a line on each (`params.ts`), the words the parser
7
+ * reads as syntax, and the helper library's signatures. `classify`, in the
8
+ * main entry, is the highlighter built on the same lists.
7
9
  */
8
10
  export { BUILTINS, NOT_YET } from "./lang/builtins"
9
11
  export type { Builtin } from "./lang/builtins"
@@ -13,6 +15,10 @@ export {
13
15
  export type { SLSurface } from "./ops"
14
16
  export { INPUTS } from "./ir"
15
17
  export { SL_SDF_SHAPES, SL_SDF_PARAMS } from "./shapes"
16
- export { BUILTIN_PARAMS, LIB_SIGNATURES, SL_SDF_PARAM_NAMES } from "./params"
18
+ export { BUILTIN_DOCS, BUILTIN_PARAMS, INPUT_DOCS, LIB_SIGNATURES, SL_SDF_PARAM_NAMES } from "./params"
19
+ export { PRELUDE_DOCS, PRELUDE_NAMES } from "./lang/prelude-source"
20
+ export { SL_KEYWORDS, SL_TYPES } from "./lang/words"
21
+ export { TYPE_WIDTH } from "./lang/ast"
22
+ export type { TypeName } from "./lang/ast"
17
23
  export type { LibSignature } from "./params"
18
24
  export type { SlSdfKind } from "./shapes"