onejs-sl 0.3.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
 
@@ -49,11 +49,15 @@ const DERIVED_NAMES = new Set(Object.keys(DERIVED_INPUTS))
49
49
  * The GLSL spellings whose HLSL name means the same wherever it is written, so
50
50
  * the hint can be a one click fix (`Specs/SL_NEXT.md` 5, Decision 1 A). The
51
51
  * rest of `SL_GLSL_HINT` gets the hint alone: `mod` floors where `%` truncates,
52
- * `ivec2` truncates where `float2` does not, `atan` is `atan2` only with two
53
- * arguments (handled at the call), and `textureLod` names a builtin that is not
54
- * implemented yet.
52
+ * `ivec2` truncates where `float2` does not, and `atan` is `atan2` only with two
53
+ * arguments (handled at the call).
55
54
  */
56
- const GLSL_RENAMES = new Set(["mix", "fract", "texture", "vec2", "vec3", "vec4", "gl_FragCoord", "iTime", "iResolution"])
55
+ const GLSL_RENAMES = new Set([
56
+ "mix", "fract", "texture", "textureLod", "vec2", "vec3", "vec4", "gl_FragCoord", "iTime", "iResolution",
57
+ ])
58
+
59
+ /** The builtins whose first argument is a texture, which is not a value anywhere else. */
60
+ const SAMPLES = new Set(["tex2D", "tex2Dlod"])
57
61
 
58
62
  const rename = (from: string, to: string): SLFix => ({ title: `Replace ${from} with ${to}`, replacement: to })
59
63
 
@@ -292,8 +296,16 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
292
296
  case "const": expr(s.init); return
293
297
  case "assign": expr(s.value); return
294
298
  case "if": expr(s.cond); s.then.forEach(stmt); s.else.forEach(stmt); return
295
- 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
296
305
  case "return": expr(s.value); return
306
+ case "block": s.body.forEach(stmt); return
307
+ case "break":
308
+ case "continue": return
297
309
  }
298
310
  }
299
311
  body.forEach(stmt)
@@ -310,31 +322,33 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
310
322
  })
311
323
  params.add(p.name)
312
324
  }
313
- 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
+ }
314
334
  }
315
335
 
316
336
  /**
317
- * `outermost` is the only place a `return` may stand.
318
- *
319
- * `Specs/SL_TEXT.md` 9.2: an `if` lowers to a `select`, which evaluates both
320
- * sides, so there is nothing for an early return to skip. Refusing it is
321
- * 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.
322
340
  */
