patram 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/lib/build-graph-identity.js +57 -24
  2. package/lib/build-graph.js +383 -17
  3. package/lib/build-graph.types.ts +5 -2
  4. package/lib/check-directive-metadata.js +516 -0
  5. package/lib/check-directive-value.js +282 -0
  6. package/lib/check-graph.js +24 -5
  7. package/lib/cli-help-metadata.js +580 -0
  8. package/lib/derived-summary.js +280 -0
  9. package/lib/directive-diagnostics.js +38 -0
  10. package/lib/directive-type-rules.js +133 -0
  11. package/lib/discover-fields.js +427 -0
  12. package/lib/discover-fields.types.ts +52 -0
  13. package/lib/format-derived-summary-row.js +9 -0
  14. package/lib/format-node-header.js +21 -0
  15. package/lib/format-output-item-block.js +22 -0
  16. package/lib/format-output-metadata.js +54 -0
  17. package/lib/layout-stored-queries.js +96 -2
  18. package/lib/load-patram-config.js +754 -18
  19. package/lib/load-patram-config.types.ts +128 -2
  20. package/lib/load-project-graph.js +4 -1
  21. package/lib/output-view.types.ts +29 -6
  22. package/lib/parse-cli-arguments-helpers.js +263 -90
  23. package/lib/parse-cli-arguments.js +160 -8
  24. package/lib/parse-cli-arguments.types.ts +49 -4
  25. package/lib/parse-where-clause.js +670 -209
  26. package/lib/parse-where-clause.types.ts +72 -0
  27. package/lib/patram-cli.js +180 -21
  28. package/lib/patram-config.js +31 -31
  29. package/lib/patram-config.types.ts +10 -4
  30. package/lib/patram.js +6 -0
  31. package/lib/query-graph.js +444 -113
  32. package/lib/query-inspection.js +798 -0
  33. package/lib/render-check-output.js +1 -1
  34. package/lib/render-cli-help.js +419 -0
  35. package/lib/render-field-discovery.js +148 -0
  36. package/lib/render-json-output.js +66 -14
  37. package/lib/render-output-view.js +272 -22
  38. package/lib/render-plain-output.js +31 -86
  39. package/lib/render-rich-output.js +34 -87
  40. package/lib/resolve-patram-graph-config.js +15 -9
  41. package/lib/resolve-where-clause.js +18 -3
  42. package/lib/show-document.js +51 -7
  43. package/lib/tagged-fenced-block-error.js +17 -0
  44. package/lib/tagged-fenced-block-markdown.js +111 -0
  45. package/lib/tagged-fenced-block-metadata.js +97 -0
  46. package/lib/tagged-fenced-block-parser.js +292 -0
  47. package/lib/tagged-fenced-blocks.js +100 -0
  48. package/lib/tagged-fenced-blocks.types.ts +38 -0
  49. package/package.json +12 -7
@@ -1,312 +1,773 @@
1
+ /* eslint-disable max-lines */
1
2
  /**
2
3
  * @import { PatramDiagnostic } from './load-patram-config.types.ts';
4
+ * @import {
5
+ * ParseWhereClauseResult,
6
+ * ParsedAggregateComparison,
7
+ * ParsedAggregateName,
8
+ * ParsedAggregateTerm,
9
+ * ParsedClause,
10
+ * ParsedFieldName,
11
+ * ParsedTerm,
12
+ * ParsedTraversalTerm,
13
+ * } from './parse-where-clause.types.ts';
3
14
  */
4
15
 
5
16
  /**
6
- * @typedef {{
7
- * field_name: 'id' | 'kind' | 'path' | 'status' | 'title',
8
- * kind: 'field',
9
- * operator: '=' | '^=' | '~',
10
- * value: string,
11
- * }} ParsedFieldTerm
12
- */
13
-
14
- /**
15
- * @typedef {{
16
- * kind: 'relation',
17
- * relation_name: string,
18
- * }} ParsedRelationTerm
19
- */
20
-
21
- /**
22
- * @typedef {{
23
- * kind: 'relation_target',
24
- * relation_name: string,
25
- * target_id: string,
26
- * }} ParsedRelationTargetTerm
27
- */
28
-
29
- /**
30
- * @typedef {{
31
- * is_negated: boolean,
32
- * term: ParsedFieldTerm | ParsedRelationTerm | ParsedRelationTargetTerm,
33
- * }} ParsedClause
34
- */
35
-
36
- /**
37
- * @typedef {{
38
- * clause: ParsedClause,
39
- * success: true,
40
- * } | {
41
- * diagnostic: PatramDiagnostic,
42
- * success: false,
43
- * }} CreateClauseResult
17
+ * @typedef {{ index: number, where_clause: string }} ParserState
44
18
  */
