@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/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.expect(TokenType.Upsert);
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.expect(TokenType.Concept);
161
- const handle = this.expectVariable();
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.advance();
195
+ this.expectSecondWord(TokenType.Attributes, setTok);
179
196
  setAttributes = this.parseSetAttributesBody(setStart);
180
197
  }
181
198
  else if (this.check(TokenType.Propositions)) {
182
- this.advance();
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.expect(TokenType.Proposition);
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.advance();
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.expect(TokenType.Update);
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.advance();
324
+ this.expectSecondWord(TokenType.Attributes, setTok);
295
325
  setAttributes = this.parseSetAttributesBody(setStart);
296
326
  }
297
327
  else if (this.check(TokenType.Metadata)) {
298
- this.advance();
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.expect(TokenType.Concept);
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.expect(TokenType.Delete);
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.expect(TokenType.Describe);
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.advance();
479
+ this.expectSecondWord(TokenType.Types, headTok);
445
480
  }
446
481
  else if (this.check(TokenType.Type)) {
447
482
  describeType = 'CONCEPT_TYPE';
448
- this.advance();
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.advance();
499
+ this.expectSecondWord(TokenType.Types, headTok);
465
500
  }
466
501
  else if (this.check(TokenType.Type)) {
467
502
  describeType = 'PROPOSITION_TYPE';
468
- this.advance();
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.expect(TokenType.Search);
550
+ this.expectKeywordWithSpace(TokenType.Search);
516
551
  let searchTarget;
517
552
  if (this.check(TokenType.Concept)) {
518
553
  searchTarget = 'CONCEPT';
519
- this.advance();
554
+ this.expectKeywordWithSpace(TokenType.Concept);
520
555
  }
521
556
  else if (this.check(TokenType.Proposition)) {
522
557
  searchTarget = 'PROPOSITION';
523
- this.advance();
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.advance();
540
- this.expect(TokenType.Type);
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.expect(TokenType.Export);
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 entries = this.parseObjectEntries();
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 minTok = this.current();
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 = Number(this.current().value);
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
- const next = this.tokens[this.pos + 1];
970
- return this.isIdKeyToken(this.current()) && next?.type === TokenType.Colon;
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.expect(TokenType.Metadata);
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.expect(TokenType.Version);
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.expect(TokenType.By);
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.expect(TokenType.Threshold);
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.expect(TokenType.Limit);
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.expect(TokenType.Cursor);
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
- // Identifier (bare word — could be used as a key value)
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 entries = this.parseObjectEntries();
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
- expectObjectColon(key) {
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.error(`Missing space after ':' — '${key}${tok.value}' was read as a parameter placeholder. ` +
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
- unescapeString(raw) {
1626
- if (raw.startsWith('"') && raw.endsWith('"')) {
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':