wesl 0.7.27 → 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 (61) hide show
  1. package/dist/index.d.ts +272 -126
  2. package/dist/index.js +1742 -1102
  3. package/package.json +1 -1
  4. package/src/AbstractElems.ts +263 -81
  5. package/src/Linker.ts +14 -2
  6. package/src/LinkerUtil.ts +141 -7
  7. package/src/LowerAndEmit.ts +660 -304
  8. package/src/ModuleResolver.ts +15 -4
  9. package/src/ParseWESL.ts +21 -33
  10. package/src/SrcMap.ts +7 -19
  11. package/src/debug/ASTtoString.ts +92 -76
  12. package/src/index.ts +1 -1
  13. package/src/parse/AttachComments.ts +289 -0
  14. package/src/parse/ExpressionUtil.ts +3 -3
  15. package/src/parse/ParseAttribute.ts +49 -71
  16. package/src/parse/ParseCall.ts +3 -4
  17. package/src/parse/ParseControlFlow.ts +100 -56
  18. package/src/parse/ParseDirective.ts +9 -8
  19. package/src/parse/ParseDoBlock.ts +63 -0
  20. package/src/parse/ParseExpression.ts +11 -10
  21. package/src/parse/ParseFn.ts +11 -33
  22. package/src/parse/ParseGlobalVar.ts +36 -27
  23. package/src/parse/ParseIdent.ts +4 -5
  24. package/src/parse/ParseLocalVar.ts +16 -12
  25. package/src/parse/ParseLoop.ts +76 -39
  26. package/src/parse/ParseModule.ts +65 -19
  27. package/src/parse/ParseSimpleStatement.ts +112 -66
  28. package/src/parse/ParseStatement.ts +39 -66
  29. package/src/parse/ParseStruct.ts +8 -22
  30. package/src/parse/ParseType.ts +10 -13
  31. package/src/parse/ParseUtil.ts +26 -12
  32. package/src/parse/ParseValueDeclaration.ts +18 -31
  33. package/src/parse/ParseWesl.ts +11 -14
  34. package/src/parse/ParsingContext.ts +11 -9
  35. package/src/parse/WeslStream.ts +138 -121
  36. package/src/parse/stream/RegexMatchers.ts +45 -0
  37. package/src/parse/stream/WeslLexer.ts +249 -0
  38. package/src/test/BevyLink.test.ts +17 -10
  39. package/src/test/ConditionalElif.test.ts +4 -2
  40. package/src/test/DeclCommentEmit.test.ts +122 -0
  41. package/src/test/DoBlock.test.ts +164 -0
  42. package/src/test/FilterValidElements.test.ts +4 -6
  43. package/src/test/Linker.test.ts +23 -7
  44. package/src/test/Mangling.test.ts +8 -4
  45. package/src/test/ParseComments.test.ts +234 -6
  46. package/src/test/ParseConditionsV2.test.ts +47 -175
  47. package/src/test/ParseElifV2.test.ts +12 -33
  48. package/src/test/ParseErrorV2.test.ts +0 -0
  49. package/src/test/ParseWeslV2.test.ts +247 -626
  50. package/src/test/StatementEmit.test.ts +143 -0
  51. package/src/test/StripWesl.ts +24 -3
  52. package/src/test/TestLink.ts +19 -3
  53. package/src/test/TestUtil.ts +18 -8
  54. package/src/test/Tokenizer.test.ts +96 -0
  55. package/src/test/__snapshots__/ParseWeslV2.test.ts.snap +10 -42
  56. package/src/RawEmit.ts +0 -103
  57. package/src/Reflection.ts +0 -336
  58. package/src/TransformBindingStructs.ts +0 -320
  59. package/src/parse/ContentsHelpers.ts +0 -70
  60. package/src/parse/stream/CachingStream.ts +0 -48
  61. package/src/parse/stream/MatchersStream.ts +0 -85
