@prisma-next/psl-parser 0.13.0-dev.4 → 0.13.0-dev.40

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