onejs-sl 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lang/check.ts CHANGED
@@ -33,7 +33,7 @@ import { TEXTURE_SLOTS, UNIFORM_SLOTS } from "../ops"
33
33
  import { SL_GLSL_HINT } from "../ops"
34
34
  import { BUILTINS, NOT_YET } from "./builtins"
35
35
  import { SL_SDF_SHAPES } from "../shapes"
36
- import type { Expr, FuncDecl, Stmt, Unit } from "./ast"
36
+ import { TYPE_WIDTH, type Expr, type FuncDecl, type Stmt, type Unit } from "./ast"
37
37
  import { readAttributes } from "./attributes"
38
38
  import { SLParseError, type Pos, type SLFix } from "./lexer"
39
39
 
@@ -296,8 +296,16 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
296
296
  case "const": expr(s.init); return
297
297
  case "assign": expr(s.value); return
298
298
  case "if": expr(s.cond); s.then.forEach(stmt); s.else.forEach(stmt); return
299
- case "for": expr(s.from); expr(s.to); expr(s.step); s.body.forEach(stmt); return
299
+ case "for": expr(s.from); expr(s.cond); stmt(s.update); s.body.forEach(stmt); return
300
+ case "while": expr(s.cond); s.body.forEach(stmt); return
301
+ case "switch":
302
+ expr(s.value)
303
+ for (const c of s.cases) { c.labels.forEach((l) => { if (l !== null) expr(l) }); c.body.forEach(stmt) }
304
+ return
300
305
  case "return": expr(s.value); return
306
+ case "block": s.body.forEach(stmt); return
307
+ case "break":
308
+ case "continue": return
301
309
  }
302
310
  }
303
311
  body.forEach(stmt)
@@ -314,31 +322,33 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
314
322
  })
315
323
  params.add(p.name)
316
324
  }
317
- checkBody(fn, fn.body, new Set(params), true)
325
+ checkBody(fn, fn.body, new Set(params), { loop: false, switchCase: false })
326
+ if (!terminates(fn.body)) {
327
+ attempt(() => fail(
328
+ returnsSomewhere(fn.body)
329
+ ? `not every way through ${fn.name} returns a ${fn.ret}; add a return at the end`
330
+ : `${fn.name} never returns a ${fn.ret}`,
331
+ fn.pos, fn.name.length,
332
+ ))
333
+ }
318
334
  }
319
335
 
320
336
  /**
321
- * `outermost` is the only place a `return` may stand.
322
- *
323
- * `Specs/SL_TEXT.md` 9.2: an `if` lowers to a `select`, which evaluates both
324
- * sides, so there is nothing for an early return to skip. Refusing it is
325
- * what keeps that cost honest rather than pretending a branch happened.
337
+ * `where` says what a break or a continue would leave. A break inside a
338
+ * case, other than the one that ends it, is refused: it would leave the
339
+ * switch early, which an if says more plainly.
326
340
  */
