@ldclabs/kip-lang 0.3.0 → 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.
Files changed (48) hide show
  1. package/README.md +69 -5
  2. package/dist/ast.d.ts +10 -1
  3. package/dist/ast.d.ts.map +1 -1
  4. package/dist/budget.d.ts +37 -0
  5. package/dist/budget.d.ts.map +1 -0
  6. package/dist/budget.js +105 -0
  7. package/dist/budget.js.map +1 -0
  8. package/dist/diagnostics.d.ts.map +1 -1
  9. package/dist/diagnostics.js +7 -1
  10. package/dist/diagnostics.js.map +1 -1
  11. package/dist/errors.d.ts +29 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +27 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/exec-ast.d.ts +314 -0
  16. package/dist/exec-ast.d.ts.map +1 -0
  17. package/dist/exec-ast.js +23 -0
  18. package/dist/exec-ast.js.map +1 -0
  19. package/dist/formatter.d.ts +5 -7
  20. package/dist/formatter.d.ts.map +1 -1
  21. package/dist/formatter.js +71 -143
  22. package/dist/formatter.js.map +1 -1
  23. package/dist/index.d.ts +8 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +6 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/lexer.d.ts.map +1 -1
  28. package/dist/lexer.js +42 -11
  29. package/dist/lexer.js.map +1 -1
  30. package/dist/lower.d.ts +18 -0
  31. package/dist/lower.d.ts.map +1 -0
  32. package/dist/lower.js +877 -0
  33. package/dist/lower.js.map +1 -0
  34. package/dist/parser.js +311 -82
  35. package/dist/parser.js.map +1 -1
  36. package/dist/semantics.d.ts +13 -0
  37. package/dist/semantics.d.ts.map +1 -0
  38. package/dist/semantics.js +228 -0
  39. package/dist/semantics.js.map +1 -0
  40. package/dist/token.d.ts +4 -4
  41. package/dist/token.d.ts.map +1 -1
  42. package/dist/token.js +5 -10
  43. package/dist/token.js.map +1 -1
  44. package/dist/version.d.ts +13 -0
  45. package/dist/version.d.ts.map +1 -0
  46. package/dist/version.js +13 -0
  47. package/dist/version.js.map +1 -0
  48. package/package.json +9 -4
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 {
@@ -74,6 +80,7 @@ class Parser {
74
80
  const start = this.currentPos();
75
81
  const comments = this.collectLeadingComments();
76
82
  this.expect(TokenType.Find);
83
+ const lparen = this.current();
77
84
  this.expect(TokenType.LParen);
78
85
  const projections = [];
79
86
  if (!this.check(TokenType.RParen)) {
@@ -82,6 +89,9 @@ class Parser {
82
89
  projections.push(this.parseExpression());
83
90
  }
84
91
  }
92
+ if (projections.length === 0) {
93
+ this.error(`FIND must declare at least one output expression, e.g. FIND(?var)`, lparen);
94
+ }
85
95
  this.expect(TokenType.RParen);
86
96
  let where;
87
97
  let orderBy;
@@ -116,11 +126,12 @@ class Parser {
116
126
  parseUpsertStatement() {
117
127
  const start = this.currentPos();
118
128
  const comments = this.collectLeadingComments();
119
- this.expect(TokenType.Upsert);
129
+ this.expectKeywordWithSpace(TokenType.Upsert);
120
130
  this.expect(TokenType.LBrace);
121
131
  const blocks = [];
122
132
  this.skipComments();
123
133
  while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
134
+ const before = this.pos;
124
135
  this.skipComments();
125
136
  if (this.check(TokenType.Concept)) {
126
137
  blocks.push(this.parseConceptBlock());
@@ -136,6 +147,11 @@ class Parser {
136
147
  this.advance();
137
148
  }
138
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;
139
155
  }
140
156
  this.expect(TokenType.RBrace);
141
157
  let metadata;
@@ -153,8 +169,12 @@ class Parser {
153
169
  parseConceptBlock() {
154
170
  const start = this.currentPos();
155
171
  const comments = this.collectLeadingComments();
156
- this.expect(TokenType.Concept);
157
- 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
+ }
158
178
  this.expect(TokenType.LBrace);
159
179
  const matcher = this.parseConceptMatcher();
160
180
  let expectVersion;
@@ -166,16 +186,17 @@ class Parser {
166
186
  let metadata;
167
187
  this.skipComments();
168
188
  while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
189
+ const before = this.pos;
169
190
  this.skipComments();
170
191
  if (this.check(TokenType.Set)) {
171
192
  const setStart = this.currentPos();
172
- this.advance(); // skip SET
193
+ const setTok = this.advance(); // skip SET
173
194
  if (this.check(TokenType.Attributes)) {
174
- this.advance();
195
+ this.expectSecondWord(TokenType.Attributes, setTok);
175
196
  setAttributes = this.parseSetAttributesBody(setStart);
176
197
  }
177
198
  else if (this.check(TokenType.Propositions)) {
178
- this.advance();
199
+ this.expectSecondWord(TokenType.Propositions, setTok);
179
200
  setPropositions = this.parseSetPropositionsBody(setStart);
180
201
  }
181
202
  else {
@@ -197,6 +218,11 @@ class Parser {
197
218
  this.advance();
198
219
  }
199
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;
200
226
  }
201
227
  this.expect(TokenType.RBrace);
202
228
  // Concept-level WITH METADATA (outside the CONCEPT braces)
@@ -218,7 +244,7 @@ class Parser {
218
244
  parsePropositionBlock() {
219
245
  const start = this.currentPos();
220
246
  const comments = this.collectLeadingComments();
221
- this.expect(TokenType.Proposition);
247
+ this.expectKeywordWithSpace(TokenType.Proposition);
222
248
  let handle;
223
249
  if (this.check(TokenType.Variable)) {
224
250
  handle = this.expectVariable();
@@ -233,12 +259,13 @@ class Parser {
233
259
  let metadata;
234
260
  this.skipComments();
235
261
  while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
262
+ const before = this.pos;
236
263
  this.skipComments();
237
264
  if (this.check(TokenType.Set)) {
238
265
  const setStart = this.currentPos();
239
- this.advance();
266
+ const setTok = this.advance();
240
267
  if (this.check(TokenType.Attributes)) {
241
- this.advance();
268
+ this.expectSecondWord(TokenType.Attributes, setTok);
242
269
  setAttributes = this.parseSetAttributesBody(setStart);
243
270
  }
244
271
  else {
@@ -250,9 +277,15 @@ class Parser {
250
277
  break;
251
278
  }
252
279
  else {
280
+ this.error(`Unexpected token '${this.current().value}' in PROPOSITION block`, this.current());
253
281
  this.advance();
254
282
  }
255
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;
256
289
  }
257
290
  this.expect(TokenType.RBrace);
258
291
  if (this.check(TokenType.With)) {
@@ -278,20 +311,21 @@ class Parser {
278
311
  parseUpdateStatement() {
279
312
  const start = this.currentPos();
280
313
  const comments = this.collectLeadingComments();
281
- this.expect(TokenType.Update);
314
+ this.expectKeywordWithSpace(TokenType.Update);
282
315
  const target = this.expectVariable();
283
316
  let setAttributes;
284
317
  let setMetadata;
285
318
  this.skipComments();
286
319
  while (this.check(TokenType.Set) && !this.isAtEnd()) {
320
+ const before = this.pos;
287
321
  const setStart = this.currentPos();
288
- this.advance();
322
+ const setTok = this.advance();
289
323
  if (this.check(TokenType.Attributes)) {
290
- this.advance();
324
+ this.expectSecondWord(TokenType.Attributes, setTok);
291
325
  setAttributes = this.parseSetAttributesBody(setStart);
292
326
  }
293
327
  else if (this.check(TokenType.Metadata)) {
294
- this.advance();
328
+ this.expectSecondWord(TokenType.Metadata, setTok);
295
329
  setMetadata = this.parseSetMetadataBody(setStart);
296
330
  }
297
331
  else {
@@ -299,6 +333,11 @@ class Parser {
299
333
  this.advance();
300
334
  }
301
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;
302
341
  }
303
342
  if (!setAttributes && !setMetadata) {
304
343
  this.error(`Expected SET ATTRIBUTES or SET METADATA in UPDATE statement`, this.current());
@@ -325,8 +364,8 @@ class Parser {
325
364
  parseMergeStatement() {
326
365
  const start = this.currentPos();
327
366
  const comments = this.collectLeadingComments();
328
- this.expect(TokenType.Merge);
329
- this.expect(TokenType.Concept);
367
+ const mergeTok = this.expect(TokenType.Merge);
368
+ this.expectSecondWord(TokenType.Concept, mergeTok);
330
369
  const source = this.expectVariable();
331
370
  this.expect(TokenType.Into);
332
371
  const target = this.expectVariable();
@@ -346,7 +385,7 @@ class Parser {
346
385
  parseDeleteStatement() {
347
386
  const start = this.currentPos();
348
387
  const comments = this.collectLeadingComments();
349
- this.expect(TokenType.Delete);
388
+ this.expectKeywordWithSpace(TokenType.Delete);
350
389
  let deleteType;
351
390
  let keys;
352
391
  let target;
@@ -419,7 +458,7 @@ class Parser {
419
458
  parseDescribeStatement() {
420
459
  const start = this.currentPos();
421
460
  const comments = this.collectLeadingComments();
422
- this.expect(TokenType.Describe);
461
+ this.expectKeywordWithSpace(TokenType.Describe);
423
462
  let describeType;
424
463
  let typeName;
425
464
  let typeNameValue;
@@ -434,14 +473,14 @@ class Parser {
434
473
  this.advance();
435
474
  }
436
475
  else if (this.check(TokenType.Concept)) {
437
- this.advance();
476
+ const headTok = this.advance();
438
477
  if (this.check(TokenType.Types)) {
439
478
  describeType = 'CONCEPT_TYPES';
440
- this.advance();
479
+ this.expectSecondWord(TokenType.Types, headTok);
441
480
  }
442
481
  else if (this.check(TokenType.Type)) {
443
482
  describeType = 'CONCEPT_TYPE';
444
- this.advance();
483
+ this.expectSecondWord(TokenType.Type, headTok);
445
484
  typeNameValue = this.parseStringOrParameterValue('DESCRIBE CONCEPT TYPE');
446
485
  typeName =
447
486
  typeNameValue.kind === 'StringLiteral'
@@ -454,14 +493,14 @@ class Parser {
454
493
  }
455
494
  }
456
495
  else if (this.check(TokenType.Proposition)) {
457
- this.advance();
496
+ const headTok = this.advance();
458
497
  if (this.check(TokenType.Types)) {
459
498
  describeType = 'PROPOSITION_TYPES';
460
- this.advance();
499
+ this.expectSecondWord(TokenType.Types, headTok);
461
500
  }
462
501
  else if (this.check(TokenType.Type)) {
463
502
  describeType = 'PROPOSITION_TYPE';
464
- this.advance();
503
+ this.expectSecondWord(TokenType.Type, headTok);
465
504
  typeNameValue = this.parseStringOrParameterValue('DESCRIBE PROPOSITION TYPE');
466
505
  typeName =
467
506
  typeNameValue.kind === 'StringLiteral'
@@ -477,10 +516,18 @@ class Parser {
477
516
  this.error(`Expected PRIMER, DOMAINS, CONCEPT, or PROPOSITION after DESCRIBE`, this.current());
478
517
  describeType = 'PRIMER';
479
518
  }
519
+ // Only the plural `... TYPES` forms are paginated (§5.1.3 / §5.1.5).
520
+ const paginable = describeType === 'CONCEPT_TYPES' || describeType === 'PROPOSITION_TYPES';
480
521
  if (this.check(TokenType.Limit)) {
522
+ if (!paginable) {
523
+ this.error(`LIMIT is only valid on DESCRIBE CONCEPT TYPES / PROPOSITION TYPES`, this.current());
524
+ }
481
525
  limit = this.parseLimitClause();
482
526
  }
483
527
  if (this.check(TokenType.Cursor)) {
528
+ if (!paginable) {
529
+ this.error(`CURSOR is only valid on DESCRIBE CONCEPT TYPES / PROPOSITION TYPES`, this.current());
530
+ }
484
531
  cursor = this.parseCursorClause();
485
532
  }
486
533
  return {
@@ -500,15 +547,15 @@ class Parser {
500
547
  parseSearchStatement() {
501
548
  const start = this.currentPos();
502
549
  const comments = this.collectLeadingComments();
503
- this.expect(TokenType.Search);
550
+ this.expectKeywordWithSpace(TokenType.Search);
504
551
  let searchTarget;
505
552
  if (this.check(TokenType.Concept)) {
506
553
  searchTarget = 'CONCEPT';
507
- this.advance();
554
+ this.expectKeywordWithSpace(TokenType.Concept);
508
555
  }
509
556
  else if (this.check(TokenType.Proposition)) {
510
557
  searchTarget = 'PROPOSITION';
511
- this.advance();
558
+ this.expectKeywordWithSpace(TokenType.Proposition);
512
559
  }
513
560
  else {
514
561
  this.error(`Expected CONCEPT or PROPOSITION after SEARCH`, this.current());
@@ -522,10 +569,15 @@ class Parser {
522
569
  let modeValue;
523
570
  let threshold;
524
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.
525
574
  while (!this.isAtEnd()) {
575
+ const before = this.pos;
576
+ const clause = this.current();
526
577
  if (this.check(TokenType.With)) {
527
- this.advance();
528
- this.expect(TokenType.Type);
578
+ this.rejectRepeat(withTypeValue, 'WITH TYPE', clause);
579
+ const withTok = this.advance();
580
+ this.expectSecondWord(TokenType.Type, withTok);
529
581
  withTypeValue = this.parseStringOrParameterValue('SEARCH WITH TYPE');
530
582
  withType =
531
583
  withTypeValue.kind === 'StringLiteral'
@@ -533,20 +585,28 @@ class Parser {
533
585
  : withTypeValue.name;
534
586
  }
535
587
  else if (this.check(TokenType.Mode)) {
588
+ this.rejectRepeat(modeValue, 'MODE', clause);
536
589
  this.advance();
537
590
  modeValue = this.parseStringOrParameterValue('SEARCH MODE');
538
591
  mode =
539
592
  modeValue.kind === 'StringLiteral' ? modeValue.parsed : modeValue.name;
540
593
  }
541
594
  else if (this.check(TokenType.Threshold)) {
595
+ this.rejectRepeat(threshold, 'THRESHOLD', clause);
542
596
  threshold = this.parseThresholdClause();
543
597
  }
544
598
  else if (this.check(TokenType.Limit)) {
599
+ this.rejectRepeat(limit, 'LIMIT', clause);
545
600
  limit = this.parseLimitClause();
546
601
  }
547
602
  else {
548
603
  break;
549
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;
550
610
  }
551
611
  return {
552
612
  kind: 'SearchStatement',
@@ -569,18 +629,23 @@ class Parser {
569
629
  parseExportStatement() {
570
630
  const start = this.currentPos();
571
631
  const comments = this.collectLeadingComments();
572
- this.expect(TokenType.Export);
632
+ this.expectKeywordWithSpace(TokenType.Export);
573
633
  const target = this.expectVariable();
574
634
  const where = this.parseWhereClause();
575
635
  let limit;
636
+ let cursor;
576
637
  if (this.check(TokenType.Limit)) {
577
638
  limit = this.parseLimitClause();
578
639
  }
640
+ if (this.check(TokenType.Cursor)) {
641
+ cursor = this.parseCursorClause();
642
+ }
579
643
  return {
580
644
  kind: 'ExportStatement',
581
645
  target,
582
646
  where,
583
647
  limit,
648
+ cursor,
584
649
  range: { start, end: this.currentPos() },
585
650
  leadingComments: comments.length > 0 ? comments : undefined
586
651
  };
@@ -604,6 +669,7 @@ class Parser {
604
669
  const patterns = [];
605
670
  this.skipComments();
606
671
  while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
672
+ const before = this.pos;
607
673
  this.skipComments();
608
674
  if (this.check(TokenType.RBrace))
609
675
  break;
@@ -611,6 +677,11 @@ class Parser {
611
677
  if (pattern)
612
678
  patterns.push(pattern);
613
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;
614
685
  }
615
686
  return patterns;
616
687
  }
@@ -678,8 +749,12 @@ class Parser {
678
749
  }
679
750
  parseConceptMatcher() {
680
751
  const start = this.currentPos();
681
- this.expect(TokenType.LBrace);
682
- 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
+ }
683
758
  this.expect(TokenType.RBrace);
684
759
  return {
685
760
  kind: 'ConceptMatcher',
@@ -820,17 +895,11 @@ class Parser {
820
895
  parseHopRange() {
821
896
  const start = this.currentPos();
822
897
  this.expect(TokenType.LBrace);
823
- const minTok = this.current();
824
- if (minTok.type !== TokenType.Number) {
825
- this.error(`Expected number in hop range`, minTok);
826
- }
827
- const min = Number(minTok.value);
828
- this.advance();
898
+ const min = this.expectHopCount();
829
899
  let max;
830
900
  if (this.match(TokenType.Comma)) {
831
901
  if (this.check(TokenType.Number)) {
832
- max = Number(this.current().value);
833
- this.advance();
902
+ max = this.expectHopCount();
834
903
  }
835
904
  // else: {m,} means unbounded
836
905
  }
@@ -845,6 +914,27 @@ class Parser {
845
914
  range: { start, end: this.currentPos() }
846
915
  };
847
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
+ }
848
938
  parseFilterClause() {
849
939
  const start = this.currentPos();
850
940
  this.expect(TokenType.Filter);
@@ -921,11 +1011,22 @@ class Parser {
921
1011
  const items = [];
922
1012
  this.skipComments();
923
1013
  while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
1014
+ const before = this.pos;
924
1015
  this.skipComments();
925
1016
  if (this.check(TokenType.RBrace))
926
1017
  break;
927
1018
  items.push(this.parsePropositionItem());
928
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;
929
1030
  }
930
1031
  this.expect(TokenType.RBrace);
931
1032
  return {
@@ -954,8 +1055,19 @@ class Parser {
954
1055
  };
955
1056
  }
956
1057
  isIdMatcherStart() {
957
- const next = this.tokens[this.pos + 1];
958
- 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];
959
1071
  }
960
1072
  parseIdMatcherValue() {
961
1073
  const keyTok = this.current();
@@ -974,7 +1086,7 @@ class Parser {
974
1086
  return {
975
1087
  kind: 'StringLiteral',
976
1088
  value: tok.value,
977
- parsed: this.unescapeString(tok.value),
1089
+ parsed: this.unescapeString(tok.value, tok),
978
1090
  range: { start, end: this.currentPos() }
979
1091
  };
980
1092
  }
@@ -1000,8 +1112,8 @@ class Parser {
1000
1112
  }
1001
1113
  parseWithMetadata() {
1002
1114
  const start = this.currentPos();
1003
- this.expect(TokenType.With);
1004
- this.expect(TokenType.Metadata);
1115
+ const withTok = this.expect(TokenType.With);
1116
+ this.expectSecondWord(TokenType.Metadata, withTok);
1005
1117
  this.expect(TokenType.LBrace);
1006
1118
  const entries = this.parseObjectEntries();
1007
1119
  this.expect(TokenType.RBrace);
@@ -1013,8 +1125,8 @@ class Parser {
1013
1125
  }
1014
1126
  parseExpectVersion() {
1015
1127
  const start = this.currentPos();
1016
- this.expect(TokenType.Expect);
1017
- this.expect(TokenType.Version);
1128
+ const expectTok = this.expect(TokenType.Expect);
1129
+ this.expectSecondWord(TokenType.Version, expectTok);
1018
1130
  const value = this.parseNumberOrParameterValue('EXPECT VERSION');
1019
1131
  return {
1020
1132
  kind: 'ExpectVersion',
@@ -1027,8 +1139,8 @@ class Parser {
1027
1139
  // ────────────────────────────────────────────────────────────────────
1028
1140
  parseOrderBy() {
1029
1141
  const start = this.currentPos();
1030
- this.expect(TokenType.Order);
1031
- this.expect(TokenType.By);
1142
+ const orderTok = this.expect(TokenType.Order);
1143
+ this.expectSecondWord(TokenType.By, orderTok);
1032
1144
  const keys = [];
1033
1145
  keys.push(this.parseOrderByKey());
1034
1146
  while (this.match(TokenType.Comma)) {
@@ -1064,7 +1176,7 @@ class Parser {
1064
1176
  }
1065
1177
  parseThresholdClause() {
1066
1178
  const start = this.currentPos();
1067
- this.expect(TokenType.Threshold);
1179
+ this.expectKeywordWithSpace(TokenType.Threshold);
1068
1180
  const value = this.parseNumberOrParameterValue('THRESHOLD');
1069
1181
  return {
1070
1182
  kind: 'ThresholdClause',
@@ -1107,7 +1219,7 @@ class Parser {
1107
1219
  }
1108
1220
  parseLimitClause() {
1109
1221
  const start = this.currentPos();
1110
- this.expect(TokenType.Limit);
1222
+ this.expectKeywordWithSpace(TokenType.Limit);
1111
1223
  const value = this.parseNumberOrParameterValue('LIMIT');
1112
1224
  return {
1113
1225
  kind: 'LimitClause',
@@ -1117,14 +1229,14 @@ class Parser {
1117
1229
  }
1118
1230
  parseCursorClause() {
1119
1231
  const start = this.currentPos();
1120
- this.expect(TokenType.Cursor);
1232
+ this.expectKeywordWithSpace(TokenType.Cursor);
1121
1233
  const tok = this.current();
1122
1234
  let value;
1123
1235
  if (tok.type === TokenType.String) {
1124
1236
  value = {
1125
1237
  kind: 'StringLiteral',
1126
1238
  value: tok.value,
1127
- parsed: this.unescapeString(tok.value),
1239
+ parsed: this.unescapeString(tok.value, tok),
1128
1240
  range: { start: this.currentPos(), end: this.currentPos() }
1129
1241
  };
1130
1242
  this.advance();
@@ -1247,13 +1359,27 @@ class Parser {
1247
1359
  name,
1248
1360
  range: { start, end: this.currentPos() }
1249
1361
  };
1250
- // 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;
1251
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
+ }
1252
1373
  this.advance();
1253
1374
  const propTok = this.current();
1375
+ if (propTok.offset !== dotTok.offset + 1) {
1376
+ this.error(`Expected property name after '.'`, propTok);
1377
+ break;
1378
+ }
1254
1379
  if (propTok.type === TokenType.Identifier ||
1255
1380
  this.isNonAmbiguousKeyword(propTok.type)) {
1256
1381
  const prop = propTok.value;
1382
+ prevEnd = propTok.offset + propTok.value.length;
1257
1383
  this.advance();
1258
1384
  expr = {
1259
1385
  kind: 'DotExpression',
@@ -1284,7 +1410,7 @@ class Parser {
1284
1410
  return {
1285
1411
  kind: 'StringLiteral',
1286
1412
  value: tok.value,
1287
- parsed: this.unescapeString(tok.value),
1413
+ parsed: this.unescapeString(tok.value, tok),
1288
1414
  range: { start, end: this.currentPos() }
1289
1415
  };
1290
1416
  }
@@ -1329,6 +1455,7 @@ class Parser {
1329
1455
  }
1330
1456
  // System identifier as literal
1331
1457
  if (tok.type === TokenType.SystemIdent) {
1458
+ this.error(`Unquoted value '${tok.value}': KIP values are JSON values, so write "${tok.value}"`, tok);
1332
1459
  this.advance();
1333
1460
  return {
1334
1461
  kind: 'StringLiteral',
@@ -1337,8 +1464,12 @@ class Parser {
1337
1464
  range: { start, end: this.currentPos() }
1338
1465
  };
1339
1466
  }
1340
- // 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.
1341
1471
  if (tok.type === TokenType.Identifier) {
1472
+ this.error(`Unquoted value '${tok.value}': KIP values are JSON values, so write "${tok.value}"`, tok);
1342
1473
  this.advance();
1343
1474
  return {
1344
1475
  kind: 'StringLiteral',
@@ -1389,13 +1520,16 @@ class Parser {
1389
1520
  const start = this.currentPos();
1390
1521
  this.expect(TokenType.LBracket);
1391
1522
  const elements = [];
1523
+ let trailingComma = false;
1392
1524
  this.skipComments();
1393
1525
  if (!this.check(TokenType.RBracket)) {
1394
1526
  elements.push(this.parseExpression());
1395
1527
  while (this.match(TokenType.Comma)) {
1396
1528
  this.skipComments();
1397
- if (this.check(TokenType.RBracket))
1529
+ if (this.check(TokenType.RBracket)) {
1530
+ trailingComma = true;
1398
1531
  break;
1532
+ }
1399
1533
  elements.push(this.parseExpression());
1400
1534
  }
1401
1535
  }
@@ -1404,30 +1538,34 @@ class Parser {
1404
1538
  return {
1405
1539
  kind: 'ArrayLiteral',
1406
1540
  elements,
1541
+ trailingComma,
1407
1542
  range: { start, end: this.currentPos() }
1408
1543
  };
1409
1544
  }
1410
1545
  parseObjectLiteral() {
1411
1546
  const start = this.currentPos();
1412
1547
  this.expect(TokenType.LBrace);
1413
- const entries = this.parseObjectEntries();
1548
+ const seen = { trailingComma: false };
1549
+ const entries = this.parseObjectEntries(seen);
1414
1550
  this.expect(TokenType.RBrace);
1415
1551
  return {
1416
1552
  kind: 'ObjectLiteral',
1417
1553
  entries,
1554
+ trailingComma: seen.trailingComma,
1418
1555
  range: { start, end: this.currentPos() }
1419
1556
  };
1420
1557
  }
1421
- parseObjectEntries() {
1558
+ parseObjectEntries(seen) {
1422
1559
  const entries = [];
1423
1560
  this.skipComments();
1424
1561
  while (!this.check(TokenType.RBrace) && !this.isAtEnd()) {
1562
+ const before = this.pos;
1425
1563
  this.skipComments();
1426
1564
  if (this.check(TokenType.RBrace))
1427
1565
  break;
1428
1566
  const entryStart = this.currentPos();
1429
1567
  const { key, isQuoted } = this.expectKeyWithQuoting();
1430
- this.expect(TokenType.Colon);
1568
+ this.expectObjectColon(key);
1431
1569
  const value = this.parseExpression();
1432
1570
  entries.push({
1433
1571
  kind: 'ObjectEntry',
@@ -1441,9 +1579,16 @@ class Parser {
1441
1579
  break;
1442
1580
  if (this.match(TokenType.Comma)) {
1443
1581
  this.skipComments();
1582
+ if (seen && this.check(TokenType.RBrace))
1583
+ seen.trailingComma = true;
1444
1584
  continue;
1445
1585
  }
1446
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;
1447
1592
  }
1448
1593
  return entries;
1449
1594
  }
@@ -1491,6 +1636,28 @@ class Parser {
1491
1636
  }
1492
1637
  return this.advance();
1493
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
+ }
1494
1661
  expectVariable() {
1495
1662
  const tok = this.current();
1496
1663
  if (tok.type !== TokenType.Variable) {
@@ -1507,7 +1674,7 @@ class Parser {
1507
1674
  return '';
1508
1675
  }
1509
1676
  this.advance();
1510
- return this.unescapeString(tok.value);
1677
+ return this.unescapeString(tok.value, tok);
1511
1678
  }
1512
1679
  expectStringValue() {
1513
1680
  const tok = this.current();
@@ -1516,29 +1683,13 @@ class Parser {
1516
1683
  return '';
1517
1684
  }
1518
1685
  this.advance();
1519
- return this.unescapeString(tok.value);
1520
- }
1521
- expectKey() {
1522
- const tok = this.current();
1523
- // Keys can be identifiers, strings, or even some keywords used as keys
1524
- if (tok.type === TokenType.String) {
1525
- this.advance();
1526
- return this.unescapeString(tok.value);
1527
- }
1528
- if (tok.type === TokenType.Identifier ||
1529
- this.isNonAmbiguousKeyword(tok.type)) {
1530
- this.advance();
1531
- return tok.value;
1532
- }
1533
- this.error(`Expected object key but got '${tok.value}'`, tok);
1534
- this.advance();
1535
- return tok.value;
1686
+ return this.unescapeString(tok.value, tok);
1536
1687
  }
1537
1688
  expectKeyWithQuoting() {
1538
1689
  const tok = this.current();
1539
1690
  if (tok.type === TokenType.String) {
1540
1691
  this.advance();
1541
- return { key: this.unescapeString(tok.value), isQuoted: true };
1692
+ return { key: this.unescapeString(tok.value, tok), isQuoted: true };
1542
1693
  }
1543
1694
  if (tok.type === TokenType.Identifier ||
1544
1695
  this.isNonAmbiguousKeyword(tok.type)) {
@@ -1549,6 +1700,12 @@ class Parser {
1549
1700
  this.advance();
1550
1701
  return { key: tok.value, isQuoted: false };
1551
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
+ }
1552
1709
  skipComments() {
1553
1710
  while (this.pos < this.tokens.length &&
1554
1711
  this.current().type === TokenType.Comment) {
@@ -1565,6 +1722,62 @@ class Parser {
1565
1722
  }
1566
1723
  return comments;
1567
1724
  }
1725
+ /**
1726
+ * Consume the `:` separating an object key from its value. A colon written
1727
+ * with no space before an identifier value (e.g. `status:active`) is lexed as
1728
+ * a single parameter placeholder token (`:active`), so surface a targeted hint
1729
+ * instead of the generic "Expected ':'" message.
1730
+ */
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) {
1749
+ if (this.check(TokenType.Colon)) {
1750
+ this.advance();
1751
+ return;
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.
1758
+ const tok = this.current();
1759
+ if (tok.type === TokenType.Parameter) {
1760
+ this.splitParameterAfterColon(tok);
1761
+ return;
1762
+ }
1763
+ this.expect(TokenType.Colon);
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
+ }
1568
1781
  isFunctionToken(type) {
1569
1782
  return (type === TokenType.Count ||
1570
1783
  type === TokenType.Sum ||
@@ -1607,15 +1820,31 @@ class Parser {
1607
1820
  type === TokenType.Mode ||
1608
1821
  type === TokenType.Threshold);
1609
1822
  }
1610
- unescapeString(raw) {
1611
- 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) {
1612
1835
  try {
1613
1836
  return JSON.parse(raw);
1614
1837
  }
1615
1838
  catch {
1839
+ if (tok) {
1840
+ this.error(`Invalid string literal ${raw}: KIP strings are JSON strings`, tok);
1841
+ }
1616
1842
  raw = raw.slice(1, -1);
1617
1843
  }
1618
1844
  }
1845
+ else if (tok) {
1846
+ this.error(`Unterminated string literal ${raw}`, tok);
1847
+ }
1619
1848
  return raw.replace(/\\(.)/g, (_, ch) => {
1620
1849
  switch (ch) {
1621
1850
  case 'n':
@@ -1635,7 +1864,7 @@ class Parser {
1635
1864
  }
1636
1865
  });
1637
1866
  }
1638
- error(message, token) {
1867
+ error(message, token, code = 'KIP_1001') {
1639
1868
  this.diagnostics.push({
1640
1869
  range: {
1641
1870
  start: { line: token.line, column: token.column },
@@ -1643,7 +1872,7 @@ class Parser {
1643
1872
  },
1644
1873
  severity: 'error',
1645
1874
  message,
1646
- code: 'KIP_PARSE'
1875
+ code
1647
1876
  });
1648
1877
  }
1649
1878
  recoverToNextStatement() {