@prisma-next/psl-parser 0.13.0 → 0.14.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/parse.ts ADDED
@@ -0,0 +1,742 @@
1
+ import type { PslDiagnosticCode } from '@prisma-next/framework-components/psl-ast';
2
+ import { UNSPECIFIED_PSL_NAMESPACE_ID } from '@prisma-next/framework-components/psl-ast';
3
+ import { type Range, SourceFile } from './source-file';
4
+ import { DocumentAst } from './syntax/ast/declarations';
5
+ import type { GreenNode } from './syntax/green';
6
+ import { GreenNodeBuilder } from './syntax/green-builder';
7
+ import { createSyntaxTree } from './syntax/red';
8
+ import type { SyntaxKind } from './syntax/syntax-kind';
9
+ import { isTerminatedStringLiteral, type Token, Tokenizer, type TokenKind } from './tokenizer';
10
+
11
+ export interface ParseDiagnostic {
12
+ readonly code: PslDiagnosticCode;
13
+ readonly message: string;
14
+ readonly range: Range;
15
+ }
16
+
17
+ export interface ParseResult {
18
+ readonly document: DocumentAst;
19
+ readonly diagnostics: readonly ParseDiagnostic[];
20
+ readonly sourceFile: SourceFile;
21
+ }
22
+
23
+ const TRIVIA_KINDS: ReadonlySet<TokenKind> = new Set<TokenKind>([
24
+ 'Whitespace',
25
+ 'Newline',
26
+ 'Comment',
27
+ ]);
28
+
29
+ /**
30
+ * The source span of a token, captured eagerly so it stays valid after the
31
+ * cursor advances past the token it points at.
32
+ */
33
+ export interface DiagnosticMark {
34
+ readonly offset: number;
35
+ readonly length: number;
36
+ }
37
+
38
+ /**
39
+ * The fault-tolerant parser substrate the grammars drive. Trivia is flushed
40
+ * into the enclosing open node, so every child node spans exactly its first
41
+ * through last significant token.
42
+ */
43
+ export class Cursor {
44
+ readonly #tokenizer: Tokenizer;
45
+ readonly #sourceFile: SourceFile;
46
+ readonly #builder = new GreenNodeBuilder();
47
+ readonly #diagnostics: ParseDiagnostic[] = [];
48
+ #offset = 0;
49
+ #depth = 0;
50
+
51
+ constructor(source: string) {
52
+ this.#tokenizer = new Tokenizer(source);
53
+ this.#sourceFile = new SourceFile(source);
54
+ }
55
+
56
+ get diagnostics(): readonly ParseDiagnostic[] {
57
+ return this.#diagnostics;
58
+ }
59
+
60
+ get sourceFile(): SourceFile {
61
+ return this.#sourceFile;
62
+ }
63
+
64
+ peekKind(ahead = 0): TokenKind {
65
+ return this.peekToken(ahead).kind;
66
+ }
67
+
68
+ peekToken(ahead = 0): Token {
69
+ let rawIndex = 0;
70
+ let remaining = ahead;
71
+ for (;;) {
72
+ const token = this.#tokenizer.peek(rawIndex);
73
+ if (token.kind === 'Eof') return token;
74
+ if (TRIVIA_KINDS.has(token.kind)) {
75
+ rawIndex++;
76
+ continue;
77
+ }
78
+ if (remaining === 0) return token;
79
+ remaining--;
80
+ rawIndex++;
81
+ }
82
+ }
83
+
84
+ /** Span of the significant token `lookahead` positions ahead (`mark(0)` = the next). */
85
+ mark(lookahead = 0): DiagnosticMark {
86
+ let rawIndex = 0;
87
+ let offset = this.#offset;
88
+ let remaining = lookahead;
89
+ for (;;) {
90
+ const token = this.#tokenizer.peek(rawIndex);
91
+ if (token.kind === 'Eof') {
92
+ return { offset, length: token.text.length };
93
+ }
94
+ if (!TRIVIA_KINDS.has(token.kind) && remaining === 0) {
95
+ return { offset, length: token.text.length };
96
+ }
97
+ if (!TRIVIA_KINDS.has(token.kind)) {
98
+ remaining--;
99
+ }
100
+ offset += token.text.length;
101
+ rawIndex++;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Zero-width mark just past the last consumed significant token — anchors an
107
+ * "expected here" diagnostic, e.g. the `{` missing after a declaration's name.
108
+ */
109
+ markAfterLastToken(): DiagnosticMark {
110
+ return { offset: this.#offset, length: 0 };
111
+ }
112
+
113
+ startNode(kind: SyntaxKind): void {
114
+ if (this.#depth > 0) {
115
+ this.flushTrivia();
116
+ }
117
+ this.#builder.startNode(kind);
118
+ this.#depth++;
119
+ }
120
+
121
+ finishNode(): GreenNode {
122
+ this.#depth--;
123
+ return this.#builder.finishNode();
124
+ }
125
+
126
+ bump(): Token {
127
+ this.flushTrivia();
128
+ const token = this.#tokenizer.peek();
129
+ if (token.kind === 'Eof') return token;
130
+ this.#builder.token(token.kind, token.text);
131
+ this.#advance();
132
+ return token;
133
+ }
134
+
135
+ recoverToSyncPoint(): void {
136
+ for (;;) {
137
+ const token = this.#tokenizer.peek();
138
+ if (token.kind === 'Eof' || token.kind === 'Newline' || token.kind === 'RBrace') {
139
+ return;
140
+ }
141
+ this.#builder.token(token.kind, token.text);
142
+ this.#advance();
143
+ }
144
+ }
145
+
146
+ flushTrivia(): void {
147
+ for (;;) {
148
+ const token = this.#tokenizer.peek();
149
+ if (!TRIVIA_KINDS.has(token.kind)) return;
150
+ this.#builder.token(token.kind, token.text);
151
+ this.#advance();
152
+ }
153
+ }
154
+
155
+ diagnostic(code: PslDiagnosticCode, message: string, mark: DiagnosticMark): void {
156
+ const start = mark.offset;
157
+ const end = start + mark.length;
158
+ this.#diagnostics.push({
159
+ code,
160
+ message,
161
+ range: {
162
+ start: this.#sourceFile.positionAt(start),
163
+ end: this.#sourceFile.positionAt(end),
164
+ },
165
+ });
166
+ }
167
+
168
+ #advance(): void {
169
+ this.#offset += this.#tokenizer.next().text.length;
170
+ }
171
+ }
172
+
173
+ function parseIdentifier(cursor: Cursor): void {
174
+ cursor.startNode('Identifier');
175
+ cursor.bump();
176
+ cursor.finishNode();
177
+ }
178
+
179
+ /**
180
+ * Returns `undefined` when the next significant token does not start a
181
+ * recognised expression, leaving recovery to the caller.
182
+ */
183
+ export function parseExpression(cursor: Cursor): GreenNode | undefined {
184
+ return (
185
+ parseStringLiteralExpr(cursor) ??
186
+ parseNumberLiteralExpr(cursor) ??
187
+ parseArrayLiteral(cursor) ??
188
+ parseObjectLiteralExpr(cursor) ??
189
+ parseFunctionCall(cursor) ??
190
+ parseBooleanLiteralExpr(cursor) ??
191
+ parseIdentifierExpr(cursor)
192
+ );
193
+ }
194
+
195
+ export function parseStringLiteralExpr(cursor: Cursor): GreenNode | undefined {
196
+ if (cursor.peekKind() !== 'StringLiteral') return undefined;
197
+ const stringMark = cursor.mark();
198
+ const text = cursor.peekToken().text;
199
+ cursor.startNode('StringLiteralExpr');
200
+ cursor.bump();
201
+ if (!isTerminatedStringLiteral(text)) {
202
+ cursor.diagnostic('PSL_UNTERMINATED_STRING', 'Unterminated string literal', stringMark);
203
+ }
204
+ return cursor.finishNode();
205
+ }
206
+
207
+ export function parseNumberLiteralExpr(cursor: Cursor): GreenNode | undefined {
208
+ if (cursor.peekKind() !== 'NumberLiteral') return undefined;
209
+ cursor.startNode('NumberLiteralExpr');
210
+ cursor.bump();
211
+ return cursor.finishNode();
212
+ }
213
+
214
+ /**
215
+ * Parses a namespace-qualified name `[space ':']? Ident ('.' Ident)*`. The
216
+ * caller guarantees a leading `Ident`.
217
+ *
218
+ * Parsing the whole chain up front lets a position decide
219
+ * constructor-vs-reference by peeking exactly one token for `(`, with no scan of
220
+ * the dotted chain's length.
221
+ */
222
+ export function parseQualifiedName(cursor: Cursor): void {
223
+ cursor.startNode('QualifiedName');
224
+ parseIdentifier(cursor); // first segment: the space, namespace, or bare name
225
+ parseQualifiedSegments(cursor, 'Colon');
226
+ parseQualifiedSegments(cursor, 'Dot');
227
+ cursor.finishNode();
228
+ }
229
+
230
+ /**
231
+ * A well-formed name carries at most one colon space and one dot namespace, so
232
+ * each separator past the first of its kind reports `PSL_INVALID_QUALIFIED_NAME`.
233
+ * The separator is consumed regardless, keeping the lossless round-trip intact.
234
+ */
235
+ function parseQualifiedSegments(cursor: Cursor, separator: 'Colon' | 'Dot'): void {
236
+ let seen = 0;
237
+ while (cursor.peekKind() === separator) {
238
+ seen++;
239
+ const separatorMark = cursor.mark();
240
+ cursor.bump();
241
+ if (seen > 1) {
242
+ cursor.diagnostic(
243
+ 'PSL_INVALID_QUALIFIED_NAME',
244
+ 'Qualified name has too many segments',
245
+ separatorMark,
246
+ );
247
+ }
248
+ if (cursor.peekKind() === 'Ident') {
249
+ parseIdentifier(cursor);
250
+ } else {
251
+ cursor.diagnostic(
252
+ 'PSL_INVALID_QUALIFIED_NAME',
253
+ 'Qualified name is missing a name after the separator',
254
+ cursor.mark(),
255
+ );
256
+ }
257
+ }
258
+ }
259
+
260
+ // Ordering among the `Ident`-leading alternatives is load-bearing: the
261
+ // `LParen`/`Dot` lookahead of `parseCall` must win before the boolean check, so
262
+ // `true(` stays a function call named `true` rather than a boolean literal.
263
+ export function parseBooleanLiteralExpr(cursor: Cursor): GreenNode | undefined {
264
+ if (cursor.peekKind() !== 'Ident') return undefined;
265
+ const text = cursor.peekToken().text;
266
+ if (text !== 'true' && text !== 'false') return undefined;
267
+ cursor.startNode('BooleanLiteralExpr');
268
+ cursor.bump();
269
+ return cursor.finishNode();
270
+ }
271
+
272
+ export function parseIdentifierExpr(cursor: Cursor): GreenNode | undefined {
273
+ if (cursor.peekKind() !== 'Ident') return undefined;
274
+ cursor.startNode('Identifier');
275
+ cursor.bump();
276
+ return cursor.finishNode();
277
+ }
278
+
279
+ export function parseArrayLiteral(cursor: Cursor): GreenNode | undefined {
280
+ if (cursor.peekKind() !== 'LBracket') return undefined;
281
+ cursor.startNode('ArrayLiteral');
282
+ cursor.bump();
283
+ while (cursor.peekKind() !== 'RBracket' && cursor.peekKind() !== 'Eof') {
284
+ const element = parseExpression(cursor);
285
+ if (!element) break;
286
+ if (cursor.peekKind() === 'Comma') {
287
+ cursor.bump();
288
+ } else {
289
+ break;
290
+ }
291
+ }
292
+ if (cursor.peekKind() === 'RBracket') {
293
+ cursor.bump();
294
+ }
295
+ return cursor.finishNode();
296
+ }
297
+
298
+ export function parseObjectLiteralExpr(cursor: Cursor): GreenNode | undefined {
299
+ if (cursor.peekKind() !== 'LBrace') return undefined;
300
+ const braceMark = cursor.mark();
301
+ cursor.startNode('ObjectLiteralExpr');
302
+ cursor.bump();
303
+ while (cursor.peekKind() !== 'RBrace' && cursor.peekKind() !== 'Eof') {
304
+ parseObjectField(cursor);
305
+ if (cursor.peekKind() === 'Comma') {
306
+ cursor.bump();
307
+ } else if (cursor.peekKind() === 'Ident') {
308
+ // A following identifier key with no comma re-enters the loop; the next
309
+ // parseObjectField consumes ≥1 token, so progress is guaranteed.
310
+ cursor.diagnostic(
311
+ 'PSL_INVALID_OBJECT_LITERAL',
312
+ 'Expected "," between object-literal fields',
313
+ cursor.markAfterLastToken(),
314
+ );
315
+ } else {
316
+ break;
317
+ }
318
+ }
319
+ if (cursor.peekKind() === 'RBrace') {
320
+ cursor.bump();
321
+ } else {
322
+ cursor.diagnostic('PSL_INVALID_OBJECT_LITERAL', 'Unterminated object literal', braceMark);
323
+ }
324
+ return cursor.finishNode();
325
+ }
326
+
327
+ export function parseObjectField(cursor: Cursor): GreenNode {
328
+ cursor.startNode('ObjectField');
329
+ const keyMark = cursor.mark();
330
+ const keyText = cursor.peekToken().text;
331
+ if (cursor.peekKind() === 'Ident') {
332
+ parseIdentifier(cursor);
333
+ } else if (cursor.peekKind() === 'StringLiteral') {
334
+ // A string-literal key (e.g. `{ "length": 35 }`) is accepted; its logical
335
+ // name is the unquoted string.
336
+ parseStringLiteralExpr(cursor);
337
+ }
338
+ if (cursor.peekKind() === 'Colon') {
339
+ cursor.bump(); // Colon
340
+ const value = parseExpression(cursor);
341
+ if (!value) {
342
+ cursor.diagnostic('PSL_INVALID_OBJECT_LITERAL', 'Expected a value after ":"', cursor.mark());
343
+ }
344
+ } else {
345
+ cursor.diagnostic('PSL_INVALID_OBJECT_LITERAL', `Expected ":" after "${keyText}"`, keyMark);
346
+ const followsWithKey = cursor.peekKind() === 'Ident' && cursor.peekKind(1) === 'Colon';
347
+ if (!followsWithKey) {
348
+ parseExpression(cursor); // best-effort: consume a value if one follows
349
+ }
350
+ }
351
+ return cursor.finishNode();
352
+ }
353
+
354
+ /**
355
+ * Whether the next tokens open a call: a bare `Ident(` or a namespace-qualified
356
+ * `Ident.Ident(`. The lookahead is deliberately bounded so a bare dotted
357
+ * reference like `a.b` is not mistaken for a call, rather than scanning an
358
+ * unbounded dotted chain ahead to find the paren.
359
+ */
360
+ function isCallAhead(cursor: Cursor): boolean {
361
+ if (cursor.peekKind() !== 'Ident') return false;
362
+ if (cursor.peekKind(1) === 'LParen') return true;
363
+ return (
364
+ cursor.peekKind(1) === 'Dot' &&
365
+ cursor.peekKind(2) === 'Ident' &&
366
+ cursor.peekKind(3) === 'LParen'
367
+ );
368
+ }
369
+
370
+ /**
371
+ * Parses a function/constructor call — bare `autoincrement()` or qualified
372
+ * `temporal.updatedAt()`. Returns `undefined` unless {@link isCallAhead}
373
+ * confirms a trailing `(`, so the `parseExpression` chain falls through to the
374
+ * boolean and bare-identifier forms.
375
+ */
376
+ export function parseFunctionCall(cursor: Cursor): GreenNode | undefined {
377
+ if (!isCallAhead(cursor)) return undefined;
378
+ cursor.startNode('FunctionCall');
379
+ parseQualifiedName(cursor);
380
+ if (cursor.peekKind() === 'LParen') {
381
+ parseParenArgs(cursor);
382
+ }
383
+ return cursor.finishNode();
384
+ }
385
+
386
+ /** Parses a parenthesised, comma-separated `AttributeArg` list into the currently open node. */
387
+ function parseParenArgs(cursor: Cursor): void {
388
+ cursor.bump();
389
+ while (cursor.peekKind() !== 'RParen' && cursor.peekKind() !== 'Eof') {
390
+ parseAttributeArg(cursor);
391
+ if (cursor.peekKind() === 'Comma') {
392
+ cursor.bump();
393
+ } else {
394
+ break;
395
+ }
396
+ }
397
+ if (cursor.peekKind() === 'RParen') {
398
+ cursor.bump();
399
+ }
400
+ }
401
+
402
+ export function parseAttributeArg(cursor: Cursor): GreenNode {
403
+ cursor.startNode('AttributeArg');
404
+ if (cursor.peekKind() === 'Ident' && cursor.peekKind(1) === 'Colon') {
405
+ parseIdentifier(cursor);
406
+ cursor.bump();
407
+ }
408
+ parseArgValue(cursor);
409
+ return cursor.finishNode();
410
+ }
411
+
412
+ function parseArgValue(cursor: Cursor): void {
413
+ parseExpression(cursor);
414
+ }
415
+
416
+ export function parseAttributeArgList(cursor: Cursor): GreenNode {
417
+ cursor.startNode('AttributeArgList');
418
+ parseParenArgs(cursor);
419
+ return cursor.finishNode();
420
+ }
421
+
422
+ export function parseAttribute(cursor: Cursor): GreenNode {
423
+ const isBlockAttribute = cursor.peekKind() === 'DoubleAt';
424
+ const attributeMark = cursor.mark();
425
+ cursor.startNode(isBlockAttribute ? 'ModelAttribute' : 'FieldAttribute');
426
+ cursor.bump();
427
+ if (cursor.peekKind() === 'Ident') {
428
+ parseQualifiedName(cursor);
429
+ } else {
430
+ cursor.diagnostic('PSL_INVALID_ATTRIBUTE_SYNTAX', 'Attribute name expected', attributeMark);
431
+ }
432
+ if (cursor.peekKind() === 'LParen') {
433
+ parseAttributeArgList(cursor);
434
+ }
435
+ return cursor.finishNode();
436
+ }
437
+
438
+ /** A type annotation: `QualifiedName (argList)? ([])? (?)?`, e.g. `pgvector.Vector(1536)[]?`. */
439
+ export function parseTypeAnnotation(cursor: Cursor): GreenNode {
440
+ cursor.startNode('TypeAnnotation');
441
+ if (cursor.peekKind() === 'Ident') {
442
+ parseQualifiedName(cursor);
443
+ if (cursor.peekKind() === 'LParen') {
444
+ parseAttributeArgList(cursor);
445
+ }
446
+ }
447
+ if (cursor.peekKind() === 'LBracket') {
448
+ cursor.bump();
449
+ if (cursor.peekKind() === 'RBracket') {
450
+ cursor.bump();
451
+ }
452
+ }
453
+ if (cursor.peekKind() === 'Question') {
454
+ cursor.bump();
455
+ }
456
+ return cursor.finishNode();
457
+ }
458
+
459
+ type MemberParser = (cursor: Cursor) => void;
460
+
461
+ /**
462
+ * Parses a full PSL document. Never throws — malformed input yields diagnostics
463
+ * and a recovered tree, not an exception.
464
+ */
465
+ export function parse(source: string): ParseResult {
466
+ const cursor = new Cursor(source);
467
+ const green = parseDocument(cursor);
468
+ const root = createSyntaxTree(green);
469
+ const document = DocumentAst.cast(root) ?? new DocumentAst(root);
470
+ return { document, diagnostics: cursor.diagnostics, sourceFile: cursor.sourceFile };
471
+ }
472
+
473
+ function parseDocument(cursor: Cursor): GreenNode {
474
+ cursor.startNode('Document');
475
+ while (cursor.peekKind() !== 'Eof') {
476
+ parseDeclaration(cursor, false);
477
+ }
478
+ cursor.flushTrivia(); // attach trailing trivia so the round-trip stays lossless
479
+ return cursor.finishNode();
480
+ }
481
+
482
+ const RESERVED_BLOCK_KEYWORDS: ReadonlySet<string> = new Set([
483
+ 'model',
484
+ 'namespace',
485
+ 'type',
486
+ 'types',
487
+ ]);
488
+
489
+ function keywordIs(cursor: Cursor, keyword: string): boolean {
490
+ return cursor.peekKind() === 'Ident' && cursor.peekToken().text === keyword;
491
+ }
492
+
493
+ /**
494
+ * Each alternative is a no-op on non-match, consuming nothing, so the
495
+ * forward-only cursor is never left half-consumed by a rejected alternative.
496
+ * Recovery runs via the `if (!node)` tail rather than as a `??` arm, because it
497
+ * appends raw tokens to the open parent instead of returning a child node.
498
+ */
499
+ function parseDeclaration(cursor: Cursor, insideNamespace: boolean): void {
500
+ const name = cursor.peekKind(1) === 'Ident' ? cursor.peekToken(1).text : '';
501
+ if (insideNamespace && keywordIs(cursor, 'namespace')) {
502
+ cursor.diagnostic(
503
+ 'PSL_INVALID_NAMESPACE_BLOCK',
504
+ `Recursive "namespace ${name}" block is not allowed; namespace blocks may not nest`,
505
+ cursor.mark(),
506
+ );
507
+ } else if (insideNamespace && keywordIs(cursor, 'types')) {
508
+ cursor.diagnostic(
509
+ 'PSL_INVALID_NAMESPACE_BLOCK',
510
+ '`types` blocks must be declared at the document top level, not inside a namespace block',
511
+ cursor.mark(),
512
+ );
513
+ } else if (keywordIs(cursor, 'namespace') && name === UNSPECIFIED_PSL_NAMESPACE_ID) {
514
+ cursor.diagnostic(
515
+ 'PSL_INVALID_NAMESPACE_BLOCK',
516
+ `Namespace name "${UNSPECIFIED_PSL_NAMESPACE_ID}" is reserved for the parser-synthesised bucket for top-level declarations`,
517
+ cursor.mark(1),
518
+ );
519
+ }
520
+
521
+ const node =
522
+ parseModel(cursor) ??
523
+ parseNamespace(cursor) ??
524
+ parseCompositeType(cursor) ??
525
+ parseTypesBlock(cursor) ??
526
+ parseGenericBlock(cursor);
527
+ if (!node) {
528
+ parseUnsupportedTopLevel(cursor);
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Reports only the first missing piece — a missing name suppresses the
534
+ * missing-brace diagnostic. `nameRequired` is false only for the `types` block.
535
+ */
536
+ function parseBlock(
537
+ cursor: Cursor,
538
+ kind: SyntaxKind,
539
+ nameRequired: boolean,
540
+ parseMember: MemberParser,
541
+ ): GreenNode {
542
+ const keyword = cursor.peekToken().text;
543
+ const keywordMark = cursor.mark();
544
+ cursor.startNode(kind);
545
+ cursor.bump();
546
+ const hasName = nameRequired && cursor.peekKind() === 'Ident';
547
+ if (hasName) {
548
+ parseIdentifier(cursor);
549
+ }
550
+ if (nameRequired && !hasName) {
551
+ cursor.diagnostic('PSL_INVALID_DECLARATION', `Expected a name after "${keyword}"`, keywordMark);
552
+ } else if (cursor.peekKind() !== 'LBrace') {
553
+ cursor.diagnostic(
554
+ 'PSL_INVALID_DECLARATION',
555
+ `Expected "{" to open the "${keyword}" block`,
556
+ cursor.markAfterLastToken(),
557
+ );
558
+ }
559
+ if (cursor.peekKind() === 'LBrace') {
560
+ parseBlockBody(cursor, parseMember);
561
+ } else {
562
+ cursor.recoverToSyncPoint();
563
+ }
564
+ return cursor.finishNode();
565
+ }
566
+
567
+ export function parseModel(cursor: Cursor): GreenNode | undefined {
568
+ if (!keywordIs(cursor, 'model')) return undefined;
569
+ return parseBlock(cursor, 'ModelDeclaration', true, parseModelMember);
570
+ }
571
+
572
+ /**
573
+ * Excluding the reserved keywords keeps a malformed reserved block (e.g. `model
574
+ * {` with no name) routed to its dedicated parser. The generic keyword set is
575
+ * open, so a bare identifier with no brace (e.g. `oops`) is read as an unfinished
576
+ * custom declaration rather than unsupported content.
577
+ */
578
+ export function parseGenericBlock(cursor: Cursor): GreenNode | undefined {
579
+ if (cursor.peekKind() !== 'Ident') return undefined;
580
+ const keyword = cursor.peekToken().text;
581
+ if (RESERVED_BLOCK_KEYWORDS.has(keyword)) return undefined;
582
+ const hasName = cursor.peekKind(1) === 'Ident' && cursor.peekKind(2) === 'LBrace';
583
+ cursor.startNode('GenericBlockDeclaration');
584
+ cursor.bump();
585
+ if (hasName) {
586
+ parseIdentifier(cursor);
587
+ }
588
+ if (cursor.peekKind() === 'LBrace') {
589
+ parseBlockBody(cursor, parseKeyValueMember);
590
+ } else {
591
+ cursor.diagnostic(
592
+ 'PSL_INVALID_DECLARATION',
593
+ `Expected "{" to open the "${keyword}" block`,
594
+ cursor.markAfterLastToken(),
595
+ );
596
+ cursor.recoverToSyncPoint();
597
+ }
598
+ return cursor.finishNode();
599
+ }
600
+
601
+ export function parseNamespace(cursor: Cursor): GreenNode | undefined {
602
+ if (!keywordIs(cursor, 'namespace')) return undefined;
603
+ return parseBlock(cursor, 'Namespace', true, (inner) => parseDeclaration(inner, true));
604
+ }
605
+
606
+ export function parseCompositeType(cursor: Cursor): GreenNode | undefined {
607
+ if (!keywordIs(cursor, 'type')) return undefined;
608
+ return parseBlock(cursor, 'CompositeTypeDeclaration', true, parseModelMember);
609
+ }
610
+
611
+ /** `types` (plural) is the no-name types block; the singular `type` is the composite type above. */
612
+ export function parseTypesBlock(cursor: Cursor): GreenNode | undefined {
613
+ if (!keywordIs(cursor, 'types')) return undefined;
614
+ return parseBlock(cursor, 'TypesBlock', false, parseNamedTypeMember);
615
+ }
616
+
617
+ /** Every `parseMember` consumes at least one significant token, so the loop always terminates. */
618
+ function parseBlockBody(cursor: Cursor, parseMember: MemberParser): void {
619
+ const braceMark = cursor.mark();
620
+ cursor.bump();
621
+ for (;;) {
622
+ const kind = cursor.peekKind();
623
+ if (kind === 'RBrace' || kind === 'Eof') break;
624
+ parseMember(cursor);
625
+ }
626
+ if (cursor.peekKind() === 'RBrace') {
627
+ cursor.bump();
628
+ } else {
629
+ cursor.diagnostic('PSL_UNTERMINATED_BLOCK', 'Unterminated block declaration', braceMark);
630
+ }
631
+ }
632
+
633
+ function parseUnsupportedTopLevel(cursor: Cursor): void {
634
+ const offending = cursor.peekToken().text;
635
+ const message =
636
+ cursor.peekKind(1) === 'LBrace'
637
+ ? `Unsupported top-level block "${offending}"`
638
+ : `Unsupported top-level declaration "${offending}"`;
639
+ cursor.diagnostic('PSL_UNSUPPORTED_TOP_LEVEL_BLOCK', message, cursor.mark());
640
+ cursor.bump();
641
+ cursor.recoverToSyncPoint();
642
+ }
643
+
644
+ /**
645
+ * Matches a leading `@@` block attribute, a no-op otherwise. Single-`@`
646
+ * attributes belong to fields and are parsed inside `parseField`.
647
+ */
648
+ export function parseBlockAttribute(cursor: Cursor): GreenNode | undefined {
649
+ if (cursor.peekKind() !== 'DoubleAt') return undefined;
650
+ return parseAttribute(cursor);
651
+ }
652
+
653
+ function parseModelMember(cursor: Cursor): void {
654
+ const node = parseBlockAttribute(cursor) ?? parseField(cursor);
655
+ if (!node) {
656
+ invalidMember(
657
+ cursor,
658
+ 'PSL_INVALID_MODEL_MEMBER',
659
+ `Invalid model member declaration "${cursor.peekToken().text}"`,
660
+ );
661
+ }
662
+ }
663
+
664
+ function parseNamedTypeMember(cursor: Cursor): void {
665
+ const node = parseNamedType(cursor);
666
+ if (!node) {
667
+ invalidMember(
668
+ cursor,
669
+ 'PSL_INVALID_TYPES_MEMBER',
670
+ `Invalid types declaration "${cursor.peekToken().text}"`,
671
+ );
672
+ }
673
+ }
674
+
675
+ /**
676
+ * A generic-block member is either a `@@`-block attribute or a `key = value`
677
+ * entry. The block-attribute alternative is purely syntactic — it does not judge
678
+ * whether the attribute is valid for the block's kind.
679
+ */
680
+ function parseKeyValueMember(cursor: Cursor): void {
681
+ const node = parseBlockAttribute(cursor) ?? parseKeyValue(cursor);
682
+ if (!node) {
683
+ invalidMember(cursor, 'PSL_INVALID_EXTENSION_BLOCK_MEMBER', 'Invalid block entry');
684
+ }
685
+ }
686
+
687
+ function invalidMember(cursor: Cursor, code: PslDiagnosticCode, message: string): void {
688
+ cursor.diagnostic(code, message, cursor.mark());
689
+ cursor.bump(); // consume the offending token so the member loop makes progress
690
+ cursor.recoverToSyncPoint();
691
+ }
692
+
693
+ export function parseField(cursor: Cursor): GreenNode | undefined {
694
+ if (cursor.peekKind() !== 'Ident') return undefined;
695
+ cursor.startNode('FieldDeclaration');
696
+ parseIdentifier(cursor);
697
+ parseTypeAnnotation(cursor);
698
+ while (cursor.peekKind() === 'At') {
699
+ parseAttribute(cursor);
700
+ }
701
+ return cursor.finishNode();
702
+ }
703
+
704
+ export function parseNamedType(cursor: Cursor): GreenNode | undefined {
705
+ if (cursor.peekKind() !== 'Ident') return undefined;
706
+ cursor.startNode('NamedTypeDeclaration');
707
+ const nameMark = cursor.mark();
708
+ const nameText = cursor.peekToken().text;
709
+ parseIdentifier(cursor);
710
+ if (cursor.peekKind() === 'Equals') {
711
+ cursor.bump();
712
+ } else {
713
+ cursor.diagnostic('PSL_INVALID_TYPES_MEMBER', `Expected "=" after "${nameText}"`, nameMark);
714
+ }
715
+ parseTypeAnnotation(cursor);
716
+ while (cursor.peekKind() === 'At') {
717
+ parseAttribute(cursor);
718
+ }
719
+ return cursor.finishNode();
720
+ }
721
+
722
+ /**
723
+ * A generic-block entry is either `key = value` or a bare `key` (committing a
724
+ * `KeyValuePair` carrying only the key). A `key =` with no following expression
725
+ * is flagged.
726
+ */
727
+ export function parseKeyValue(cursor: Cursor): GreenNode | undefined {
728
+ if (cursor.peekKind() !== 'Ident') return undefined;
729
+ cursor.startNode('KeyValuePair');
730
+ parseIdentifier(cursor);
731
+ if (cursor.peekKind() === 'Equals') {
732
+ cursor.bump();
733
+ if (!parseExpression(cursor)) {
734
+ cursor.diagnostic(
735
+ 'PSL_INVALID_EXTENSION_BLOCK_MEMBER',
736
+ 'Expected a value after "="',
737
+ cursor.mark(),
738
+ );
739
+ }
740
+ }
741
+ return cursor.finishNode();
742
+ }