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.
- package/README.md +1 -1
- package/dist/index.d.ts +283 -147
- package/dist/index.js +1765 -1143
- package/package.json +2 -2
- package/src/AbstractElems.ts +264 -82
- package/src/ClickableError.ts +8 -1
- package/src/Linker.ts +63 -67
- package/src/LinkerUtil.ts +141 -7
- package/src/LowerAndEmit.ts +660 -304
- package/src/Mangler.ts +1 -1
- package/src/ModuleResolver.ts +15 -4
- package/src/ParseWESL.ts +21 -33
- package/src/SrcMap.ts +7 -19
- package/src/StandardTypes.ts +1 -1
- package/src/WeslDevice.ts +1 -0
- package/src/debug/ASTtoString.ts +92 -76
- package/src/index.ts +1 -1
- package/src/parse/AttachComments.ts +289 -0
- package/src/parse/ExpressionUtil.ts +3 -3
- package/src/parse/Keywords.ts +1 -1
- package/src/parse/ParseAttribute.ts +49 -71
- package/src/parse/ParseCall.ts +3 -4
- package/src/parse/ParseControlFlow.ts +100 -56
- package/src/parse/ParseDirective.ts +9 -8
- package/src/parse/ParseDoBlock.ts +63 -0
- package/src/parse/ParseExpression.ts +11 -10
- package/src/parse/ParseFn.ts +11 -33
- package/src/parse/ParseGlobalVar.ts +36 -27
- package/src/parse/ParseIdent.ts +4 -5
- package/src/parse/ParseLocalVar.ts +16 -12
- package/src/parse/ParseLoop.ts +76 -39
- package/src/parse/ParseModule.ts +65 -19
- package/src/parse/ParseSimpleStatement.ts +112 -66
- package/src/parse/ParseStatement.ts +40 -67
- package/src/parse/ParseStruct.ts +8 -22
- package/src/parse/ParseType.ts +10 -13
- package/src/parse/ParseUtil.ts +26 -12
- package/src/parse/ParseValueDeclaration.ts +18 -31
- package/src/parse/ParseWesl.ts +11 -14
- package/src/parse/ParsingContext.ts +11 -9
- package/src/parse/WeslStream.ts +138 -121
- package/src/parse/stream/RegexMatchers.ts +45 -0
- package/src/parse/stream/WeslLexer.ts +249 -0
- package/src/test/BevyLink.test.ts +18 -11
- package/src/test/ConditionalElif.test.ts +4 -2
- package/src/test/DeclCommentEmit.test.ts +122 -0
- package/src/test/DoBlock.test.ts +164 -0
- package/src/test/FilterValidElements.test.ts +4 -6
- package/src/test/Linker.test.ts +23 -7
- package/src/test/Mangling.test.ts +8 -4
- package/src/test/ParseComments.test.ts +234 -6
- package/src/test/ParseConditionsV2.test.ts +47 -175
- package/src/test/ParseElifV2.test.ts +12 -33
- package/src/test/ParseErrorV2.test.ts +0 -0
- package/src/test/ParseWeslV2.test.ts +247 -626
- package/src/test/StatementEmit.test.ts +143 -0
- package/src/test/StripWesl.ts +24 -3
- package/src/test/TestLink.ts +19 -3
- package/src/test/TestUtil.ts +18 -8
- package/src/test/Tokenizer.test.ts +96 -0
- package/src/test/__snapshots__/ParseWeslV2.test.ts.snap +10 -42
- package/src/RawEmit.ts +0 -103
- package/src/Reflection.ts +0 -336
- package/src/TransformBindingStructs.ts +0 -320
- package/src/parse/ContentsHelpers.ts +0 -70
- package/src/parse/stream/CachingStream.ts +0 -48
- package/src/parse/stream/MatchersStream.ts +0 -85
package/src/parse/WeslStream.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
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
|
|
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
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
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 =
|
|
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
|
|
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)
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
227
|
+
const token = this.nextToken();
|
|
218
228
|
this.stream.reset(startPosition);
|
|
219
|
-
if (token === null) return null;
|
|
220
229
|
|
|
221
|
-
|
|
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
|
-
|
|
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
|
|
245
|
+
const token = this.nextToken();
|
|
242
246
|
this.stream.reset(startPosition);
|
|
243
247
|
if (token === null) return null;
|
|
244
248
|
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|