@@ -0,0 +1,289 @@
1
+ import type {
2
+ AbstractElem,
3
+ AbstractElemBase,
4
+ CommentElem,
5
+ ModuleElem,
6
+ SyntheticElem,
7
+ } from "../AbstractElems.ts";
8
+ import { childElems } from "../LinkerUtil.ts";
9
+ import type { SrcModule } from "../Scope.ts";
10
+ import type { ParsingContext } from "./ParsingContext.ts";
11
+ import type { CommentTrivia } from "./WeslStream.ts";
12
+
13
+ /** An AST node with a source position (i.e. not a synthetic elem). Every such
14
+ * node can carry comments via the {@link AbstractElemBase} fields. */
15
+ type Positioned = Exclude<AbstractElem, SyntheticElem>;
16
+
17
+ /** Positioned children per node, computed once per attach pass. Attached
18
+ * comments are not structural fields, so entries never go stale. */
19
+ type ChildCache = Map<Positioned, Positioned[]>;
20
+
21
+ const tab = 0x09;
22
+ const lineFeed = 0x0a;
23
+ const verticalTab = 0x0b;
24
+ const formFeed = 0x0c;
25
+ const carriageReturn = 0x0d;
26
+ const space = 0x20;
27
+ const nextLine = 0x85;
28
+ const leftToRightMark = 0x200e;
29
+ const rightToLeftMark = 0x200f;
30
+ const lineSeparator = 0x2028;
31
+ const paragraphSeparator = 0x2029;
32
+
33
+ /**
34
+ * Attach every recorded comment to a node in the parsed tree.
35
+ *
36
+ * Each comment run (a contiguous group of comments between two real tokens) is
37
+ * anchored to the deepest node that contains it in a gap between children, then
38
+ * distributed to the surrounding children:
39
+ * - a comment that begins its own line leads the next child (`commentsBefore`);
40
+ * - an inline comment trails the previous child (`commentsAfter`), unless it
41
+ * hugs the next child -- only blank space and comments, no separator token,
42
+ * between them -- in which case it leads the next child instead;
43
+ * - comments before a closing token with no following child trail the last child,
44
+ * or land in an empty block's `innerComments`.
45
+ *
46
+ * Descending into expressions (via {@link childElems}) means interior comments
47
+ * like the one in `foo(1, /* x *\/ 2)` are preserved on the `2`, not dropped.
48
+ */
49
+ export function attachComments(
50
+ ctx: ParsingContext,
51
+ moduleElem: ModuleElem,
52
+ ): void {
53
+ const { srcModule } = ctx.state.stable;
54
+ const cache: ChildCache = new Map();
55
+ for (const run of ctx.stream.commentRuns()) {
56
+ const anchor = deepestContaining(
57
+ moduleElem,
58
+ run[0].start,
59
+ runEnd(run),
60
+ cache,
61
+ );
62
+ distribute(anchor, run, srcModule, cache);
63
+ }
64
+ }
65
+
66
+ /** The deepest node whose span contains the whole [start, end) range, found by
67
+ * descending into the child that brackets it. Comments live in gaps between
68
+ * tokens, so the result is the node holding the gap, with the run between two
69
+ * of its children. */
70
+ function deepestContaining(
71
+ root: Positioned,
72
+ start: number,
73
+ end: number,
74
+ cache: ChildCache,
75
+ ): Positioned {
76
+ let node = root;
77
+ while (true) {
78
+ const child = positionedChildren(node, cache).find(
79
+ c => c.start <= start && end <= c.end,
80
+ );
81
+ if (!child) return node;
82
+ node = child;
83
+ }
84
+ }
85
+
86
+ function runEnd(run: CommentTrivia[]): number {
87
+ return run[run.length - 1].end;
88
+ }
89
+
90
+ /** Split a comment run between the previous child (trailing) and the next child
91
+ * (leading) of its anchor, or onto the last child / inner comments when it
92
+ * dangles before a closing token. */
93
+ function distribute(
94
+ anchor: Positioned,
95
+ run: CommentTrivia[],
96
+ srcModule: SrcModule,
97
+ cache: ChildCache,
98
+ ): void {
99
+ const children = positionedChildren(anchor, cache);
100
+ const start = run[0].start;
101
+ const end = runEnd(run);
102
+
103
+ // the AbstractElem nodes bracketing the comment run.
104
+ // prev ends at/before the comments
105
+ // next starts at/after the comments
106
+ // prev, next may be missing:
107
+ // prev when the run leads the anchor's first child
108
+ // next when the run dangles after the last child
109
+ // both when the anchor has no children (e.g. an empty block)
110
+ const prev = children.findLast(c => c.end <= start);
111
+ const next = children.find(c => c.start >= end);
112
+
113
+ if (!next) {
114
+ // run dangles after the last child, before the anchor's closing token
115
+ if (prev) addComments(prev, "commentsAfter", run, srcModule);
116
+ // empty block: nothing to attach to, so the comments live inside it
117
+ else if (anchor.kind === "block")
118
+ anchor.innerComments = run.map(t => makeComment(t, srcModule));
119
+ // empty non-block container: keep the comments rather than drop them
120
+ else addComments(anchor, "commentsBefore", run, srcModule);
121
+ return;
122
+ }
123
+
124
+ // one run in a gap can hold comments belonging to both sides -- some trailing
125
+ // prev (on its line), some leading next -- so split it at the boundary:
126
+ // [0, split) trail prev, [split, end) lead next.
127
+ const split = splitPoint(prev, next, run, srcModule.src);
128
+ if (prev && split > 0)
129
+ addComments(prev, "commentsAfter", run.slice(0, split), srcModule);
130
+ if (split < run.length)
131
+ addComments(next, "commentsBefore", run.slice(split), srcModule);
132
+ }
133
+
134
+ function positionedChildren(node: Positioned, cache: ChildCache): Positioned[] {
135
+ let children = cache.get(node);
136
+ if (children === undefined) {
137
+ children = positioned(childElems(node));
138
+ cache.set(node, children);
139
+ }
140
+ return children;
141
+ }
142
+
143
+ /** Append converted comments to an element's leading or trailing list. */
144
+ function addComments(
145
+ elem: AbstractElemBase,
146
+ field: "commentsBefore" | "commentsAfter",
147
+ trivia: CommentTrivia[],
148
+ srcModule: SrcModule,
149
+ ): void {
150
+ const comments = trivia.map(t => makeComment(t, srcModule));
151
+ const existing = elem[field];
152
+ elem[field] = existing ? [...existing, ...comments] : comments;
153
+ }
154
+
155
+ function makeComment(trivia: CommentTrivia, srcModule: SrcModule): CommentElem {
156
+ const { style, start, end } = trivia;
157
+ const comment: CommentElem = {
158
+ kind: "comment",
159
+ style,
160
+ start,
161
+ end,
162
+ srcModule,
163
+ };
164
+ // a fully blank line above the comment is preserved in the output
165
+ if (lineBreaksBefore(srcModule.src, start) >= 2) comment.blankBefore = true;
166
+ return comment;
167
+ }
168
+
169
+ /**
170
+ * Index into `run` where it flips from trailing `prev` to leading `next`:
171
+ * caller attaches run[0, split) to `prev` and run[split, end) to `next`.
172
+ *
173
+ * The aim is to keep each comment with the code it describes, so it stays
174
+ * meaningful after the tree is reordered or reformatted. That follows how people
175
+ * write comments: a comment on its own line documents what comes after it, while
176
+ * a comment sharing a line with code documents that code. Hence:
177
+ * - with no previous child the whole run leads (split 0);
178
+ * - otherwise the split is the first comment that begins its own line;
179
+ * - failing that (an all-inline run), it is the start of the suffix that hugs
180
+ * `next` on the same line, so the comment in `foo(1, /* x *\/ 2)` documents,
181
+ * and lands on, the `2`.
182
+ */
183
+ function splitPoint(
184
+ prev: Positioned | undefined,
185
+ next: Positioned,
186
+ run: CommentTrivia[],
187
+ src: string,
188
+ ): number {
189
+ if (!prev) return 0;
190
+ const ownLine = firstOwnLine(run, src);
191
+ return ownLine >= 0 ? ownLine : hugsNextStart(run, next.start, src);
192
+ }
193
+
194
+ /** Source-positioned children, sorted in source order. Synthetic elems (no
195
+ * source position) cannot anchor comments and are dropped. Children usually
196
+ * arrive already in source order, so the sort is skipped when possible.
197
+ * Filtering and order-checking share one pass: this runs per node, so it's
198
+ * kept deliberately lean. */
199
+ function positioned(elems: readonly AbstractElem[]): Positioned[] {
200
+ const result: Positioned[] = [];
201
+ let sorted = true; // starts in source order until proven otherwise
202
+ let lastStart = -1;
203
+ for (const e of elems) {
204
+ if (e.kind === "synthetic") continue;
205
+ if (e.start < lastStart) sorted = false;
206
+ lastStart = e.start;
207
+ result.push(e);
208
+ }
209
+ if (!sorted) result.sort((a, b) => a.start - b.start);
210
+ return result;
211
+ }
212
+
213
+ // Blankspace classification per https://www.w3.org/TR/WGSL/#blankspace-and-line-breaks
214
+ // (the tokenizer matches the same set via the blankspaces/lineBreak regexes).
215
+
216
+ /** Count line breaks in the whitespace run immediately before `pos`, capped at 2:
217
+ * callers only need none / one line break / a blank line. Scans backward over
218
+ * blankspace, stopping at the first non-blankspace char, so cost is the gap
219
+ * length, not the source length. `\r\n` counts as one break. */
220
+ function lineBreaksBefore(src: string, pos: number): number {
221
+ let count = 0;
222
+ for (let i = pos - 1; i >= 0; i--) {
223
+ const c = src.charCodeAt(i);
224
+ if (isLineBreak(c)) {
225
+ if (c === lineFeed && src.charCodeAt(i - 1) === carriageReturn) i--; // \r\n
226
+ } else if (isInlineSpace(c)) {
227
+ continue; // still inside the run
228
+ } else {
229
+ break; // run ended at a non-blankspace char
230
+ }
231
+ if (++count >= 2) return 2;
232
+ }
233
+ return count;
234
+ }
235
+
236
+ /** Index of the first comment that begins its own line (after a line break),
237
+ * or -1 if none do. */
238
+ function firstOwnLine(run: CommentTrivia[], src: string): number {
239
+ return run.findIndex(t => lineBreaksBefore(src, t.start) >= 1);
240
+ }
241
+
242
+ /** Start index of the trailing suffix of `run` that is joined to `next` by
243
+ * same-line whitespace only (no line break, no separator token between them).
244
+ * Returns run.length when nothing hugs `next`. */
245
+ function hugsNextStart(
246
+ run: CommentTrivia[],
247
+ nextStart: number,
248
+ src: string,
249
+ ): number {
250
+ let suffixStart = run.length; // nothing hugs `next` until proven otherwise
251
+ let rightStart = nextStart; // start of the neighbor just right of this comment
252
+ for (let i = run.length - 1; i >= 0; i--) {
253
+ const comment = run[i];
254
+ // stop once more than blank space sits between this comment and its neighbor
255
+ if (!sameLineGap(src, comment.end, rightStart)) break;
256
+ suffixStart = i;
257
+ rightStart = comment.start;
258
+ }
259
+ return suffixStart;
260
+ }
261
+
262
+ /** A WGSL line break code point. `\r\n` is two of these; callers coalesce it. */
263
+ function isLineBreak(c: number): boolean {
264
+ return (
265
+ c === lineFeed ||
266
+ c === carriageReturn ||
267
+ c === verticalTab ||
268
+ c === formFeed ||
269
+ c === nextLine ||
270
+ c === lineSeparator ||
271
+ c === paragraphSeparator
272
+ );
273
+ }
274
+
275
+ /** WGSL blankspace that stays on the same line (not a line break). */
276
+ function isInlineSpace(c: number): boolean {
277
+ return (
278
+ c === space || c === tab || c === leftToRightMark || c === rightToLeftMark
279
+ );
280
+ }
281
+
282
+ /** True when [from, to) is inline blankspace only (no line break): the two ends
283
+ * sit on the same line with nothing but same-line spaces between. */
284
+ function sameLineGap(src: string, from: number, to: number): boolean {
285
+ for (let i = from; i < to; i++) {
286
+ if (!isInlineSpace(src.charCodeAt(i))) return false;
287
+ }
288
+ return true;
289
+ }
@@ -21,7 +21,8 @@ export function makeLiteral(token: WeslToken<"keyword" | "number">): Literal {
21
21
  }