327
- function checkBody(fn: FuncDecl, body: Stmt[], scope: Set<string>, outermost: boolean): void {
328
- let returned: Stmt | null = null
341
+ function checkBody(fn: FuncDecl, body: Stmt[], scope: Set<string>, where: { loop: boolean; switchCase: boolean }): void {
342
+ let ended: Stmt | null = null
329
343
  for (const s of body) {
330
- if (returned !== null) {
344
+ if (ended !== null) {
331
345
  // Once: everything after it is the same mistake.
332
- attempt(() => fail("this is after the return, so it can never run", s.pos))
346
+ attempt(() => fail(unreachable(ended!), s.pos))
333
347
  break
334
348
  }
335
349
  attempt(() => checkStmt(s))
336
350
  if (s.k === "var" || s.k === "const") scope.add(s.name)
337
- // Only the outermost return ends a body; one inside an if is refused as that.
338
- if (s.k === "return" && outermost) returned = s
339
- }
340
- if (outermost && returned === null) {
341
- attempt(() => fail(`${fn.name} never returns a ${fn.ret}`, fn.pos, fn.name.length))
351
+ if (terminates([s])) ended = s
342
352
  }
343
353
 
344
354
  function checkStmt(s: Stmt): void {
@@ -351,80 +361,129 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
351
361
  checkExpr(fn, s.init, scope)
352
362
  break
353
363
  }
354
- case "assign": {
355
- // `p.x = 1` and `c.rgb *= 0.5` write the components they name
356
- // and keep the rest; lowering rebuilds the local from both.
357
- let target = s.target
358
- if (target.k === "member") {
359
- const sw = target.name
360
- if (!/^([xyzw]{1,4}|[rgba]{1,4})$/.test(sw)) {
361
- fail(`${sw} is not a swizzle that can be assigned to; name components with xyzw or rgba`, target.pos, sw.length)
362
- }
363
- if (new Set(sw).size !== sw.length) {
364
- fail(`${sw} names a component twice, so assigning to it would write one component two ways`, target.pos, sw.length)
365
- }
366
- target = target.obj
367
- }
368
- if (target.k !== "ident") fail("only a local can be assigned to", target.pos)
369
- const n = target.name
370
- // A local shadows what it is named after, a builtin or a
371
- // derived input, so it is assignable like any other local.
372
- const why = scope.has(n) ? null : taken(n)
373
- if (why !== null) fail(`"${n}" is ${why} and cannot be assigned to`, target.pos, n.length)
374
- if (counters.has(n)) {
375
- fail(
376
- `"${n}" is a loop counter. The loop unrolls and substitutes it as a ` +
377
- `number, so assigning to it would change nothing. Use another local`,
378
- target.pos, n.length,
379
- )
380
- }
381
- if (!scope.has(n)) unknown(n, scope, target.pos)
382
- checkExpr(fn, s.value, scope)
364
+ case "assign":
365
+ checkAssign(s, scope)
383
366
  break
384
- }
385
367
  case "if":
386
368
  checkExpr(fn, s.cond, scope)
387
- checkBody(fn, s.then, new Set(scope), false)
388
- checkBody(fn, s.else, new Set(scope), false)
369
+ checkBody(fn, s.then, new Set(scope), where)
370
+ checkBody(fn, s.else, new Set(scope), where)
389
371
  break
390
372
  case "for": {
391
373
  const clash = valueClash(s.counter)
392
374
  if (clash !== null) fail(`"${s.counter}" already names ${clash}`, s.pos, s.counter.length)
393
375
  if (scope.has(s.counter)) fail(`"${s.counter}" is already declared in this body`, s.pos)
394
376
  checkExpr(fn, s.from, scope)
395
- checkExpr(fn, s.to, scope)
396
- checkExpr(fn, s.step, scope)
397
377
  const inner = new Set(scope)
398
378
  inner.add(s.counter)
379
+ checkExpr(fn, s.cond, inner)
380
+ checkExpr(fn, s.update.value, inner)
399
381
  counters.add(s.counter)
400
382
  try {
401
- checkBody(fn, s.body, inner, false)
383
+ checkBody(fn, s.body, new Set(inner), { loop: true, switchCase: false })
402
384
  } finally {
403
385
  counters.delete(s.counter)
404
386
  }
405
387
  break
406
388
  }
407
- case "return":
408
- if (!outermost) {
389
+ case "while":
390
+ checkExpr(fn, s.cond, scope)
391
+ checkBody(fn, s.body, new Set(scope), { loop: true, switchCase: false })
392
+ break
393
+ case "break":
394
+ if (where.switchCase) {
409
395
  fail(
410
- "a return inside an if or a for has nothing to skip: an if evaluates " +
411
- "both sides and a for unrolls, so there is no branch to leave early. " +
412
- "Assign to a local and return it once at the end, or use ?:",
413
- s.pos,
396
+ "this break would leave the switch before the end of its case. Put the rest " +
397
+ "of the case under an if instead",
398
+ s.pos, 5,
414
399
  )
415
400
  }
401
+ if (!where.loop) fail("break leaves a loop, or ends a switch's case, and this is in neither", s.pos, 5)
402
+ break
403
+ case "continue":
404
+ if (!where.loop) fail("continue starts a loop's next turn, and this is not in a loop", s.pos, 8)
405
+ break
406
+ case "switch": {
407
+ checkExpr(fn, s.value, scope)
408
+ let defaults = 0
409
+ for (const c of s.cases) {
410
+ for (const l of c.labels) {
411
+ if (l === null) {
412
+ defaults++
413
+ if (defaults > 1) fail("this switch already has a default", c.pos, 7)
414
+ } else {
415
+ checkExpr(fn, l, scope)
416
+ }
417
+ }
418
+ if (c.body.length === 0 && !c.closed) {
419
+ fail("this case has no body; a case that shares the next one's body stacks its label on it", c.pos, 4)
420
+ }
421
+ checkBody(fn, c.body, new Set(scope), { loop: where.loop, switchCase: true })
422
+ if (!c.closed && !terminates(c.body)) {
423
+ fail(
424
+ "this case does not end in break or return. Cases never fall through into the " +
425
+ "next one here, so end it with `break;`",
426
+ c.pos, 4,
427
+ )
428
+ }
429
+ }
430
+ break
431
+ }
432
+ case "return":
416
433
  checkExpr(fn, s.value, scope)
417
434
  break
435
+ case "block":
436
+ checkBody(fn, s.body, new Set(scope), where)
437
+ break
438
+ }
439
+ }
440
+
441
+ function checkAssign(s: Extract<Stmt, { k: "assign" }>, scope: Set<string>): void {
442
+ // `p.x = 1` and `c.rgb *= 0.5` write the components they name
443
+ // and keep the rest; lowering rebuilds the local from both.
444
+ let target = s.target
445
+ if (target.k === "member") {
446
+ const sw = target.name
447
+ if (!/^([xyzw]{1,4}|[rgba]{1,4})$/.test(sw)) {
448
+ fail(`${sw} is not a swizzle that can be assigned to; name components with xyzw or rgba`, target.pos, sw.length)
449
+ }
450
+ if (new Set(sw).size !== sw.length) {
451
+ fail(`${sw} names a component twice, so assigning to it would write one component two ways`, target.pos, sw.length)
452
+ }
453
+ target = target.obj
454
+ }
455
+ if (target.k !== "ident") fail("only a local can be assigned to", target.pos)
456
+ const n = target.name
457
+ // A local shadows what it is named after, a builtin or a
458
+ // derived input, so it is assignable like any other local.
459
+ const why = scope.has(n) ? null : taken(n)
460
+ if (why !== null) fail(`"${n}" is ${why} and cannot be assigned to`, target.pos, n.length)
461
+ if (counters.has(n)) {
462
+ fail(
463
+ `"${n}" is a loop counter, which only the loop's update changes, so the loop's ` +
464
+ `turns can be counted. Use another local`,
465
+ target.pos, n.length,
466
+ )
418
467
  }
468
+ if (!scope.has(n)) unknown(n, scope, target.pos)
469
+ checkExpr(fn, s.value, scope)
419
470
  }
420
471
  }
421
472
 
473
+ /** Why the statement after `s` never runs. */
474
+ function unreachable(s: Stmt): string {
475
+ if (s.k === "return" || s.k === "break" || s.k === "continue") return `this is after the ${s.k}, so it can never run`
476
+ const above = s.k === "block" ? "braces" : s.k
477
+ return `this can never run: every way through the ${above} above ends in a return, break or continue`
478
+ }
479
+
422
480
  // MARK: expressions
423
481
 
424
482
  function checkExpr(fn: FuncDecl, e: Expr, scope: Set<string>): void {
425
483
  switch (e.k) {
426
484
  case "num":
427
485
  case "hex":
486
+ case "bool":
428
487
  return
429
488
  case "ident": {
430
489
  if (scope.has(e.name) || INPUT_NAMES.has(e.name) || DERIVED_NAMES.has(e.name) || uniforms.has(e.name) || consts.has(e.name)) return
@@ -507,7 +566,8 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
507
566
  }
508
567
  const n = callee.name
509
568
 
510
- if (n === "float2" || n === "float3" || n === "float4" || n === "float") {
569
+ // A constructor, or a conversion: `float3(...)`, `int(x)`, `bool(x)`.
570
+ if (n in TYPE_WIDTH) {
511
571
  for (const a of e.args) checkExpr(fn, a, scope)
512
572
  if (e.args.length === 0) fail(`${n}() needs at least one component`, e.pos)
513
573
  return
@@ -648,3 +708,39 @@ function distance(a: string, b: string): number {
648
708
  }
649
709
  return rows[a.length]![b.length]!
650
710
  }
711
+
712
+ /**
713
+ * Whether a body ends on every way through it in a return, a break or a
714
+ * continue, so nothing after it runs. A loop never counts: its condition, or
715
+ * its turn limit, can always end it. The lowering reads this too, to put what
716
+ * follows an if into the side that does not end.
717
+ */
718
+ export function terminates(body: Stmt[]): boolean {
719
+ return body.some((s) => {
720
+ switch (s.k) {
721
+ case "return":
722
+ case "break":
723
+ case "continue": return true
724
+ case "if": return terminates(s.then) && terminates(s.else)
725
+ case "block": return terminates(s.body)
726
+ // A case's break only ends the switch, so it counts only when every
727
+ // case leaves by some other way and a default leaves nothing out.
728
+ case "switch": return s.cases.some((c) => c.labels.includes(null)) && s.cases.every((c) => !c.closed && terminates(c.body))
729
+ default: return false
730
+ }
731
+ })
732
+ }
733
+
734
+ function returnsSomewhere(body: Stmt[]): boolean {
735
+ return body.some((s) => {
736
+ switch (s.k) {
737
+ case "return": return true
738
+ case "if": return returnsSomewhere(s.then) || returnsSomewhere(s.else)
739
+ case "block": return returnsSomewhere(s.body)
740
+ case "for":
741
+ case "while": return returnsSomewhere(s.body)
742
+ case "switch": return s.cases.some((c) => returnsSomewhere(c.body))
743
+ default: return false
744
+ }
745
+ })
746
+ }
package/src/lang/glsl.ts CHANGED
@@ -78,7 +78,10 @@ const RENAME: Record<string, string> = {
78
78
  texture: "tex2D", texture2D: "tex2D", textureLod: "tex2Dlod", iTime: "time", iGlobalTime: "time", gl_FragCoord: "fragCoord",
79
79
  }
80
80
 
81
- /** A rename that changes what the value can hold, so it is said. */
81
+ /**
82
+ * A rename that changes what the value can hold, so it is said. `int`, `uint`
83
+ * and `bool` are types here too and are kept; their vectors are not yet.
84
+ */
82
85
  const NARROW: Record<string, [string, string]> = {
83
86
  ivec2: ["float2", "an ivec2 is a float2 here, so dividing one no longer truncates"],
84
87
  ivec3: ["float3", "an ivec3 is a float3 here, so dividing one no longer truncates"],
@@ -87,9 +90,6 @@ const NARROW: Record<string, [string, string]> = {
87
90
  uvec4: ["float4", "a uvec4 is a float4 here"],
88
91
  bvec2: ["float2", "a bvec2 is a float2 of 0s and 1s here"], bvec3: ["float3", "a bvec3 is a float3 of 0s and 1s here"],
89
92
  bvec4: ["float4", "a bvec4 is a float4 of 0s and 1s here"],
90
- bool: ["float", "a bool is a float that is 0 or 1 here; a comparison already is one"],
91
- int: ["float", "an int is a float here, so dividing one no longer truncates; wrap it in floor() where that mattered"],
92
- uint: ["float", "a uint is a float here"],
93
93
  }
94
94
 
95
95
  /** Shadertoy's inputs with no counterpart yet, and why. */
@@ -501,7 +501,7 @@ export function fromGLSL(glsl: string, options: { file?: string } = {}): FromGLS
501
501
  const span = spans.get(i)
502
502
  if (span !== undefined && span.end < to) { out += span.render(inEntry); i = span.end; continue }
503
503
  if (t.kind === "directive") { out += directive(t); continue }
504
- if (t.kind === "number") { out += t.text.replace(/[uU]$/, ""); continue }
504
+ if (t.kind === "number") { out += t.text.replace(/U$/, "u"); continue }
505
505
  if (t.kind !== "ident") { out += t.text; continue }
506
506
 
507
507
  const name = t.text
@@ -547,9 +547,6 @@ export function fromGLSL(glsl: string, options: { file?: string } = {}): FromGLS
547
547
  continue
548
548
  }
549
549
  if (NARROW[name] !== undefined) {
550
- // A for loop's counter is the one int the language has.
551
- const forCounter = name === "int" && is(back(i - 1), "(") && is(back(back(i - 1) - 1), "for")
552
- if (forCounter) { out += name; continue }
553
550
  const [to2, why] = NARROW[name]!
554
551
  noteOnce("narrow:" + name, t.line, why)
555
552
  out += to2
package/src/lang/lexer.ts CHANGED
@@ -31,6 +31,10 @@ export interface Token extends Pos {
31
31
  text: string
32
32
  /** Numbers only: the parsed value. */
33
33
  value?: number
34
+ /** Numbers only: written with no point, no exponent and no `f`, as `3` or `0xff`. */
35
+ whole?: boolean
36
+ /** Numbers only: written with a `u`, as `3u`. */
37
+ unsigned?: boolean
34
38
  /** Strings only: the text between the quotes, with `\"` and `\\` read. */
35
39
  str?: string
36
40
  }
@@ -87,9 +91,10 @@ export class SLParseError extends SLError {
87
91
  * baffling about the second half.
88
92
  */
89
93
  const PUNCT = [
90
- "<=", ">=", "==", "!=", "&&", "||", "++", "--", "+=", "-=", "*=", "/=", "%=",
94
+ "<<=", ">>=",
95
+ "<=", ">=", "==", "!=", "&&", "||", "++", "--", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "<<", ">>",
91
96
  "(", ")", "{", "}", "[", "]", ",", ";", ".", "+", "-", "*", "/", "%",
92
- "<", ">", "!", "?", ":", "=",
97
+ "<", ">", "!", "?", ":", "=", "&", "|", "^", "~",
93
98
  ]
94
99
 
95
100
  /** Directives that exist in HLSL and deliberately do not exist here. */
@@ -185,6 +190,19 @@ export function lex(source: string, file: string, keep: boolean): Token[] | Lexe
185
190
  continue
186
191
  }
187
192
 
193
+ // A whole number in hex, as an integer hash writes its constants: `0x9E3779B9u`.
194
+ if (c === "0" && (source[i + 1] === "x" || source[i + 1] === "X") && isHex(source[i + 2] ?? "")) {
195
+ const start = here()
196
+ let j = i + 2
197
+ while (j < source.length && isHex(source[j]!)) j++
198
+ const text = source.slice(i, j)
199
+ const unsigned = source[j] === "u" || source[j] === "U"
200
+ if (unsigned) j++
201
+ push({ kind: "number", text, value: Number(text), whole: true, unsigned, ...start }, j)
202
+ i = j
203
+ continue
204
+ }
205
+
188
206
  if (isDigit(c) || (c === "." && isDigit(source[i + 1] ?? ""))) {
189
207
  const start = here()
190
208
  let j = i
@@ -196,15 +214,21 @@ export function lex(source: string, file: string, keep: boolean): Token[] | Lexe
196
214
  if (isDigit(source[k] ?? "")) { k++; while (k < source.length && isDigit(source[k]!)) k++; j = k }
197
215
  }
198
216
  const text = source.slice(i, j)
217
+ let whole = /^[0-9]+$/.test(text)
218
+ let unsigned = false
199
219
  // HLSL's float suffix. Accepted and dropped: `1.0f` is the same
200
220
  // number, and refusing it would only teach the author that this is
201
- // not quite the language they think it is.
202
- if (source[j] === "f" || source[j] === "F") {
203
- if (!isIdentPart(source[j + 1] ?? "")) j++
221
+ // not quite the language they think it is. It does make a float.
222
+ if ((source[j] === "f" || source[j] === "F") && !isIdentPart(source[j + 1] ?? "")) {
223
+ j++
224
+ whole = false
225
+ } else if ((source[j] === "u" || source[j] === "U") && whole && !isIdentPart(source[j + 1] ?? "")) {
226
+ j++
227
+ unsigned = true
204
228
  }
205
229
  const value = Number(text)
206
230
  if (!Number.isFinite(value)) fail(`"${text}" is not a number`, start, text.length)
207
- else push({ kind: "number", text, value, ...start }, j)
231
+ else push({ kind: "number", text, value, whole, unsigned, ...start }, j)
208
232
  i = j
209
233
  continue
210
234
  }