45
19
 
46
20
  /**
47
21
  * @typedef {{
48
- * clauses: ParsedClause[],
49
22
  * success: true,
23
+ * term: ParsedTerm,
50
24
  * } | {
51
25
  * diagnostic: PatramDiagnostic,
52
26
  * success: false,
53
- * }} ParseWhereClauseResult
27
+ * }} ParseTermResult
54
28
  */
55
29
 
56
30
  /**
57
- * Parse one v0 where clause into structured clauses.
31
+ * Parse one where clause into structured clauses.
58
32
  *
59
33
  * @param {string} where_clause
60
34
  * @returns {ParseWhereClauseResult}
61
35
  */
62
36
  export function parseWhereClause(where_clause) {
63
- const tokens = tokenizeWhereClause(where_clause);
37
+ /** @type {ParserState} */
38
+ const parser_state = { index: 0, where_clause };
39
+
40
+ skipWhitespace(parser_state);
64
41
 
65
- if (tokens.length === 0) {
66
- return {
67
- diagnostic: createQueryDiagnostic(1, 'Query must not be empty.'),
68
- success: false,
69
- };
42
+ if (isAtEnd(parser_state)) {
43
+ return fail(1, 'Query must not be empty.');
70
44
  }
71
45
 
72
- return parseTokens(tokens, where_clause);
46
+ const clauses_result = parseClauses(parser_state, null);
47
+
48
+ if (!clauses_result.success) {
49
+ return clauses_result;
50
+ }
51
+
52
+ skipWhitespace(parser_state);
53
+
54
+ if (!isAtEnd(parser_state)) {
55
+ return failToken(parser_state);
56
+ }
57
+
58
+ return {
59
+ clauses: clauses_result.clauses,
60
+ success: true,
61
+ };
73
62
  }
74
63
 
75
64
  /**
76
- * @param {{ value: string, column: number }[]} tokens
77
- * @param {string} where_clause
65
+ * @param {ParserState} parser_state
66
+ * @param {')' | null} stop_character
78
67
  * @returns {ParseWhereClauseResult}
79
68
  */
80
- function parseTokens(tokens, where_clause) {
69
+ function parseClauses(parser_state, stop_character) {
81
70
  /** @type {ParsedClause[]} */
82
71
  const clauses = [];
83
- let should_expect_term = true;
84
- let is_negated = false;
85
-
86
- for (const token of tokens) {
87
- if (token.value === 'and') {
88
- if (should_expect_term) {
89
- return {
90
- diagnostic: createQueryDiagnostic(
91
- token.column,
92
- `Unsupported query token "${token.value}".`,
93
- ),
94
- success: false,
95
- };
96
- }
97
-
98
- should_expect_term = true;
99
- continue;
72
+ let is_first_clause = true;
73
+
74
+ while (true) {
75
+ skipWhitespace(parser_state);
76
+
77
+ if (
78
+ currentCharacter(parser_state) === stop_character ||
79
+ isAtEnd(parser_state)
80
+ ) {
81
+ return is_first_clause
82
+ ? fail(parser_state.where_clause.length + 1, 'Expected a query term.')
83
+ : { clauses, success: true };
100
84
  }
101
85
 
102
- if (!should_expect_term) {
103
- return {
104
- diagnostic: createQueryDiagnostic(
105
- token.column,
106
- `Unsupported query token "${token.value}".`,
107
- ),
108
- success: false,
109
- };
110
- }
86
+ if (!is_first_clause) {
87
+ if (!consumeKeyword(parser_state, 'and')) {
88
+ return failToken(parser_state);
89
+ }
90
+
91
+ skipWhitespace(parser_state);
111
92
 
112
- if (token.value === 'not') {
113
- is_negated = true;
114
- continue;
93
+ if (
94
+ currentCharacter(parser_state) === stop_character ||
95
+ isAtEnd(parser_state)
96
+ ) {
97
+ return fail(
98
+ parser_state.where_clause.length + 1,
99
+ 'Expected a query term.',
100
+ );
101
+ }
115
102
  }
116
103
 
117
- const clause_result = createClause(token, is_negated);
104
+ const clause_result = parseClause(parser_state);
118
105
 
119
106
  if (!clause_result.success) {
120
107
  return clause_result;
121
108
  }
122
109
 
123
110
  clauses.push(clause_result.clause);
124
- is_negated = false;
125
- should_expect_term = false;
111
+ is_first_clause = false;
126
112
  }
113
+ }
127
114
 
128
- if (should_expect_term) {
129
- return {
130
- diagnostic: createQueryDiagnostic(
131
- where_clause.length + 1,
132
- 'Expected a query term.',
133
- ),
134
- success: false,
135
- };
115
+ /**
116
+ * @param {ParserState} parser_state
117
+ * @returns {{ clause: ParsedClause, success: true } | { diagnostic: PatramDiagnostic, success: false }}
118
+ */
119
+ function parseClause(parser_state) {
120
+ const clause_start = parser_state.index;
121
+ const is_negated = consumeKeyword(parser_state, 'not');
122
+
123
+ if (is_negated && !consumeRequiredWhitespace(parser_state)) {
124
+ return fail(
125
+ clause_start + 1,
126
+ `Unsupported query token "${readToken(parser_state, clause_start)}".`,
127
+ );
128
+ }
129
+
130
+ const term_result = parseTerm(parser_state);
131
+
132
+ if (!term_result.success) {
133
+ return term_result;
136
134
  }
137
135
 
138
136
  return {
139
- clauses,
137
+ clause: {
138
+ is_negated,
139
+ term: term_result.term,
140
+ },
140
141
  success: true,
141
142
  };
142
143
  }
143
144
 
144
145
  /**
145
- * @param {{ value: string, column: number }} token
146
- * @param {boolean} is_negated
147
- * @returns {CreateClauseResult}
146
+ * @param {ParserState} parser_state
147
+ * @returns {ParseTermResult}
148
148
  */
149
- function createClause(token, is_negated) {
150
- const field_term = createFieldTerm(token.value);
149
+ function parseTerm(parser_state) {
150
+ return parseAggregate(parser_state) ?? parseAtomicTerm(parser_state);
151
+ }
151
152
 
152
- if (field_term) {
153
- return {
154
- clause: {
155
- is_negated,
156
- term: field_term,
157
- },
158
- success: true,
159
- };
153
+ /**
154
+ * @param {ParserState} parser_state
155
+ * @returns {ParseTermResult | null}
156
+ */
157
+ function parseAggregate(parser_state) {
158
+ const start_index = parser_state.index;
159
+ const aggregate_name = parseIdentifier(parser_state);
160
+
161
+ if (
162
+ aggregate_name !== 'any' &&
163
+ aggregate_name !== 'count' &&
164
+ aggregate_name !== 'none'
165
+ ) {
166
+ parser_state.index = start_index;
167
+ return null;
160
168
  }
161
169
 
162
- const relation_target_term = createRelationTargetTerm(token.value);
170
+ skipWhitespace(parser_state);
163
171
 
164
- if (relation_target_term) {
165
- return {
166
- clause: {
167
- is_negated,
168
- term: relation_target_term,
169
- },
170
- success: true,
171
- };
172
+ if (currentCharacter(parser_state) !== '(') {
173
+ parser_state.index = start_index;
174
+ return null;
172
175
  }
173
176
 
174
- if (/^[a-z_]+:\*$/u.test(token.value)) {
175
- return {
176
- clause: {
177
- is_negated,
178
- term: {
179
- kind: 'relation',
180
- relation_name: token.value.slice(0, -2),
181
- },
182
- },
183
- success: true,
184
- };
177
+ parser_state.index += 1;
178
+ skipWhitespace(parser_state);
179
+
180
+ const traversal_result = parseTraversal(parser_state);
181
+
182
+ if (!traversal_result.success) {
183
+ return traversal_result;
185
184
  }
186
185
 
187
- return {
188
- diagnostic: createQueryDiagnostic(
189
- token.column,
190
- `Unsupported query token "${token.value}".`,
191
- ),
192
- success: false,
186
+ if (!consumeOperator(parser_state, ',')) {
187
+ return failToken(parser_state);
188
+ }
189
+
190
+ skipWhitespace(parser_state);
191
+ const clauses_result = parseClauses(parser_state, ')');
192
+
193
+ if (!clauses_result.success) {
194
+ return clauses_result;
195
+ }
196
+
197
+ if (!consumeOperator(parser_state, ')')) {
198
+ return failToken(parser_state);
199
+ }
200
+
201
+ return createAggregateTerm(
202
+ parser_state,
203
+ aggregate_name,
204
+ traversal_result.traversal,
205
+ clauses_result.clauses,
206
+ );
207
+ }
208
+
209
+ /**
210
+ * @param {ParserState} parser_state
211
+ * @returns {ParseTermResult}
212
+ */
213
+ function parseAtomicTerm(parser_state) {
214
+ const start_index = parser_state.index;
215
+ const field_or_relation_name = parseIdentifier(parser_state);
216
+
217
+ if (!field_or_relation_name) {
218
+ return failToken(parser_state);
219
+ }
220
+
221
+ return parseOperatorTerm(parser_state, start_index, field_or_relation_name);
222
+ }
223
+
224
+ /**
225
+ * @param {ParserState} parser_state
226
+ * @param {ParsedAggregateName} aggregate_name
227
+ * @param {ParsedTraversalTerm} traversal
228
+ * @param {ParsedClause[]} clauses
229
+ * @returns {ParseTermResult}
230
+ */
231
+ function createAggregateTerm(parser_state, aggregate_name, traversal, clauses) {
232
+ /** @type {ParsedAggregateTerm} */
233
+ const aggregate_term = {
234
+ aggregate_name,
235
+ clauses,
236
+ kind: 'aggregate',
237
+ traversal,
193
238
  };
239
+
240
+ if (aggregate_name !== 'count') {
241
+ return success(aggregate_term);
242
+ }
243
+
244
+ const count_tail = parseCountTail(parser_state);
245
+
246
+ if (!count_tail) {
247
+ return failToken(parser_state);
248
+ }
249
+
250
+ return success({
251
+ ...aggregate_term,
252
+ comparison: count_tail.comparison,
253
+ value: count_tail.value,
254
+ });
194
255
  }
195
256
 
196
257
  /**
197
- * @param {string} query_term
198
- * @returns {ParsedFieldTerm | null}
258
+ * @param {ParserState} parser_state
259
+ * @param {number} start_index
260
+ * @param {ParsedFieldName | string} field_name
261
+ * @returns {ParseTermResult | null}
199
262
  */
200
- function createFieldTerm(query_term) {
201
- if (query_term.startsWith('id=')) {
202
- return {
203
- field_name: 'id',
204
- kind: 'field',
205
- operator: '=',
206
- value: query_term.slice('id='.length),
207
- };
263
+ function parseFieldSet(parser_state, start_index, field_name) {
264
+ const operator_start_index = parser_state.index;
265
+ if (!consumeRequiredWhitespace(parser_state)) {
266
+ return null;
208
267
  }
209
268
 
210
- if (query_term.startsWith('id^=')) {
211
- return {
212
- field_name: 'id',
213
- kind: 'field',
214
- operator: '^=',
215
- value: query_term.slice('id^='.length),
216
- };
269
+ const operator = parseSetOperator(parser_state);
270
+
271
+ if (!operator) {
272
+ parser_state.index = operator_start_index;
273
+ return null;
217
274
  }
218
275
 
219
- if (query_term.startsWith('kind=')) {
220
- return {
221
- field_name: 'kind',
222
- kind: 'field',
223
- operator: '=',
224
- value: query_term.slice('kind='.length),
225
- };
276
+ if (!consumeRequiredWhitespace(parser_state)) {
277
+ return failToken(parser_state);
226
278
  }
227
279
 
228
- if (query_term.startsWith('status=')) {
229
- return {
230
- field_name: 'status',
231
- kind: 'field',
232
- operator: '=',
233
- value: query_term.slice('status='.length),
234
- };
280
+ const values = parseList(parser_state);
281
+
282
+ return values
283
+ ? success({
284
+ column: start_index + 1,
285
+ field_name,
286
+ kind: 'field_set',
287
+ operator,
288
+ values,
289
+ })
290
+ : failToken(parser_state);
291
+ }
292
+
293
+ /**
294
+ * @param {ParserState} parser_state
295
+ * @param {number} start_index
296
+ * @param {string} field_or_relation_name
297
+ * @returns {ParseTermResult}
298
+ */
299
+ function parseOperatorTerm(parser_state, start_index, field_or_relation_name) {
300
+ const field_set = parseFieldSet(
301
+ parser_state,
302
+ start_index,
303
+ field_or_relation_name,
304
+ );
305
+
306
+ if (field_set) {
307
+ return field_set;
308
+ }
309
+
310
+ const prefix_term = parsePrefixTerm(
311
+ parser_state,
312
+ start_index,
313
+ field_or_relation_name,
314
+ );
315
+
316
+ if (prefix_term) {
317
+ return prefix_term;
318
+ }
319
+
320
+ const contains_term = parseContainsTerm(
321
+ parser_state,
322
+ start_index,
323
+ field_or_relation_name,
324
+ );
325
+
326
+ if (contains_term) {
327
+ return contains_term;
328
+ }
329
+
330
+ const comparison_term = parseFieldComparisonTerm(
331
+ parser_state,
332
+ start_index,
333
+ field_or_relation_name,
334
+ );
335
+
336
+ if (comparison_term) {
337
+ return comparison_term;
338
+ }
339
+
340
+ const equality_term = parseEqualityTerm(
341
+ parser_state,
342
+ start_index,
343
+ field_or_relation_name,
344
+ );
345
+
346
+ if (equality_term) {
347
+ return equality_term;
348
+ }
349
+
350
+ if (consumeOperator(parser_state, ':*')) {
351
+ return success({
352
+ column: start_index + 1,
353
+ kind: 'relation',
354
+ relation_name: field_or_relation_name,
355
+ });
356
+ }
357
+
358
+ parser_state.index = start_index;
359
+ return failToken(parser_state);
360
+ }
361
+
362
+ /**
363
+ * @param {ParserState} parser_state
364
+ * @returns {{ comparison: ParsedAggregateComparison, value: number } | null}
365
+ */
366
+ function parseCountTail(parser_state) {
367
+ if (!consumeRequiredWhitespace(parser_state)) {
368
+ return null;
369
+ }
370
+
371
+ const comparison = parseComparison(parser_state);
372
+
373
+ if (!comparison || !consumeRequiredWhitespace(parser_state)) {
374
+ return null;
375
+ }
376
+
377
+ const value = parseInteger(parser_state);
378
+
379
+ return value === null ? null : { comparison, value };
380
+ }
381
+
382
+ /**
383
+ * @param {ParserState} parser_state
384
+ * @returns {{ success: true, traversal: ParsedTraversalTerm } | { diagnostic: PatramDiagnostic, success: false }}
385
+ */
386
+ function parseTraversal(parser_state) {
387
+ const column = parser_state.index + 1;
388
+ const direction = parseIdentifier(parser_state);
389
+
390
+ if (
391
+ (direction !== 'in' && direction !== 'out') ||
392
+ !consumeOperator(parser_state, ':')
393
+ ) {
394
+ return failToken(parser_state);
395
+ }
396
+
397
+ const relation_name = parseIdentifier(parser_state);
398
+
399
+ return relation_name
400
+ ? { success: true, traversal: { column, direction, relation_name } }
401
+ : failToken(parser_state);
402
+ }
403
+
404
+ /**
405
+ * @param {ParserState} parser_state
406
+ * @returns {'in' | 'not in' | null}
407
+ */
408
+ function parseSetOperator(parser_state) {
409
+ if (consumeKeyword(parser_state, 'in')) {
410
+ return 'in';
411
+ }
412
+
413
+ if (
414
+ !consumeKeyword(parser_state, 'not') ||
415
+ !consumeRequiredWhitespace(parser_state)
416
+ ) {
417
+ return null;
418
+ }
419
+
420
+ return consumeKeyword(parser_state, 'in') ? 'not in' : null;
421
+ }
422
+
423
+ /**
424
+ * @param {ParserState} parser_state
425
+ * @param {number} start_index
426
+ * @param {string} field_name
427
+ * @returns {ParseTermResult | null}
428
+ */
429
+ function parsePrefixTerm(parser_state, start_index, field_name) {
430
+ if (!consumeOperator(parser_state, '^=')) {
431
+ return null;
432
+ }
433
+
434
+ skipWhitespace(parser_state);
435
+ const value = parseBareValue(parser_state);
436
+
437
+ return value
438
+ ? success({
439
+ column: start_index + 1,
440
+ field_name,
441
+ kind: 'field',
442
+ operator: '^=',
443
+ value,
444
+ })
445
+ : failToken(parser_state);
446
+ }
447
+
448
+ /**
449
+ * @param {ParserState} parser_state
450
+ * @param {number} start_index
451
+ * @param {string} field_name
452
+ * @returns {ParseTermResult | null}
453
+ */
454
+ function parseContainsTerm(parser_state, start_index, field_name) {
455
+ if (!consumeOperator(parser_state, '~')) {
456
+ return null;
235
457
  }
236
458
 
237
- if (query_term.startsWith('path=')) {
238
- return {
239
- field_name: 'path',
459
+ skipWhitespace(parser_state);
460
+ const value = parseBareValue(parser_state);
461
+
462
+ return value
463
+ ? success({
464
+ column: start_index + 1,
465
+ field_name,
466
+ kind: 'field',
467
+ operator: '~',
468
+ value,
469
+ })
470
+ : failToken(parser_state);
471
+ }
472
+
473
+ /**
474
+ * @param {ParserState} parser_state
475
+ * @param {number} start_index
476
+ * @param {string} field_or_relation_name
477
+ * @returns {ParseTermResult | null}
478
+ */
479
+ function parseEqualityTerm(parser_state, start_index, field_or_relation_name) {
480
+ if (!consumeOperator(parser_state, '=')) {
481
+ return null;
482
+ }
483
+
484
+ skipWhitespace(parser_state);
485
+ const value = parseBareValue(parser_state);
486
+
487
+ if (!value) {
488
+ return failToken(parser_state);
489
+ }
490
+
491
+ if (
492
+ field_or_relation_name.startsWith('$') ||
493
+ field_or_relation_name === 'title' ||
494
+ !value.includes(':')
495
+ ) {
496
+ return success({
497
+ column: start_index + 1,
498
+ field_name: field_or_relation_name,
240
499
  kind: 'field',
241
500
  operator: '=',
242
- value: query_term.slice('path='.length),
243
- };
501
+ value,
502
+ });
244
503
  }
245
504
 
246
- if (query_term.startsWith('path^=')) {
247
- return {
248
- field_name: 'path',
249
- kind: 'field',
250
- operator: '^=',
251
- value: query_term.slice('path^='.length),
252
- };
505
+ return success({
506
+ column: start_index + 1,
507
+ kind: 'relation_target',
508
+ relation_name: field_or_relation_name,
509
+ target_id: value,
510
+ });
511
+ }
512
+
513
+ /**
514
+ * @param {ParserState} parser_state
515
+ * @param {number} start_index
516
+ * @param {string} field_name
517
+ * @returns {ParseTermResult | null}
518
+ */
519
+ function parseFieldComparisonTerm(parser_state, start_index, field_name) {
520
+ const operator = parseFieldComparisonOperator(parser_state);
521
+
522
+ if (!operator) {
523
+ return null;
253
524
  }
254
525
 
255
- if (query_term.startsWith('title~')) {
256
- return {
257
- field_name: 'title',
258
- kind: 'field',
259
- operator: '~',
260
- value: query_term.slice('title~'.length),
261
- };
526
+ skipWhitespace(parser_state);
527
+ const value = parseBareValue(parser_state);
528
+
529
+ if (!value) {
530
+ return failToken(parser_state);
262
531
  }
263
532
 
264
- return null;
533
+ return success({
534
+ column: start_index + 1,
535
+ field_name,
536
+ kind: 'field',
537
+ operator,
538
+ value,
539
+ });
265
540
  }
266
541
 
267
542
  /**
268
- * @param {string} query_term
269
- * @returns {ParsedRelationTargetTerm | null}
543
+ * @param {ParserState} parser_state
544
+ * @returns {'!=' | '<=' | '>=' | '<' | '>' | null}
270
545
  */
271
- function createRelationTargetTerm(query_term) {
272
- const relation_target_match = query_term.match(
273
- /^(?<relation_name>[a-z_]+)=(?<target_id>[^\s=:]+:[^\s]+)$/u,
546
+ function parseFieldComparisonOperator(parser_state) {
547
+ /** @type {Array<'!=' | '<=' | '>=' | '<' | '>'>} */
548
+ const comparisons = ['!=', '<=', '>=', '<', '>'];
549
+
550
+ return (
551
+ comparisons.find((value) => consumeOperator(parser_state, value)) ?? null
274
552
  );
553
+ }
275
554
 
276
- if (!relation_target_match?.groups) {
555
+ /**
556
+ * @param {ParserState} parser_state
557
+ * @returns {string[] | null}
558
+ */
559
+ function parseList(parser_state) {
560
+ if (!consumeOperator(parser_state, '[')) {
277
561
  return null;
278
562
  }
279
563
 
280
- return {
281
- kind: 'relation_target',
282
- relation_name: relation_target_match.groups.relation_name,
283
- target_id: relation_target_match.groups.target_id,
284
- };
564
+ /** @type {string[]} */
565
+ const values = [];
566
+
567
+ while (true) {
568
+ skipWhitespace(parser_state);
569
+
570
+ if (consumeOperator(parser_state, ']')) {
571
+ return values.length > 0 ? values : null;
572
+ }
573
+
574
+ const list_value = parseListValue(parser_state);
575
+
576
+ if (!list_value) {
577
+ return null;
578
+ }
579
+
580
+ values.push(list_value);
581
+ skipWhitespace(parser_state);
582
+
583
+ if (consumeOperator(parser_state, ']')) {
584
+ return values;
585
+ }
586
+
587
+ if (!consumeOperator(parser_state, ',')) {
588
+ return null;
589
+ }
590
+ }
591
+ }
592
+
593
+ /**
594
+ * @param {ParserState} parser_state
595
+ * @returns {ParsedAggregateComparison | null}
596
+ */
597
+ function parseComparison(parser_state) {
598
+ /** @type {ParsedAggregateComparison[]} */
599
+ const comparisons = ['>=', '<=', '!=', '=', '>', '<'];
600
+
601
+ return (
602
+ comparisons.find((value) => consumeOperator(parser_state, value)) ?? null
603
+ );
285
604
  }
286
605
 
287
606
  /**
288
- * @param {number} column_number
607
+ * @param {ParserState} parser_state
608
+ * @returns {string | null}
609
+ */
610
+ function parseIdentifier(parser_state) {
611
+ return readMatch(parser_state, /^\$?[a-z_][a-z0-9_]*/u);
612
+ }
613
+
614
+ /**
615
+ * @param {ParserState} parser_state
616
+ * @returns {number | null}
617
+ */
618
+ function parseInteger(parser_state) {
619
+ const numeric_text = readMatch(parser_state, /^\d+/u);
620
+ return numeric_text ? Number.parseInt(numeric_text, 10) : null;
621
+ }
622
+
623
+ /**
624
+ * @param {ParserState} parser_state
625
+ * @returns {string | null}
626
+ */
627
+ function parseBareValue(parser_state) {
628
+ return readMatch(parser_state, /^[^\s\],)]+/u);
629
+ }
630
+
631
+ /**
632
+ * @param {ParserState} parser_state
633
+ * @returns {string | null}
634
+ */
635
+ function parseListValue(parser_state) {
636
+ const list_value = readMatch(parser_state, /^[^\s\],][^\],)]*/u);
637
+ return list_value?.trim() || null;
638
+ }
639
+
640
+ /**
641
+ * @param {ParserState} parser_state
642
+ * @param {RegExp} pattern
643
+ * @returns {string | null}
644
+ */
645
+ function readMatch(parser_state, pattern) {
646
+ const match = parser_state.where_clause
647
+ .slice(parser_state.index)
648
+ .match(pattern);
649
+
650
+ if (!match) {
651
+ return null;
652
+ }
653
+
654
+ parser_state.index += match[0].length;
655
+ return match[0];
656
+ }
657
+
658
+ /**
659
+ * @param {ParserState} parser_state
660
+ * @param {string} operator
661
+ * @returns {boolean}
662
+ */
663
+ function consumeOperator(parser_state, operator) {
664
+ skipWhitespace(parser_state);
665
+
666
+ if (!parser_state.where_clause.startsWith(operator, parser_state.index)) {
667
+ return false;
668
+ }
669
+
670
+ parser_state.index += operator.length;
671
+ return true;
672
+ }
673
+
674
+ /**
675
+ * @param {ParserState} parser_state
676
+ * @param {string} keyword
677
+ * @returns {boolean}
678
+ */
679
+ function consumeKeyword(parser_state, keyword) {
680
+ if (!parser_state.where_clause.startsWith(keyword, parser_state.index)) {
681
+ return false;
682
+ }
683
+
684
+ const next_character =
685
+ parser_state.where_clause[parser_state.index + keyword.length];
686
+
687
+ if (next_character && /[a-z_]/u.test(next_character)) {
688
+ return false;
689
+ }
690
+
691
+ parser_state.index += keyword.length;
692
+ return true;
693
+ }
694
+
695
+ /**
696
+ * @param {ParserState} parser_state
697
+ * @returns {boolean}
698
+ */
699
+ function consumeRequiredWhitespace(parser_state) {
700
+ return skipWhitespace(parser_state) > 0;
701
+ }
702
+
703
+ /**
704
+ * @param {ParserState} parser_state
705
+ * @returns {number}
706
+ */
707
+ function skipWhitespace(parser_state) {
708
+ const whitespace = readMatch(parser_state, /^\s+/u);
709
+ return whitespace?.length ?? 0;
710
+ }
711
+
712
+ /**
713
+ * @param {ParserState} parser_state
714
+ * @returns {string | undefined}
715
+ */
716
+ function currentCharacter(parser_state) {
717
+ return parser_state.where_clause[parser_state.index];
718
+ }
719
+
720
+ /**
721
+ * @param {ParserState} parser_state
722
+ * @returns {boolean}
723
+ */
724
+ function isAtEnd(parser_state) {
725
+ return parser_state.index >= parser_state.where_clause.length;
726
+ }
727
+
728
+ /**
729
+ * @param {ParsedTerm} term
730
+ * @returns {ParseTermResult}
731
+ */
732
+ function success(term) {
733
+ return { success: true, term };
734
+ }
735
+
736
+ /**
737
+ * @param {ParserState} parser_state
738
+ * @returns {{ diagnostic: PatramDiagnostic, success: false }}
739
+ */
740
+ function failToken(parser_state) {
741
+ return fail(
742
+ parser_state.index + 1,
743
+ `Unsupported query token "${readToken(parser_state, parser_state.index)}".`,
744
+ );
745
+ }
746
+
747
+ /**
748
+ * @param {number} column
289
749
  * @param {string} message
290
- * @returns {PatramDiagnostic}
750
+ * @returns {{ diagnostic: PatramDiagnostic, success: false }}
291
751
  */
292
- function createQueryDiagnostic(column_number, message) {
752
+ function fail(column, message) {
293
753
  return {
294
- code: 'query.invalid',
295
- column: column_number,
296
- level: 'error',
297
- line: 1,
298
- message,
299
- path: '<query>',
754
+ diagnostic: {
755
+ code: 'query.invalid',
756
+ column,
757
+ level: 'error',
758
+ line: 1,
759
+ message,
760
+ path: '<query>',
761
+ },
762
+ success: false,
300
763
  };
301
764
  }
302
765
 
303
766
  /**
304
- * @param {string} where_clause
305
- * @returns {{ value: string, column: number }[]}
767
+ * @param {ParserState} parser_state
768
+ * @param {number} start_index
769
+ * @returns {string}
306
770
  */
307
- function tokenizeWhereClause(where_clause) {
308
- return [...where_clause.matchAll(/\S+/gu)].map((token_match) => ({
309
- column: (token_match.index ?? 0) + 1,
310
- value: token_match[0],
311
- }));
771
+ function readToken(parser_state, start_index) {
772
+ return parser_state.where_clause.slice(start_index).match(/^\S+/u)?.[0] ?? '';
312
773
  }