22
22
 
23
23
  export function makeUnaryOperator(token: WeslToken<"symbol">): UnaryOperator {
24
- return { value: token.text as UnaryOperator["value"], span: token.span };
24
+ const [start, end] = token.span;
25
+ return { value: token.text as UnaryOperator["value"], start, end };
25
26
  }
26
27
 
27
28
  export function makeBinaryOperator(token: {
@@ -36,12 +37,11 @@ export function makeUnaryExpression(
36
37
  operator: UnaryOperator,
37
38
  expr: ExpressionElem,
38
39
  ): UnaryExpression {
39
- const [start] = operator.span;
40
40
  return {
41
41
  kind: "unary-expression",
42
42
  operator,
43
43
  expression: expr,
44
- start,
44
+ start: operator.start,
45
45
  end: expr.end,
46
46
  };
47
47
  }
@@ -6,7 +6,6 @@ import type {
6
6
  DiagnosticRule,
7
7
  ElifAttribute,
8
8
  ElseAttribute,
9
- ExpressionElem,
10
9
  IfAttribute,
11
10
  InterpolateAttribute,
12
11
  NameElem,
@@ -15,7 +14,6 @@ import type {
15
14
  UnknownExpressionElem,
16
15
  } from "../AbstractElems.ts";
17
16
  import { ParseError } from "../ParseError.ts";
18
- import { beginElem, finishContents } from "./ContentsHelpers.ts";
19
17
  import { parseExpression } from "./ParseExpression.ts";
20
18
  import {
21
19
  expect,
@@ -23,6 +21,7 @@ import {
23
21
  makeNameElem,
24
22
  parseCommaList,
25
23
  parseMany,
24
+ throwParseError,
26
25
  } from "./ParseUtil.ts";
27
26
  import type { ParsingContext } from "./ParsingContext.ts";
28
27
 
@@ -47,6 +46,27 @@ export function parseElifAttribute(ctx: ParsingContext): ElifAttribute | null {
47
46
  return parseConditionalAttribute(ctx, "elif", makeElifAttribute);
48
47
  }
49
48
 
49
+ /** Parse WESL conditional attributes (@if, @elif, @else) */
50
+ export function parseWeslConditional(
51
+ ctx: ParsingContext,
52
+ ): AttributeElem | null {
53
+ const { stream } = ctx;
54
+ const peeked = stream.peek();
55
+ if (peeked?.text !== "@") return null;
56
+ const startPos = peeked.span[0]; // Use token position, not stream checkpoint
57
+
58
+ const ifAttr = parseIfAttribute(ctx);
59
+ if (ifAttr) return attributeElem(ifAttr, startPos, stream.checkpoint());
60
+
61
+ const elifAttr = parseElifAttribute(ctx);
62
+ if (elifAttr) return attributeElem(elifAttr, startPos, stream.checkpoint());
63
+
64
+ const elseAttr = parseElseAttribute(ctx);
65
+ if (elseAttr) return attributeElem(elseAttr, startPos, stream.checkpoint());
66
+
67
+ return null;
68
+ }
69
+
50
70
  /**
51
71
  * Grammar: attribute :
52
72
  * '@' ident_pattern_token argument_expression_list ?
@@ -57,18 +77,9 @@ export function parseElifAttribute(ctx: ParsingContext): ElifAttribute | null {
57
77
  * WESL extensions: @if, @elif, @else
58
78
  */
59
79
  function parseAttribute(ctx: ParsingContext): AttributeElem | null {
60
- const { stream } = ctx;
61
- const startPos = stream.checkpoint();
62
- if (!stream.matchText("@")) return null;
63
- stream.reset(startPos);
64
-
65
- const weslAttr = parseWeslConditional(ctx);
66
- if (weslAttr) return weslAttr;
80
+ if (ctx.stream.peek()?.text !== "@") return null;
67
81
 
68
- const stdAttr = parseStandardAttribute(ctx);
69
- if (stdAttr) return stdAttr;
70
-
71
- return null;
82
+ return parseWeslConditional(ctx) ?? parseStandardAttribute(ctx);
72
83
  }
73
84
 
74
85
  /** Parse `@if(expr)` or `@elif(expr)` conditional attributes. */
@@ -82,16 +93,21 @@ function parseConditionalAttribute<T>(
82
93
  if (!stream.matchSequence("@", keyword)) return null;
83
94
 
84
95
  expect(stream, "(", `@${keyword}`);
96
+ // Past `@keyword(` we're committed: a missing/invalid condition is a hard
97
+ // parse error, not a backtrack (the stream is already advanced past `(`).
85
98
  const expr = parseExpression(ctx, true);
86
- if (!expr) return null;
99
+ if (!expr) throwParseError(stream, `Expected expression after @${keyword}(`);
87
100
 
88
101
  stream.matchText(",");
89
102
  expect(stream, ")", `@${keyword} expression`);
90
103
 
91
- const translateTimeExpr = makeTranslateTimeExpressionElem({
92
- value: expr,
93
- span: [startPos, stream.checkpoint()],
94
- });
104
+ // TODO remove translate-time once we drop v1
105
+ const translateTimeExpr: TranslateTimeExpressionElem = {
106
+ kind: "translate-time-expression",
107
+ expression: expr,
108
+ start: startPos,
109
+ end: stream.checkpoint(),
110
+ };
95
111
  return makeAttr(translateTimeExpr);
96
112
  }
97
113
 
@@ -107,25 +123,12 @@ function makeElifAttribute(param: TranslateTimeExpressionElem): ElifAttribute {
107
123
  return { kind: "@elif", param } as const;
108
124
  }
109
125
 
110
- /** Parse WESL conditional attributes (@if, @elif, @else) */
111
- export function parseWeslConditional(
112
- ctx: ParsingContext,
113
- ): AttributeElem | null {
114
- const { stream } = ctx;
115
- const peeked = stream.peek();
116
- if (peeked?.text !== "@") return null;
117
- const startPos = peeked.span[0]; // Use token position, not stream checkpoint
118
-
119
- const ifAttr = parseIfAttribute(ctx);
120
- if (ifAttr) return attributeElem(ifAttr, startPos, stream.checkpoint());
121
-
122
- const elifAttr = parseElifAttribute(ctx);
123
- if (elifAttr) return attributeElem(elifAttr, startPos, stream.checkpoint());
124
-
125
- const elseAttr = parseElseAttribute(ctx);
126
- if (elseAttr) return attributeElem(elseAttr, startPos, stream.checkpoint());
127
-
128
- return null;
126
+ function attributeElem(
127
+ attribute: Attribute,
128
+ start: number,
129
+ end: number,
130
+ ): AttributeElem {
131
+ return { kind: "attribute", attribute, start, end };
129
132
  }
130
133
 
131
134
  /** Parse a standard attribute (not @if/@elif/@else) */
@@ -137,10 +140,7 @@ function parseStandardAttribute(ctx: ParsingContext): AttributeElem | null {
137
140
  const startPos = atToken.span[0]; // Use actual @ position, not before whitespace
138
141
 
139
142
  const nameToken = stream.peek();
140
- if (
141
- !nameToken ||
142
- (nameToken.kind !== "word" && nameToken.kind !== "keyword")
143
- ) {
143
+ if (nameToken?.kind !== "word" && nameToken?.kind !== "keyword") {
144
144
  stream.reset(resetPos);
145
145
  return null;
146
146
  }
@@ -171,26 +171,6 @@ function parseStandardAttribute(ctx: ParsingContext): AttributeElem | null {
171
171
  return attributeElem(stdAttr, startPos, stream.checkpoint());
172
172
  }
173
173
 
174
- // TODO remove translate-time once we drop v1
175
- function makeTranslateTimeExpressionElem(args: {
176
- value: ExpressionElem;
177
- span: [number, number];
178
- }): TranslateTimeExpressionElem {
179
- return {
180
- kind: "translate-time-expression",
181
- expression: args.value,
182
- span: args.span,
183
- };
184
- }
185
-
186
- function attributeElem(
187
- attribute: Attribute,
188
- start: number,
189
- end: number,
190
- ): AttributeElem {
191
- return { kind: "attribute", attribute, start, end, contents: [] };
192
- }
193
-
194
174
  function parseBuiltinAttribute(
195
175
  ctx: ParsingContext,
196
176
  startPos: number,
@@ -224,11 +204,6 @@ function parseInterpolateAttribute(
224
204
  return attributeElem(interpolateAttr, startPos, stream.checkpoint());
225
205
  }
226
206
 
227
- function parseNameElem(ctx: ParsingContext): NameElem {
228
- const nameToken = expectWord(ctx.stream, "Expected identifier");
229
- return makeNameElem(nameToken);
230
- }
231
-
232
207
  /** @diagnostic(severity, rule) or @diagnostic(severity, namespace.rule) */
233
208
  function parseDiagnosticAttribute(
234
209
  ctx: ParsingContext,
@@ -263,12 +238,15 @@ function parseAttributeParams(ctx: ParsingContext): UnknownExpressionElem[] {
263
238
  return parseCommaList(ctx, parseAttrParam);
264
239
  }
265
240
 
241
+ function parseNameElem(ctx: ParsingContext): NameElem {
242
+ const nameToken = expectWord(ctx.stream, "Expected identifier");
243
+ return makeNameElem(nameToken);
244
+ }
245
+
266
246
  function parseAttrParam(ctx: ParsingContext): UnknownExpressionElem {
267
247
  const { stream } = ctx;
268
248
  const start = stream.checkpoint();
269
- beginElem(ctx, "expression");
270
- parseExpression(ctx);
271
- const end = stream.checkpoint();
272
- const contents = finishContents(ctx, start, end);
273
- return { kind: "expression", start, end, contents };
249
+ const expression = parseExpression(ctx);
250
+ if (!expression) throwParseError(stream, "Expected attribute parameter");
251
+ return { kind: "expression", expression, start, end: stream.checkpoint() };
274
252
  }
@@ -24,11 +24,10 @@ export function parseCallSuffix(
24
24
  if (current.kind !== "ref" && current.kind !== "type") return null;
25
25
 
26
26
  const { stream } = ctx;
27
- // TypeRefElem has templates already; RefIdentElem needs separate parsing
27
+ // Constructor calls (e.g. vec4<f32>(...)) already have their template args
28
+ // on the TypeRefElem; only function calls need template args parsed here.
28
29
  const templateArgs =
29
- current.kind === "type"
30
- ? (current.templateParams ?? null)
31
- : parseCallTemplateArgs(ctx);
30
+ current.kind === "ref" ? parseCallTemplateArgs(ctx) : null;
32
31
 
33
32
  if (!stream.matchText("(")) return null;
34
33