323
- function checkBody(fn: FuncDecl, body: Stmt[], scope: Set<string>, outermost: boolean): void {
324
- 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
325
343
  for (const s of body) {
326
- if (returned !== null) {
344
+ if (ended !== null) {
327
345
  // Once: everything after it is the same mistake.
328
- attempt(() => fail("this is after the return, so it can never run", s.pos))
346
+ attempt(() => fail(unreachable(ended!), s.pos))
329
347
  break
330
348
  }
331
349
  attempt(() => checkStmt(s))
332
350
  if (s.k === "var" || s.k === "const") scope.add(s.name)
333
- // Only the outermost return ends a body; one inside an if is refused as that.
334
- if (s.k === "return" && outermost) returned = s
335
- }
336
- if (outermost && returned === null) {
337
- attempt(() => fail(`${fn.name} never returns a ${fn.ret}`, fn.pos, fn.name.length))
351
+ if (terminates([s])) ended = s
338
352
  }
339
353
 
340
354
  function checkStmt(s: Stmt): void {
@@ -347,72 +361,120 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
347
361
  checkExpr(fn, s.init, scope)
348
362
  break
349
363
  }
350
- case "assign": {
351
- // `p.x = 1` and `c.rgb *= 0.5` write the components they name
352
- // and keep the rest; lowering rebuilds the local from both.
353
- let target = s.target
354
- if (target.k === "member") {
355
- const sw = target.name
356
- if (!/^([xyzw]{1,4}|[rgba]{1,4})$/.test(sw)) {
357
- fail(`${sw} is not a swizzle that can be assigned to; name components with xyzw or rgba`, target.pos, sw.length)
358
- }
359
- if (new Set(sw).size !== sw.length) {
360
- fail(`${sw} names a component twice, so assigning to it would write one component two ways`, target.pos, sw.length)
361
- }
362
- target = target.obj
363
- }
364
- if (target.k !== "ident") fail("only a local can be assigned to", target.pos)
365
- const n = target.name
366
- // A local shadows what it is named after, a builtin or a
367
- // derived input, so it is assignable like any other local.
368
- const why = scope.has(n) ? null : taken(n)
369
- if (why !== null) fail(`"${n}" is ${why} and cannot be assigned to`, target.pos, n.length)
370
- if (counters.has(n)) {
371
- fail(
372
- `"${n}" is a loop counter. The loop unrolls and substitutes it as a ` +
373
- `number, so assigning to it would change nothing. Use another local`,
374
- target.pos, n.length,
375
- )
376
- }
377
- if (!scope.has(n)) unknown(n, scope, target.pos)
378
- checkExpr(fn, s.value, scope)
364
+ case "assign":
365
+ checkAssign(s, scope)
379
366
  break
380
- }
381
367
  case "if":
382
368
  checkExpr(fn, s.cond, scope)
383
- checkBody(fn, s.then, new Set(scope), false)
384
- 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)
385
371
  break
386
372
  case "for": {
387
373
  const clash = valueClash(s.counter)
388
374
  if (clash !== null) fail(`"${s.counter}" already names ${clash}`, s.pos, s.counter.length)
389
375
  if (scope.has(s.counter)) fail(`"${s.counter}" is already declared in this body`, s.pos)
390
376
  checkExpr(fn, s.from, scope)
391
- checkExpr(fn, s.to, scope)
392
- checkExpr(fn, s.step, scope)
393
377
  const inner = new Set(scope)
394
378
  inner.add(s.counter)
379
+ checkExpr(fn, s.cond, inner)
380
+ checkExpr(fn, s.update.value, inner)
395
381
  counters.add(s.counter)
396
382
  try {
397
- checkBody(fn, s.body, inner, false)
383
+ checkBody(fn, s.body, new Set(inner), { loop: true, switchCase: false })
398
384
  } finally {
399
385
  counters.delete(s.counter)
400
386
  }
401
387
  break
402
388
  }
403
- case "return":
404
- 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) {
405
395
  fail(
406
- "a return inside an if or a for has nothing to skip: an if evaluates " +
407
- "both sides and a for unrolls, so there is no branch to leave early. " +
408
- "Assign to a local and return it once at the end, or use ?:",
409
- 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,
410
399
  )
411
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":
412
433
  checkExpr(fn, s.value, scope)
413
434
  break
435
+ case "block":
436
+ checkBody(fn, s.body, new Set(scope), where)
437
+ break
414
438
  }
415
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
+ )
467
+ }
468
+ if (!scope.has(n)) unknown(n, scope, target.pos)
469
+ checkExpr(fn, s.value, scope)
470
+ }
471
+ }
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`
416
478
  }
417
479
 
418
480
  // MARK: expressions
@@ -421,13 +483,14 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
421
483
  switch (e.k) {
422
484
  case "num":
423
485
  case "hex":
486
+ case "bool":
424
487
  return
425
488
  case "ident": {
426
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
427
490
  if (textures.has(e.name)) {
428
491
  fail(
429
492
  `"${e.name}" is a texture, and a texture is only ever the first argument of ` +
430
- `tex2D. Write \`tex2D(${e.name}, uv)\``,
493
+ `tex2D or tex2Dlod. Write \`tex2D(${e.name}, uv)\``,
431
494
  e.pos, e.name.length,
432
495
  )
433
496
  }
@@ -458,9 +521,9 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
458
521
  // Only reached while collecting: the call was refused, and its
459
522
  // arguments are separate questions, so a mistake inside one is
460
523
  // not hidden by the call's own. `fract(uv * wrap)` is two
461
- // mistakes. tex2D's first argument is the texture, which is a
462
- // value only there.
463
- const tex = e.callee.k === "ident" && e.callee.name === "tex2D"
524
+ // mistakes. A sample's first argument is the texture, which is
525
+ // not a value.
526
+ const tex = e.callee.k === "ident" && SAMPLES.has(e.callee.name)
464
527
  for (const a of tex ? e.args.slice(1) : e.args) attempt(() => checkExpr(fn, a, scope))
