@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/dist/index.d.mts +2 -2
- package/dist/index.mjs +3 -2
- package/dist/index.mjs.map +1 -1
- package/dist/{parser-Cw_zV0M5.mjs → parser-CaplKvRs.mjs} +110 -141
- package/dist/parser-CaplKvRs.mjs.map +1 -0
- package/dist/parser.mjs +1 -1
- package/dist/syntax.d.mts +63 -6
- package/dist/syntax.d.mts.map +1 -1
- package/dist/syntax.mjs +699 -15
- package/dist/syntax.mjs.map +1 -1
- package/dist/tokenizer-DdaOstnz.mjs +233 -0
- package/dist/tokenizer-DdaOstnz.mjs.map +1 -0
- package/dist/tokenizer.mjs +1 -190
- package/package.json +5 -5
- package/src/exports/index.ts +13 -2
- package/src/exports/syntax.ts +7 -1
- package/src/parse.ts +767 -0
- package/src/parser.ts +125 -196
- package/src/source-file.ts +89 -0
- package/src/syntax/ast/declarations.ts +7 -5
- package/src/syntax/ast/expressions.ts +144 -17
- package/src/syntax/syntax-kind.ts +7 -2
- package/src/tokenizer.ts +48 -0
- package/dist/parser-Cw_zV0M5.mjs.map +0 -1
- package/dist/tokenizer.mjs.map +0 -1
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
|
+
}
|