patram 0.1.1 → 0.2.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 (35) hide show
  1. package/lib/build-graph-identity.js +39 -7
  2. package/lib/build-graph.js +14 -1
  3. package/lib/cli-help-metadata.js +552 -0
  4. package/lib/derived-summary.js +278 -0
  5. package/lib/format-derived-summary-row.js +9 -0
  6. package/lib/format-node-header.js +19 -0
  7. package/lib/format-output-item-block.js +22 -0
  8. package/lib/format-output-metadata.js +62 -0
  9. package/lib/layout-stored-queries.js +150 -2
  10. package/lib/load-patram-config.js +401 -2
  11. package/lib/load-patram-config.types.ts +31 -0
  12. package/lib/output-view.types.ts +15 -0
  13. package/lib/parse-cli-arguments-helpers.js +263 -90
  14. package/lib/parse-cli-arguments.js +160 -8
  15. package/lib/parse-cli-arguments.types.ts +48 -3
  16. package/lib/parse-where-clause.js +604 -209
  17. package/lib/parse-where-clause.types.ts +70 -0
  18. package/lib/patram-cli.js +144 -17
  19. package/lib/patram.js +6 -0
  20. package/lib/query-graph.js +231 -119
  21. package/lib/query-inspection.js +523 -0
  22. package/lib/render-check-output.js +1 -1
  23. package/lib/render-cli-help.js +419 -0
  24. package/lib/render-json-output.js +57 -4
  25. package/lib/render-output-view.js +37 -8
  26. package/lib/render-plain-output.js +31 -86
  27. package/lib/render-rich-output.js +34 -87
  28. package/lib/resolve-where-clause.js +18 -3
  29. package/lib/tagged-fenced-block-error.js +17 -0
  30. package/lib/tagged-fenced-block-markdown.js +111 -0
  31. package/lib/tagged-fenced-block-metadata.js +97 -0
  32. package/lib/tagged-fenced-block-parser.js +292 -0
  33. package/lib/tagged-fenced-blocks.js +100 -0
  34. package/lib/tagged-fenced-blocks.types.ts +38 -0
  35. package/package.json +8 -3
@@ -1,312 +1,707 @@
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
17
+ * @typedef {{ index: number, where_clause: string }} ParserState
27
18
  */
28
19
 
29
20
  /**
30
21
  * @typedef {{
31
- * is_negated: boolean,
32
- * term: ParsedFieldTerm | ParsedRelationTerm | ParsedRelationTargetTerm,
33
- * }} ParsedClause
34
- */
35
-
36
- /**
37
- * @typedef {{
38
- * clause: ParsedClause,
39
22
  * success: true,
23
+ * term: ParsedTerm,
40
24
  * } | {
41
25
  * diagnostic: PatramDiagnostic,
42
26
  * success: false,
43
- * }} CreateClauseResult
27
+ * }} ParseTermResult
44
28
  */
45
29
 
46
30
  /**
47
- * @typedef {{
48
- * clauses: ParsedClause[],
49
- * success: true,
50
- * } | {
51
- * diagnostic: PatramDiagnostic,
52
- * success: false,
53
- * }} ParseWhereClauseResult
54
- */
55
-
56
- /**
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);
41
+
42
+ if (isAtEnd(parser_state)) {
43
+ return fail(1, 'Query must not be empty.');
44
+ }
45
+
46
+ const clauses_result = parseClauses(parser_state, null);
64
47
 
65
- if (tokens.length === 0) {
66
- return {
67
- diagnostic: createQueryDiagnostic(1, 'Query must not be empty.'),
68
- success: false,
69
- };
48
+ if (!clauses_result.success) {
49
+ return clauses_result;
70
50
  }
71
51
 
72
- return parseTokens(tokens, where_clause);
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;
112
+ }
113
+ }
114
+
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
+ );
126
128
  }
127
129
 
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
- };
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
+ */
149
+ function parseTerm(parser_state) {
150
+ return parseAggregate(parser_state) ?? parseAtomicTerm(parser_state);
151
+ }
152
+
153
+ /**
154
+ * @param {ParserState} parser_state
155
+ * @returns {ParseTermResult | null}
148
156
  */
149
- function createClause(token, is_negated) {
150
- const field_term = createFieldTerm(token.value);
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;
168
+ }
151
169
 
