@prisma-next/psl-parser 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,23 +9,20 @@ import { IdentifierAst } from './identifier';
9
9
  import { TypeAnnotationAst } from './type-annotation';
10
10
 
11
11
  /**
12
- * What may appear inside a `namespace` block: models, enums, composite types,
13
- * and extension (block) declarations. Mirrors what `parsePslDocument` accepts
14
- * in a namespace body. `types {}` blocks and nested `namespace` blocks are
15
- * document-only, so they are not namespace members.
12
+ * What may appear inside a `namespace` block: models, composite types, and
13
+ * extension (block) declarations. `types {}` blocks and nested `namespace`
14
+ * blocks are document-only, so they are not namespace members.
16
15
  */
17
16
  export type NamespaceMemberAst =
18
17
  | ModelDeclarationAst
19
- | EnumDeclarationAst
20
18
  | CompositeTypeDeclarationAst
21
- | BlockDeclarationAst;
19
+ | GenericBlockDeclarationAst;
22
20
 
23
21
  function castNamespaceMember(node: SyntaxNode): NamespaceMemberAst | undefined {
24
22
  return (
25
23
  ModelDeclarationAst.cast(node) ??
26
- EnumDeclarationAst.cast(node) ??
27
24
  CompositeTypeDeclarationAst.cast(node) ??
28
- BlockDeclarationAst.cast(node)
25
+ GenericBlockDeclarationAst.cast(node)
29
26
  );
30
27
  }
31
28
 
@@ -85,42 +82,6 @@ export class ModelDeclarationAst implements AstNode {
85
82
  }
86
83
  }
87
84
 
88
- export class EnumDeclarationAst implements AstNode {
89
- readonly syntax: SyntaxNode;
90
-
91
- constructor(syntax: SyntaxNode) {
92
- this.syntax = syntax;
93
- }
94
-
95
- keyword(): Token | undefined {
96
- return findChildToken(this.syntax, 'Ident');
97
- }
98
-
99
- name(): IdentifierAst | undefined {
100
- return findFirstChild(this.syntax, IdentifierAst.cast);
101
- }
102
-
103
- lbrace(): Token | undefined {
104
- return findChildToken(this.syntax, 'LBrace');
105
- }
106
-
107
- rbrace(): Token | undefined {
108
- return findChildToken(this.syntax, 'RBrace');
109
- }
110
-
111
- *values(): Iterable<EnumValueDeclarationAst> {
112
- yield* filterChildren(this.syntax, EnumValueDeclarationAst.cast);
113
- }
114
-
115
- *attributes(): Iterable<ModelAttributeAst> {
116
- yield* filterChildren(this.syntax, ModelAttributeAst.cast);
117
- }
118
-
119
- static cast(node: SyntaxNode): EnumDeclarationAst | undefined {
120
- return node.kind === 'EnumDeclaration' ? new EnumDeclarationAst(node) : undefined;
121
- }
122
- }
123
-
124
85
  export class CompositeTypeDeclarationAst implements AstNode {
125
86
  readonly syntax: SyntaxNode;
126
87
 
@@ -219,7 +180,7 @@ export class TypesBlockAst implements AstNode {
219
180
  }
220
181
  }
221
182
 
