wesl 0.7.26 → 0.7.28

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.
Files changed (67) hide show
  1. package/README.md +1 -1
  2. package/dist/index.d.ts +283 -147
  3. package/dist/index.js +1765 -1143
  4. package/package.json +2 -2
  5. package/src/AbstractElems.ts +264 -82
  6. package/src/ClickableError.ts +8 -1
  7. package/src/Linker.ts +63 -67
  8. package/src/LinkerUtil.ts +141 -7
  9. package/src/LowerAndEmit.ts +660 -304
  10. package/src/Mangler.ts +1 -1
  11. package/src/ModuleResolver.ts +15 -4
  12. package/src/ParseWESL.ts +21 -33
  13. package/src/SrcMap.ts +7 -19
  14. package/src/StandardTypes.ts +1 -1
  15. package/src/WeslDevice.ts +1 -0
  16. package/src/debug/ASTtoString.ts +92 -76
  17. package/src/index.ts +1 -1
  18. package/src/parse/AttachComments.ts +289 -0
  19. package/src/parse/ExpressionUtil.ts +3 -3
  20. package/src/parse/Keywords.ts +1 -1
  21. package/src/parse/ParseAttribute.ts +49 -71
  22. package/src/parse/ParseCall.ts +3 -4
  23. package/src/parse/ParseControlFlow.ts +100 -56
  24. package/src/parse/ParseDirective.ts +9 -8
  25. package/src/parse/ParseDoBlock.ts +63 -0
  26. package/src/parse/ParseExpression.ts +11 -10
  27. package/src/parse/ParseFn.ts +11 -33
  28. package/src/parse/ParseGlobalVar.ts +36 -27
  29. package/src/parse/ParseIdent.ts +4 -5
  30. package/src/parse/ParseLocalVar.ts +16 -12
  31. package/src/parse/ParseLoop.ts +76 -39
  32. package/src/parse/ParseModule.ts +65 -19
  33. package/src/parse/ParseSimpleStatement.ts +112 -66
  34. package/src/parse/ParseStatement.ts +40 -67
  35. package/src/parse/ParseStruct.ts +8 -22
  36. package/src/parse/ParseType.ts +10 -13
  37. package/src/parse/ParseUtil.ts +26 -12
  38. package/src/parse/ParseValueDeclaration.ts +18 -31
  39. package/src/parse/ParseWesl.ts +11 -14
  40. package/src/parse/ParsingContext.ts +11 -9
  41. package/src/parse/WeslStream.ts +138 -121
  42. package/src/parse/stream/RegexMatchers.ts +45 -0
  43. package/src/parse/stream/WeslLexer.ts +249 -0
  44. package/src/test/BevyLink.test.ts +18 -11
  45. package/src/test/ConditionalElif.test.ts +4 -2
  46. package/src/test/DeclCommentEmit.test.ts +122 -0
  47. package/src/test/DoBlock.test.ts +164 -0
  48. package/src/test/FilterValidElements.test.ts +4 -6
  49. package/src/test/Linker.test.ts +23 -7
  50. package/src/test/Mangling.test.ts +8 -4
  51. package/src/test/ParseComments.test.ts +234 -6
  52. package/src/test/ParseConditionsV2.test.ts +47 -175
  53. package/src/test/ParseElifV2.test.ts +12 -33
  54. package/src/test/ParseErrorV2.test.ts +0 -0
  55. package/src/test/ParseWeslV2.test.ts +247 -626
  56. package/src/test/StatementEmit.test.ts +143 -0
  57. package/src/test/StripWesl.ts +24 -3
  58. package/src/test/TestLink.ts +19 -3
  59. package/src/test/TestUtil.ts +18 -8
  60. package/src/test/Tokenizer.test.ts +96 -0
  61. package/src/test/__snapshots__/ParseWeslV2.test.ts.snap +10 -42
  62. package/src/RawEmit.ts +0 -103
  63. package/src/Reflection.ts +0 -336
  64. package/src/TransformBindingStructs.ts +0 -320
  65. package/src/parse/ContentsHelpers.ts +0 -70
  66. package/src/parse/stream/CachingStream.ts +0 -48
  67. package/src/parse/stream/MatchersStream.ts +0 -85
@@ -1,89 +1,51 @@
1
1
  import { ParseError } from "../ParseError.ts";
2
2
  import type { Stream, TypedToken } from "../Stream.ts";
3
3
  import { keywords, reservedWords } from "./Keywords.ts";