152
- if (field_term) {
153
- return {
154
- clause: {
155
- is_negated,
156
- term: field_term,
157
- },
158
- success: true,
159
- };
170
+ skipWhitespace(parser_state);
171
+
172
+ if (currentCharacter(parser_state) !== '(') {
173
+ parser_state.index = start_index;
174
+ return null;
160
175
  }
161
176
 
162
- const relation_target_term = createRelationTargetTerm(token.value);
177
+ parser_state.index += 1;
178
+ skipWhitespace(parser_state);
163
179
 
164
- if (relation_target_term) {
165
- return {
166
- clause: {
167
- is_negated,
168
- term: relation_target_term,
169
- },
170
- success: true,
171
- };
180
+ const traversal_result = parseTraversal(parser_state);
181
+
182
+ if (!traversal_result.success) {
183
+ return traversal_result;
172
184
  }
173
185
 
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
- };
186
+ if (!consumeOperator(parser_state, ',')) {
187
+ return failToken(parser_state);
185
188
  }
186
189
 
187
- return {
188
- diagnostic: createQueryDiagnostic(
189
- token.column,
190
- `Unsupported query token "${token.value}".`,
191
- ),
192
- success: false,
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 (
222
+ parseFieldSet(parser_state, field_or_relation_name) ??
223
+ parseOperatorTerm(parser_state, start_index, field_or_relation_name)
224
+ );
225
+ }
226
+
227
+ /**
228
+ * @param {ParserState} parser_state
229
+ * @param {ParsedAggregateName} aggregate_name
230
+ * @param {ParsedTraversalTerm} traversal
231
+ * @param {ParsedClause[]} clauses
232
+ * @returns {ParseTermResult}
233
+ */
234
+ function createAggregateTerm(parser_state, aggregate_name, traversal, clauses) {
235
+ /** @type {ParsedAggregateTerm} */
236
+ const aggregate_term = {
237
+ aggregate_name,
238
+ clauses,
239
+ kind: 'aggregate',
240
+ traversal,
193
241
  };
242
+
243
+ if (aggregate_name !== 'count') {
244
+ return success(aggregate_term);
245
+ }
246
+
247
+ const count_tail = parseCountTail(parser_state);
248
+
249
+ if (!count_tail) {
250
+ return failToken(parser_state);
251
+ }
252
+
253
+ return success({
254
+ ...aggregate_term,
255
+ comparison: count_tail.comparison,
256
+ value: count_tail.value,
257
+ });
194
258
  }
195
259
 
196
260
  /**
197
- * @param {string} query_term
198
- * @returns {ParsedFieldTerm | null}
261
+ * @param {ParserState} parser_state
262
+ * @param {ParsedFieldName | string} field_name
263
+ * @returns {ParseTermResult | null}
199
264
  */
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
- };
265
+ function parseFieldSet(parser_state, field_name) {
266
+ if (!isSupportedFieldName(field_name)) {
267
+ return null;
208
268
  }
209
269
 
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
- };
270
+ const start_index = parser_state.index;
271
+
272
+ if (!consumeRequiredWhitespace(parser_state)) {
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
+ const operator = parseSetOperator(parser_state);
277
+
278
+ if (!operator) {
279
+ parser_state.index = start_index;
280
+ return null;
226
281
  }
227
282
 
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
- };
283
+ if (!consumeRequiredWhitespace(parser_state)) {
284
+ return failToken(parser_state);
285
+ }
286
+
287
+ const values = parseList(parser_state);
288
+
289
+ return values
290
+ ? success({ field_name, kind: 'field_set', operator, values })
291
+ : failToken(parser_state);
292
+ }
293
+
294
+ /**
295
+ * @param {ParserState} parser_state
296
+ * @param {number} start_index
297
+ * @param {string} field_or_relation_name
298
+ * @returns {ParseTermResult}
299
+ */
300
+ function parseOperatorTerm(parser_state, start_index, field_or_relation_name) {
301
+ const prefix_term = parsePrefixTerm(parser_state, field_or_relation_name);
302
+
303
+ if (prefix_term) {
304
+ return prefix_term;
305
+ }
306
+
307
+ const contains_term = parseContainsTerm(parser_state, field_or_relation_name);
308
+
309
+ if (contains_term) {
310
+ return contains_term;
311
+ }
312
+
313
+ const equality_term = parseEqualityTerm(
314
+ parser_state,
315
+ start_index,
316
+ field_or_relation_name,
317
+ );
318
+
319
+ if (equality_term) {
320
+ return equality_term;
321
+ }
322
+
323
+ if (consumeOperator(parser_state, ':*')) {
324
+ return success({
325
+ column: start_index + 1,
326
+ kind: 'relation',
327
+ relation_name: field_or_relation_name,
328
+ });
329
+ }
330
+
331
+ parser_state.index = start_index;
332
+ return failToken(parser_state);
333
+ }
334
+
335
+ /**
336
+ * @param {ParserState} parser_state
337
+ * @returns {{ comparison: ParsedAggregateComparison, value: number } | null}
338
+ */
339
+ function parseCountTail(parser_state) {
340
+ if (!consumeRequiredWhitespace(parser_state)) {
341
+ return null;
342
+ }
343
+
344
+ const comparison = parseComparison(parser_state);
345
+
346
+ if (!comparison || !consumeRequiredWhitespace(parser_state)) {
347
+ return null;
348
+ }
349
+
350
+ const value = parseInteger(parser_state);
351
+
352
+ return value === null ? null : { comparison, value };
353
+ }
354
+
355
+ /**
356
+ * @param {ParserState} parser_state
357
+ * @returns {{ success: true, traversal: ParsedTraversalTerm } | { diagnostic: PatramDiagnostic, success: false }}
358
+ */
359
+ function parseTraversal(parser_state) {
360
+ const column = parser_state.index + 1;
361
+ const direction = parseIdentifier(parser_state);
362
+
363
+ if (
364
+ (direction !== 'in' && direction !== 'out') ||
365
+ !consumeOperator(parser_state, ':')
366
+ ) {
367
+ return failToken(parser_state);
368
+ }
369
+
370
+ const relation_name = parseIdentifier(parser_state);
371
+
372
+ return relation_name
373
+ ? { success: true, traversal: { column, direction, relation_name } }
374
+ : failToken(parser_state);
375
+ }
376
+
377
+ /**
378
+ * @param {ParserState} parser_state
379
+ * @returns {'in' | 'not in' | null}
380
+ */
381
+ function parseSetOperator(parser_state) {
382
+ if (consumeKeyword(parser_state, 'in')) {
383
+ return 'in';
235
384
  }
236
385
 
237
- if (query_term.startsWith('path=')) {
238
- return {
239
- field_name: 'path',
386
+ if (
387
+ !consumeKeyword(parser_state, 'not') ||
388
+ !consumeRequiredWhitespace(parser_state)
389
+ ) {
390
+ return null;
391
+ }
392
+
393
+ return consumeKeyword(parser_state, 'in') ? 'not in' : null;
394
+ }
395
+
396
+ /**
397
+ * @param {ParserState} parser_state
398
+ * @param {string} field_name
399
+ * @returns {ParseTermResult | null}
400
+ */
401
+ function parsePrefixTerm(parser_state, field_name) {
402
+ if (field_name !== 'id' && field_name !== 'path') {
403
+ return null;
404
+ }
405
+
406
+ if (!consumeOperator(parser_state, '^=')) {
407
+ return null;
408
+ }
409
+
410
+ const value = parseBareValue(parser_state);
411
+
412
+ return value
413
+ ? success({ field_name, kind: 'field', operator: '^=', value })
414
+ : failToken(parser_state);
415
+ }
416
+
417
+ /**
418
+ * @param {ParserState} parser_state
419
+ * @param {string} field_name
420
+ * @returns {ParseTermResult | null}
421
+ */
422
+ function parseContainsTerm(parser_state, field_name) {
423
+ if (field_name !== 'title' || !consumeOperator(parser_state, '~')) {
424
+ return null;
425
+ }
426
+
427
+ const value = parseBareValue(parser_state);
428
+
429
+ return value
430
+ ? success({ field_name: 'title', kind: 'field', operator: '~', value })
431
+ : failToken(parser_state);
432
+ }
433
+
434
+ /**
435
+ * @param {ParserState} parser_state
436
+ * @param {number} start_index
437
+ * @param {string} field_or_relation_name
438
+ * @returns {ParseTermResult | null}
439
+ */
440
+ function parseEqualityTerm(parser_state, start_index, field_or_relation_name) {
441
+ if (!consumeOperator(parser_state, '=')) {
442
+ return null;
443
+ }
444
+
445
+ const value = parseBareValue(parser_state);
446
+
447
+ if (!value) {
448
+ return failToken(parser_state);
449
+ }
450
+
451
+ if (isExactMatchField(field_or_relation_name)) {
452
+ return success({
453
+ field_name: field_or_relation_name,
240
454
  kind: 'field',
241
455
  operator: '=',
242
- value: query_term.slice('path='.length),
243
- };
456
+ value,
457
+ });
244
458
  }
245
459
 
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
- };
460
+ if (value.includes(':')) {
461
+ return success({
462
+ column: start_index + 1,
463
+ kind: 'relation_target',
464
+ relation_name: field_or_relation_name,
465
+ target_id: value,
466
+ });
253
467
  }
254
468
 
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
- };
469
+ parser_state.index = start_index;
470
+ return failToken(parser_state);
471
+ }
472
+
473
+ /**
474
+ * @param {ParserState} parser_state
475
+ * @returns {string[] | null}
476
+ */
477
+ function parseList(parser_state) {
478
+ if (!consumeOperator(parser_state, '[')) {
479
+ return null;
262
480
  }
263
481
 
264
- return null;
482
+ /** @type {string[]} */
483
+ const values = [];
484
+
485
+ while (true) {
486
+ skipWhitespace(parser_state);
487
+
488
+ if (consumeOperator(parser_state, ']')) {
489
+ return values.length > 0 ? values : null;
490
+ }
491
+
492
+ const list_value = parseListValue(parser_state);
493
+
494
+ if (!list_value) {
495
+ return null;
496
+ }
497
+
498
+ values.push(list_value);
499
+ skipWhitespace(parser_state);
500
+
501
+ if (consumeOperator(parser_state, ']')) {
502
+ return values;
503
+ }
504
+
505
+ if (!consumeOperator(parser_state, ',')) {
506
+ return null;
507
+ }
508
+ }
265
509
  }