465
528
  return
466
529
  }
@@ -503,7 +566,8 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
503
566
  }
504
567
  const n = callee.name
505
568
 
506
- 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) {
507
571
  for (const a of e.args) checkExpr(fn, a, scope)
508
572
  if (e.args.length === 0) fail(`${n}() needs at least one component`, e.pos)
509
573
  return
@@ -529,13 +593,22 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
529
593
 
530
594
  const builtin = BUILTINS[n]
531
595
  if (builtin !== undefined) {
532
- if (n === "tex2D") {
596
+ if (SAMPLES.has(n)) {
533
597
  const [tex, ...rest] = e.args
598
+ // HLSL's own tex2Dlod packs the level into a float4 with the uv.
599
+ if (n === "tex2Dlod" && e.args.length === 2) {
600
+ fail(
601
+ "tex2Dlod takes the uv and the mip level separately, `tex2Dlod(t, uv, lod)`, " +
602
+ "rather than HLSL's float4(uv, 0, lod)",
603
+ e.pos,
604
+ )
605
+ }
534
606
  arity(e, n, builtin.min, builtin.max)
535
607
  if (tex === undefined || tex.k !== "ident" || !textures.has(tex.name)) {
608
+ const lod = n === "tex2Dlod" ? ", 0" : ""
536
609
  fail(
537
- `tex2D samples a texture declared in this file, as in \`texture2D grain;\` ` +
538
- `then \`tex2D(grain, uv)\``,
610
+ `${n} samples a texture declared in this file, as in \`texture2D grain;\` ` +
611
+ `then \`${n}(grain, uv${lod})\``,
539
612
  (tex ?? e).pos,
540
613
  )
541
614
  }
@@ -635,3 +708,39 @@ function distance(a: string, b: string): number {
635
708
  }
636
709
  return rows[a.length]![b.length]!
637
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
@@ -75,10 +75,13 @@ const GLSL_TYPES = new Set(["float", "int", "uint", "bool", "vec2", "vec3", "vec
75
75
  /** A straight rename, the meaning unchanged. */
76
76
  const RENAME: Record<string, string> = {
77
77
  vec2: "float2", vec3: "float3", vec4: "float4", mix: "lerp", fract: "frac",
78
- texture: "tex2D", texture2D: "tex2D", iTime: "time", iGlobalTime: "time", gl_FragCoord: "fragCoord",
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. */
@@ -101,7 +101,6 @@ const MISSING: Record<string, string> = {
101
101
  iChannelTime: "a texture has no time of its own",
102
102
  iChannelResolution: "a texture's size cannot be read yet",
103
103
  iSampleRate: "there is no audio",
104
- textureLod: "tex2Dlod is not implemented yet; tex2D samples with the texture's own filtering",
105
104
  texelFetch: "a texture cannot be read without filtering yet",
106
105
  textureSize: "a texture's size cannot be read yet",
107
106
  dFdx: "there are no derivatives", dFdy: "there are no derivatives", fwidth: "there are no derivatives",
@@ -502,7 +501,7 @@ export function fromGLSL(glsl: string, options: { file?: string } = {}): FromGLS
502
501
  const span = spans.get(i)
503
502
  if (span !== undefined && span.end < to) { out += span.render(inEntry); i = span.end; continue }
504
503
  if (t.kind === "directive") { out += directive(t); continue }
505
- if (t.kind === "number") { out += t.text.replace(/[uU]$/, ""); continue }
504
+ if (t.kind === "number") { out += t.text.replace(/U$/, "u"); continue }
506
505
  if (t.kind !== "ident") { out += t.text; continue }
507
506
 
508
507
  const name = t.text
@@ -548,9 +547,6 @@ export function fromGLSL(glsl: string, options: { file?: string } = {}): FromGLS
548
547
  continue
549
548
  }
550
549
  if (NARROW[name] !== undefined) {
551
- // A for loop's counter is the one int the language has.
552
- const forCounter = name === "int" && is(back(i - 1), "(") && is(back(back(i - 1) - 1), "for")
553
- if (forCounter) { out += name; continue }
554
550
  const [to2, why] = NARROW[name]!
555
551
  noteOnce("narrow:" + name, t.line, why)
556
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
  }