4
- import { CachingStream } from "./stream/CachingStream.ts";
5
- import { MatchersStream, RegexMatchers } from "./stream/MatchersStream.ts";
6
- import { matchOneOf } from "./stream/RegexHelpers.ts";
4
+ import { type InternalTokenKind, WeslLexer } from "./stream/WeslLexer.ts";
7
5
  export type WeslTokenKind = "word" | "keyword" | "number" | "symbol";
8
6
 
9
7
  export type WeslToken<Kind extends WeslTokenKind = WeslTokenKind> =
10
8
  TypedToken<Kind>;
11
9
 
12
- // https://www.w3.org/TR/WGSL/#blankspace-and-line-breaks
13
- /** Whitespaces including new lines */
14
- const blankspaces = /[ \t\n\v\f\r\u{0085}\u{200E}\u{200F}\u{2028}\u{2029}]+/u;
15
- const symbolSet =
16
- "& && -> @ / ! [ ] { } :: : , == = != >>= >> >= > <<= << <= < % - --" +
17
- " . + ++ | || ( ) ; * ~ ^ // /* */ += -= *= /= %= &= |= ^=" +
18
- // For the _ = expr; syntax
19
- " _";
20
-
21
- const ident =
22
- /(?:(?:[_\p{XID_Start}][\p{XID_Continue}]+)|(?:[\p{XID_Start}]))/u;
23
-
24
- /** Checks if a word is a valid WGSL ident, and not a keyword */
25
- export function isIdent(text: string): boolean {
26
- if (text.match(ident)?.[0] !== text) {
27
- return false;
28
- }
29
- if (keywordOrReserved.has(text)) {
30
- return false;
31
- }
32
- return true;
10
+ /** A comment skipped by the tokenizer, recorded as leading trivia of the next token.
11
+ * Line-break flags (newline/blank before) are not stored here: they are derived
12
+ * on demand during comment attachment, so the tokenizer hot path never counts
13
+ * line breaks. */
14
+ export interface CommentTrivia {
15
+ style: "line" | "block";
16
+ /** Source range of the comment text (excluding the trailing newline of a line comment). */
17
+ start: number;
18
+ end: number;
33
19
  }
34
20
 
35
- const keywordOrReserved = new Set(keywords.concat(reservedWords));
36
-
37
- const digits = new RegExp(
38
- // decimal_float_literal
39
- /(?:0[fh])|(?:[1-9][0-9]*[fh])/.source +
40
- /|(?:[0-9]*\.[0-9]+(?:[eE][+-]?[0-9]+)?[fh]?)/.source +
41
- /|(?:[0-9]+\.[0-9]*(?:[eE][+-]?[0-9]+)?[fh]?)/.source +
42
- /|(?:[0-9]+[eE][+-]?[0-9]+[fh]?)/.source +
43
- // hex_float_literal
44
- /|(?:0[xX][0-9a-fA-F]*\.[0-9a-fA-F]+(?:[pP][+-]?[0-9]+[fh]?)?)/.source +
45
- /|(?:0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*(?:[pP][+-]?[0-9]+[fh]?)?)/.source +
46
- /|(?:0[xX][0-9a-fA-F]+[pP][+-]?[0-9]+[fh]?)/.source +
47
- // hex_int_literal
48
- /|(?:0[xX][0-9a-fA-F]+[iu]?)/.source +
49
- // decimal_int_literal
50
- /|(?:0[iu]?)|(?:[1-9][0-9]*[iu]?)/.source,
51
- );
21
+ /** One line break, treating \r\n as a single break.
22
+ * Same code points as `isLineBreak` in AttachComments.ts (charCode form). */
23
+ const lineBreak = String.raw`\r\n?|[\n\v\f\u{0085}\u{2028}\u{2029}]`;
52
24
 
53
- const commentStart = /\/\/|\/\*/;
54
-
55
- type InternalTokenKind =
56
- | "word"
57
- | "number"
58
- | "blankspaces"
59
- | "commentStart"
60
- | "symbol"
61
- | "invalid";
62
- const weslMatcher = new RegexMatchers<InternalTokenKind>({
63
- word: ident,
64
- number: digits,
65
- blankspaces,
66
- commentStart,
67
- symbol: matchOneOf(symbolSet),
68
- // biome-ignore lint/correctness/noEmptyCharacterClassInRegex: TODO
69
- invalid: /[^]/,
70
- });
25
+ const keywordOrReserved = new Set(keywords.concat(reservedWords));
71
26
 
72
- /** To mark parts of the grammar implementation that are WESL specific extensions */
73
- export function weslExtension<T>(combinator: T): T {
74
- return combinator;
27
+ /** A peeked token cached at the position it was read from. */
28
+ interface PeekedToken {
29
+ pos: number;
30
+ token: WeslToken | null;
31
+ end: number;
75
32
  }
76
33
 
77
34
  /** A stream that produces WESL tokens, skipping over comments and white space */
78
35
  export class WeslStream implements Stream<WeslToken> {
79
36
  private stream: Stream<TypedToken<InternalTokenKind>>;
80
- /** New line */
81
- private eolPattern = /[\n\v\f\u{0085}\u{2028}\u{2029}]|\r\n?/gu;
37
+ /** New line (stateful: scanned via lastIndex, so kept per-instance). */
38
+ private eolPattern = new RegExp(lineBreak, "gu");
82
39
  private blockCommentPattern = /\/\*|\*\//g;
40
+ /** Comments skipped before a real token, keyed by that token's start position. */
41
+ private triviaByPos = new Map<number, CommentTrivia[]>();
42
+ /** Last peeked token, so the following nextToken() skips the rescan.
43
+ * Never invalidated: tokenization is deterministic per position. */
44
+ private peeked: PeekedToken | null = null;
83
45
  public src: string;
84
46
  constructor(src: string) {
85
47
  this.src = src;
86
- this.stream = new CachingStream(new MatchersStream(src, weslMatcher));
48
+ this.stream = new WeslLexer(src);
87
49
  }
88
50
  checkpoint(): number {
89
51
  return this.stream.checkpoint();
@@ -91,40 +53,87 @@ export class WeslStream implements Stream<WeslToken> {
91
53
  reset(position: number): void {
92
54
  this.stream.reset(position);
93
55
  }
56
+ /** All recorded comment runs (each a contiguous group of comments between two
57
+ * real tokens), in source order. Consumed by the post-parse comment pass. */
58
+ commentRuns(): CommentTrivia[][] {
59
+ return [...this.triviaByPos.entries()]
60
+ .sort((a, b) => a[0] - b[0])
61
+ .map(([, run]) => run);
62
+ }
63
+ private recordTrivia(pos: number, pending?: CommentTrivia[]): void {
64
+ if (pending) this.triviaByPos.set(pos, pending);
65
+ }
66
+
67
+ /** Next real token (comments/blankspace skipped and recorded as trivia); null at EOF. */
94
68
  nextToken(): WeslToken | null {
69
+ const peeked = this.usePeeked();
70
+ if (peeked !== null) return peeked.token;
71
+
72
+ let pending: CommentTrivia[] | undefined;
95
73
  while (true) {
96
74
  const token = this.stream.nextToken();
97
- if (token === null) return null;
75
+ if (token === null) {
76
+ // trailing comments at end of file: key them past the last position
77
+ this.recordTrivia(this.src.length, pending);
78
+ return null;
79
+ }
98
80
 
99
81
  const kind = token.kind;
100
82
  if (kind === "blankspaces") {
101
- continue;
83
+ continue; // newline/blank flags are derived later, at attach time
102
84
  } else if (kind === "commentStart") {
103
- // SAFETY: The underlying streams can be seeked to any position
104
- if (token.text === "//") {
105
- this.stream.reset(this.skipToEol(token.span[1]));
106
- } else {
107
- this.stream.reset(this.skipBlockComment(token.span[1]));
108
- }
109
- } else if (kind === "word") {
110
- const returnToken = token as TypedToken<typeof kind | "keyword">;
111
- if (keywordOrReserved.has(token.text)) {
112
- returnToken.kind = "keyword";
113
- }
114
- return returnToken;
85
+ pending ??= [];
86
+ pending.push(this.consumeComment(token));
115
87
  } else if (kind === "invalid") {
116
88
  throw new ParseError("Invalid token " + token.text, token.span);
117
89
  } else {
118
- return token as TypedToken<typeof kind>;
90
+ this.recordTrivia(token.span[0], pending);
91
+ const result = token as WeslToken;
92
+ if (kind === "word" && keywordOrReserved.has(token.text)) {
93
+ result.kind = "keyword";
94
+ }
95
+ return result;
119
96
  }
120
97
  }
121
98
  }
122
99
 
100
+ /** If the last peek() was at the current position, consume and return it. */
101
+ private usePeeked(): PeekedToken | null {
102
+ const peeked = this.peeked;
103
+ if (peeked !== null && peeked.pos === this.checkpoint()) {
104
+ this.reset(peeked.end);
105
+ return peeked;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /** Skip a comment (rejecting embedded \0), advance the stream past it,
111
+ * and return it as trivia. */
112
+ private consumeComment(token: TypedToken<InternalTokenKind>): CommentTrivia {
113
+ const style = token.text === "//" ? "line" : "block";
114
+ const start = token.span[0];
115
+ const end = this.commentEnd(token);
116
+ // WGSL forbids the null code point anywhere, including inside comments
117
+ // (a comment body is skipped here, so the `invalid` matcher never sees
118
+ // it). Scan only the comment body, keeping this O(comment length).
119
+ const bodyNull = this.src.slice(start, end).indexOf("\0");
120
+ if (bodyNull >= 0) {
121
+ const at = start + bodyNull;
122
+ throw new ParseError("Invalid token \\0", [at, at + 1]);
123
+ }
124
+ this.stream.reset(end);
125
+ return { style, start, end };
126
+ }
127
+
123
128
  /** Peek at the next token without consuming it */
124
129
  peek(): WeslToken | null {
125
130
  const pos = this.checkpoint();
131
+ const peeked = this.peeked;
132
+ if (peeked !== null && peeked.pos === pos) return peeked.token;
126
133
  const token = this.nextToken();
134
+ const end = this.checkpoint();
127
135
  this.reset(pos);
136
+ this.peeked = { pos, token, end };
128
137
  return token;
129
138
  }
130
139
 
@@ -176,16 +185,18 @@ export class WeslStream implements Stream<WeslToken> {
176
185
  return tokens;
177
186
  }
178
187
 
179
- private skipToEol(position: number): number {
188
+ /** End position of a comment opened by a commentStart token. */
189
+ private commentEnd(token: TypedToken<InternalTokenKind>): number {
190
+ return token.text === "//"
191
+ ? this.lineCommentEnd(token.span[1])
192
+ : this.skipBlockComment(token.span[1]);
193
+ }
194
+
195
+ /** End of a line comment: the start of the next line break (or end of file). */
196
+ private lineCommentEnd(position: number): number {
180
197
  this.eolPattern.lastIndex = position;
181
198
  const result = this.eolPattern.exec(this.src);
182
- if (result === null) {
183
- // We reached the end of the file
184
- return this.src.length;
185
- } else {
186
- // Move forward
187
- return this.eolPattern.lastIndex;
188
- }
199
+ return result === null ? this.src.length : result.index;
189
200
  }
190
201
 
191
202
  private skipBlockComment(start: number): number {
@@ -196,10 +207,9 @@ export class WeslStream implements Stream<WeslToken> {
196
207
  if (result === null) {
197
208
  throw new ParseError("Unclosed block comment!", [position, position]);
198
209
  } else if (result[0] === "*/") {
199
- // Close block
200
210
  return this.blockCommentPattern.lastIndex;
201
211
  } else if (result[0] === "/*") {
202
- // Open block
212
+ // nested block comment: recurse so its */ doesn't close the outer one
203
213
  position = this.skipBlockComment(this.blockCommentPattern.lastIndex);
204
214
  } else {
205
215
  throw new Error("Unreachable, invalid block comment pattern");
@@ -214,58 +224,66 @@ export class WeslStream implements Stream<WeslToken> {
214
224
  */
215
225
  nextTemplateStartToken(): (WeslToken & { kind: "symbol" }) | null {
216
226
  const startPosition = this.stream.checkpoint();
217
- const token: WeslToken | null = this.nextToken();
227
+ const token = this.nextToken();
218
228
  this.stream.reset(startPosition);
219
- if (token === null) return null;
220
229
 
221
- if (token.kind !== "symbol") {
230
+ //<<= << <= cannot be templates, so we match the entire token text
231
+ if (token === null || token.kind !== "symbol" || token.text !== "<") {
222
232
  return null;
223
233
  }
224
-
225
- //<<= << <= cannot be templates, so we match the entire token text
226
- if (token.text === "<") {
227
- if (this.isTemplateStart(token.span[1])) {
228
- this.stream.reset(token.span[1]);
229
- return token as WeslToken & { kind: typeof token.kind };
230
- } else {
231
- this.stream.reset(startPosition);
232
- return null;
233
- }
234
- } else {
234
+ if (!this.isTemplateStart(token.span[1])) {
235
+ this.stream.reset(startPosition); // isTemplateStart advanced the stream
235
236
  return null;
236
237
  }
238
+ this.stream.reset(token.span[1]);
239
+ return token as WeslToken & { kind: "symbol" };
237
240
  }
238
241
 
242
+ /** Match a template-closing `>`, splitting it off a `>>`/`>=`/`>>=` token when needed. */
239
243
  nextTemplateEndToken(): (WeslToken & { kind: "symbol" }) | null {
240
244
  const startPosition = this.stream.checkpoint();
241
- const token: WeslToken | null = this.nextToken();
245
+ const token = this.nextToken();
242
246
  this.stream.reset(startPosition);
243
247
  if (token === null) return null;
244
248
 
245
- // template closing can also match a >= or >>, so we split the token
246
- if (token.kind === "symbol" && token.text[0] === ">") {
247
- // SAFETY: The underlying streams implementations can be reset to any position.
248
- const tokenPosition = token.span[0];
249
- this.stream.reset(tokenPosition + 1);
250
- return {
251
- kind: "symbol",
252
- span: [tokenPosition, tokenPosition + 1],
253
- text: ">",
254
- };
255
- } else {
256
- return null;
249
+ // Template closing can also match a >= or >> here, so split one `>` off
250
+ // and re-lex the rest. Safe for the closed set of `>`-leading symbols in
251
+ // WeslLexer.ts (`>` `>=` `>>` `>>=`): each remainder (``, `=`, `>`, `>=`)
252
+ // re-lexes to the intended tokens. A new `>`-leading symbol would break
253
+ // this split; isTemplateStart's exhaustive check throws if the set grows.
254
+ if (token.kind !== "symbol" || token.text[0] !== ">") return null;
255
+
256
+ // SAFETY: The underlying streams implementations can be reset to any position.
257
+ const tokenPosition = token.span[0];
258
+ this.stream.reset(tokenPosition + 1);
259
+ return {
260
+ kind: "symbol",
261
+ span: [tokenPosition, tokenPosition + 1],
262
+ text: ">",
263
+ };
264
+ }
265
+
266
+ /** Next symbol from the raw stream, skipping comment bodies (so symbols
267
+ * inside comments don't count) and non-symbol tokens; null at EOF. */
268
+ private nextRawSymbol(): TypedToken<InternalTokenKind> | null {
269
+ while (true) {
270
+ const token = this.stream.nextToken();
271
+ if (token === null) return null;
272
+ if (token.kind === "commentStart") {
273
+ this.stream.reset(this.commentEnd(token));
274
+ } else if (token.kind === "symbol") return token;
257
275
  }
258
276
  }
259
277
 
278
+ /** Template-list discovery: scan forward from `<` for a balanced closing `>`. */
260
279
  private isTemplateStart(afterToken: number): boolean {
261
280
  // Skip over <
262
281
  this.stream.reset(afterToken);
263
282
  // We start with a < token
264
283
  let pendingCounter = 1;
265
284
  while (true) {
266
- const nextToken = this.stream.nextToken();
285
+ const nextToken = this.nextRawSymbol();
267
286
  if (nextToken === null) return false;
268
- if (nextToken.kind !== "symbol") continue;
269
287
  if (nextToken.text === "<") {
270
288
  // Start a nested template
271
289
  pendingCounter += 1;
@@ -306,12 +324,11 @@ export class WeslStream implements Stream<WeslToken> {
306
324
  */
307
325
  private skipBracketsTo(closingBracket: string): void {
308
326
  while (true) {
309
- const nextToken = this.stream.nextToken();
327
+ const nextToken = this.nextRawSymbol();
310
328
  if (nextToken === null) {
311
329
  const after = this.stream.checkpoint();
312
330
  throw new ParseError("Unclosed bracket!", [after, after]);
313
331
  }
314
- if (nextToken.kind !== "symbol") continue;
315
332
  if (nextToken.text === "(") {
316
333
  this.skipBracketsTo(")");
317
334
  } else if (nextToken.text === "[") {
@@ -0,0 +1,45 @@
1
+ import type { Span } from "../../Span.ts";
2
+ import type { TypedToken } from "../../Stream.ts";
3
+ import { toRegexSource } from "./RegexHelpers.ts";
4
+
5
+ /**
6
+ * Matches tokens by kind with one combined regex.
7
+ *
8
+ * The matchers passed to this object must follow certain rules:
9
+ * - They must use non-capturing groups: `(?:...)`
10
+ * - They must NOT use `^` or `$`
11
+ */
12
+ export class RegexMatchers<Kind extends string> {
13
+ private groups: Kind[];
14
+ private exp: RegExp;
15
+ constructor(matchers: Record<Kind, string | RegExp>) {
16
+ this.groups = Object.keys(matchers) as Kind[];
17
+ const expParts = Object.entries(matchers as Record<string, string | RegExp>)
18
+ .map(toRegexSource)
19
+ .join("|");
20
+ // y = sticky, match exactly at lastIndex (set per call in execAt)
21
+ // u = unicode aware
22
+ this.exp = new RegExp(expParts, "yu");
23
+ }
24
+
25
+ execAt(text: string, position: number): TypedToken<Kind> | null {
26
+ this.exp.lastIndex = position;
27
+ const matches = this.exp.exec(text);
28
+ if (matches === null) return null;
29
+
30
+ // each matcher is a full-token capturing group, so the matched substring
31
+ // is the whole match and the span starts at the sticky position
32
+ const matched = matches[0];
33
+ const span: Span = [position, position + matched.length];
34
+ const kind = this.groups[matchedGroupIndex(matches)];
35
+ return { kind, span, text: matched };
36
+ }
37
+ }
38
+
39
+ /** @return index of the alternation group that matched */
40
+ function matchedGroupIndex(matches: RegExpExecArray): number {
41
+ for (let i = 1; i < matches.length; i++) {
42
+ if (matches[i] !== undefined) return i - 1;
43
+ }
44
+ throw new Error("no matching group");
45
+ }
@@ -0,0 +1,249 @@
1
+ import type { Stream, TypedToken } from "../../Stream.ts";
2
+ import { matchOneOf } from "./RegexHelpers.ts";
3
+ import { RegexMatchers } from "./RegexMatchers.ts";
4
+
5
+ export type InternalTokenKind =
6
+ | "word"
7
+ | "number"
8
+ | "blankspaces"
9
+ | "commentStart"
10
+ | "symbol"
11
+ | "invalid";
12
+
13
+ type RawToken = TypedToken<InternalTokenKind>;
14
+
15
+ const ident =
16
+ /(?:(?:[_\p{XID_Start}][\p{XID_Continue}]+)|(?:[\p{XID_Start}]))/u;
17
+
18
+ // https://www.w3.org/TR/WGSL/#blankspace-and-line-breaks
19
+ /** Whitespaces including new lines */
20
+ const blankspaces = /[ \t\n\v\f\r\u{0085}\u{200E}\u{200F}\u{2028}\u{2029}]+/u;
21
+
22
+ const digits = new RegExp(
23
+ // decimal_float_literal
24
+ /(?:0[fh])|(?:[1-9][0-9]*[fh])/.source +
25
+ /|(?:[0-9]*\.[0-9]+(?:[eE][+-]?[0-9]+)?[fh]?)/.source +
26
+ /|(?:[0-9]+\.[0-9]*(?:[eE][+-]?[0-9]+)?[fh]?)/.source +
27
+ /|(?:[0-9]+[eE][+-]?[0-9]+[fh]?)/.source +
28
+ // hex_float_literal
29
+ /|(?:0[xX][0-9a-fA-F]*\.[0-9a-fA-F]+(?:[pP][+-]?[0-9]+[fh]?)?)/.source +
30
+ /|(?:0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*(?:[pP][+-]?[0-9]+[fh]?)?)/.source +
31
+ /|(?:0[xX][0-9a-fA-F]+[pP][+-]?[0-9]+[fh]?)/.source +
32
+ // hex_int_literal
33
+ /|(?:0[xX][0-9a-fA-F]+[iu]?)/.source +
34
+ // decimal_int_literal
35
+ /|(?:0[iu]?)|(?:[1-9][0-9]*[iu]?)/.source,
36
+ );
37
+
38
+ const symbolSet =
39
+ "& && -> @ / ! [ ] { } :: : , == = != >>= >> >= > <<= << <= < % - --" +
40
+ " . + ++ | || ( ) ; * ~ ^ // /* */ += -= *= /= %= &= |= ^=" +
41
+ // For the _ = expr; syntax
42
+ " _";
43
+
44
+ const commentStart = /\/\/|\/\*/;
45
+
46
+ /** Unicode-aware fallback for characters the ASCII fast path can't handle.
47
+ * lastIndex is set on every use, so sharing one instance is safe. */
48
+ const unicodeMatcher = new RegexMatchers<InternalTokenKind>({
49
+ word: ident,
50
+ number: digits,
51
+ blankspaces,
52
+ commentStart,
53
+ symbol: matchOneOf(symbolSet),
54
+ // biome-ignore lint/correctness/noEmptyCharacterClassInRegex: [^] deliberately matches any char (incl. newlines) as the catch-all
55
+ invalid: /[^]/,
56
+ });
57
+
58
+ /**
59
+ * Hand-written scanner for WESL/WGSL raw tokens.
60
+ *
61
+ * The common case is dispatched on ASCII char codes: whitespace is skipped
62
+ * without producing tokens, and idents, numbers, symbols, and comment openers
63
+ * are matched directly. Any character >= 0x80 (unicode idents, unicode
64
+ * blankspace) falls back to the unicode-aware regex, so there is no up-front
65
+ * scan and no unicode restriction.
66
+ */
67
+ export class WeslLexer implements Stream<RawToken> {
68
+ private position = 0;
69
+ /** sticky number matcher (stateful via lastIndex, so kept per-instance) */
70
+ private numberExp = new RegExp(digits.source, "y");
71
+ public src: string;
72
+
73
+ constructor(src: string) {
74
+ this.src = src;
75
+ }
76
+
77
+ checkpoint(): number {
78
+ return this.position;
79
+ }
80
+ reset(position: number): void {
81
+ this.position = position;
82
+ }
83
+
84
+ nextToken(): RawToken | null {
85
+ const { src } = this;
86
+ const len = src.length;
87
+ let pos = this.position;
88
+ // skip ASCII blankspace; unicode blankspace falls through to the regex
89
+ while (pos < len) {
90
+ const c = src.charCodeAt(pos);
91
+ if (c === 0x20 || (c >= 0x09 && c <= 0x0d)) pos++;
92
+ else break;
93
+ }
94
+ if (pos >= len) {
95
+ this.position = pos;
96
+ return null;
97
+ }
98
+
99
+ const c = src.charCodeAt(pos);
100
+ if ((c >= 0x61 && c <= 0x7a) || (c >= 0x41 && c <= 0x5a)) {
101
+ return this.word(pos);
102
+ }
103
+ if (c >= 0x30 && c <= 0x39) return this.number(pos);
104
+
105
+ // n is NaN past the end of source; all comparisons with NaN are false
106
+ const n = src.charCodeAt(pos + 1);
107
+ switch (c) {
108
+ case 0x5f: // _
109
+ if (isWordChar(n)) return this.word(pos);
110
+ if (n >= 0x80) return this.regexToken(pos); // _ + unicode continue
111
+ return this.symbol(pos, "_");
112
+ case 0x2f: // /
113
+ if (n === 0x2f) return this.token("commentStart", pos, "//");
114
+ if (n === 0x2a) return this.token("commentStart", pos, "/*");
115
+ if (n === 0x3d) return this.symbol(pos, "/=");
116
+ return this.symbol(pos, "/");
117
+ case 0x2e: // .
118
+ if (n >= 0x30 && n <= 0x39) return this.number(pos); // .5 float
119
+ return this.symbol(pos, ".");
120
+ case 0x3e: // >
121
+ if (n === 0x3e) {
122
+ if (src.charCodeAt(pos + 2) === 0x3d) return this.symbol(pos, ">>=");
123
+ return this.symbol(pos, ">>");
124
+ }
125
+ if (n === 0x3d) return this.symbol(pos, ">=");
126
+ return this.symbol(pos, ">");
127
+ case 0x3c: // <
128
+ if (n === 0x3c) {
129
+ if (src.charCodeAt(pos + 2) === 0x3d) return this.symbol(pos, "<<=");
130
+ return this.symbol(pos, "<<");
131
+ }
132
+ if (n === 0x3d) return this.symbol(pos, "<=");
133
+ return this.symbol(pos, "<");
134
+ case 0x26: // &
135
+ if (n === 0x26) return this.symbol(pos, "&&");
136
+ if (n === 0x3d) return this.symbol(pos, "&=");
137
+ return this.symbol(pos, "&");
138
+ case 0x7c: // |
139
+ if (n === 0x7c) return this.symbol(pos, "||");
140
+ if (n === 0x3d) return this.symbol(pos, "|=");
141
+ return this.symbol(pos, "|");
142
+ case 0x2d: // -
143
+ if (n === 0x3e) return this.symbol(pos, "->");
144
+ if (n === 0x2d) return this.symbol(pos, "--");
145
+ if (n === 0x3d) return this.symbol(pos, "-=");
146
+ return this.symbol(pos, "-");
147
+ case 0x2b: // +
148
+ if (n === 0x2b) return this.symbol(pos, "++");
149
+ if (n === 0x3d) return this.symbol(pos, "+=");
150
+ return this.symbol(pos, "+");
151
+ case 0x3a: // :
152
+ if (n === 0x3a) return this.symbol(pos, "::");
153
+ return this.symbol(pos, ":");
154
+ case 0x3d: // =
155
+ if (n === 0x3d) return this.symbol(pos, "==");
156
+ return this.symbol(pos, "=");
157
+ case 0x21: // !
158
+ if (n === 0x3d) return this.symbol(pos, "!=");
159
+ return this.symbol(pos, "!");
160
+ case 0x2a: // *
161
+ if (n === 0x2f) return this.symbol(pos, "*/");
162
+ if (n === 0x3d) return this.symbol(pos, "*=");
163
+ return this.symbol(pos, "*");
164
+ case 0x25: // %
165
+ if (n === 0x3d) return this.symbol(pos, "%=");
166
+ return this.symbol(pos, "%");
167
+ case 0x5e: // ^
168
+ if (n === 0x3d) return this.symbol(pos, "^=");
169
+ return this.symbol(pos, "^");
170
+ case 0x40: // @
171
+ return this.symbol(pos, "@");
172
+ case 0x5b: // [
173
+ return this.symbol(pos, "[");
174
+ case 0x5d: // ]
175
+ return this.symbol(pos, "]");
176
+ case 0x7b: // {
177
+ return this.symbol(pos, "{");
178
+ case 0x7d: // }
179
+ return this.symbol(pos, "}");
180
+ case 0x28: // (
181
+ return this.symbol(pos, "(");
182
+ case 0x29: // )
183
+ return this.symbol(pos, ")");
184
+ case 0x2c: // ,
185
+ return this.symbol(pos, ",");
186
+ case 0x3b: // ;
187
+ return this.symbol(pos, ";");
188
+ case 0x7e: // ~
189
+ return this.symbol(pos, "~");
190
+ }
191
+ if (c >= 0x80) return this.regexToken(pos);
192
+ this.position = pos + 1;
193
+ return { kind: "invalid", span: [pos, pos + 1], text: src[pos] };
194
+ }
195
+
196
+ private token(
197
+ kind: InternalTokenKind,
198
+ start: number,
199
+ text: string,
200
+ ): RawToken {
201
+ const end = start + text.length;
202
+ this.position = end;
203
+ return { kind, span: [start, end], text };
204
+ }
205
+
206
+ private symbol(start: number, text: string): RawToken {
207
+ return this.token("symbol", start, text);
208
+ }
209
+
210
+ private word(start: number): RawToken | null {
211
+ const { src } = this;
212
+ const len = src.length;
213
+ let pos = start + 1;
214
+ while (pos < len) {
215
+ const c = src.charCodeAt(pos);
216
+ if (isWordChar(c)) pos++;
217
+ else if (c >= 0x80)
218
+ return this.regexToken(start); // unicode ident
219
+ else break;
220
+ }
221
+ this.position = pos;
222
+ return { kind: "word", span: [start, pos], text: src.slice(start, pos) };
223
+ }
224
+
225
+ private number(start: number): RawToken {
226
+ this.numberExp.lastIndex = start;
227
+ // non-null: callers guarantee a leading digit (matches decimal_int) or
228
+ // a `.` followed by a digit (matches the fraction-first float alternative)
229
+ const text = this.numberExp.exec(this.src)![0];
230
+ return this.token("number", start, text);
231
+ }
232
+
233
+ /** match one token (or blankspace run) with the unicode-aware regex */
234
+ private regexToken(start: number): RawToken | null {
235
+ const token = unicodeMatcher.execAt(this.src, start);
236
+ if (token === null) return null; // unreachable: `invalid` matches any char
237
+ this.position = token.span[1];
238
+ return token;
239
+ }
240
+ }
241
+
242
+ function isWordChar(c: number): boolean {
243
+ return (
244
+ (c >= 0x61 && c <= 0x7a) || // a-z
245
+ (c >= 0x41 && c <= 0x5a) || // A-Z
246
+ (c >= 0x30 && c <= 0x39) || // 0-9
247
+ c === 0x5f // _
248
+ );
249
+ }