266
510
 
267
511
  /**
268
- * @param {string} query_term
269
- * @returns {ParsedRelationTargetTerm | null}
512
+ * @param {ParserState} parser_state
513
+ * @returns {ParsedAggregateComparison | null}
270
514
  */
271
- function createRelationTargetTerm(query_term) {
272
- const relation_target_match = query_term.match(
273
- /^(?<relation_name>[a-z_]+)=(?<target_id>[^\s=:]+:[^\s]+)$/u,
515
+ function parseComparison(parser_state) {
516
+ /** @type {ParsedAggregateComparison[]} */
517
+ const comparisons = ['>=', '<=', '!=', '=', '>', '<'];
518
+
519
+ return (
520
+ comparisons.find((value) => consumeOperator(parser_state, value)) ?? null
274
521
  );
522
+ }
523
+
524
+ /**
525
+ * @param {ParserState} parser_state
526
+ * @returns {string | null}
527
+ */
528
+ function parseIdentifier(parser_state) {
529
+ return readMatch(parser_state, /^[a-z_]+/u);
530
+ }
531
+
532
+ /**
533
+ * @param {ParserState} parser_state
534
+ * @returns {number | null}
535
+ */
536
+ function parseInteger(parser_state) {
537
+ const numeric_text = readMatch(parser_state, /^\d+/u);
538
+ return numeric_text ? Number.parseInt(numeric_text, 10) : null;
539
+ }
540
+
541
+ /**
542
+ * @param {ParserState} parser_state
543
+ * @returns {string | null}
544
+ */
545
+ function parseBareValue(parser_state) {
546
+ return readMatch(parser_state, /^[^\s\],)]+/u);
547
+ }
548
+
549
+ /**
550
+ * @param {ParserState} parser_state
551
+ * @returns {string | null}
552
+ */
553
+ function parseListValue(parser_state) {
554
+ const list_value = readMatch(parser_state, /^[^\s\],][^\],)]*/u);
555
+ return list_value?.trim() || null;
556
+ }
275
557
 