222
- export class BlockDeclarationAst implements AstNode {
183
+ export class GenericBlockDeclarationAst implements AstNode {
223
184
  readonly syntax: SyntaxNode;
224
185
 
225
186
  constructor(syntax: SyntaxNode) {
@@ -246,8 +207,14 @@ export class BlockDeclarationAst implements AstNode {
246
207
  yield* filterChildren(this.syntax, KeyValuePairAst.cast);
247
208
  }
248
209
 
249
- static cast(node: SyntaxNode): BlockDeclarationAst | undefined {
250
- return node.kind === 'BlockDeclaration' ? new BlockDeclarationAst(node) : undefined;
210
+ *attributes(): Iterable<ModelAttributeAst> {
211
+ yield* filterChildren(this.syntax, ModelAttributeAst.cast);
212
+ }
213
+
214
+ static cast(node: SyntaxNode): GenericBlockDeclarationAst | undefined {
215
+ return node.kind === 'GenericBlockDeclaration'
216
+ ? new GenericBlockDeclarationAst(node)
217
+ : undefined;
251
218
  }
252
219
  }
253
220
 
@@ -310,26 +277,6 @@ export class FieldDeclarationAst implements AstNode {
310
277
  }
311
278
  }
312
279
 
313
- export class EnumValueDeclarationAst implements AstNode {
314
- readonly syntax: SyntaxNode;
315
-
316
- constructor(syntax: SyntaxNode) {
317
- this.syntax = syntax;
318
- }
319
-
320
- name(): IdentifierAst | undefined {
321
- return findFirstChild(this.syntax, IdentifierAst.cast);
322
- }
323
-
324
- *attributes(): Iterable<FieldAttributeAst> {
325
- yield* filterChildren(this.syntax, FieldAttributeAst.cast);
326
- }
327
-
328
- static cast(node: SyntaxNode): EnumValueDeclarationAst | undefined {
329
- return node.kind === 'EnumValueDeclaration' ? new EnumValueDeclarationAst(node) : undefined;
330
- }
331
- }
332
-
333
280
  export class NamedTypeDeclarationAst implements AstNode {
334
281
  readonly syntax: SyntaxNode;
335
282
 
@@ -3,6 +3,7 @@ import type { AstNode } from '../ast-helpers';
3
3
  import { filterChildren, findChildToken, findFirstChild } from '../ast-helpers';
4
4
  import { SyntaxNode } from '../red';
5
5
  import { IdentifierAst } from './identifier';
6
+ import { QualifiedNameAst } from './qualified-name';
6
7
 
7
8
  export class FunctionCallAst implements AstNode {
8
9
  readonly syntax: SyntaxNode;
@@ -11,8 +12,24 @@ export class FunctionCallAst implements AstNode {
11
12
  this.syntax = syntax;
12
13
  }
13
14
 
14
- name(): IdentifierAst | undefined {
15
- return findFirstChild(this.syntax, IdentifierAst.cast);
15
+ /** The qualified-name callee, or `undefined` when identifier segments sit directly under the node. */
16
+ name(): QualifiedNameAst | undefined {
17
+ return findFirstChild(this.syntax, QualifiedNameAst.cast);
18
+ }
19
+
20
+ /**
21
+ * The dotted call path, in source order. A bare `Vector(…)` yields
22
+ * `['Vector']`; a namespace-qualified `pgvector.Vector(…)` yields
23
+ * `['pgvector', 'Vector']`. Empty when the call carries no identifier.
24
+ */
25
+ path(): readonly string[] {
26
+ const qualified = this.name();
27
+ const segments: string[] = [];
28
+ for (const segment of filterChildren(qualified?.syntax ?? this.syntax, IdentifierAst.cast)) {
29
+ const text = segment.token()?.text;
30
+ if (text !== undefined) segments.push(text);
31
+ }
32
+ return segments;
16
33
  }
17
34
 
18
35
  lparen(): Token | undefined {
@@ -56,6 +73,82 @@ export class ArrayLiteralAst implements AstNode {
56
73
  }
57
74
  }
58
75
 
76
+ const HEX = /^[0-9a-fA-F]+$/;
77
+
78
+ function decodeFixedHex(raw: string, start: number, width: number): string | undefined {
79
+ if (start + width > raw.length) return undefined;
80
+ const hex = raw.slice(start, start + width);
81
+ if (!HEX.test(hex)) return undefined;
82
+ return String.fromCharCode(Number.parseInt(hex, 16));
83
+ }
84
+
85
+ function decodeStringLiteral(raw: string): string {
86
+ let out = '';
87
+ let i = 0;
88
+ while (i < raw.length) {
89
+ const ch = raw.charAt(i);
90
+ if (ch !== '\\' || i + 1 >= raw.length) {
91
+ out += ch;
92
+ i++;
93
+ continue;
94
+ }
95
+ const next = raw.charAt(i + 1);
96
+ switch (next) {
97
+ case 'n':
98
+ out += '\n';
99
+ i += 2;
100
+ continue;
101
+ case 'r':
102
+ out += '\r';
103
+ i += 2;
104
+ continue;
105
+ case 't':
106
+ out += '\t';
107
+ i += 2;
108
+ continue;
109
+ case '"':
110
+ out += '"';
111
+ i += 2;
112
+ continue;
113
+ case "'":
114
+ out += "'";
115
+ i += 2;
116
+ continue;
117
+ case '\\':
118
+ out += '\\';
119
+ i += 2;
120
+ continue;
121
+ case 'x': {
122
+ const decoded = decodeFixedHex(raw, i + 2, 2);
123
+ if (decoded === undefined) {
124
+ out += '\\x';
125
+ i += 2;
126
+ continue;
127
+ }
128
+ out += decoded;
129
+ i += 4;
130
+ continue;
131
+ }
132
+ case 'u': {
133
+ const decoded = decodeFixedHex(raw, i + 2, 4);
134
+ if (decoded === undefined) {
135
+ out += '\\u';
136
+ i += 2;
137
+ continue;
138
+ }
139
+ out += decoded;
140
+ i += 6;
141
+ continue;
142
+ }
143
+ default:
144
+ out += `\\${next}`;
145
+ i += 2;
146
+ continue;
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
59
152
  export class StringLiteralExprAst implements AstNode {
60
153
  readonly syntax: SyntaxNode;
61
154
 
@@ -70,23 +163,7 @@ export class StringLiteralExprAst implements AstNode {
70
163
  value(): string | undefined {
71
164
  const tok = this.token();
72
165
  if (!tok) return undefined;
73
- const raw = tok.text.slice(1, -1);
74
- return raw.replace(/\\(.)/g, (_match, char: string) => {
75
- switch (char) {
76
- case 'n':
77
- return '\n';
78
- case 'r':
79
- return '\r';
80
- case 't':
81
- return '\t';
82
- case '"':
83
- return '"';
84
- case '\\':
85
- return '\\';
86
- default:
87
- return `\\${char}`;
88
- }
89
- });
166
+ return decodeStringLiteral(tok.text.slice(1, -1));
90
167
  }
91
168
 
92
169
  static cast(node: SyntaxNode): StringLiteralExprAst | undefined {
@@ -140,12 +217,102 @@ export class BooleanLiteralExprAst implements AstNode {
140
217
  }
141
218
  }
142
219
 
220
+ export class ObjectLiteralExprAst implements AstNode {
221
+ readonly syntax: SyntaxNode;
222
+
223
+ constructor(syntax: SyntaxNode) {
224
+ this.syntax = syntax;
225
+ }
226
+
227
+ lbrace(): Token | undefined {
228
+ return findChildToken(this.syntax, 'LBrace');
229
+ }
230
+
231
+ rbrace(): Token | undefined {
232
+ return findChildToken(this.syntax, 'RBrace');
233
+ }
234
+
235
+ *fields(): Iterable<ObjectFieldAst> {
236
+ yield* filterChildren(this.syntax, ObjectFieldAst.cast);
237
+ }
238
+
239
+ static cast(node: SyntaxNode): ObjectLiteralExprAst | undefined {
240
+ return node.kind === 'ObjectLiteralExpr' ? new ObjectLiteralExprAst(node) : undefined;
241
+ }
242
+ }
243
+
244
+ export class ObjectFieldAst implements AstNode {
245
+ readonly syntax: SyntaxNode;
246
+
247
+ constructor(syntax: SyntaxNode) {
248
+ this.syntax = syntax;
249
+ }
250
+
251
+ key(): IdentifierAst | undefined {
252
+ for (const child of this.syntax.children()) {
253
+ if (!(child instanceof SyntaxNode)) {
254
+ if (child.kind === 'Colon') break;
255
+ continue;
256
+ }
257
+ return IdentifierAst.cast(child);
258
+ }
259
+ return undefined;
260
+ }
261
+
262
+ /**
263
+ * The field's logical key name, unquoted. An identifier key (`length:`) yields
264
+ * its text; a string-literal key (`"length":`) yields the decoded string.
265
+ * `undefined` when the field carries no key node.
266
+ */
267
+ keyName(): string | undefined {
268
+ for (const child of this.syntax.children()) {
269
+ if (!(child instanceof SyntaxNode)) {
270
+ if (child.kind === 'Colon') break;
271
+ continue;
272
+ }
273
+ const identifier = IdentifierAst.cast(child);
274
+ if (identifier) return identifier.token()?.text;
275
+ const stringKey = StringLiteralExprAst.cast(child);
276
+ if (stringKey) return stringKey.value();
277
+ return undefined;
278
+ }
279
+ return undefined;
280
+ }
281
+
282
+ colon(): Token | undefined {
283
+ return findChildToken(this.syntax, 'Colon');
284
+ }
285
+
286
+ value(): ExpressionAst | undefined {
287
+ if (this.colon()) {
288
+ let pastColon = false;
289
+ for (const child of this.syntax.children()) {
290
+ if (!(child instanceof SyntaxNode)) {
291
+ if (child.kind === 'Colon') pastColon = true;
292
+ continue;
293
+ }
294
+ if (pastColon) {
295
+ const expr = castExpression(child);
296
+ if (expr) return expr;
297
+ }
298
+ }
299
+ return undefined;
300
+ }
301
+ return findFirstChild(this.syntax, castExpression);
302
+ }
303
+
304
+ static cast(node: SyntaxNode): ObjectFieldAst | undefined {
305
+ return node.kind === 'ObjectField' ? new ObjectFieldAst(node) : undefined;
306
+ }
307
+ }
308
+
143
309
  export type ExpressionAst =
144
310
  | FunctionCallAst
145
311
  | ArrayLiteralAst
146
312
  | StringLiteralExprAst
147
313
  | NumberLiteralExprAst
148
314
  | BooleanLiteralExprAst
315
+ | ObjectLiteralExprAst
149
316
  | IdentifierAst;
150
317
 
151
318
  export function castExpression(node: SyntaxNode): ExpressionAst | undefined {
@@ -155,6 +322,7 @@ export function castExpression(node: SyntaxNode): ExpressionAst | undefined {
155
322
  StringLiteralExprAst.cast(node) ??
156
323
  NumberLiteralExprAst.cast(node) ??
157
324
  BooleanLiteralExprAst.cast(node) ??
325
+ ObjectLiteralExprAst.cast(node) ??
158
326
  IdentifierAst.cast(node)
159
327
  );
160
328
  }
@@ -14,6 +14,10 @@ export class IdentifierAst implements AstNode {
14
14
  return findChildToken(this.syntax, 'Ident');
15
15
  }
16
16
 
17
+ name(): string | undefined {
18
+ return this.token()?.text;
19
+ }
20
+
17
21
  static cast(node: SyntaxNode): IdentifierAst | undefined {
18
22
  return node.kind === 'Identifier' ? new IdentifierAst(node) : undefined;
19
23
  }
@@ -0,0 +1,87 @@
1
+ import type { Token } from '../../tokenizer';
2
+ import type { AstNode } from '../ast-helpers';
3
+ import { filterChildren, findChildToken, findFirstChild } from '../ast-helpers';
4
+ import { SyntaxNode } from '../red';
5
+ import { IdentifierAst } from './identifier';
6
+
7
+ /** A namespace-qualified name, e.g. `pgvector.Vector` or `supabase:auth.User`. */
8
+ export class QualifiedNameAst implements AstNode {
9
+ readonly syntax: SyntaxNode;
10
+
11
+ constructor(syntax: SyntaxNode) {
12
+ this.syntax = syntax;
13
+ }
14
+
15
+ #lastSegment(): IdentifierAst | undefined {
16
+ let last: IdentifierAst | undefined;
17
+ for (const segment of filterChildren(this.syntax, IdentifierAst.cast)) {
18
+ last = segment;
19
+ }
20
+ return last;
21
+ }
22
+
23
+ #penultimateSegment(): IdentifierAst | undefined {
24
+ let last: IdentifierAst | undefined;
25
+ let penultimate: IdentifierAst | undefined;
26
+ for (const segment of filterChildren(this.syntax, IdentifierAst.cast)) {
27
+ penultimate = last;
28
+ last = segment;
29
+ }
30
+ return penultimate;
31
+ }
32
+
33
+ #separatorCount(kind: 'Dot' | 'Colon'): number {
34
+ let count = 0;
35
+ for (const child of this.syntax.children()) {
36
+ if (!(child instanceof SyntaxNode) && child.kind === kind) count++;
37
+ }
38
+ return count;
39
+ }
40
+
41
+ colon(): Token | undefined {
42
+ return findChildToken(this.syntax, 'Colon');
43
+ }
44
+
45
+ dot(): Token | undefined {
46
+ return findChildToken(this.syntax, 'Dot');
47
+ }
48
+
49
+ space(): IdentifierAst | undefined {
50
+ if (!this.colon()) return undefined;
51
+ return findFirstChild(this.syntax, IdentifierAst.cast);
52
+ }
53
+
54
+ namespace(): IdentifierAst | undefined {
55
+ if (!this.dot()) return undefined;
56
+ return this.#penultimateSegment();
57
+ }
58
+
59
+ identifier(): IdentifierAst | undefined {
60
+ return this.#lastSegment();
61
+ }
62
+
63
+ /**
64
+ * Every identifier segment, in source order. A bare `Vector` yields
65
+ * `['Vector']`; a qualified `pgvector.Vector` yields `['pgvector', 'Vector']`.
66
+ */
67
+ path(): readonly string[] {
68
+ const segments: string[] = [];
69
+ for (const segment of filterChildren(this.syntax, IdentifierAst.cast)) {
70
+ const text = segment.token()?.text;
71
+ if (text !== undefined) segments.push(text);
72
+ }
73
+ return segments;
74
+ }
75
+
76
+ /**
77
+ * Flags a malformed name with more qualifier segments than allowed (a second
78
+ * `:`-space or a second `.`-namespace).
79
+ */
80
+ isOverQualified(): boolean {
81
+ return this.#separatorCount('Dot') > 1 || this.#separatorCount('Colon') > 1;
82
+ }
83
+
84
+ static cast(node: SyntaxNode): QualifiedNameAst | undefined {
85
+ return node.kind === 'QualifiedName' ? new QualifiedNameAst(node) : undefined;
86
+ }
87
+ }
@@ -1,9 +1,9 @@
1
1
  import type { Token } from '../../tokenizer';
2
2
  import type { AstNode } from '../ast-helpers';
3
- import { filterChildren, findChildToken, findFirstChild } from '../ast-helpers';
3
+ import { findChildToken, findFirstChild } from '../ast-helpers';
4
4
  import type { SyntaxNode } from '../red';
5
- import { FunctionCallAst } from './expressions';
6
- import { IdentifierAst } from './identifier';
5
+ import { AttributeArgListAst } from './attributes';
6
+ import { QualifiedNameAst } from './qualified-name';
7
7
 
8
8
  export class TypeAnnotationAst implements AstNode {
9
9
  readonly syntax: SyntaxNode;
@@ -12,48 +12,18 @@ export class TypeAnnotationAst implements AstNode {
12
12
  this.syntax = syntax;
13
13
  }
14
14
 
15
- #lastSegment(): IdentifierAst | undefined {
16
- let last: IdentifierAst | undefined;
17
- for (const segment of filterChildren(this.syntax, IdentifierAst.cast)) {
18
- last = segment;
19
- }
20
- return last;
15
+ /** The annotation's reference, doubling as the constructor callee when an {@link argList} follows. */
16
+ name(): QualifiedNameAst | undefined {
17
+ return findFirstChild(this.syntax, QualifiedNameAst.cast);
21
18
  }
22
19
 
23
- #penultimateSegment(): IdentifierAst | undefined {
24
- let last: IdentifierAst | undefined;
25
- let penultimate: IdentifierAst | undefined;
26
- for (const segment of filterChildren(this.syntax, IdentifierAst.cast)) {
27
- penultimate = last;
28
- last = segment;
29
- }
30
- return penultimate;
20
+ /** Present when the annotation is a constructor (`Vector(1536)`) rather than a plain reference. */
21
+ argList(): AttributeArgListAst | undefined {
22
+ return findFirstChild(this.syntax, AttributeArgListAst.cast);
31
23
  }
32
24
 
33
- name(): IdentifierAst | undefined {
34
- return this.#lastSegment();
35
- }
36
-
37
- colon(): Token | undefined {
38
- return findChildToken(this.syntax, 'Colon');
39
- }
40
-
41
- dot(): Token | undefined {
42
- return findChildToken(this.syntax, 'Dot');
43
- }
44
-
45
- spaceName(): IdentifierAst | undefined {
46
- if (!this.colon()) return undefined;
47
- return findFirstChild(this.syntax, IdentifierAst.cast);
48
- }
49
-
50
- namespaceName(): IdentifierAst | undefined {
51
- if (!this.dot()) return undefined;
52
- return this.#penultimateSegment();
53
- }
54
-
55
- constructorCall(): FunctionCallAst | undefined {
56
- return findFirstChild(this.syntax, FunctionCallAst.cast);
25
+ isConstructor(): boolean {
26
+ return this.argList() !== undefined;
57
27
  }
58
28
 
59
29
  lbracket(): Token | undefined {
@@ -34,3 +34,15 @@ export function* filterChildren<T>(
34
34
  if (result !== undefined) yield result;
35
35
  }
36
36
  }
37
+
38
+ /**
39
+ * Raw source text of a CST node, verbatim (quotes and brackets preserved). For
40
+ * the decoded value of a string literal, decode it instead.
41
+ */
42
+ export function printSyntax(node: SyntaxNode): string {
43
+ let text = '';
44
+ for (const token of node.tokens()) {
45
+ text += token.text;
46
+ }
47
+ return text;
48
+ }
@@ -1,13 +1,13 @@
1
1
  export type SyntaxKind =
2
2
  | 'Document'
3
3
  | 'ModelDeclaration'
4
- | 'EnumDeclaration'
5
4
  | 'CompositeTypeDeclaration'
6
5
  | 'Namespace'
7
6
  | 'TypesBlock'
8
- | 'BlockDeclaration'
7
+ // The generic/extension block node: the `kw [name] { key = value }` form,
8
+ // distinct from the reserved `model`/`namespace`/`type`/`types` declarations.
9
+ | 'GenericBlockDeclaration'
9
10
  | 'FieldDeclaration'
10
- | 'EnumValueDeclaration'
11
11
  | 'NamedTypeDeclaration'
12
12
  | 'KeyValuePair'
13
13
  | 'FieldAttribute'
@@ -16,8 +16,12 @@ export type SyntaxKind =
16
16
  | 'AttributeArg'
17
17
  | 'TypeAnnotation'
18
18
  | 'Identifier'
19
+ // A namespace-qualified name `[space ':']? Ident ('.' Ident)*`.
20
+ | 'QualifiedName'
19
21
  | 'FunctionCall'
20
22
  | 'ArrayLiteral'
21
23
  | 'StringLiteralExpr'
22
24
  | 'NumberLiteralExpr'
23
- | 'BooleanLiteralExpr';
25
+ | 'BooleanLiteralExpr'
26
+ | 'ObjectLiteralExpr'
27
+ | 'ObjectField';
package/src/tokenizer.ts CHANGED
@@ -86,6 +86,7 @@ function scan(source: string, pos: number): Token {
86
86
  scanWhitespace(source, pos) ??
87
87
  scanComment(source, pos) ??
88
88
  scanAt(source, pos) ??
89
+ scanKeywordNumber(source, pos) ??
89
90
  scanIdent(source, pos) ??
90
91
  scanNumber(source, pos) ??
91
92
  scanString(source, pos) ??
@@ -151,6 +152,20 @@ function scanIdent(source: string, pos: number): Token | undefined {
151
152
  return { kind: 'Ident', text: source.slice(pos, end) };
152
153
  }
153
154
 
155
+ const KEYWORD_NUMBERS = ['-Infinity', 'Infinity', 'NaN'] as const;
156
+
157
+ // `NaN`, `Infinity`, and `-Infinity` are numeric literals; they must match
158
+ // before `scanIdent` and are word-bounded, so `Infinityx` stays an `Ident`.
159
+ function scanKeywordNumber(source: string, pos: number): Token | undefined {
160
+ for (const word of KEYWORD_NUMBERS) {
161
+ if (!source.startsWith(word, pos)) continue;
162
+ const after = readChar(source, pos + word.length);
163
+ if (after !== '' && isIdentPart(after)) return undefined;
164
+ return { kind: 'NumberLiteral', text: word };
165
+ }
166
+ return undefined;
167
+ }
168
+
154
169
  function scanNumber(source: string, pos: number): Token | undefined {
155
170
  let end = pos;
156
171
  if (source.charAt(end) === '-') {
@@ -164,7 +179,7 @@ function scanNumber(source: string, pos: number): Token | undefined {
164
179
  end++;
165
180
  }
166
181
  if (source.charAt(end) === '.' && end + 1 < source.length && isDigit(source.charAt(end + 1))) {
167
- end++; // consume the dot
182
+ end++;
168
183
  while (end < source.length && isDigit(source.charAt(end))) {
169
184
  end++;
170
185
  }
@@ -173,28 +188,53 @@ function scanNumber(source: string, pos: number): Token | undefined {
173
188
  }
174
189
 
175
190
  function scanString(source: string, pos: number): Token | undefined {
176
- if (source.charAt(pos) !== '"') return undefined;
191
+ const quote = source.charAt(pos);
192
+ if (quote !== '"' && quote !== "'") return undefined;
177
193
  let end = pos + 1;
178
194
  while (end < source.length) {
179
195
  const c = source.charAt(end);
180
196
  if (c === '\\' && end + 1 < source.length) {
181
- end += 2; // skip escape sequence
197
+ end += 2;
182
198
  continue;
183
199
  }
184
- if (c === '"') {
185
- end++; // include closing quote
200
+ if (c === quote) {
201
+ end++;
186
202
  return { kind: 'StringLiteral', text: source.slice(pos, end) };
187
203
  }
188
204
  if (c === '\n' || c === '\r') {
189
- // Unterminated: stop before newline
205
+ // Unterminated string: stop before the newline.
190
206
  return { kind: 'StringLiteral', text: source.slice(pos, end) };
191
207
  }
192
208
  end++;
193
209
  }
194
- // Unterminated at EOF
195
210
  return { kind: 'StringLiteral', text: source.slice(pos, end) };
196
211
  }
197
212
 
213
+ /**
214
+ * `scanString` emits the same `StringLiteral` kind for well-formed and
215
+ * unterminated strings, so callers ask here to tell them apart.
216
+ *
217
+ * The closing quote is unescaped iff an **even** number of backslashes precede
218
+ * it (each `\\` pair cancels; an odd run leaves the final `\` escaping the
219
+ * quote), so counting the trailing backslash run suffices — no full re-scan:
220
+ *
221
+ * - `"ok"` → 0 backslashes (even) → terminated
222
+ * - `'a\'` → 1 backslash (odd) → the quote is escaped → unterminated
223
+ * - `"a\\"` → 2 backslashes (even) → escaped `\`, real `"` → terminated
224
+ */
225
+ export function isTerminatedStringLiteral(text: string): boolean {
226
+ const quote = text.charAt(0);
227
+ if (quote !== '"' && quote !== "'") return false;
228
+ if (text.length < 2 || text.charAt(text.length - 1) !== quote) {
229
+ return false;
230
+ }
231
+ let backslashes = 0;
232
+ for (let i = text.length - 2; i >= 1 && text.charAt(i) === '\\'; i--) {
233
+ backslashes++;
234
+ }
235
+ return backslashes % 2 === 0;
236
+ }
237
+
198
238
  function scanPunctuation(source: string, pos: number): Token | undefined {
199
239
  const kind = PUNCTUATION[source.charAt(pos)];
200
240
  if (kind === undefined) return undefined;