@ldclabs/kip-lang 0.3.1 → 0.4.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/README.md +69 -5
- package/dist/ast.d.ts +10 -1
- package/dist/ast.d.ts.map +1 -1
- package/dist/budget.d.ts +37 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +105 -0
- package/dist/budget.js.map +1 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +27 -0
- package/dist/errors.js.map +1 -0
- package/dist/exec-ast.d.ts +314 -0
- package/dist/exec-ast.d.ts.map +1 -0
- package/dist/exec-ast.js +23 -0
- package/dist/exec-ast.js.map +1 -0
- package/dist/formatter.js +1 -1
- package/dist/formatter.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lexer.d.ts.map +1 -1
- package/dist/lexer.js +42 -11
- package/dist/lexer.js.map +1 -1
- package/dist/lower.d.ts +18 -0
- package/dist/lower.d.ts.map +1 -0
- package/dist/lower.js +877 -0
- package/dist/lower.js.map +1 -0
- package/dist/parser.js +280 -66
- package/dist/parser.js.map +1 -1
- package/dist/version.d.ts +13 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +13 -0
- package/dist/version.js.map +1 -0
- package/package.json +3 -3
package/dist/parser.js
CHANGED
|
@@ -20,6 +20,7 @@ class Parser {
|
|
|
20
20
|
const start = this.currentPos();
|
|
21
21
|
this.skipComments();
|
|
22
22
|
while (!this.isAtEnd()) {
|
|
23
|
+
const before = this.pos;
|
|
23
24
|
this.skipComments();
|
|
24
25
|
if (this.isAtEnd())
|
|
25
26
|
break;
|
|
@@ -32,6 +33,11 @@ class Parser {
|
|
|
32
33
|
// Error recovery: skip to next statement-level keyword
|
|
33
34
|
this.recoverToNextStatement();
|
|
34
35
|
}
|
|
36
|
+
// A sub-parser that rejects its first token reports and returns
|
|
37
|
+
// without consuming it, so a loop keyed on that token would spin
|
|
38
|
+
// forever building diagnostics. Stop as soon as nothing moved.
|
|
39
|
+
if (this.pos === before)
|
|
40
|
+
break;
|
|
35
41
|
}
|
|
36
42
|
const end = this.currentPos();
|
|
37
43
|
return {
|
|
@@ -120,11 +126,12 @@ class Parser {
|
|
|
120
126
|
parseUpsertStatement() {
|
|
121
127
|
const start = this.currentPos();
|
|
122
128
|
const comments = this.collectLeadingComments();
|
|
123
|
-
this.
|
|
129
|
+
this.expectKeywordWithSpace(TokenType.Upsert);
|
|
124
130
|
this.expect(TokenType.LBrace);
|
|
125
131
|
const blocks = [];
|
|
126
132
|
this.skipComments();
|
|
127
133
|
while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
|
|
134
|
+
const before = this.pos;
|
|
128
135
|
this.skipComments();
|
|
129
136
|
if (this.check(TokenType.Concept)) {
|
|
130
137
|
blocks.push(this.parseConceptBlock());
|
|
@@ -140,6 +147,11 @@ class Parser {
|
|
|
140
147
|
this.advance();
|
|
141
148
|
}
|
|
142
149
|
this.skipComments();
|
|
150
|
+
// A sub-parser that rejects its first token reports and returns
|
|
151
|
+
// without consuming it, so a loop keyed on that token would spin
|
|
152
|
+
// forever building diagnostics. Stop as soon as nothing moved.
|
|
153
|
+
if (this.pos === before)
|
|
154
|
+
break;
|
|
143
155
|
}
|
|
144
156
|
this.expect(TokenType.RBrace);
|
|
145
157
|
let metadata;
|
|
@@ -157,8 +169,12 @@ class Parser {
|
|
|
157
169
|
parseConceptBlock() {
|
|
158
170
|
const start = this.currentPos();
|
|
159
171
|
const comments = this.collectLeadingComments();
|
|
160
|
-
this.
|
|
161
|
-
|
|
172
|
+
this.expectKeywordWithSpace(TokenType.Concept);
|
|
173
|
+
// The handle is optional: a block nothing else refers to needs no name.
|
|
174
|
+
let handle;
|
|
175
|
+
if (this.check(TokenType.Variable)) {
|
|
176
|
+
handle = this.expectVariable();
|
|
177
|
+
}
|
|
162
178
|
this.expect(TokenType.LBrace);
|
|
163
179
|
const matcher = this.parseConceptMatcher();
|
|
164
180
|
let expectVersion;
|
|
@@ -170,16 +186,17 @@ class Parser {
|
|
|
170
186
|
let metadata;
|
|
171
187
|
this.skipComments();
|
|
172
188
|
while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
|
|
189
|
+
const before = this.pos;
|
|
173
190
|
this.skipComments();
|
|
174
191
|
if (this.check(TokenType.Set)) {
|
|
175
192
|
const setStart = this.currentPos();
|
|
176
|
-
this.advance(); // skip SET
|
|
193
|
+
const setTok = this.advance(); // skip SET
|
|
177
194
|
if (this.check(TokenType.Attributes)) {
|
|
178
|
-
this.
|
|
195
|
+
this.expectSecondWord(TokenType.Attributes, setTok);
|
|
179
196
|
setAttributes = this.parseSetAttributesBody(setStart);
|
|
180
197
|
}
|
|
181
198
|
else if (this.check(TokenType.Propositions)) {
|
|
182
|
-
this.
|
|
199
|
+
this.expectSecondWord(TokenType.Propositions, setTok);
|
|
183
200
|
setPropositions = this.parseSetPropositionsBody(setStart);
|
|
184
201
|
}
|
|
185
202
|
else {
|
|
@@ -201,6 +218,11 @@ class Parser {
|
|
|
201
218
|
this.advance();
|
|
202
219
|
}
|
|
203
220
|
this.skipComments();
|
|
221
|
+
// A sub-parser that rejects its first token reports and returns
|
|
222
|
+
// without consuming it, so a loop keyed on that token would spin
|
|
223
|
+
// forever building diagnostics. Stop as soon as nothing moved.
|
|
224
|
+
if (this.pos === before)
|
|
225
|
+
break;
|
|
204
226
|
}
|
|
205
227
|
this.expect(TokenType.RBrace);
|
|
206
228
|
// Concept-level WITH METADATA (outside the CONCEPT braces)
|
|
@@ -222,7 +244,7 @@ class Parser {
|
|
|
222
244
|
parsePropositionBlock() {
|
|
223
245
|
const start = this.currentPos();
|
|
224
246
|
const comments = this.collectLeadingComments();
|
|
225
|
-
this.
|
|
247
|
+
this.expectKeywordWithSpace(TokenType.Proposition);
|
|
226
248
|
let handle;
|
|
227
249
|
if (this.check(TokenType.Variable)) {
|
|
228
250
|
handle = this.expectVariable();
|
|
@@ -237,12 +259,13 @@ class Parser {
|
|
|
237
259
|
let metadata;
|
|
238
260
|
this.skipComments();
|
|
239
261
|
while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
|
|
262
|
+
const before = this.pos;
|
|
240
263
|
this.skipComments();
|
|
241
264
|
if (this.check(TokenType.Set)) {
|
|
242
265
|
const setStart = this.currentPos();
|
|
243
|
-
this.advance();
|
|
266
|
+
const setTok = this.advance();
|
|
244
267
|
if (this.check(TokenType.Attributes)) {
|
|
245
|
-
this.
|
|
268
|
+
this.expectSecondWord(TokenType.Attributes, setTok);
|
|
246
269
|
setAttributes = this.parseSetAttributesBody(setStart);
|
|
247
270
|
}
|
|
248
271
|
else {
|
|
@@ -254,9 +277,15 @@ class Parser {
|
|
|
254
277
|
break;
|
|
255
278
|
}
|
|
256
279
|
else {
|
|
280
|
+
this.error(`Unexpected token '${this.current().value}' in PROPOSITION block`, this.current());
|
|
257
281
|
this.advance();
|
|
258
282
|
}
|
|
259
283
|
this.skipComments();
|
|
284
|
+
// A sub-parser that rejects its first token reports and returns
|
|
285
|
+
// without consuming it, so a loop keyed on that token would spin
|
|
286
|
+
// forever building diagnostics. Stop as soon as nothing moved.
|
|
287
|
+
if (this.pos === before)
|
|
288
|
+
break;
|
|
260
289
|
}
|
|
261
290
|
this.expect(TokenType.RBrace);
|
|
262
291
|
if (this.check(TokenType.With)) {
|
|
@@ -282,20 +311,21 @@ class Parser {
|
|
|
282
311
|
parseUpdateStatement() {
|
|
283
312
|
const start = this.currentPos();
|
|
284
313
|
const comments = this.collectLeadingComments();
|
|
285
|
-
this.
|
|
314
|
+
this.expectKeywordWithSpace(TokenType.Update);
|
|
286
315
|
const target = this.expectVariable();
|
|
287
316
|
let setAttributes;
|
|
288
317
|
let setMetadata;
|
|
289
318
|
this.skipComments();
|
|
290
319
|
while (this.check(TokenType.Set) && !this.isAtEnd()) {
|
|
320
|
+
const before = this.pos;
|
|
291
321
|
const setStart = this.currentPos();
|
|
292
|
-
this.advance();
|
|
322
|
+
const setTok = this.advance();
|
|
293
323
|
if (this.check(TokenType.Attributes)) {
|
|
294
|
-
this.
|
|
324
|
+
this.expectSecondWord(TokenType.Attributes, setTok);
|
|
295
325
|
setAttributes = this.parseSetAttributesBody(setStart);
|
|
296
326
|
}
|
|
297
327
|
else if (this.check(TokenType.Metadata)) {
|
|
298
|
-
this.
|
|
328
|
+
this.expectSecondWord(TokenType.Metadata, setTok);
|
|
299
329
|
setMetadata = this.parseSetMetadataBody(setStart);
|
|
300
330
|
}
|
|
301
331
|
else {
|
|
@@ -303,6 +333,11 @@ class Parser {
|
|
|
303
333
|
this.advance();
|
|
304
334
|
}
|
|
305
335
|
this.skipComments();
|
|
336
|
+
// A sub-parser that rejects its first token reports and returns
|
|
337
|
+
// without consuming it, so a loop keyed on that token would spin
|
|
338
|
+
// forever building diagnostics. Stop as soon as nothing moved.
|
|
339
|
+
if (this.pos === before)
|
|
340
|
+
break;
|
|
306
341
|
}
|
|
307
342
|
if (!setAttributes && !setMetadata) {
|
|
308
343
|
this.error(`Expected SET ATTRIBUTES or SET METADATA in UPDATE statement`, this.current());
|
|
@@ -329,8 +364,8 @@ class Parser {
|
|
|
329
364
|
parseMergeStatement() {
|
|
330
365
|
const start = this.currentPos();
|
|
331
366
|
const comments = this.collectLeadingComments();
|
|
332
|
-
this.expect(TokenType.Merge);
|
|
333
|
-
this.
|
|
367
|
+
const mergeTok = this.expect(TokenType.Merge);
|
|
368
|
+
this.expectSecondWord(TokenType.Concept, mergeTok);
|
|
334
369
|
const source = this.expectVariable();
|
|
335
370
|
this.expect(TokenType.Into);
|
|
336
371
|
const target = this.expectVariable();
|
|
@@ -350,7 +385,7 @@ class Parser {
|
|
|
350
385
|
parseDeleteStatement() {
|
|
351
386
|
const start = this.currentPos();
|
|
352
387
|
const comments = this.collectLeadingComments();
|
|
353
|
-
this.
|
|
388
|
+
this.expectKeywordWithSpace(TokenType.Delete);
|
|
354
389
|
let deleteType;
|
|
355
390
|
let keys;
|
|
356
391
|
let target;
|
|
@@ -423,7 +458,7 @@ class Parser {
|
|
|
423
458
|
parseDescribeStatement() {
|
|
424
459
|
const start = this.currentPos();
|
|
425
460
|
const comments = this.collectLeadingComments();
|
|
426
|
-
this.
|
|
461
|
+
this.expectKeywordWithSpace(TokenType.Describe);
|
|
427
462
|
let describeType;
|
|
428
463
|
let typeName;
|
|
429
464
|
let typeNameValue;
|
|
@@ -438,14 +473,14 @@ class Parser {
|
|
|
438
473
|
this.advance();
|
|
439
474
|
}
|
|
440
475
|
else if (this.check(TokenType.Concept)) {
|
|
441
|
-
this.advance();
|
|
476
|
+
const headTok = this.advance();
|
|
442
477
|
if (this.check(TokenType.Types)) {
|
|
443
478
|
describeType = 'CONCEPT_TYPES';
|
|
444
|
-
this.
|
|
479
|
+
this.expectSecondWord(TokenType.Types, headTok);
|
|
445
480
|
}
|
|
446
481
|
else if (this.check(TokenType.Type)) {
|
|
447
482
|
describeType = 'CONCEPT_TYPE';
|
|
448
|
-
this.
|
|
483
|
+
this.expectSecondWord(TokenType.Type, headTok);
|
|
449
484
|
typeNameValue = this.parseStringOrParameterValue('DESCRIBE CONCEPT TYPE');
|
|
450
485
|
typeName =
|
|
451
486
|
typeNameValue.kind === 'StringLiteral'
|
|
@@ -458,14 +493,14 @@ class Parser {
|
|
|
458
493
|
}
|
|
459
494
|
}
|
|
460
495
|
else if (this.check(TokenType.Proposition)) {
|
|
461
|
-
this.advance();
|
|
496
|
+
const headTok = this.advance();
|
|
462
497
|
if (this.check(TokenType.Types)) {
|
|
463
498
|
describeType = 'PROPOSITION_TYPES';
|
|
464
|
-
this.
|
|
499
|
+
this.expectSecondWord(TokenType.Types, headTok);
|
|
465
500
|
}
|
|
466
501
|
else if (this.check(TokenType.Type)) {
|
|
467
502
|
describeType = 'PROPOSITION_TYPE';
|
|
468
|
-
this.
|
|
503
|
+
this.expectSecondWord(TokenType.Type, headTok);
|
|
469
504
|
typeNameValue = this.parseStringOrParameterValue('DESCRIBE PROPOSITION TYPE');
|
|
470
505
|
typeName =
|
|
471
506
|
typeNameValue.kind === 'StringLiteral'
|
|
@@ -512,15 +547,15 @@ class Parser {
|
|
|
512
547
|
parseSearchStatement() {
|
|
513
548
|
const start = this.currentPos();
|
|
514
549
|
const comments = this.collectLeadingComments();
|
|
515
|
-
this.
|
|
550
|
+
this.expectKeywordWithSpace(TokenType.Search);
|
|
516
551
|
let searchTarget;
|
|
517
552
|
if (this.check(TokenType.Concept)) {
|
|
518
553
|
searchTarget = 'CONCEPT';
|
|
519
|
-
this.
|
|
554
|
+
this.expectKeywordWithSpace(TokenType.Concept);
|
|
520
555
|
}
|
|
521
556
|
else if (this.check(TokenType.Proposition)) {
|
|
522
557
|
searchTarget = 'PROPOSITION';
|
|
523
|
-
this.
|
|
558
|
+
this.expectKeywordWithSpace(TokenType.Proposition);
|
|
524
559
|
}
|
|
525
560
|
else {
|
|
526
561
|
this.error(`Expected CONCEPT or PROPOSITION after SEARCH`, this.current());
|
|
@@ -534,10 +569,15 @@ class Parser {
|
|
|
534
569
|
let modeValue;
|
|
535
570
|
let threshold;
|
|
536
571
|
let limit;
|
|
572
|
+
// Clauses may appear in any order but each at most once — a second
|
|
573
|
+
// `LIMIT` is trailing input, not an override.
|
|
537
574
|
while (!this.isAtEnd()) {
|
|
575
|
+
const before = this.pos;
|
|
576
|
+
const clause = this.current();
|
|
538
577
|
if (this.check(TokenType.With)) {
|
|
539
|
-
this.
|
|
540
|
-
this.
|
|
578
|
+
this.rejectRepeat(withTypeValue, 'WITH TYPE', clause);
|
|
579
|
+
const withTok = this.advance();
|
|
580
|
+
this.expectSecondWord(TokenType.Type, withTok);
|
|
541
581
|
withTypeValue = this.parseStringOrParameterValue('SEARCH WITH TYPE');
|
|
542
582
|
withType =
|
|
543
583
|
withTypeValue.kind === 'StringLiteral'
|
|
@@ -545,20 +585,28 @@ class Parser {
|
|
|
545
585
|
: withTypeValue.name;
|
|
546
586
|
}
|
|
547
587
|
else if (this.check(TokenType.Mode)) {
|
|
588
|
+
this.rejectRepeat(modeValue, 'MODE', clause);
|
|
548
589
|
this.advance();
|
|
549
590
|
modeValue = this.parseStringOrParameterValue('SEARCH MODE');
|
|
550
591
|
mode =
|
|
551
592
|
modeValue.kind === 'StringLiteral' ? modeValue.parsed : modeValue.name;
|
|
552
593
|
}
|
|
553
594
|
else if (this.check(TokenType.Threshold)) {
|
|
595
|
+
this.rejectRepeat(threshold, 'THRESHOLD', clause);
|
|
554
596
|
threshold = this.parseThresholdClause();
|
|
555
597
|
}
|
|
556
598
|
else if (this.check(TokenType.Limit)) {
|
|
599
|
+
this.rejectRepeat(limit, 'LIMIT', clause);
|
|
557
600
|
limit = this.parseLimitClause();
|
|
558
601
|
}
|
|
559
602
|
else {
|
|
560
603
|
break;
|
|
561
604
|
}
|
|
605
|
+
// A sub-parser that rejects its first token reports and returns
|
|
606
|
+
// without consuming it, so a loop keyed on that token would spin
|
|
607
|
+
// forever building diagnostics. Stop as soon as nothing moved.
|
|
608
|
+
if (this.pos === before)
|
|
609
|
+
break;
|
|
562
610
|
}
|
|
563
611
|
return {
|
|
564
612
|
kind: 'SearchStatement',
|
|
@@ -581,18 +629,23 @@ class Parser {
|
|
|
581
629
|
parseExportStatement() {
|
|
582
630
|
const start = this.currentPos();
|
|
583
631
|
const comments = this.collectLeadingComments();
|
|
584
|
-
this.
|
|
632
|
+
this.expectKeywordWithSpace(TokenType.Export);
|
|
585
633
|
const target = this.expectVariable();
|
|
586
634
|
const where = this.parseWhereClause();
|
|
587
635
|
let limit;
|
|
636
|
+
let cursor;
|
|
588
637
|
if (this.check(TokenType.Limit)) {
|
|
589
638
|
limit = this.parseLimitClause();
|
|
590
639
|
}
|
|
640
|
+
if (this.check(TokenType.Cursor)) {
|
|
641
|
+
cursor = this.parseCursorClause();
|
|
642
|
+
}
|
|
591
643
|
return {
|
|
592
644
|
kind: 'ExportStatement',
|
|
593
645
|
target,
|
|
594
646
|
where,
|
|
595
647
|
limit,
|
|
648
|
+
cursor,
|
|
596
649
|
range: { start, end: this.currentPos() },
|
|
597
650
|
leadingComments: comments.length > 0 ? comments : undefined
|
|
598
651
|
};
|
|
@@ -616,6 +669,7 @@ class Parser {
|
|
|
616
669
|
const patterns = [];
|
|
617
670
|
this.skipComments();
|
|
618
671
|
while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
|
|
672
|
+
const before = this.pos;
|
|
619
673
|
this.skipComments();
|
|
620
674
|
if (this.check(TokenType.RBrace))
|
|
621
675
|
break;
|
|
@@ -623,6 +677,11 @@ class Parser {
|
|
|
623
677
|
if (pattern)
|
|
624
678
|
patterns.push(pattern);
|
|
625
679
|
this.skipComments();
|
|
680
|
+
// A sub-parser that rejects its first token reports and returns
|
|
681
|
+
// without consuming it, so a loop keyed on that token would spin
|
|
682
|
+
// forever building diagnostics. Stop as soon as nothing moved.
|
|
683
|
+
if (this.pos === before)
|
|
684
|
+
break;
|
|
626
685
|
}
|
|
627
686
|
return patterns;
|
|
628
687
|
}
|
|
@@ -690,8 +749,12 @@ class Parser {
|
|
|
690
749
|
}
|
|
691
750
|
parseConceptMatcher() {
|
|
692
751
|
const start = this.currentPos();
|
|
693
|
-
this.expect(TokenType.LBrace);
|
|
694
|
-
const
|
|
752
|
+
const brace = this.expect(TokenType.LBrace);
|
|
753
|
+
const seen = { trailingComma: false };
|
|
754
|
+
const entries = this.parseObjectEntries(seen);
|
|
755
|
+
if (seen.trailingComma) {
|
|
756
|
+
this.error(`A concept matcher takes no trailing comma`, brace);
|
|
757
|
+
}
|
|
695
758
|
this.expect(TokenType.RBrace);
|
|
696
759
|
return {
|
|
697
760
|
kind: 'ConceptMatcher',
|
|
@@ -832,17 +895,11 @@ class Parser {
|
|
|
832
895
|
parseHopRange() {
|
|
833
896
|
const start = this.currentPos();
|
|
834
897
|
this.expect(TokenType.LBrace);
|
|
835
|
-
const
|
|
836
|
-
if (minTok.type !== TokenType.Number) {
|
|
837
|
-
this.error(`Expected number in hop range`, minTok);
|
|
838
|
-
}
|
|
839
|
-
const min = Number(minTok.value);
|
|
840
|
-
this.advance();
|
|
898
|
+
const min = this.expectHopCount();
|
|
841
899
|
let max;
|
|
842
900
|
if (this.match(TokenType.Comma)) {
|
|
843
901
|
if (this.check(TokenType.Number)) {
|
|
844
|
-
max =
|
|
845
|
-
this.advance();
|
|
902
|
+
max = this.expectHopCount();
|
|
846
903
|
}
|
|
847
904
|
// else: {m,} means unbounded
|
|
848
905
|
}
|
|
@@ -857,6 +914,27 @@ class Parser {
|
|
|
857
914
|
range: { start, end: this.currentPos() }
|
|
858
915
|
};
|
|
859
916
|
}
|
|
917
|
+
/**
|
|
918
|
+
* Reads one bound of a `{m,n}` hop quantifier.
|
|
919
|
+
*
|
|
920
|
+
* A hop count is a plain 16-bit integer — no sign, no decimal point, no
|
|
921
|
+
* exponent. `"p"{1e9,}` is not an enormous traversal, it is a typo, and
|
|
922
|
+
* accepting it would hand the engine a bound it cannot honour.
|
|
923
|
+
*/
|
|
924
|
+
expectHopCount() {
|
|
925
|
+
const tok = this.current();
|
|
926
|
+
if (tok.type !== TokenType.Number || !/^[0-9]+$/.test(tok.value)) {
|
|
927
|
+
this.error(`Expected a whole number in a hop range`, tok);
|
|
928
|
+
this.advance();
|
|
929
|
+
return 0;
|
|
930
|
+
}
|
|
931
|
+
const value = Number(tok.value);
|
|
932
|
+
if (value > 0xffff) {
|
|
933
|
+
this.error(`Hop count ${tok.value} exceeds the maximum of 65535`, tok);
|
|
934
|
+
}
|
|
935
|
+
this.advance();
|
|
936
|
+
return value;
|
|
937
|
+
}
|
|
860
938
|
parseFilterClause() {
|
|
861
939
|
const start = this.currentPos();
|
|
862
940
|
this.expect(TokenType.Filter);
|
|
@@ -933,11 +1011,22 @@ class Parser {
|
|
|
933
1011
|
const items = [];
|
|
934
1012
|
this.skipComments();
|
|
935
1013
|
while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
|
|
1014
|
+
const before = this.pos;
|
|
936
1015
|
this.skipComments();
|
|
937
1016
|
if (this.check(TokenType.RBrace))
|
|
938
1017
|
break;
|
|
939
1018
|
items.push(this.parsePropositionItem());
|
|
940
1019
|
this.skipComments();
|
|
1020
|
+
// Items are juxtaposed, but a separating comma — including a trailing
|
|
1021
|
+
// one — is tolerated. Generated KML reaches for it constantly, and the
|
|
1022
|
+
// reference grammar accepts it.
|
|
1023
|
+
this.match(TokenType.Comma);
|
|
1024
|
+
this.skipComments();
|
|
1025
|
+
// A sub-parser that rejects its first token reports and returns
|
|
1026
|
+
// without consuming it, so a loop keyed on that token would spin
|
|
1027
|
+
// forever building diagnostics. Stop as soon as nothing moved.
|
|
1028
|
+
if (this.pos === before)
|
|
1029
|
+
break;
|
|
941
1030
|
}
|
|
942
1031
|
this.expect(TokenType.RBrace);
|
|
943
1032
|
return {
|
|
@@ -966,8 +1055,19 @@ class Parser {
|
|
|
966
1055
|
};
|
|
967
1056
|
}
|
|
968
1057
|
isIdMatcherStart() {
|
|
969
|
-
|
|
970
|
-
|
|
1058
|
+
if (!this.isIdKeyToken(this.current()))
|
|
1059
|
+
return false;
|
|
1060
|
+
const next = this.peekPast(this.pos + 1);
|
|
1061
|
+
// `(id: "...")` may be written with a comment between the key and the
|
|
1062
|
+
// colon; comments are trivia everywhere else, so they are here too.
|
|
1063
|
+
return (next?.type === TokenType.Colon ||
|
|
1064
|
+
(next?.type === TokenType.Parameter && next.value.startsWith(':')));
|
|
1065
|
+
}
|
|
1066
|
+
/** The first non-comment token at or after `i`. */
|
|
1067
|
+
peekPast(i) {
|
|
1068
|
+
while (this.tokens[i]?.type === TokenType.Comment)
|
|
1069
|
+
i++;
|
|
1070
|
+
return this.tokens[i];
|
|
971
1071
|
}
|
|
972
1072
|
parseIdMatcherValue() {
|
|
973
1073
|
const keyTok = this.current();
|
|
@@ -986,7 +1086,7 @@ class Parser {
|
|
|
986
1086
|
return {
|
|
987
1087
|
kind: 'StringLiteral',
|
|
988
1088
|
value: tok.value,
|
|
989
|
-
parsed: this.unescapeString(tok.value),
|
|
1089
|
+
parsed: this.unescapeString(tok.value, tok),
|
|
990
1090
|
range: { start, end: this.currentPos() }
|
|
991
1091
|
};
|
|
992
1092
|
}
|
|
@@ -1012,8 +1112,8 @@ class Parser {
|
|
|
1012
1112
|
}
|
|
1013
1113
|
parseWithMetadata() {
|
|
1014
1114
|
const start = this.currentPos();
|
|
1015
|
-
this.expect(TokenType.With);
|
|
1016
|
-
this.
|
|
1115
|
+
const withTok = this.expect(TokenType.With);
|
|
1116
|
+
this.expectSecondWord(TokenType.Metadata, withTok);
|
|
1017
1117
|
this.expect(TokenType.LBrace);
|
|
1018
1118
|
const entries = this.parseObjectEntries();
|
|
1019
1119
|
this.expect(TokenType.RBrace);
|
|
@@ -1025,8 +1125,8 @@ class Parser {
|
|
|
1025
1125
|
}
|
|
1026
1126
|
parseExpectVersion() {
|
|
1027
1127
|
const start = this.currentPos();
|
|
1028
|
-
this.expect(TokenType.Expect);
|
|
1029
|
-
this.
|
|
1128
|
+
const expectTok = this.expect(TokenType.Expect);
|
|
1129
|
+
this.expectSecondWord(TokenType.Version, expectTok);
|
|
1030
1130
|
const value = this.parseNumberOrParameterValue('EXPECT VERSION');
|
|
1031
1131
|
return {
|
|
1032
1132
|
kind: 'ExpectVersion',
|
|
@@ -1039,8 +1139,8 @@ class Parser {
|
|
|
1039
1139
|
// ────────────────────────────────────────────────────────────────────
|
|
1040
1140
|
parseOrderBy() {
|
|
1041
1141
|
const start = this.currentPos();
|
|
1042
|
-
this.expect(TokenType.Order);
|
|
1043
|
-
this.
|
|
1142
|
+
const orderTok = this.expect(TokenType.Order);
|
|
1143
|
+
this.expectSecondWord(TokenType.By, orderTok);
|
|
1044
1144
|
const keys = [];
|
|
1045
1145
|
keys.push(this.parseOrderByKey());
|
|
1046
1146
|
while (this.match(TokenType.Comma)) {
|
|
@@ -1076,7 +1176,7 @@ class Parser {
|
|
|
1076
1176
|
}
|
|
1077
1177
|
parseThresholdClause() {
|
|
1078
1178
|
const start = this.currentPos();
|
|
1079
|
-
this.
|
|
1179
|
+
this.expectKeywordWithSpace(TokenType.Threshold);
|
|
1080
1180
|
const value = this.parseNumberOrParameterValue('THRESHOLD');
|
|
1081
1181
|
return {
|
|
1082
1182
|
kind: 'ThresholdClause',
|
|
@@ -1119,7 +1219,7 @@ class Parser {
|
|
|
1119
1219
|
}
|
|
1120
1220
|
parseLimitClause() {
|
|
1121
1221
|
const start = this.currentPos();
|
|
1122
|
-
this.
|
|
1222
|
+
this.expectKeywordWithSpace(TokenType.Limit);
|
|
1123
1223
|
const value = this.parseNumberOrParameterValue('LIMIT');
|
|
1124
1224
|
return {
|
|
1125
1225
|
kind: 'LimitClause',
|
|
@@ -1129,14 +1229,14 @@ class Parser {
|
|
|
1129
1229
|
}
|
|
1130
1230
|
parseCursorClause() {
|
|
1131
1231
|
const start = this.currentPos();
|
|
1132
|
-
this.
|
|
1232
|
+
this.expectKeywordWithSpace(TokenType.Cursor);
|
|
1133
1233
|
const tok = this.current();
|
|
1134
1234
|
let value;
|
|
1135
1235
|
if (tok.type === TokenType.String) {
|
|
1136
1236
|
value = {
|
|
1137
1237
|
kind: 'StringLiteral',
|
|
1138
1238
|
value: tok.value,
|
|
1139
|
-
parsed: this.unescapeString(tok.value),
|
|
1239
|
+
parsed: this.unescapeString(tok.value, tok),
|
|
1140
1240
|
range: { start: this.currentPos(), end: this.currentPos() }
|
|
1141
1241
|
};
|
|
1142
1242
|
this.advance();
|
|
@@ -1259,13 +1359,27 @@ class Parser {
|
|
|
1259
1359
|
name,
|
|
1260
1360
|
range: { start, end: this.currentPos() }
|
|
1261
1361
|
};
|
|
1262
|
-
// Dot access chain
|
|
1362
|
+
// Dot access chain. A dot path is written with no whitespace anywhere
|
|
1363
|
+
// inside it: `?x.name` is a path, but `?x. name` is a path followed by
|
|
1364
|
+
// stray input, and reading them alike would let `ORDER BY ?x.name. ASC`
|
|
1365
|
+
// silently sort by a field named `ASC` with no direction.
|
|
1366
|
+
let prevEnd = tok.offset + tok.value.length;
|
|
1263
1367
|
while (this.check(TokenType.Dot)) {
|
|
1368
|
+
const dotTok = this.current();
|
|
1369
|
+
if (dotTok.offset !== prevEnd) {
|
|
1370
|
+
this.error(`Unexpected whitespace before '.' in a dot path`, dotTok);
|
|
1371
|
+
break;
|
|
1372
|
+
}
|
|
1264
1373
|
this.advance();
|
|
1265
1374
|
const propTok = this.current();
|
|
1375
|
+
if (propTok.offset !== dotTok.offset + 1) {
|
|
1376
|
+
this.error(`Expected property name after '.'`, propTok);
|
|
1377
|
+
break;
|
|
1378
|
+
}
|
|
1266
1379
|
if (propTok.type === TokenType.Identifier ||
|
|
1267
1380
|
this.isNonAmbiguousKeyword(propTok.type)) {
|
|
1268
1381
|
const prop = propTok.value;
|
|
1382
|
+
prevEnd = propTok.offset + propTok.value.length;
|
|
1269
1383
|
this.advance();
|
|
1270
1384
|
expr = {
|
|
1271
1385
|
kind: 'DotExpression',
|
|
@@ -1296,7 +1410,7 @@ class Parser {
|
|
|
1296
1410
|
return {
|
|
1297
1411
|
kind: 'StringLiteral',
|
|
1298
1412
|
value: tok.value,
|
|
1299
|
-
parsed: this.unescapeString(tok.value),
|
|
1413
|
+
parsed: this.unescapeString(tok.value, tok),
|
|
1300
1414
|
range: { start, end: this.currentPos() }
|
|
1301
1415
|
};
|
|
1302
1416
|
}
|
|
@@ -1341,6 +1455,7 @@ class Parser {
|
|
|
1341
1455
|
}
|
|
1342
1456
|
// System identifier as literal
|
|
1343
1457
|
if (tok.type === TokenType.SystemIdent) {
|
|
1458
|
+
this.error(`Unquoted value '${tok.value}': KIP values are JSON values, so write "${tok.value}"`, tok);
|
|
1344
1459
|
this.advance();
|
|
1345
1460
|
return {
|
|
1346
1461
|
kind: 'StringLiteral',
|
|
@@ -1349,8 +1464,12 @@ class Parser {
|
|
|
1349
1464
|
range: { start, end: this.currentPos() }
|
|
1350
1465
|
};
|
|
1351
1466
|
}
|
|
1352
|
-
//
|
|
1467
|
+
// A bare word is not a KIP value — only object *keys* may go unquoted, and
|
|
1468
|
+
// those never reach here. Recover as a string so the tree stays usable in
|
|
1469
|
+
// an editor, and report it: `lower` sees only the tree, so it is the
|
|
1470
|
+
// caller's error-diagnostic check that keeps this reading off the wire.
|
|
1353
1471
|
if (tok.type === TokenType.Identifier) {
|
|
1472
|
+
this.error(`Unquoted value '${tok.value}': KIP values are JSON values, so write "${tok.value}"`, tok);
|
|
1354
1473
|
this.advance();
|
|
1355
1474
|
return {
|
|
1356
1475
|
kind: 'StringLiteral',
|
|
@@ -1401,13 +1520,16 @@ class Parser {
|
|
|
1401
1520
|
const start = this.currentPos();
|
|
1402
1521
|
this.expect(TokenType.LBracket);
|
|
1403
1522
|
const elements = [];
|
|
1523
|
+
let trailingComma = false;
|
|
1404
1524
|
this.skipComments();
|
|
1405
1525
|
if (!this.check(TokenType.RBracket)) {
|
|
1406
1526
|
elements.push(this.parseExpression());
|
|
1407
1527
|
while (this.match(TokenType.Comma)) {
|
|
1408
1528
|
this.skipComments();
|
|
1409
|
-
if (this.check(TokenType.RBracket))
|
|
1529
|
+
if (this.check(TokenType.RBracket)) {
|
|
1530
|
+
trailingComma = true;
|
|
1410
1531
|
break;
|
|
1532
|
+
}
|
|
1411
1533
|
elements.push(this.parseExpression());
|
|
1412
1534
|
}
|
|
1413
1535
|
}
|
|
@@ -1416,24 +1538,28 @@ class Parser {
|
|
|
1416
1538
|
return {
|
|
1417
1539
|
kind: 'ArrayLiteral',
|
|
1418
1540
|
elements,
|
|
1541
|
+
trailingComma,
|
|
1419
1542
|
range: { start, end: this.currentPos() }
|
|
1420
1543
|
};
|
|
1421
1544
|
}
|
|
1422
1545
|
parseObjectLiteral() {
|
|
1423
1546
|
const start = this.currentPos();
|
|
1424
1547
|
this.expect(TokenType.LBrace);
|
|
1425
|
-
const
|
|
1548
|
+
const seen = { trailingComma: false };
|
|
1549
|
+
const entries = this.parseObjectEntries(seen);
|
|
1426
1550
|
this.expect(TokenType.RBrace);
|
|
1427
1551
|
return {
|
|
1428
1552
|
kind: 'ObjectLiteral',
|
|
1429
1553
|
entries,
|
|
1554
|
+
trailingComma: seen.trailingComma,
|
|
1430
1555
|
range: { start, end: this.currentPos() }
|
|
1431
1556
|
};
|
|
1432
1557
|
}
|
|
1433
|
-
parseObjectEntries() {
|
|
1558
|
+
parseObjectEntries(seen) {
|
|
1434
1559
|
const entries = [];
|
|
1435
1560
|
this.skipComments();
|
|
1436
1561
|
while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
|
|
1562
|
+
const before = this.pos;
|
|
1437
1563
|
this.skipComments();
|
|
1438
1564
|
if (this.check(TokenType.RBrace))
|
|
1439
1565
|
break;
|
|
@@ -1453,9 +1579,16 @@ class Parser {
|
|
|
1453
1579
|
break;
|
|
1454
1580
|
if (this.match(TokenType.Comma)) {
|
|
1455
1581
|
this.skipComments();
|
|
1582
|
+
if (seen && this.check(TokenType.RBrace))
|
|
1583
|
+
seen.trailingComma = true;
|
|
1456
1584
|
continue;
|
|
1457
1585
|
}
|
|
1458
1586
|
this.error(`Expected ',' or '}' after object entry`, this.current());
|
|
1587
|
+
// A sub-parser that rejects its first token reports and returns
|
|
1588
|
+
// without consuming it, so a loop keyed on that token would spin
|
|
1589
|
+
// forever building diagnostics. Stop as soon as nothing moved.
|
|
1590
|
+
if (this.pos === before)
|
|
1591
|
+
break;
|
|
1459
1592
|
}
|
|
1460
1593
|
return entries;
|
|
1461
1594
|
}
|
|
@@ -1503,6 +1636,28 @@ class Parser {
|
|
|
1503
1636
|
}
|
|
1504
1637
|
return this.advance();
|
|
1505
1638
|
}
|
|
1639
|
+
/**
|
|
1640
|
+
* Consumes a keyword that the grammar requires to be followed by whitespace.
|
|
1641
|
+
*
|
|
1642
|
+
* Most KIP keywords only need a word boundary, so `WHERE{...}` is legal.
|
|
1643
|
+
* A handful — the statement introducers and the clause keywords whose
|
|
1644
|
+
* operand may itself start with a brace or a quote — require real
|
|
1645
|
+
* whitespace, which is what keeps `UPSERT{` from reading as a statement.
|
|
1646
|
+
* The distinction is per-keyword-position, not per-keyword, so it lives at
|
|
1647
|
+
* the call site rather than in the lexer.
|
|
1648
|
+
*/
|
|
1649
|
+
expectKeywordWithSpace(type) {
|
|
1650
|
+
const tok = this.current();
|
|
1651
|
+
if (tok.type !== type) {
|
|
1652
|
+
this.error(`Expected '${type}' but got '${tok.value}'`, tok);
|
|
1653
|
+
return tok;
|
|
1654
|
+
}
|
|
1655
|
+
const after = this.source[tok.offset + tok.value.length] ?? '';
|
|
1656
|
+
if (after !== ' ' && after !== '\t' && after !== '\r' && after !== '\n') {
|
|
1657
|
+
this.error(`'${tok.value}' must be followed by whitespace`, tok, 'KIP_1001');
|
|
1658
|
+
}
|
|
1659
|
+
return this.advance();
|
|
1660
|
+
}
|
|
1506
1661
|
expectVariable() {
|
|
1507
1662
|
const tok = this.current();
|
|
1508
1663
|
if (tok.type !== TokenType.Variable) {
|
|
@@ -1519,7 +1674,7 @@ class Parser {
|
|
|
1519
1674
|
return '';
|
|
1520
1675
|
}
|
|
1521
1676
|
this.advance();
|
|
1522
|
-
return this.unescapeString(tok.value);
|
|
1677
|
+
return this.unescapeString(tok.value, tok);
|
|
1523
1678
|
}
|
|
1524
1679
|
expectStringValue() {
|
|
1525
1680
|
const tok = this.current();
|
|
@@ -1528,13 +1683,13 @@ class Parser {
|
|
|
1528
1683
|
return '';
|
|
1529
1684
|
}
|
|
1530
1685
|
this.advance();
|
|
1531
|
-
return this.unescapeString(tok.value);
|
|
1686
|
+
return this.unescapeString(tok.value, tok);
|
|
1532
1687
|
}
|
|
1533
1688
|
expectKeyWithQuoting() {
|
|
1534
1689
|
const tok = this.current();
|
|
1535
1690
|
if (tok.type === TokenType.String) {
|
|
1536
1691
|
this.advance();
|
|
1537
|
-
return { key: this.unescapeString(tok.value), isQuoted: true };
|
|
1692
|
+
return { key: this.unescapeString(tok.value, tok), isQuoted: true };
|
|
1538
1693
|
}
|
|
1539
1694
|
if (tok.type === TokenType.Identifier ||
|
|
1540
1695
|
this.isNonAmbiguousKeyword(tok.type)) {
|
|
@@ -1545,6 +1700,12 @@ class Parser {
|
|
|
1545
1700
|
this.advance();
|
|
1546
1701
|
return { key: tok.value, isQuoted: false };
|
|
1547
1702
|
}
|
|
1703
|
+
/** Reports a clause written twice in a statement that allows it once. */
|
|
1704
|
+
rejectRepeat(seen, name, tok) {
|
|
1705
|
+
if (seen !== undefined) {
|
|
1706
|
+
this.error(`Duplicate ${name} clause`, tok);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1548
1709
|
skipComments() {
|
|
1549
1710
|
while (this.pos < this.tokens.length &&
|
|
1550
1711
|
this.current().type === TokenType.Comment) {
|
|
@@ -1567,19 +1728,56 @@ class Parser {
|
|
|
1567
1728
|
* a single parameter placeholder token (`:active`), so surface a targeted hint
|
|
1568
1729
|
* instead of the generic "Expected ':'" message.
|
|
1569
1730
|
*/
|
|
1570
|
-
|
|
1731
|
+
/**
|
|
1732
|
+
* Consumes the second word of a two-word keyword (`SET ATTRIBUTES`,
|
|
1733
|
+
* `ORDER BY`, `EXPECT VERSION`, ...).
|
|
1734
|
+
*
|
|
1735
|
+
* The grammar joins these with whitespace only. A comment between the words
|
|
1736
|
+
* is not a smaller gap, it is a different token sequence, and reading
|
|
1737
|
+
* `SET//c\nMETADATA` as `SET METADATA` would accept text the reference
|
|
1738
|
+
* grammar rejects.
|
|
1739
|
+
*/
|
|
1740
|
+
expectSecondWord(type, first) {
|
|
1741
|
+
const tok = this.current();
|
|
1742
|
+
const gap = this.source.slice(first.offset + first.value.length, tok.offset);
|
|
1743
|
+
if (tok.type === type && !/^\s+$/.test(gap)) {
|
|
1744
|
+
this.error(`'${first.value} ${tok.value}' must be separated by whitespace only`, tok);
|
|
1745
|
+
}
|
|
1746
|
+
return this.expect(type);
|
|
1747
|
+
}
|
|
1748
|
+
expectObjectColon(_key) {
|
|
1571
1749
|
if (this.check(TokenType.Colon)) {
|
|
1572
1750
|
this.advance();
|
|
1573
1751
|
return;
|
|
1574
1752
|
}
|
|
1753
|
+
// `{"a":true}` lexes as a key followed by the parameter `:true`, because
|
|
1754
|
+
// `:name` is the placeholder syntax and the lexer cannot see that this
|
|
1755
|
+
// colon separates a key from its value. In key position the separator
|
|
1756
|
+
// reading is the only valid one, so split the token back apart and re-lex
|
|
1757
|
+
// the tail as the value.
|
|
1575
1758
|
const tok = this.current();
|
|
1576
1759
|
if (tok.type === TokenType.Parameter) {
|
|
1577
|
-
this.
|
|
1578
|
-
`Write '${key}: ${tok.value.slice(1)}' (or quote the value).`, tok);
|
|
1760
|
+
this.splitParameterAfterColon(tok);
|
|
1579
1761
|
return;
|
|
1580
1762
|
}
|
|
1581
1763
|
this.expect(TokenType.Colon);
|
|
1582
1764
|
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Rewrites a `:value` parameter token in separator position into the value
|
|
1767
|
+
* tokens it spells, so the parser sees `: value`.
|
|
1768
|
+
*/
|
|
1769
|
+
splitParameterAfterColon(tok) {
|
|
1770
|
+
const tail = tok.value.slice(1);
|
|
1771
|
+
const retoken = tokenize(tail)
|
|
1772
|
+
.filter((t) => !isTrivia(t.type) && t.type !== TokenType.EOF)
|
|
1773
|
+
.map((t) => ({
|
|
1774
|
+
...t,
|
|
1775
|
+
offset: tok.offset + 1 + t.offset,
|
|
1776
|
+
line: tok.line,
|
|
1777
|
+
column: tok.column + 1 + t.column
|
|
1778
|
+
}));
|
|
1779
|
+
this.tokens.splice(this.pos, 1, ...retoken);
|
|
1780
|
+
}
|
|
1583
1781
|
isFunctionToken(type) {
|
|
1584
1782
|
return (type === TokenType.Count ||
|
|
1585
1783
|
type === TokenType.Sum ||
|
|
@@ -1622,15 +1820,31 @@ class Parser {
|
|
|
1622
1820
|
type === TokenType.Mode ||
|
|
1623
1821
|
type === TokenType.Threshold);
|
|
1624
1822
|
}
|
|
1625
|
-
|
|
1626
|
-
|
|
1823
|
+
/**
|
|
1824
|
+
* Reads the value of a string token.
|
|
1825
|
+
*
|
|
1826
|
+
* KIP strings are JSON strings, so `"a\xb"` and an unterminated literal are
|
|
1827
|
+
* both errors — but an editor still wants a tree, so the malformed value is
|
|
1828
|
+
* recovered leniently *and* reported. The lenient reading survives into the
|
|
1829
|
+
* tree: `lower` is handed a `Program` and never sees a diagnostic, so a
|
|
1830
|
+
* caller must reject on `severity === 'error'` before lowering, or `"a\xb"`
|
|
1831
|
+
* reaches the engine as `axb`.
|
|
1832
|
+
*/
|
|
1833
|
+
unescapeString(raw, tok) {
|
|
1834
|
+
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
|
|
1627
1835
|
try {
|
|
1628
1836
|
return JSON.parse(raw);
|
|
1629
1837
|
}
|
|
1630
1838
|
catch {
|
|
1839
|
+
if (tok) {
|
|
1840
|
+
this.error(`Invalid string literal ${raw}: KIP strings are JSON strings`, tok);
|
|
1841
|
+
}
|
|
1631
1842
|
raw = raw.slice(1, -1);
|
|
1632
1843
|
}
|
|
1633
1844
|
}
|
|
1845
|
+
else if (tok) {
|
|
1846
|
+
this.error(`Unterminated string literal ${raw}`, tok);
|
|
1847
|
+
}
|
|
1634
1848
|
return raw.replace(/\\(.)/g, (_, ch) => {
|
|
1635
1849
|
switch (ch) {
|
|
1636
1850
|
case 'n':
|