@prisma-next/psl-parser 0.0.1
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 +75 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +3 -0
- package/dist/parser-BP2RP4rk.mjs +681 -0
- package/dist/parser-BP2RP4rk.mjs.map +1 -0
- package/dist/parser-CUqO9VeG.d.mts +7 -0
- package/dist/parser-CUqO9VeG.d.mts.map +1 -0
- package/dist/parser.d.mts +2 -0
- package/dist/parser.mjs +3 -0
- package/dist/types-QqDFOzNR.d.mts +119 -0
- package/dist/types-QqDFOzNR.d.mts.map +1 -0
- package/dist/types.d.mts +2 -0
- package/dist/types.mjs +1 -0
- package/package.json +45 -0
- package/src/exports/index.ts +26 -0
- package/src/exports/parser.ts +1 -0
- package/src/exports/types.ts +28 -0
- package/src/parser.ts +927 -0
- package/src/types.ts +151 -0
package/src/parser.ts
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
import { ifDefined } from '@prisma-next/utils/defined';
|
|
2
|
+
import type {
|
|
3
|
+
ParsePslDocumentInput,
|
|
4
|
+
ParsePslDocumentResult,
|
|
5
|
+
PslAttribute,
|
|
6
|
+
PslAttributeArgument,
|
|
7
|
+
PslAttributeTarget,
|
|
8
|
+
PslDiagnostic,
|
|
9
|
+
PslDiagnosticCode,
|
|
10
|
+
PslDocumentAst,
|
|
11
|
+
PslEnum,
|
|
12
|
+
PslEnumValue,
|
|
13
|
+
PslField,
|
|
14
|
+
PslFieldAttribute,
|
|
15
|
+
PslModel,
|
|
16
|
+
PslModelAttribute,
|
|
17
|
+
PslNamedTypeDeclaration,
|
|
18
|
+
PslPosition,
|
|
19
|
+
PslSpan,
|
|
20
|
+
PslTypesBlock,
|
|
21
|
+
} from './types';
|
|
22
|
+
|
|
23
|
+
const SCALAR_TYPES = new Set([
|
|
24
|
+
'String',
|
|
25
|
+
'Boolean',
|
|
26
|
+
'Int',
|
|
27
|
+
'BigInt',
|
|
28
|
+
'Float',
|
|
29
|
+
'Decimal',
|
|
30
|
+
'DateTime',
|
|
31
|
+
'Json',
|
|
32
|
+
'Bytes',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
interface BlockBounds {
|
|
36
|
+
readonly startLine: number;
|
|
37
|
+
readonly endLine: number;
|
|
38
|
+
readonly closed: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ParserContext {
|
|
42
|
+
readonly schema: string;
|
|
43
|
+
readonly sourceId: string;
|
|
44
|
+
readonly lines: readonly string[];
|
|
45
|
+
readonly lineOffsets: readonly number[];
|
|
46
|
+
readonly diagnostics: PslDiagnostic[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parsePslDocument(input: ParsePslDocumentInput): ParsePslDocumentResult {
|
|
50
|
+
const normalizedSchema = input.schema.replaceAll('\r\n', '\n');
|
|
51
|
+
const lines = normalizedSchema.split('\n');
|
|
52
|
+
const lineOffsets = computeLineOffsets(normalizedSchema);
|
|
53
|
+
const diagnostics: PslDiagnostic[] = [];
|
|
54
|
+
const context: ParserContext = {
|
|
55
|
+
schema: normalizedSchema,
|
|
56
|
+
sourceId: input.sourceId,
|
|
57
|
+
lines,
|
|
58
|
+
lineOffsets,
|
|
59
|
+
diagnostics,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const models: PslModel[] = [];
|
|
63
|
+
const enums: PslEnum[] = [];
|
|
64
|
+
let typesBlock: PslTypesBlock | undefined;
|
|
65
|
+
|
|
66
|
+
let lineIndex = 0;
|
|
67
|
+
while (lineIndex < lines.length) {
|
|
68
|
+
const rawLine = lines[lineIndex] ?? '';
|
|
69
|
+
const line = stripInlineComment(rawLine).trim();
|
|
70
|
+
if (line.length === 0) {
|
|
71
|
+
lineIndex += 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const modelMatch = line.match(/^model\s+([A-Za-z_]\w*)\s*\{$/);
|
|
76
|
+
if (modelMatch) {
|
|
77
|
+
const bounds = findBlockBounds(context, lineIndex);
|
|
78
|
+
const name = modelMatch[1] ?? '';
|
|
79
|
+
if (name.length === 0) {
|
|
80
|
+
lineIndex = bounds.endLine + 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
models.push(parseModelBlock(context, name, bounds));
|
|
84
|
+
lineIndex = bounds.endLine + 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const enumMatch = line.match(/^enum\s+([A-Za-z_]\w*)\s*\{$/);
|
|
89
|
+
if (enumMatch) {
|
|
90
|
+
const bounds = findBlockBounds(context, lineIndex);
|
|
91
|
+
const name = enumMatch[1] ?? '';
|
|
92
|
+
if (name.length === 0) {
|
|
93
|
+
lineIndex = bounds.endLine + 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
enums.push(parseEnumBlock(context, name, bounds));
|
|
97
|
+
lineIndex = bounds.endLine + 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (/^types\s*\{$/.test(line)) {
|
|
102
|
+
const bounds = findBlockBounds(context, lineIndex);
|
|
103
|
+
typesBlock = parseTypesBlock(context, bounds);
|
|
104
|
+
lineIndex = bounds.endLine + 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (line.includes('{')) {
|
|
109
|
+
const blockName = line.split(/\s+/)[0] ?? 'block';
|
|
110
|
+
pushDiagnostic(context, {
|
|
111
|
+
code: 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK',
|
|
112
|
+
message: `Unsupported top-level block "${blockName}"`,
|
|
113
|
+
span: createTrimmedLineSpan(context, lineIndex),
|
|
114
|
+
});
|
|
115
|
+
const bounds = findBlockBounds(context, lineIndex);
|
|
116
|
+
lineIndex = bounds.endLine + 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
pushDiagnostic(context, {
|
|
121
|
+
code: 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK',
|
|
122
|
+
message: `Unsupported top-level declaration "${line}"`,
|
|
123
|
+
span: createTrimmedLineSpan(context, lineIndex),
|
|
124
|
+
});
|
|
125
|
+
lineIndex += 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const namedTypeNames = new Set(
|
|
129
|
+
(typesBlock?.declarations ?? []).map((declaration) => declaration.name),
|
|
130
|
+
);
|
|
131
|
+
const modelNames = new Set(models.map((model) => model.name));
|
|
132
|
+
const enumNames = new Set(enums.map((enumBlock) => enumBlock.name));
|
|
133
|
+
for (const declaration of typesBlock?.declarations ?? []) {
|
|
134
|
+
if (SCALAR_TYPES.has(declaration.name)) {
|
|
135
|
+
pushDiagnostic(context, {
|
|
136
|
+
code: 'PSL_INVALID_TYPES_MEMBER',
|
|
137
|
+
message: `Named type "${declaration.name}" conflicts with scalar type "${declaration.name}"`,
|
|
138
|
+
span: declaration.span,
|
|
139
|
+
});
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (modelNames.has(declaration.name)) {
|
|
143
|
+
pushDiagnostic(context, {
|
|
144
|
+
code: 'PSL_INVALID_TYPES_MEMBER',
|
|
145
|
+
message: `Named type "${declaration.name}" conflicts with model name "${declaration.name}"`,
|
|
146
|
+
span: declaration.span,
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (enumNames.has(declaration.name)) {
|
|
151
|
+
pushDiagnostic(context, {
|
|
152
|
+
code: 'PSL_INVALID_TYPES_MEMBER',
|
|
153
|
+
message: `Named type "${declaration.name}" conflicts with enum name "${declaration.name}"`,
|
|
154
|
+
span: declaration.span,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const normalizedModels = models.map((model) => ({
|
|
159
|
+
...model,
|
|
160
|
+
fields: model.fields.map((field) => {
|
|
161
|
+
if (!namedTypeNames.has(field.typeName)) {
|
|
162
|
+
return field;
|
|
163
|
+
}
|
|
164
|
+
const hasRelationAttribute = field.attributes.some(
|
|
165
|
+
(attribute) => attribute.name === 'relation',
|
|
166
|
+
);
|
|
167
|
+
if (
|
|
168
|
+
hasRelationAttribute ||
|
|
169
|
+
modelNames.has(field.typeName) ||
|
|
170
|
+
enumNames.has(field.typeName) ||
|
|
171
|
+
SCALAR_TYPES.has(field.typeName)
|
|
172
|
+
) {
|
|
173
|
+
return field;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
...field,
|
|
177
|
+
typeRef: field.typeName,
|
|
178
|
+
};
|
|
179
|
+
}),
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
const ast: PslDocumentAst = {
|
|
183
|
+
kind: 'document',
|
|
184
|
+
sourceId: input.sourceId,
|
|
185
|
+
models: normalizedModels,
|
|
186
|
+
enums,
|
|
187
|
+
...ifDefined('types', typesBlock),
|
|
188
|
+
span: {
|
|
189
|
+
start: createPosition(context, 0, 0),
|
|
190
|
+
end: createPosition(
|
|
191
|
+
context,
|
|
192
|
+
Math.max(lines.length - 1, 0),
|
|
193
|
+
(lines[Math.max(lines.length - 1, 0)] ?? '').length,
|
|
194
|
+
),
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
ast,
|
|
200
|
+
diagnostics,
|
|
201
|
+
ok: diagnostics.length === 0,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseModelBlock(context: ParserContext, name: string, bounds: BlockBounds): PslModel {
|
|
206
|
+
const fields: PslField[] = [];
|
|
207
|
+
const attributes: PslModelAttribute[] = [];
|
|
208
|
+
|
|
209
|
+
for (let lineIndex = bounds.startLine + 1; lineIndex < bounds.endLine; lineIndex += 1) {
|
|
210
|
+
const raw = context.lines[lineIndex] ?? '';
|
|
211
|
+
const line = stripInlineComment(raw).trim();
|
|
212
|
+
if (line.length === 0) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (line.startsWith('@@')) {
|
|
217
|
+
const attribute = parseModelAttribute(context, line, lineIndex);
|
|
218
|
+
if (attribute) {
|
|
219
|
+
attributes.push(attribute);
|
|
220
|
+
}
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const field = parseField(context, line, lineIndex);
|
|
225
|
+
if (field) {
|
|
226
|
+
fields.push(field);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
kind: 'model',
|
|
232
|
+
name,
|
|
233
|
+
fields,
|
|
234
|
+
attributes,
|
|
235
|
+
span: createLineRangeSpan(context, bounds.startLine, bounds.endLine),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseEnumBlock(context: ParserContext, name: string, bounds: BlockBounds): PslEnum {
|
|
240
|
+
const values: PslEnumValue[] = [];
|
|
241
|
+
|
|
242
|
+
for (let lineIndex = bounds.startLine + 1; lineIndex < bounds.endLine; lineIndex += 1) {
|
|
243
|
+
const raw = context.lines[lineIndex] ?? '';
|
|
244
|
+
const line = stripInlineComment(raw).trim();
|
|
245
|
+
if (line.length === 0) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const valueMatch = line.match(/^([A-Za-z_]\w*)$/);
|
|
250
|
+
if (!valueMatch) {
|
|
251
|
+
pushDiagnostic(context, {
|
|
252
|
+
code: 'PSL_INVALID_ENUM_MEMBER',
|
|
253
|
+
message: `Invalid enum value declaration "${line}"`,
|
|
254
|
+
span: createTrimmedLineSpan(context, lineIndex),
|
|
255
|
+
});
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
values.push({
|
|
260
|
+
kind: 'enumValue',
|
|
261
|
+
name: valueMatch[1] ?? '',
|
|
262
|
+
span: createTrimmedLineSpan(context, lineIndex),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
kind: 'enum',
|
|
268
|
+
name,
|
|
269
|
+
values,
|
|
270
|
+
span: createLineRangeSpan(context, bounds.startLine, bounds.endLine),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function parseTypesBlock(context: ParserContext, bounds: BlockBounds): PslTypesBlock {
|
|
275
|
+
const declarations: PslNamedTypeDeclaration[] = [];
|
|
276
|
+
|
|
277
|
+
for (let lineIndex = bounds.startLine + 1; lineIndex < bounds.endLine; lineIndex += 1) {
|
|
278
|
+
const raw = context.lines[lineIndex] ?? '';
|
|
279
|
+
const lineWithoutComment = stripInlineComment(raw);
|
|
280
|
+
const line = lineWithoutComment.trim();
|
|
281
|
+
if (line.length === 0) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const declarationMatch = line.match(/^([A-Za-z_]\w*)\s*=\s*([A-Za-z_]\w*)(.*)$/);
|
|
286
|
+
if (!declarationMatch) {
|
|
287
|
+
pushDiagnostic(context, {
|
|
288
|
+
code: 'PSL_INVALID_TYPES_MEMBER',
|
|
289
|
+
message: `Invalid types declaration "${line}"`,
|
|
290
|
+
span: createTrimmedLineSpan(context, lineIndex),
|
|
291
|
+
});
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const declarationName = declarationMatch[1] ?? '';
|
|
296
|
+
const baseType = declarationMatch[2] ?? '';
|
|
297
|
+
const attributePart = declarationMatch[3] ?? '';
|
|
298
|
+
const trimmedStartColumn = firstNonWhitespaceColumn(raw);
|
|
299
|
+
const attributeOffset = line.length - attributePart.length;
|
|
300
|
+
const attributeSource = attributePart.trimStart();
|
|
301
|
+
const leadingAttributeWhitespace = attributePart.length - attributeSource.length;
|
|
302
|
+
const attributeParse = extractAttributeTokensWithSpans(
|
|
303
|
+
context,
|
|
304
|
+
lineIndex,
|
|
305
|
+
attributeSource,
|
|
306
|
+
trimmedStartColumn + attributeOffset + leadingAttributeWhitespace,
|
|
307
|
+
);
|
|
308
|
+
if (!attributeParse.ok) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const attributes = attributeParse.tokens
|
|
312
|
+
.map((token) =>
|
|
313
|
+
parseAttributeToken(context, {
|
|
314
|
+
token: token.text,
|
|
315
|
+
target: 'namedType',
|
|
316
|
+
lineIndex,
|
|
317
|
+
span: token.span,
|
|
318
|
+
}),
|
|
319
|
+
)
|
|
320
|
+
.filter((attribute): attribute is PslAttribute => Boolean(attribute));
|
|
321
|
+
|
|
322
|
+
declarations.push({
|
|
323
|
+
kind: 'namedType',
|
|
324
|
+
name: declarationName,
|
|
325
|
+
baseType,
|
|
326
|
+
attributes,
|
|
327
|
+
span: createTrimmedLineSpan(context, lineIndex),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
kind: 'types',
|
|
333
|
+
declarations,
|
|
334
|
+
span: createLineRangeSpan(context, bounds.startLine, bounds.endLine),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function parseModelAttribute(
|
|
339
|
+
context: ParserContext,
|
|
340
|
+
line: string,
|
|
341
|
+
lineIndex: number,
|
|
342
|
+
): PslModelAttribute | undefined {
|
|
343
|
+
const rawLine = context.lines[lineIndex] ?? '';
|
|
344
|
+
const tokenParse = extractAttributeTokensWithSpans(
|
|
345
|
+
context,
|
|
346
|
+
lineIndex,
|
|
347
|
+
line,
|
|
348
|
+
firstNonWhitespaceColumn(rawLine),
|
|
349
|
+
);
|
|
350
|
+
if (!tokenParse.ok || tokenParse.tokens.length !== 1) {
|
|
351
|
+
pushDiagnostic(context, {
|
|
352
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
353
|
+
message: `Invalid model attribute syntax "${line}"`,
|
|
354
|
+
span: createTrimmedLineSpan(context, lineIndex),
|
|
355
|
+
});
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
const token = tokenParse.tokens[0];
|
|
359
|
+
if (!token) {
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
return parseAttributeToken(context, {
|
|
363
|
+
token: token.text,
|
|
364
|
+
target: 'model',
|
|
365
|
+
lineIndex,
|
|
366
|
+
span: token.span,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function parseField(context: ParserContext, line: string, lineIndex: number): PslField | undefined {
|
|
371
|
+
const fieldMatch = line.match(/^([A-Za-z_]\w*)\s+([A-Za-z_]\w*(?:\[\])?)(\?)?(.*)$/);
|
|
372
|
+
if (!fieldMatch) {
|
|
373
|
+
pushDiagnostic(context, {
|
|
374
|
+
code: 'PSL_INVALID_MODEL_MEMBER',
|
|
375
|
+
message: `Invalid model member declaration "${line}"`,
|
|
376
|
+
span: createTrimmedLineSpan(context, lineIndex),
|
|
377
|
+
});
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const fieldName = fieldMatch[1] ?? '';
|
|
382
|
+
const rawTypeToken = fieldMatch[2] ?? '';
|
|
383
|
+
const optionalMarker = fieldMatch[3] ?? '';
|
|
384
|
+
const attributePart = fieldMatch[4] ?? '';
|
|
385
|
+
const list = rawTypeToken.endsWith('[]');
|
|
386
|
+
const typeName = list ? rawTypeToken.slice(0, -2) : rawTypeToken;
|
|
387
|
+
const optional = optionalMarker === '?';
|
|
388
|
+
|
|
389
|
+
const attributes: PslFieldAttribute[] = [];
|
|
390
|
+
const rawLine = context.lines[lineIndex] ?? '';
|
|
391
|
+
const trimmedStartColumn = firstNonWhitespaceColumn(rawLine);
|
|
392
|
+
const attributeOffset = line.length - attributePart.length;
|
|
393
|
+
const attributeSource = attributePart.trimStart();
|
|
394
|
+
const leadingAttributeWhitespace = attributePart.length - attributeSource.length;
|
|
395
|
+
const tokenParse = extractAttributeTokensWithSpans(
|
|
396
|
+
context,
|
|
397
|
+
lineIndex,
|
|
398
|
+
attributeSource,
|
|
399
|
+
trimmedStartColumn + attributeOffset + leadingAttributeWhitespace,
|
|
400
|
+
);
|
|
401
|
+
if (!tokenParse.ok) {
|
|
402
|
+
return {
|
|
403
|
+
kind: 'field',
|
|
404
|
+
name: fieldName,
|
|
405
|
+
typeName,
|
|
406
|
+
optional,
|
|
407
|
+
list,
|
|
408
|
+
attributes,
|
|
409
|
+
span: createTrimmedLineSpan(context, lineIndex),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
for (const token of tokenParse.tokens) {
|
|
414
|
+
const parsed = parseAttributeToken(context, {
|
|
415
|
+
token: token.text,
|
|
416
|
+
target: 'field',
|
|
417
|
+
lineIndex,
|
|
418
|
+
span: token.span,
|
|
419
|
+
});
|
|
420
|
+
if (parsed) {
|
|
421
|
+
attributes.push(parsed);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
kind: 'field',
|
|
427
|
+
name: fieldName,
|
|
428
|
+
typeName,
|
|
429
|
+
optional,
|
|
430
|
+
list,
|
|
431
|
+
attributes,
|
|
432
|
+
span: createTrimmedLineSpan(context, lineIndex),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function parseAttributeToken(
|
|
437
|
+
context: ParserContext,
|
|
438
|
+
input: {
|
|
439
|
+
readonly token: string;
|
|
440
|
+
readonly target: PslAttributeTarget;
|
|
441
|
+
readonly lineIndex: number;
|
|
442
|
+
readonly span: PslSpan;
|
|
443
|
+
},
|
|
444
|
+
): PslAttribute | undefined {
|
|
445
|
+
const expectsModelPrefix = input.target === 'model';
|
|
446
|
+
if (expectsModelPrefix && !input.token.startsWith('@@')) {
|
|
447
|
+
pushDiagnostic(context, {
|
|
448
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
449
|
+
message: `Model attribute "${input.token}" must use @@ prefix`,
|
|
450
|
+
span: input.span,
|
|
451
|
+
});
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
if (!expectsModelPrefix && !input.token.startsWith('@')) {
|
|
455
|
+
pushDiagnostic(context, {
|
|
456
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
457
|
+
message: `Attribute "${input.token}" must use @ prefix`,
|
|
458
|
+
span: input.span,
|
|
459
|
+
});
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
if (!expectsModelPrefix && input.token.startsWith('@@')) {
|
|
463
|
+
pushDiagnostic(context, {
|
|
464
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
465
|
+
message: `Attribute "${input.token}" is not valid in ${input.target} context`,
|
|
466
|
+
span: input.span,
|
|
467
|
+
});
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const rawBody = expectsModelPrefix ? input.token.slice(2) : input.token.slice(1);
|
|
472
|
+
const openParen = rawBody.indexOf('(');
|
|
473
|
+
const closeParen = rawBody.lastIndexOf(')');
|
|
474
|
+
const hasArgs = openParen >= 0 || closeParen >= 0;
|
|
475
|
+
if ((openParen >= 0 && closeParen === -1) || (openParen === -1 && closeParen >= 0)) {
|
|
476
|
+
pushDiagnostic(context, {
|
|
477
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
478
|
+
message: `Invalid attribute syntax "${input.token}"`,
|
|
479
|
+
span: input.span,
|
|
480
|
+
});
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const name = (openParen >= 0 ? rawBody.slice(0, openParen) : rawBody).trim();
|
|
485
|
+
if (!/^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z_][A-Za-z0-9_-]*)*$/.test(name)) {
|
|
486
|
+
pushDiagnostic(context, {
|
|
487
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
488
|
+
message: `Invalid attribute name "${name || input.token}"`,
|
|
489
|
+
span: input.span,
|
|
490
|
+
});
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let args: readonly PslAttributeArgument[] = [];
|
|
495
|
+
if (hasArgs && openParen >= 0 && closeParen >= openParen) {
|
|
496
|
+
if (closeParen !== rawBody.length - 1) {
|
|
497
|
+
pushDiagnostic(context, {
|
|
498
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
499
|
+
message: `Invalid trailing syntax in attribute "${input.token}"`,
|
|
500
|
+
span: input.span,
|
|
501
|
+
});
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
const argsRaw = rawBody.slice(openParen + 1, closeParen);
|
|
505
|
+
const parsedArgs = parseAttributeArguments(context, {
|
|
506
|
+
argsRaw,
|
|
507
|
+
argsOffset: input.span.start.column - 1 + (expectsModelPrefix ? 2 : 1) + openParen + 1,
|
|
508
|
+
lineIndex: input.lineIndex,
|
|
509
|
+
token: input.token,
|
|
510
|
+
span: input.span,
|
|
511
|
+
});
|
|
512
|
+
if (!parsedArgs) {
|
|
513
|
+
return undefined;
|
|
514
|
+
}
|
|
515
|
+
args = parsedArgs;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
kind: 'attribute',
|
|
520
|
+
target: input.target,
|
|
521
|
+
name,
|
|
522
|
+
args,
|
|
523
|
+
span: input.span,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function parseAttributeArguments(
|
|
528
|
+
context: ParserContext,
|
|
529
|
+
input: {
|
|
530
|
+
readonly argsRaw: string;
|
|
531
|
+
readonly argsOffset: number;
|
|
532
|
+
readonly lineIndex: number;
|
|
533
|
+
readonly token: string;
|
|
534
|
+
readonly span: PslSpan;
|
|
535
|
+
},
|
|
536
|
+
): readonly PslAttributeArgument[] | undefined {
|
|
537
|
+
const trimmed = input.argsRaw.trim();
|
|
538
|
+
if (trimmed.length === 0) {
|
|
539
|
+
return [];
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const parts = splitTopLevelSegments(input.argsRaw, ',');
|
|
543
|
+
const args: PslAttributeArgument[] = [];
|
|
544
|
+
|
|
545
|
+
for (const part of parts) {
|
|
546
|
+
const original = part.value;
|
|
547
|
+
const trimmedPart = original.trim();
|
|
548
|
+
if (trimmedPart.length === 0) {
|
|
549
|
+
pushDiagnostic(context, {
|
|
550
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
551
|
+
message: `Invalid empty argument in attribute "${input.token}"`,
|
|
552
|
+
span: input.span,
|
|
553
|
+
});
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const leadingWhitespace = original.length - original.trimStart().length;
|
|
558
|
+
const partStart = input.argsOffset + part.start + leadingWhitespace;
|
|
559
|
+
const partEnd = partStart + trimmedPart.length;
|
|
560
|
+
const partSpan = createInlineSpan(context, input.lineIndex, partStart, partEnd);
|
|
561
|
+
|
|
562
|
+
const namedSplit = splitTopLevelSegments(trimmedPart, ':');
|
|
563
|
+
if (namedSplit.length > 1) {
|
|
564
|
+
const first = namedSplit[0];
|
|
565
|
+
if (!first) {
|
|
566
|
+
pushDiagnostic(context, {
|
|
567
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
568
|
+
message: `Invalid named argument syntax "${trimmedPart}"`,
|
|
569
|
+
span: partSpan,
|
|
570
|
+
});
|
|
571
|
+
return undefined;
|
|
572
|
+
}
|
|
573
|
+
const name = first.value.trim();
|
|
574
|
+
const rawValue = trimmedPart.slice(first.end + 1).trim();
|
|
575
|
+
if (!name || rawValue.length === 0) {
|
|
576
|
+
pushDiagnostic(context, {
|
|
577
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
578
|
+
message: `Invalid named argument syntax "${trimmedPart}"`,
|
|
579
|
+
span: partSpan,
|
|
580
|
+
});
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
args.push({
|
|
584
|
+
kind: 'named',
|
|
585
|
+
name,
|
|
586
|
+
value: normalizeAttributeArgumentValue(rawValue),
|
|
587
|
+
span: partSpan,
|
|
588
|
+
});
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
args.push({
|
|
593
|
+
kind: 'positional',
|
|
594
|
+
value: normalizeAttributeArgumentValue(trimmedPart),
|
|
595
|
+
span: partSpan,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return args;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function normalizeAttributeArgumentValue(value: string): string {
|
|
603
|
+
return value.trim();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function findBlockBounds(context: ParserContext, startLine: number): BlockBounds {
|
|
607
|
+
let depth = 0;
|
|
608
|
+
|
|
609
|
+
for (let lineIndex = startLine; lineIndex < context.lines.length; lineIndex += 1) {
|
|
610
|
+
const line = stripInlineComment(context.lines[lineIndex] ?? '');
|
|
611
|
+
let quote: '"' | "'" | null = null;
|
|
612
|
+
let previousCharacter = '';
|
|
613
|
+
for (const character of line) {
|
|
614
|
+
if (quote) {
|
|
615
|
+
if (character === quote && previousCharacter !== '\\') {
|
|
616
|
+
quote = null;
|
|
617
|
+
}
|
|
618
|
+
previousCharacter = character;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (character === '"' || character === "'") {
|
|
623
|
+
quote = character;
|
|
624
|
+
previousCharacter = character;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (character === '{') {
|
|
629
|
+
depth += 1;
|
|
630
|
+
}
|
|
631
|
+
if (character === '}') {
|
|
632
|
+
depth -= 1;
|
|
633
|
+
if (depth === 0) {
|
|
634
|
+
return { startLine, endLine: lineIndex, closed: true };
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
previousCharacter = character;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
pushDiagnostic(context, {
|
|
642
|
+
code: 'PSL_UNTERMINATED_BLOCK',
|
|
643
|
+
message: 'Unterminated block declaration',
|
|
644
|
+
span: createTrimmedLineSpan(context, startLine),
|
|
645
|
+
});
|
|
646
|
+
return {
|
|
647
|
+
startLine,
|
|
648
|
+
endLine: context.lines.length - 1,
|
|
649
|
+
closed: false,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
interface TopLevelSegment {
|
|
654
|
+
readonly value: string;
|
|
655
|
+
readonly start: number;
|
|
656
|
+
readonly end: number;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function splitTopLevelSegments(value: string, separator: ',' | ':'): TopLevelSegment[] {
|
|
660
|
+
const parts: TopLevelSegment[] = [];
|
|
661
|
+
let depthParen = 0;
|
|
662
|
+
let depthBracket = 0;
|
|
663
|
+
let quote: '"' | "'" | null = null;
|
|
664
|
+
let start = 0;
|
|
665
|
+
|
|
666
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
667
|
+
const character = value[index] ?? '';
|
|
668
|
+
if (quote) {
|
|
669
|
+
if (character === quote && value[index - 1] !== '\\') {
|
|
670
|
+
quote = null;
|
|
671
|
+
}
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (character === '"' || character === "'") {
|
|
676
|
+
quote = character;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (character === '(') {
|
|
681
|
+
depthParen += 1;
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (character === ')') {
|
|
685
|
+
depthParen = Math.max(0, depthParen - 1);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (character === '[') {
|
|
689
|
+
depthBracket += 1;
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
if (character === ']') {
|
|
693
|
+
depthBracket = Math.max(0, depthBracket - 1);
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (character === separator && depthParen === 0 && depthBracket === 0) {
|
|
698
|
+
parts.push({
|
|
699
|
+
value: value.slice(start, index),
|
|
700
|
+
start,
|
|
701
|
+
end: index,
|
|
702
|
+
});
|
|
703
|
+
start = index + 1;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
parts.push({
|
|
708
|
+
value: value.slice(start),
|
|
709
|
+
start,
|
|
710
|
+
end: value.length,
|
|
711
|
+
});
|
|
712
|
+
return parts;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function extractAttributeTokensWithSpans(
|
|
716
|
+
context: ParserContext,
|
|
717
|
+
lineIndex: number,
|
|
718
|
+
value: string,
|
|
719
|
+
startColumn: number,
|
|
720
|
+
): { readonly ok: boolean; readonly tokens: readonly { text: string; span: PslSpan }[] } {
|
|
721
|
+
const tokens: { text: string; span: PslSpan }[] = [];
|
|
722
|
+
let index = 0;
|
|
723
|
+
while (index < value.length) {
|
|
724
|
+
while (index < value.length && /\s/.test(value[index] ?? '')) {
|
|
725
|
+
index += 1;
|
|
726
|
+
}
|
|
727
|
+
if (index >= value.length) {
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (value[index] !== '@') {
|
|
732
|
+
pushDiagnostic(context, {
|
|
733
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
734
|
+
message: `Invalid attribute syntax "${value.trim()}"`,
|
|
735
|
+
span: createInlineSpan(context, lineIndex, startColumn + index, startColumn + value.length),
|
|
736
|
+
});
|
|
737
|
+
return { ok: false, tokens };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const start = index;
|
|
741
|
+
index += 1;
|
|
742
|
+
if (value[index] === '@') {
|
|
743
|
+
index += 1;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const nameStart = index;
|
|
747
|
+
while (index < value.length && /[A-Za-z0-9_.-]/.test(value[index] ?? '')) {
|
|
748
|
+
index += 1;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (index === nameStart) {
|
|
752
|
+
pushDiagnostic(context, {
|
|
753
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
754
|
+
message: `Invalid attribute syntax "${value.slice(start).trim()}"`,
|
|
755
|
+
span: createInlineSpan(context, lineIndex, startColumn + start, startColumn + value.length),
|
|
756
|
+
});
|
|
757
|
+
return { ok: false, tokens };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (value[index] === '(') {
|
|
761
|
+
let depth = 0;
|
|
762
|
+
let quote: '"' | "'" | null = null;
|
|
763
|
+
while (index < value.length) {
|
|
764
|
+
const char = value[index] ?? '';
|
|
765
|
+
if (quote) {
|
|
766
|
+
if (char === quote && value[index - 1] !== '\\') {
|
|
767
|
+
quote = null;
|
|
768
|
+
}
|
|
769
|
+
index += 1;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (char === '"' || char === "'") {
|
|
774
|
+
quote = char;
|
|
775
|
+
index += 1;
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (char === '(') {
|
|
780
|
+
depth += 1;
|
|
781
|
+
} else if (char === ')') {
|
|
782
|
+
depth -= 1;
|
|
783
|
+
if (depth === 0) {
|
|
784
|
+
index += 1;
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
index += 1;
|
|
789
|
+
}
|
|
790
|
+
if (depth !== 0) {
|
|
791
|
+
pushDiagnostic(context, {
|
|
792
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
793
|
+
message: `Unterminated attribute argument list in "${value.slice(start).trim()}"`,
|
|
794
|
+
span: createInlineSpan(
|
|
795
|
+
context,
|
|
796
|
+
lineIndex,
|
|
797
|
+
startColumn + start,
|
|
798
|
+
startColumn + value.length,
|
|
799
|
+
),
|
|
800
|
+
});
|
|
801
|
+
return { ok: false, tokens };
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const tokenText = value.slice(start, index).trim();
|
|
806
|
+
tokens.push({
|
|
807
|
+
text: tokenText,
|
|
808
|
+
span: createInlineSpan(context, lineIndex, startColumn + start, startColumn + index),
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
while (index < value.length && /\s/.test(value[index] ?? '')) {
|
|
812
|
+
index += 1;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (index < value.length && value[index] !== '@') {
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (index < value.length && value[index] !== '@') {
|
|
821
|
+
pushDiagnostic(context, {
|
|
822
|
+
code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
|
|
823
|
+
message: `Invalid attribute syntax "${value.trim()}"`,
|
|
824
|
+
span: createInlineSpan(context, lineIndex, startColumn + index, startColumn + value.length),
|
|
825
|
+
});
|
|
826
|
+
return { ok: false, tokens };
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return { ok: true, tokens };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function stripInlineComment(line: string): string {
|
|
833
|
+
let quote: '"' | "'" | null = null;
|
|
834
|
+
for (let index = 0; index < line.length - 1; index += 1) {
|
|
835
|
+
const current = line[index] ?? '';
|
|
836
|
+
const next = line[index + 1] ?? '';
|
|
837
|
+
|
|
838
|
+
if (quote) {
|
|
839
|
+
if (current === quote && line[index - 1] !== '\\') {
|
|
840
|
+
quote = null;
|
|
841
|
+
}
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (current === '"' || current === "'") {
|
|
846
|
+
quote = current;
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (current === '/' && next === '/') {
|
|
851
|
+
return line.slice(0, index);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return line;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function computeLineOffsets(schema: string): number[] {
|
|
859
|
+
const offsets = [0];
|
|
860
|
+
for (let index = 0; index < schema.length; index += 1) {
|
|
861
|
+
if (schema[index] === '\n') {
|
|
862
|
+
offsets.push(index + 1);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return offsets;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function firstNonWhitespaceColumn(line: string): number {
|
|
869
|
+
const first = line.search(/\S/);
|
|
870
|
+
return first === -1 ? 0 : first;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function createInlineSpan(
|
|
874
|
+
context: ParserContext,
|
|
875
|
+
lineIndex: number,
|
|
876
|
+
startColumn: number,
|
|
877
|
+
endColumn: number,
|
|
878
|
+
): PslSpan {
|
|
879
|
+
return {
|
|
880
|
+
start: createPosition(context, lineIndex, startColumn),
|
|
881
|
+
end: createPosition(context, lineIndex, endColumn),
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function createTrimmedLineSpan(context: ParserContext, lineIndex: number): PslSpan {
|
|
886
|
+
const line = context.lines[lineIndex] ?? '';
|
|
887
|
+
const startColumn = firstNonWhitespaceColumn(line);
|
|
888
|
+
return {
|
|
889
|
+
start: createPosition(context, lineIndex, startColumn),
|
|
890
|
+
end: createPosition(context, lineIndex, line.length),
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function createLineRangeSpan(context: ParserContext, startLine: number, endLine: number): PslSpan {
|
|
895
|
+
const startLineText = context.lines[startLine] ?? '';
|
|
896
|
+
const endLineText = context.lines[endLine] ?? '';
|
|
897
|
+
const startColumn = firstNonWhitespaceColumn(startLineText);
|
|
898
|
+
return {
|
|
899
|
+
start: createPosition(context, startLine, startColumn),
|
|
900
|
+
end: createPosition(context, endLine, endLineText.length),
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function createPosition(
|
|
905
|
+
context: ParserContext,
|
|
906
|
+
lineIndex: number,
|
|
907
|
+
columnIndex: number,
|
|
908
|
+
): PslPosition {
|
|
909
|
+
const clampedLineIndex = Math.max(0, Math.min(lineIndex, context.lineOffsets.length - 1));
|
|
910
|
+
const lineText = context.lines[clampedLineIndex] ?? '';
|
|
911
|
+
const clampedColumnIndex = Math.max(0, Math.min(columnIndex, lineText.length));
|
|
912
|
+
return {
|
|
913
|
+
offset: (context.lineOffsets[clampedLineIndex] ?? 0) + clampedColumnIndex,
|
|
914
|
+
line: clampedLineIndex + 1,
|
|
915
|
+
column: clampedColumnIndex + 1,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function pushDiagnostic(
|
|
920
|
+
context: ParserContext,
|
|
921
|
+
diagnostic: Omit<PslDiagnostic, 'sourceId'> & { readonly code: PslDiagnosticCode },
|
|
922
|
+
): void {
|
|
923
|
+
context.diagnostics.push({
|
|
924
|
+
...diagnostic,
|
|
925
|
+
sourceId: context.sourceId,
|
|
926
|
+
});
|
|
927
|
+
}
|