@prisma-next/psl-parser 0.3.0-dev.99 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parser.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  PslAttribute,
6
6
  PslAttributeArgument,
7
7
  PslAttributeTarget,
8
+ PslCompositeType,
8
9
  PslDiagnostic,
9
10
  PslDiagnosticCode,
10
11
  PslDocumentAst,
@@ -17,6 +18,7 @@ import type {
17
18
  PslNamedTypeDeclaration,
18
19
  PslPosition,
19
20
  PslSpan,
21
+ PslTypeConstructorCall,
20
22
  PslTypesBlock,
21
23
  } from './types';
22
24
 
@@ -61,6 +63,7 @@ export function parsePslDocument(input: ParsePslDocumentInput): ParsePslDocument
61
63
 
62
64
  const models: PslModel[] = [];
63
65
  const enums: PslEnum[] = [];
66
+ const compositeTypes: PslCompositeType[] = [];
64
67
  let typesBlock: PslTypesBlock | undefined;
65
68
 
66
69
  let lineIndex = 0;
@@ -98,6 +101,19 @@ export function parsePslDocument(input: ParsePslDocumentInput): ParsePslDocument
98
101
  continue;
99
102
  }
100
103
 
104
+ const compositeTypeMatch = line.match(/^type\s+([A-Za-z_]\w*)\s*\{$/);
105
+ if (compositeTypeMatch) {
106
+ const bounds = findBlockBounds(context, lineIndex);
107
+ const name = compositeTypeMatch[1] ?? '';
108
+ if (name.length === 0) {
109
+ lineIndex = bounds.endLine + 1;
110
+ continue;
111
+ }
112
+ compositeTypes.push(parseCompositeTypeBlock(context, name, bounds));
113
+ lineIndex = bounds.endLine + 1;
114
+ continue;
115
+ }
116
+
101
117
  if (/^types\s*\{$/.test(line)) {
102
118
  const bounds = findBlockBounds(context, lineIndex);
103
119
  typesBlock = parseTypesBlock(context, bounds);
@@ -130,6 +146,7 @@ export function parsePslDocument(input: ParsePslDocumentInput): ParsePslDocument
130
146
  );
131
147
  const modelNames = new Set(models.map((model) => model.name));
132
148
  const enumNames = new Set(enums.map((enumBlock) => enumBlock.name));
149
+ const compositeTypeNames = new Set(compositeTypes.map((ct) => ct.name));
133
150
  for (const declaration of typesBlock?.declarations ?? []) {
134
151
  if (SCALAR_TYPES.has(declaration.name)) {
135
152
  pushDiagnostic(context, {
@@ -168,6 +185,7 @@ export function parsePslDocument(input: ParsePslDocumentInput): ParsePslDocument
168
185
  hasRelationAttribute ||
169
186
  modelNames.has(field.typeName) ||
170
187
  enumNames.has(field.typeName) ||
188
+ compositeTypeNames.has(field.typeName) ||
171
189
  SCALAR_TYPES.has(field.typeName)
172
190
  ) {
173
191
  return field;
@@ -184,6 +202,7 @@ export function parsePslDocument(input: ParsePslDocumentInput): ParsePslDocument
184
202
  sourceId: input.sourceId,
185
203
  models: normalizedModels,
186
204
  enums,
205
+ compositeTypes,
187
206
  ...ifDefined('types', typesBlock),
188
207
  span: {
189
208
  start: createPosition(context, 0, 0),
@@ -236,8 +255,47 @@ function parseModelBlock(context: ParserContext, name: string, bounds: BlockBoun
236
255
  };
237
256
  }
238
257
 
258
+ function parseCompositeTypeBlock(
259
+ context: ParserContext,
260
+ name: string,
261
+ bounds: BlockBounds,
262
+ ): PslCompositeType {
263
+ const fields: PslField[] = [];
264
+ const attributes: PslAttribute[] = [];
265
+
266
+ for (let lineIndex = bounds.startLine + 1; lineIndex < bounds.endLine; lineIndex += 1) {
267
+ const raw = context.lines[lineIndex] ?? '';
268
+ const line = stripInlineComment(raw).trim();
269
+ if (line.length === 0) {
270
+ continue;
271
+ }
272
+
273
+ if (line.startsWith('@@')) {
274
+ const attribute = parseModelAttribute(context, line, lineIndex);
275
+ if (attribute) {
276
+ attributes.push(attribute);
277
+ }
278
+ continue;
279
+ }
280
+
281
+ const field = parseField(context, line, lineIndex);
282
+ if (field) {
283
+ fields.push(field);
284
+ }
285
+ }
286
+
287
+ return {
288
+ kind: 'compositeType',
289
+ name,
290
+ fields,
291
+ attributes,
292
+ span: createLineRangeSpan(context, bounds.startLine, bounds.endLine),
293
+ };
294
+ }
295
+
239
296
  function parseEnumBlock(context: ParserContext, name: string, bounds: BlockBounds): PslEnum {
240
297
  const values: PslEnumValue[] = [];
298
+ const attributes: PslAttribute[] = [];
241
299
 
242
300
  for (let lineIndex = bounds.startLine + 1; lineIndex < bounds.endLine; lineIndex += 1) {
243
301
  const raw = context.lines[lineIndex] ?? '';
@@ -246,6 +304,14 @@ function parseEnumBlock(context: ParserContext, name: string, bounds: BlockBound
246
304
  continue;
247
305
  }
248
306
 
307
+ if (line.startsWith('@@')) {
308
+ const attribute = parseEnumAttribute(context, line, lineIndex);
309
+ if (attribute) {
310
+ attributes.push(attribute);
311
+ }
312
+ continue;
313
+ }
314
+
249
315
  const valueMatch = line.match(/^([A-Za-z_]\w*)$/);
250
316
  if (!valueMatch) {
251
317
  pushDiagnostic(context, {
@@ -267,6 +333,7 @@ function parseEnumBlock(context: ParserContext, name: string, bounds: BlockBound
267
333
  kind: 'enum',
268
334
  name,
269
335
  values,
336
+ attributes,
270
337
  span: createLineRangeSpan(context, bounds.startLine, bounds.endLine),
271
338
  };
272
339
  }
@@ -282,7 +349,7 @@ function parseTypesBlock(context: ParserContext, bounds: BlockBounds): PslTypesB
282
349
  continue;
283
350
  }
284
351
 
285
- const declarationMatch = line.match(/^([A-Za-z_]\w*)\s*=\s*([A-Za-z_]\w*)(.*)$/);
352
+ const declarationMatch = line.match(/^([A-Za-z_]\w*)\s*=\s*(.+)$/);
286
353
  if (!declarationMatch) {
287
354
  pushDiagnostic(context, {
288
355
  code: 'PSL_INVALID_TYPES_MEMBER',
@@ -293,17 +360,33 @@ function parseTypesBlock(context: ParserContext, bounds: BlockBounds): PslTypesB
293
360
  }
294
361
 
295
362
  const declarationName = declarationMatch[1] ?? '';
296
- const baseType = declarationMatch[2] ?? '';
297
- const attributePart = declarationMatch[3] ?? '';
298
363
  const trimmedStartColumn = firstNonWhitespaceColumn(raw);
299
- const attributeOffset = line.length - attributePart.length;
300
- const attributeSource = attributePart.trimStart();
301
- const leadingAttributeWhitespace = attributePart.length - attributeSource.length;
364
+ const declarationValue = (declarationMatch[2] ?? '').trim();
365
+ const valueOffset = line.indexOf(declarationValue);
366
+ const declarationValueColumn = trimmedStartColumn + Math.max(valueOffset, 0);
367
+
368
+ const typeAndAttributeSplit = splitTypeAndAttributes(declarationValue);
369
+ const typeSource = typeAndAttributeSplit.typeSource.trim();
370
+ const attributeSource = typeAndAttributeSplit.attributeSource.trimStart();
371
+ const leadingAttributeWhitespace =
372
+ typeAndAttributeSplit.attributeSource.length - attributeSource.length;
373
+
374
+ const typeConstructor = parseTypeConstructorCall(context, {
375
+ declarationValue: typeSource,
376
+ lineIndex,
377
+ startColumn: declarationValueColumn,
378
+ invalidCode: 'PSL_INVALID_TYPES_MEMBER',
379
+ invalidMessage: (value) => `Invalid types declaration "${value}"`,
380
+ });
381
+ if (typeConstructor === 'malformed') {
382
+ continue;
383
+ }
384
+
302
385
  const attributeParse = extractAttributeTokensWithSpans(
303
386
  context,
304
387
  lineIndex,
305
388
  attributeSource,
306
- trimmedStartColumn + attributeOffset + leadingAttributeWhitespace,
389
+ declarationValueColumn + typeAndAttributeSplit.attributeOffset + leadingAttributeWhitespace,
307
390
  );
308
391
  if (!attributeParse.ok) {
309
392
  continue;
@@ -319,6 +402,29 @@ function parseTypesBlock(context: ParserContext, bounds: BlockBounds): PslTypesB
319
402
  )
320
403
  .filter((attribute): attribute is PslAttribute => Boolean(attribute));
321
404
 
405
+ if (typeConstructor) {
406
+ declarations.push({
407
+ kind: 'namedType',
408
+ name: declarationName,
409
+ typeConstructor,
410
+ attributes,
411
+ span: createTrimmedLineSpan(context, lineIndex),
412
+ });
413
+ continue;
414
+ }
415
+
416
+ const baseTypeMatch = typeSource.match(/^([A-Za-z_]\w*)$/);
417
+ if (!baseTypeMatch) {
418
+ pushDiagnostic(context, {
419
+ code: 'PSL_INVALID_TYPES_MEMBER',
420
+ message: `Invalid types declaration "${line}"`,
421
+ span: createTrimmedLineSpan(context, lineIndex),
422
+ });
423
+ continue;
424
+ }
425
+
426
+ const baseType = baseTypeMatch[1] ?? '';
427
+
322
428
  declarations.push({
323
429
  kind: 'namedType',
324
430
  name: declarationName,
@@ -335,6 +441,78 @@ function parseTypesBlock(context: ParserContext, bounds: BlockBounds): PslTypesB
335
441
  };
336
442
  }
337
443
 
444
+ function parseTypeConstructorCall(
445
+ context: ParserContext,
446
+ input: {
447
+ readonly declarationValue: string;
448
+ readonly lineIndex: number;
449
+ readonly startColumn: number;
450
+ readonly invalidCode: PslDiagnosticCode;
451
+ readonly invalidMessage: (value: string) => string;
452
+ },
453
+ ): PslTypeConstructorCall | 'malformed' | undefined {
454
+ const value = input.declarationValue.trim();
455
+ const constructorMatch = value.match(
456
+ /^([A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*)*)\s*\(/,
457
+ );
458
+ if (!constructorMatch) {
459
+ return undefined;
460
+ }
461
+
462
+ // constructorMatch already required `(`; openParen is guaranteed ≥ 0.
463
+ const openParen = value.indexOf('(');
464
+ const closeParen = value.lastIndexOf(')');
465
+
466
+ if (closeParen !== value.length - 1) {
467
+ pushDiagnostic(context, {
468
+ code: input.invalidCode,
469
+ message: input.invalidMessage(value),
470
+ span: createInlineSpan(
471
+ context,
472
+ input.lineIndex,
473
+ input.startColumn,
474
+ input.startColumn + value.length,
475
+ ),
476
+ });
477
+ return 'malformed';
478
+ }
479
+
480
+ const constructorPath = constructorMatch[1] ?? '';
481
+
482
+ const argsRaw = value.slice(openParen + 1, closeParen);
483
+ const args = parseArgumentList(context, {
484
+ argsRaw,
485
+ argsOffset: input.startColumn + openParen + 1,
486
+ lineIndex: input.lineIndex,
487
+ token: value,
488
+ span: createInlineSpan(
489
+ context,
490
+ input.lineIndex,
491
+ input.startColumn,
492
+ input.startColumn + value.length,
493
+ ),
494
+ invalidCode: input.invalidCode,
495
+ invalidEmptyArgumentMessage: `Invalid empty argument in type constructor "${value}"`,
496
+ invalidNamedArgumentMessage: (part) =>
497
+ `Invalid named argument syntax "${part}" in type constructor "${value}"`,
498
+ });
499
+ if (!args) {
500
+ return 'malformed';
501
+ }
502
+
503
+ return {
504
+ kind: 'typeConstructor',
505
+ path: constructorPath.split('.'),
506
+ args,
507
+ span: createInlineSpan(
508
+ context,
509
+ input.lineIndex,
510
+ input.startColumn,
511
+ input.startColumn + value.length,
512
+ ),
513
+ };
514
+ }
515
+
338
516
  function parseModelAttribute(
339
517
  context: ParserContext,
340
518
  line: string,
@@ -367,8 +545,52 @@ function parseModelAttribute(
367
545
  });
368
546
  }
369
547
 
548
+ function parseEnumAttribute(
549
+ context: ParserContext,
550
+ line: string,
551
+ lineIndex: number,
552
+ ): PslAttribute | undefined {
553
+ const rawLine = context.lines[lineIndex] ?? '';
554
+ const tokenParse = extractAttributeTokensWithSpans(
555
+ context,
556
+ lineIndex,
557
+ line,
558
+ firstNonWhitespaceColumn(rawLine),
559
+ );
560
+ if (!tokenParse.ok || tokenParse.tokens.length !== 1) {
561
+ pushDiagnostic(context, {
562
+ code: 'PSL_INVALID_ENUM_MEMBER',
563
+ message: `Invalid enum value declaration "${line}"`,
564
+ span: createTrimmedLineSpan(context, lineIndex),
565
+ });
566
+ return undefined;
567
+ }
568
+ const token = tokenParse.tokens[0];
569
+ if (!token) {
570
+ return undefined;
571
+ }
572
+ const parsed = parseAttributeToken(context, {
573
+ token: token.text,
574
+ target: 'enum',
575
+ lineIndex,
576
+ span: token.span,
577
+ });
578
+ if (!parsed) {
579
+ return undefined;
580
+ }
581
+ if (parsed.name !== 'map') {
582
+ pushDiagnostic(context, {
583
+ code: 'PSL_INVALID_ENUM_MEMBER',
584
+ message: `Invalid enum value declaration "${line}"`,
585
+ span: createTrimmedLineSpan(context, lineIndex),
586
+ });
587
+ return undefined;
588
+ }
589
+ return parsed;
590
+ }
591
+
370
592
  function parseField(context: ParserContext, line: string, lineIndex: number): PslField | undefined {
371
- const fieldMatch = line.match(/^([A-Za-z_]\w*)\s+([A-Za-z_]\w*(?:\[\])?)(\?)?(.*)$/);
593
+ const fieldMatch = line.match(/^([A-Za-z_]\w*)(\s+)(.+)$/);
372
594
  if (!fieldMatch) {
373
595
  pushDiagnostic(context, {
374
596
  code: 'PSL_INVALID_MODEL_MEMBER',
@@ -379,30 +601,62 @@ function parseField(context: ParserContext, line: string, lineIndex: number): Ps
379
601
  }
380
602
 
381
603
  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[] = [];
604
+ const separator = fieldMatch[2] ?? '';
605
+ const remainder = fieldMatch[3] ?? '';
606
+ const typeAndAttributeSplit = splitTypeAndAttributes(remainder);
607
+ const rawTypeSource = typeAndAttributeSplit.typeSource.trim();
608
+ const attributePart = typeAndAttributeSplit.attributeSource;
609
+ const optional = rawTypeSource.endsWith('?');
610
+ const typeSourceWithoutOptional = optional ? rawTypeSource.slice(0, -1).trimEnd() : rawTypeSource;
611
+ const list = typeSourceWithoutOptional.endsWith('[]');
612
+ const baseTypeSource = list
613
+ ? typeSourceWithoutOptional.slice(0, -2).trimEnd()
614
+ : typeSourceWithoutOptional;
390
615
  const rawLine = context.lines[lineIndex] ?? '';
391
616
  const trimmedStartColumn = firstNonWhitespaceColumn(rawLine);
392
- const attributeOffset = line.length - attributePart.length;
617
+ const typeStartColumn = trimmedStartColumn + fieldName.length + separator.length;
618
+
619
+ const typeConstructor = parseTypeConstructorCall(context, {
620
+ declarationValue: baseTypeSource,
621
+ lineIndex,
622
+ startColumn: typeStartColumn,
623
+ invalidCode: 'PSL_INVALID_MODEL_MEMBER',
624
+ invalidMessage: (value) => `Invalid field type constructor "${value}"`,
625
+ });
626
+ if (typeConstructor === 'malformed') {
627
+ return undefined;
628
+ }
629
+
630
+ const simpleTypeMatch = baseTypeSource.match(/^([A-Za-z_]\w*)$/);
631
+ const typeName = typeConstructor?.path.join('.') ?? simpleTypeMatch?.[1];
632
+ if (!typeName) {
633
+ pushDiagnostic(context, {
634
+ code: 'PSL_INVALID_MODEL_MEMBER',
635
+ message: `Invalid model member declaration "${line}"`,
636
+ span: createTrimmedLineSpan(context, lineIndex),
637
+ });
638
+ return undefined;
639
+ }
640
+
641
+ const attributes: PslFieldAttribute[] = [];
393
642
  const attributeSource = attributePart.trimStart();
394
643
  const leadingAttributeWhitespace = attributePart.length - attributeSource.length;
395
644
  const tokenParse = extractAttributeTokensWithSpans(
396
645
  context,
397
646
  lineIndex,
398
647
  attributeSource,
399
- trimmedStartColumn + attributeOffset + leadingAttributeWhitespace,
648
+ trimmedStartColumn +
649
+ fieldName.length +
650
+ separator.length +
651
+ typeAndAttributeSplit.attributeOffset +
652
+ leadingAttributeWhitespace,
400
653
  );
401
654
  if (!tokenParse.ok) {
402
655
  return {
403
656
  kind: 'field',
404
657
  name: fieldName,
405
658
  typeName,
659
+ ...ifDefined('typeConstructor', typeConstructor),
406
660
  optional,
407
661
  list,
408
662
  attributes,
@@ -426,6 +680,7 @@ function parseField(context: ParserContext, line: string, lineIndex: number): Ps
426
680
  kind: 'field',
427
681
  name: fieldName,
428
682
  typeName,
683
+ ...ifDefined('typeConstructor', typeConstructor),
429
684
  optional,
430
685
  list,
431
686
  attributes,
@@ -433,6 +688,80 @@ function parseField(context: ParserContext, line: string, lineIndex: number): Ps
433
688
  };
434
689
  }
435
690
 
691
+ function isQuoteEscaped(value: string, quoteIndex: number): boolean {
692
+ let backslashCount = 0;
693
+
694
+ for (let index = quoteIndex - 1; index >= 0 && value[index] === '\\'; index -= 1) {
695
+ backslashCount += 1;
696
+ }
697
+
698
+ return backslashCount % 2 === 1;
699
+ }
700
+
701
+ function splitTypeAndAttributes(value: string): {
702
+ readonly typeSource: string;
703
+ readonly attributeSource: string;
704
+ readonly attributeOffset: number;
705
+ } {
706
+ let depthParen = 0;
707
+ let depthBracket = 0;
708
+ let depthBrace = 0;
709
+ let quote: '"' | "'" | null = null;
710
+
711
+ for (let index = 0; index < value.length; index += 1) {
712
+ const character = value[index] ?? '';
713
+ if (quote) {
714
+ if (character === quote && !isQuoteEscaped(value, index)) {
715
+ quote = null;
716
+ }
717
+ continue;
718
+ }
719
+
720
+ if (character === '"' || character === "'") {
721
+ quote = character;
722
+ continue;
723
+ }
724
+ if (character === '(') {
725
+ depthParen += 1;
726
+ continue;
727
+ }
728
+ if (character === ')') {
729
+ depthParen = Math.max(0, depthParen - 1);
730
+ continue;
731
+ }
732
+ if (character === '[') {
733
+ depthBracket += 1;
734
+ continue;
735
+ }
736
+ if (character === ']') {
737
+ depthBracket = Math.max(0, depthBracket - 1);
738
+ continue;
739
+ }
740
+ if (character === '{') {
741
+ depthBrace += 1;
742
+ continue;
743
+ }
744
+ if (character === '}') {
745
+ depthBrace = Math.max(0, depthBrace - 1);
746
+ continue;
747
+ }
748
+
749
+ if (character === '@' && depthParen === 0 && depthBracket === 0 && depthBrace === 0) {
750
+ return {
751
+ typeSource: value.slice(0, index).trimEnd(),
752
+ attributeSource: value.slice(index),
753
+ attributeOffset: index,
754
+ };
755
+ }
756
+ }
757
+
758
+ return {
759
+ typeSource: value.trimEnd(),
760
+ attributeSource: '',
761
+ attributeOffset: value.length,
762
+ };
763
+ }
764
+
436
765
  function parseAttributeToken(
437
766
  context: ParserContext,
438
767
  input: {
@@ -442,16 +771,17 @@ function parseAttributeToken(
442
771
  readonly span: PslSpan;
443
772
  },
444
773
  ): PslAttribute | undefined {
445
- const expectsModelPrefix = input.target === 'model';
446
- if (expectsModelPrefix && !input.token.startsWith('@@')) {
774
+ const expectsBlockPrefix = input.target === 'model' || input.target === 'enum';
775
+ const targetLabel = input.target === 'enum' ? 'Enum' : 'Model';
776
+ if (expectsBlockPrefix && !input.token.startsWith('@@')) {
447
777
  pushDiagnostic(context, {
448
778
  code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
449
- message: `Model attribute "${input.token}" must use @@ prefix`,
779
+ message: `${targetLabel} attribute "${input.token}" must use @@ prefix`,
450
780
  span: input.span,
451
781
  });
452
782
  return undefined;
453
783
  }
454
- if (!expectsModelPrefix && !input.token.startsWith('@')) {
784
+ if (!expectsBlockPrefix && !input.token.startsWith('@')) {
455
785
  pushDiagnostic(context, {
456
786
  code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
457
787
  message: `Attribute "${input.token}" must use @ prefix`,
@@ -459,7 +789,7 @@ function parseAttributeToken(
459
789
  });
460
790
  return undefined;
461
791
  }
462
- if (!expectsModelPrefix && input.token.startsWith('@@')) {
792
+ if (!expectsBlockPrefix && input.token.startsWith('@@')) {
463
793
  pushDiagnostic(context, {
464
794
  code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
465
795
  message: `Attribute "${input.token}" is not valid in ${input.target} context`,
@@ -468,7 +798,7 @@ function parseAttributeToken(
468
798
  return undefined;
469
799
  }
470
800
 
471
- const rawBody = expectsModelPrefix ? input.token.slice(2) : input.token.slice(1);
801
+ const rawBody = expectsBlockPrefix ? input.token.slice(2) : input.token.slice(1);
472
802
  const openParen = rawBody.indexOf('(');
473
803
  const closeParen = rawBody.lastIndexOf(')');
474
804
  const hasArgs = openParen >= 0 || closeParen >= 0;
@@ -502,12 +832,15 @@ function parseAttributeToken(
502
832
  return undefined;
503
833
  }
504
834
  const argsRaw = rawBody.slice(openParen + 1, closeParen);
505
- const parsedArgs = parseAttributeArguments(context, {
835
+ const parsedArgs = parseArgumentList(context, {
506
836
  argsRaw,
507
- argsOffset: input.span.start.column - 1 + (expectsModelPrefix ? 2 : 1) + openParen + 1,
837
+ argsOffset: input.span.start.column - 1 + (expectsBlockPrefix ? 2 : 1) + openParen + 1,
508
838
  lineIndex: input.lineIndex,
509
839
  token: input.token,
510
840
  span: input.span,
841
+ invalidCode: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
842
+ invalidEmptyArgumentMessage: `Invalid empty argument in attribute "${input.token}"`,
843
+ invalidNamedArgumentMessage: (part) => `Invalid named argument syntax "${part}"`,
511
844
  });
512
845
  if (!parsedArgs) {
513
846
  return undefined;
@@ -524,7 +857,7 @@ function parseAttributeToken(
524
857
  };
525
858
  }
526
859
 
527
- function parseAttributeArguments(
860
+ function parseArgumentList(
528
861
  context: ParserContext,
529
862
  input: {
530
863
  readonly argsRaw: string;
@@ -532,6 +865,9 @@ function parseAttributeArguments(
532
865
  readonly lineIndex: number;
533
866
  readonly token: string;
534
867
  readonly span: PslSpan;
868
+ readonly invalidCode: PslDiagnosticCode;
869
+ readonly invalidEmptyArgumentMessage: string;
870
+ readonly invalidNamedArgumentMessage: (part: string) => string;
535
871
  },
536
872
  ): readonly PslAttributeArgument[] | undefined {
537
873
  const trimmed = input.argsRaw.trim();
@@ -547,8 +883,8 @@ function parseAttributeArguments(
547
883
  const trimmedPart = original.trim();
548
884
  if (trimmedPart.length === 0) {
549
885
  pushDiagnostic(context, {
550
- code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
551
- message: `Invalid empty argument in attribute "${input.token}"`,
886
+ code: input.invalidCode,
887
+ message: input.invalidEmptyArgumentMessage,
552
888
  span: input.span,
553
889
  });
554
890
  return undefined;
@@ -564,8 +900,8 @@ function parseAttributeArguments(
564
900
  const first = namedSplit[0];
565
901
  if (!first) {
566
902
  pushDiagnostic(context, {
567
- code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
568
- message: `Invalid named argument syntax "${trimmedPart}"`,
903
+ code: input.invalidCode,
904
+ message: input.invalidNamedArgumentMessage(trimmedPart),
569
905
  span: partSpan,
570
906
  });
571
907
  return undefined;
@@ -574,8 +910,8 @@ function parseAttributeArguments(
574
910
  const rawValue = trimmedPart.slice(first.end + 1).trim();
575
911
  if (!name || rawValue.length === 0) {
576
912
  pushDiagnostic(context, {
577
- code: 'PSL_INVALID_ATTRIBUTE_SYNTAX',
578
- message: `Invalid named argument syntax "${trimmedPart}"`,
913
+ code: input.invalidCode,
914
+ message: input.invalidNamedArgumentMessage(trimmedPart),
579
915
  span: partSpan,
580
916
  });
581
917
  return undefined;
@@ -609,19 +945,17 @@ function findBlockBounds(context: ParserContext, startLine: number): BlockBounds
609
945
  for (let lineIndex = startLine; lineIndex < context.lines.length; lineIndex += 1) {
610
946
  const line = stripInlineComment(context.lines[lineIndex] ?? '');
611
947
  let quote: '"' | "'" | null = null;
612
- let previousCharacter = '';
613
- for (const character of line) {
948
+ for (let index = 0; index < line.length; index += 1) {
949
+ const character = line[index] ?? '';
614
950
  if (quote) {
615
- if (character === quote && previousCharacter !== '\\') {
951
+ if (character === quote && !isQuoteEscaped(line, index)) {
616
952
  quote = null;
617
953
  }
618
- previousCharacter = character;
619
954
  continue;
620
955
  }
621
956
 
622
957
  if (character === '"' || character === "'") {
623
958
  quote = character;
624
- previousCharacter = character;
625
959
  continue;
626
960
  }
627
961
 
@@ -634,7 +968,6 @@ function findBlockBounds(context: ParserContext, startLine: number): BlockBounds
634
968
  return { startLine, endLine: lineIndex, closed: true };
635
969
  }
636
970
  }
637
- previousCharacter = character;
638
971
  }
639
972
  }
640
973
 
@@ -660,13 +993,14 @@ function splitTopLevelSegments(value: string, separator: ',' | ':'): TopLevelSeg
660
993
  const parts: TopLevelSegment[] = [];
661
994
  let depthParen = 0;
662
995
  let depthBracket = 0;
996
+ let depthBrace = 0;
663
997
  let quote: '"' | "'" | null = null;
664
998
  let start = 0;
665
999
 
666
1000
  for (let index = 0; index < value.length; index += 1) {
667
1001
  const character = value[index] ?? '';
668
1002
  if (quote) {
669
- if (character === quote && value[index - 1] !== '\\') {
1003
+ if (character === quote && !isQuoteEscaped(value, index)) {
670
1004
  quote = null;
671
1005
  }
672
1006
  continue;
@@ -693,8 +1027,16 @@ function splitTopLevelSegments(value: string, separator: ',' | ':'): TopLevelSeg
693
1027
  depthBracket = Math.max(0, depthBracket - 1);
694
1028
  continue;
695
1029
  }
1030
+ if (character === '{') {
1031
+ depthBrace += 1;
1032
+ continue;
1033
+ }
1034
+ if (character === '}') {
1035
+ depthBrace = Math.max(0, depthBrace - 1);
1036
+ continue;
1037
+ }
696
1038
 
697
- if (character === separator && depthParen === 0 && depthBracket === 0) {
1039
+ if (character === separator && depthParen === 0 && depthBracket === 0 && depthBrace === 0) {
698
1040
  parts.push({
699
1041
  value: value.slice(start, index),
700
1042
  start,
@@ -763,7 +1105,7 @@ function extractAttributeTokensWithSpans(
763
1105
  while (index < value.length) {
764
1106
  const char = value[index] ?? '';
765
1107
  if (quote) {
766
- if (char === quote && value[index - 1] !== '\\') {
1108
+ if (char === quote && !isQuoteEscaped(value, index)) {
767
1109
  quote = null;
768
1110
  }
769
1111
  index += 1;
@@ -836,7 +1178,7 @@ function stripInlineComment(line: string): string {
836
1178
  const next = line[index + 1] ?? '';
837
1179
 
838
1180
  if (quote) {
839
- if (current === quote && line[index - 1] !== '\\') {
1181
+ if (current === quote && !isQuoteEscaped(line, index)) {
840
1182
  quote = null;
841
1183
  }
842
1184
  continue;