276
- if (!relation_target_match?.groups) {
558
+ /**
559
+ * @param {ParserState} parser_state
560
+ * @param {RegExp} pattern
561
+ * @returns {string | null}
562
+ */
563
+ function readMatch(parser_state, pattern) {
564
+ const match = parser_state.where_clause
565
+ .slice(parser_state.index)
566
+ .match(pattern);
567
+
568
+ if (!match) {
277
569
  return null;
278
570
  }
279
571
 
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
- };
572
+ parser_state.index += match[0].length;
573
+ return match[0];
285
574
  }
286
575
 
287
576
  /**
288
- * @param {number} column_number
577
+ * @param {ParserState} parser_state
578
+ * @param {string} operator
579
+ * @returns {boolean}
580
+ */
581
+ function consumeOperator(parser_state, operator) {
582
+ skipWhitespace(parser_state);
583
+
584
+ if (!parser_state.where_clause.startsWith(operator, parser_state.index)) {
585
+ return false;
586
+ }
587
+
588
+ parser_state.index += operator.length;
589
+ return true;
590
+ }
591
+
592
+ /**
593
+ * @param {ParserState} parser_state
594
+ * @param {string} keyword
595
+ * @returns {boolean}
596
+ */
597
+ function consumeKeyword(parser_state, keyword) {
598
+ if (!parser_state.where_clause.startsWith(keyword, parser_state.index)) {
599
+ return false;
600
+ }
601
+
602
+ const next_character =
603
+ parser_state.where_clause[parser_state.index + keyword.length];
604
+
605
+ if (next_character && /[a-z_]/u.test(next_character)) {
606
+ return false;
607
+ }
608
+
609
+ parser_state.index += keyword.length;
610
+ return true;
611
+ }
612
+
613
+ /**
614
+ * @param {ParserState} parser_state
615
+ * @returns {boolean}
616
+ */
617
+ function consumeRequiredWhitespace(parser_state) {
618
+ return skipWhitespace(parser_state) > 0;
619
+ }
620
+
621
+ /**
622
+ * @param {ParserState} parser_state
623
+ * @returns {number}
624
+ */
625
+ function skipWhitespace(parser_state) {
626
+ const whitespace = readMatch(parser_state, /^\s+/u);
627
+ return whitespace?.length ?? 0;
628
+ }
629
+
630
+ /**
631
+ * @param {ParserState} parser_state
632
+ * @returns {string | undefined}
633
+ */
634
+ function currentCharacter(parser_state) {
635
+ return parser_state.where_clause[parser_state.index];
636
+ }
637
+
638
+ /**
639
+ * @param {ParserState} parser_state
640
+ * @returns {boolean}
641
+ */
642
+ function isAtEnd(parser_state) {
643
+ return parser_state.index >= parser_state.where_clause.length;
644
+ }
645
+
646
+ /**
647
+ * @param {string} field_name
648
+ * @returns {field_name is ParsedFieldName}
649
+ */
650
+ function isSupportedFieldName(field_name) {
651
+ return ['id', 'kind', 'path', 'status', 'title'].includes(field_name);
652
+ }
653
+
654
+ /**
655
+ * @param {string} field_name
656
+ * @returns {field_name is 'id' | 'kind' | 'path' | 'status'}
657
+ */
658
+ function isExactMatchField(field_name) {
659
+ return ['id', 'kind', 'path', 'status'].includes(field_name);
660
+ }
661
+
662
+ /**
663
+ * @param {ParsedTerm} term
664
+ * @returns {ParseTermResult}
665
+ */
666
+ function success(term) {
667
+ return { success: true, term };
668
+ }
669
+
670
+ /**
671
+ * @param {ParserState} parser_state
672
+ * @returns {{ diagnostic: PatramDiagnostic, success: false }}
673
+ */
674
+ function failToken(parser_state) {
675
+ return fail(
676
+ parser_state.index + 1,
677
+ `Unsupported query token "${readToken(parser_state, parser_state.index)}".`,
678
+ );
679
+ }
680
+
681
+ /**
682
+ * @param {number} column
289
683
  * @param {string} message
290
- * @returns {PatramDiagnostic}
684
+ * @returns {{ diagnostic: PatramDiagnostic, success: false }}
291
685
  */
292
- function createQueryDiagnostic(column_number, message) {
686
+ function fail(column, message) {
293
687
  return {
294
- code: 'query.invalid',
295
- column: column_number,
296
- level: 'error',
297
- line: 1,
298
- message,
299
- path: '<query>',
688
+ diagnostic: {
689
+ code: 'query.invalid',
690
+ column,
691
+ level: 'error',
692
+ line: 1,
693
+ message,
694
+ path: '<query>',
695
+ },
696
+ success: false,
300
697
  };
301
698
  }
302
699
 
303
700
  /**
304
- * @param {string} where_clause
305
- * @returns {{ value: string, column: number }[]}
701
+ * @param {ParserState} parser_state
702
+ * @param {number} start_index
703
+ * @returns {string}
306
704
  */
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
- }));
705
+ function readToken(parser_state, start_index) {
706
+ return parser_state.where_clause.slice(start_index).match(/^\S+/u)?.[0] ?? '';
312
707
  }