@dxos/echo-query 0.8.4-main.a4bbb77 → 0.8.4-main.abd8ff62ef
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/lib/neutral/index.mjs +917 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/query-lite/index.d.ts +9975 -0
- package/dist/query-lite/index.d.ts.map +1 -0
- package/dist/query-lite/index.js +548 -0
- package/dist/query-lite/index.js.map +1 -0
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/parser/gen/index.d.ts.map +1 -1
- package/dist/types/src/parser/gen/query.terms.d.ts +1 -1
- package/dist/types/src/parser/gen/query.terms.d.ts.map +1 -1
- package/dist/types/src/parser/query-builder.d.ts +21 -5
- package/dist/types/src/parser/query-builder.d.ts.map +1 -1
- package/dist/types/src/query-lite/index.d.ts +2 -0
- package/dist/types/src/query-lite/index.d.ts.map +1 -0
- package/dist/types/src/query-lite/query-lite.d.ts +8 -0
- package/dist/types/src/query-lite/query-lite.d.ts.map +1 -0
- package/dist/types/src/sandbox/index.d.ts +2 -0
- package/dist/types/src/sandbox/index.d.ts.map +1 -0
- package/dist/types/src/sandbox/query-sandbox.d.ts +21 -0
- package/dist/types/src/sandbox/query-sandbox.d.ts.map +1 -0
- package/dist/types/src/sandbox/query-sandbox.test.d.ts +2 -0
- package/dist/types/src/sandbox/query-sandbox.test.d.ts.map +1 -0
- package/dist/types/src/sandbox/quickjs.d.ts +8 -0
- package/dist/types/src/sandbox/quickjs.d.ts.map +1 -0
- package/dist/types/src/sandbox/quickjs.test.d.ts +2 -0
- package/dist/types/src/sandbox/quickjs.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +25 -14
- package/src/env.d.ts +8 -0
- package/src/index.ts +1 -0
- package/src/parser/gen/query.terms.ts +24 -23
- package/src/parser/gen/query.ts +8 -8
- package/src/parser/query-builder.ts +359 -30
- package/src/parser/query.grammar +8 -2
- package/src/parser/query.test.ts +212 -43
- package/src/query-lite/index.ts +5 -0
- package/src/query-lite/query-lite.ts +744 -0
- package/src/sandbox/index.ts +5 -0
- package/src/sandbox/query-sandbox.test.ts +53 -0
- package/src/sandbox/query-sandbox.ts +72 -0
- package/src/sandbox/quickjs.test.ts +67 -0
- package/src/sandbox/quickjs.ts +33 -0
- package/dist/lib/browser/index.mjs +0 -505
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/node-esm/index.mjs +0 -505
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/types/src/search.test.d.ts +0 -2
- package/dist/types/src/search.test.d.ts.map +0 -1
- package/src/search.test.ts +0 -49
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import { type Parser, type Tree, type TreeCursor } from '@lezer/common';
|
|
6
6
|
|
|
7
|
-
import { Filter } from '@dxos/echo';
|
|
7
|
+
import { Filter, type Tag } from '@dxos/echo';
|
|
8
|
+
import { invariant } from '@dxos/invariant';
|
|
8
9
|
|
|
9
10
|
import { QueryDSL } from './gen';
|
|
10
11
|
|
|
12
|
+
// TODO(burdon): Return Query AST.
|
|
13
|
+
export type BuildResult = { filter?: Filter.Any; name?: string };
|
|
14
|
+
|
|
11
15
|
/**
|
|
12
16
|
* Stateless query builder that parses DSL trees into filters.
|
|
13
17
|
*
|
|
@@ -15,14 +19,16 @@ import { QueryDSL } from './gen';
|
|
|
15
19
|
* To modify the functionality, create a minimal breaking test and direct the LLM to fix either the grammar or builder.
|
|
16
20
|
*/
|
|
17
21
|
export class QueryBuilder {
|
|
18
|
-
|
|
22
|
+
private readonly _parser: Parser = QueryDSL.Parser.configure({ strict: true });
|
|
23
|
+
|
|
24
|
+
constructor(private readonly _tags?: Tag.Map) {}
|
|
19
25
|
|
|
20
26
|
/**
|
|
21
27
|
* Check valid input.
|
|
22
28
|
*/
|
|
23
29
|
validate(input: string): boolean {
|
|
24
30
|
try {
|
|
25
|
-
const tree = this._parser.parse(input);
|
|
31
|
+
const tree = this._parser.parse(normalizeInput(input));
|
|
26
32
|
return tree.cursor().node.name === 'Query';
|
|
27
33
|
} catch {
|
|
28
34
|
return false;
|
|
@@ -32,24 +38,25 @@ export class QueryBuilder {
|
|
|
32
38
|
/**
|
|
33
39
|
* Build a query from the input string.
|
|
34
40
|
*/
|
|
35
|
-
build(input: string):
|
|
41
|
+
build(input: string): BuildResult {
|
|
36
42
|
try {
|
|
37
|
-
const
|
|
38
|
-
|
|
43
|
+
const normalized = normalizeInput(input);
|
|
44
|
+
const tree = this._parser.parse(normalized);
|
|
45
|
+
return this.buildQuery(tree, normalized);
|
|
39
46
|
} catch {
|
|
40
|
-
return
|
|
47
|
+
return {};
|
|
41
48
|
}
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
/**
|
|
45
52
|
* Build a query from a parsed DSL tree.
|
|
46
53
|
*/
|
|
47
|
-
buildQuery(tree: Tree, input: string):
|
|
54
|
+
buildQuery(tree: Tree, input: string): BuildResult {
|
|
48
55
|
const cursor = tree.cursor();
|
|
49
56
|
|
|
50
57
|
// Start at root (Query node).
|
|
51
58
|
if (cursor.node.name !== 'Query') {
|
|
52
|
-
return
|
|
59
|
+
return {};
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
// Check if Query has multiple children (binary expression).
|
|
@@ -61,26 +68,76 @@ export class QueryBuilder {
|
|
|
61
68
|
cursor.parent();
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
// Check if this is an assignment.
|
|
72
|
+
const hasAssignment = children.some((child) => child.name === 'Assignment');
|
|
73
|
+
if (hasAssignment) {
|
|
74
|
+
return this._parseAssignment(cursor, input);
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
// If we have an operator in the children, or multiple expressions (implicit AND), parse as binary expression.
|
|
65
78
|
const hasOperator = children.some((child) => child.name === 'And' || child.name === 'Or');
|
|
66
79
|
const hasMultipleExpressions =
|
|
67
80
|
children.filter((child) => child.name === 'Filter' || child.name === 'Not' || child.name === '(').length > 1;
|
|
68
81
|
if (hasOperator || hasMultipleExpressions) {
|
|
69
|
-
|
|
82
|
+
const filter = this._parseBinaryExpression(cursor, input);
|
|
83
|
+
return { filter };
|
|
70
84
|
}
|
|
71
85
|
|
|
72
86
|
// Otherwise, parse the single expression.
|
|
73
87
|
if (!cursor.firstChild()) {
|
|
74
|
-
return Filter.nothing();
|
|
88
|
+
return { filter: Filter.nothing() };
|
|
75
89
|
}
|
|
76
90
|
|
|
77
|
-
|
|
91
|
+
const filter = this._parseExpression(cursor, input);
|
|
92
|
+
return { filter };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse an assignment node.
|
|
97
|
+
*/
|
|
98
|
+
private _parseAssignment(cursor: TreeCursor, input: string): BuildResult {
|
|
99
|
+
if (!cursor.firstChild()) {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let name: string | undefined;
|
|
104
|
+
let filter: Filter.Any | undefined;
|
|
105
|
+
|
|
106
|
+
// Find the Assignment node
|
|
107
|
+
do {
|
|
108
|
+
if (cursor.node.name === 'Assignment') {
|
|
109
|
+
// Get the full assignment text first
|
|
110
|
+
const assignmentText = this._getNodeText(cursor, input);
|
|
111
|
+
|
|
112
|
+
if (cursor.firstChild()) {
|
|
113
|
+
// First child should be the variable name (Identifier)
|
|
114
|
+
name = this._getNodeText(cursor, input);
|
|
115
|
+
|
|
116
|
+
// Find the parentheses in the assignment text and extract the content
|
|
117
|
+
const openParenIndex = assignmentText.indexOf('(');
|
|
118
|
+
const closeParenIndex = assignmentText.lastIndexOf(')');
|
|
119
|
+
|
|
120
|
+
if (openParenIndex !== -1 && closeParenIndex !== -1 && closeParenIndex > openParenIndex) {
|
|
121
|
+
const subInput = assignmentText.slice(openParenIndex + 1, closeParenIndex).trim();
|
|
122
|
+
const subTree = this._parser.parse(subInput);
|
|
123
|
+
const subResult = this.buildQuery(subTree, subInput);
|
|
124
|
+
filter = subResult.filter;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
cursor.parent(); // Back to Assignment
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
} while (cursor.nextSibling());
|
|
132
|
+
|
|
133
|
+
cursor.parent(); // Back to Query
|
|
134
|
+
return { filter, name };
|
|
78
135
|
}
|
|
79
136
|
|
|
80
137
|
/**
|
|
81
138
|
* Parse an expression node.
|
|
82
139
|
*/
|
|
83
|
-
private _parseExpression(cursor: TreeCursor, input: string): Filter.Any {
|
|
140
|
+
private _parseExpression(cursor: TreeCursor, input: string): Filter.Any | undefined {
|
|
84
141
|
const nodeName = cursor.node.name;
|
|
85
142
|
|
|
86
143
|
switch (nodeName) {
|
|
@@ -91,7 +148,7 @@ export class QueryBuilder {
|
|
|
91
148
|
// Move past NOT token to the expression.
|
|
92
149
|
cursor.nextSibling();
|
|
93
150
|
const notFilter = this._parseExpression(cursor, input);
|
|
94
|
-
return Filter.not(notFilter);
|
|
151
|
+
return notFilter ? Filter.not(notFilter) : undefined;
|
|
95
152
|
}
|
|
96
153
|
|
|
97
154
|
case 'And':
|
|
@@ -189,8 +246,9 @@ export class QueryBuilder {
|
|
|
189
246
|
let exprEnd = cursor.to;
|
|
190
247
|
|
|
191
248
|
while (cursor.nextSibling() && depth > 0) {
|
|
192
|
-
if (cursor.node.name === '(')
|
|
193
|
-
|
|
249
|
+
if (cursor.node.name === '(') {
|
|
250
|
+
depth++;
|
|
251
|
+
} else if (cursor.node.name === ')') {
|
|
194
252
|
depth--;
|
|
195
253
|
if (depth === 0) {
|
|
196
254
|
exprEnd = cursor.from;
|
|
@@ -201,15 +259,25 @@ export class QueryBuilder {
|
|
|
201
259
|
// Parse the expression inside parentheses as a subtree.
|
|
202
260
|
const subInput = input.slice(exprStart, exprEnd);
|
|
203
261
|
const subTree = this._parser.parse(subInput);
|
|
204
|
-
|
|
262
|
+
const subResult = this.buildQuery(subTree, subInput);
|
|
263
|
+
if (subResult.filter) {
|
|
264
|
+
filters.push(subResult.filter);
|
|
265
|
+
}
|
|
205
266
|
} else {
|
|
206
267
|
// Simple parenthesized expression.
|
|
207
|
-
|
|
268
|
+
const subFilter = this._parseExpression(cursor, input);
|
|
269
|
+
if (subFilter) {
|
|
270
|
+
filters.push(subFilter);
|
|
271
|
+
}
|
|
272
|
+
|
|
208
273
|
// Skip until we find the closing parenthesis.
|
|
209
274
|
while (cursor.nextSibling() && cursor.node.name !== ')') {}
|
|
210
275
|
}
|
|
211
276
|
} else if (nodeName !== ')') {
|
|
212
|
-
|
|
277
|
+
const subFilter = this._parseExpression(cursor, input);
|
|
278
|
+
if (subFilter) {
|
|
279
|
+
filters.push(subFilter);
|
|
280
|
+
}
|
|
213
281
|
}
|
|
214
282
|
} while (cursor.nextSibling());
|
|
215
283
|
|
|
@@ -230,17 +298,18 @@ export class QueryBuilder {
|
|
|
230
298
|
/**
|
|
231
299
|
* Parse a Filter node.
|
|
232
300
|
*/
|
|
233
|
-
private _parseFilter(cursor: TreeCursor, input: string): Filter.Any {
|
|
301
|
+
private _parseFilter(cursor: TreeCursor, input: string): Filter.Any | undefined {
|
|
234
302
|
if (!cursor.firstChild()) {
|
|
235
303
|
return Filter.nothing();
|
|
236
304
|
}
|
|
237
305
|
|
|
306
|
+
let result: Filter.Any | undefined = undefined;
|
|
238
307
|
const filterType = cursor.node.name;
|
|
239
|
-
let result: Filter.Any;
|
|
240
|
-
|
|
241
308
|
switch (filterType) {
|
|
242
309
|
case 'TagFilter':
|
|
243
|
-
|
|
310
|
+
if (this._tags) {
|
|
311
|
+
result = this._parseTagFilter(cursor, input);
|
|
312
|
+
}
|
|
244
313
|
break;
|
|
245
314
|
|
|
246
315
|
case 'TextFilter':
|
|
@@ -288,8 +357,8 @@ export class QueryBuilder {
|
|
|
288
357
|
cursor.firstChild(); // Move to String node.
|
|
289
358
|
const text = this._getNodeText(cursor, input);
|
|
290
359
|
cursor.parent(); // Go back to TextFilter.
|
|
291
|
-
// Remove quotes.
|
|
292
|
-
return Filter.text(text.slice(1, -1));
|
|
360
|
+
// Remove quotes and decode escapes.
|
|
361
|
+
return Filter.text(unescapeStringLiteral(text.slice(1, -1)));
|
|
293
362
|
}
|
|
294
363
|
|
|
295
364
|
/**
|
|
@@ -397,9 +466,11 @@ export class QueryBuilder {
|
|
|
397
466
|
/**
|
|
398
467
|
* Parse a TagFilter node (#tag).
|
|
399
468
|
*/
|
|
400
|
-
private _parseTagFilter(cursor: TreeCursor, input: string): Filter.Any {
|
|
401
|
-
|
|
402
|
-
|
|
469
|
+
private _parseTagFilter(cursor: TreeCursor, input: string): Filter.Any | undefined {
|
|
470
|
+
invariant(this._tags);
|
|
471
|
+
const str = this._getNodeText(cursor, input).slice(1).toLowerCase();
|
|
472
|
+
const [key] = Object.entries(this._tags!).find(([, value]) => value.label.toLowerCase() === str) ?? [];
|
|
473
|
+
return key ? Filter.tag(key) : undefined;
|
|
403
474
|
}
|
|
404
475
|
|
|
405
476
|
/**
|
|
@@ -410,9 +481,9 @@ export class QueryBuilder {
|
|
|
410
481
|
|
|
411
482
|
switch (valueType) {
|
|
412
483
|
case 'String': {
|
|
413
|
-
// Remove quotes.
|
|
484
|
+
// Remove quotes and decode escapes.
|
|
414
485
|
const str = this._getNodeText(cursor, input);
|
|
415
|
-
return str.slice(1, -1);
|
|
486
|
+
return unescapeStringLiteral(str.slice(1, -1));
|
|
416
487
|
}
|
|
417
488
|
|
|
418
489
|
case 'Number':
|
|
@@ -468,3 +539,261 @@ export class QueryBuilder {
|
|
|
468
539
|
return input.slice(cursor.from, cursor.to);
|
|
469
540
|
}
|
|
470
541
|
}
|
|
542
|
+
|
|
543
|
+
const KEYWORDS = new Set(['AND', 'OR', 'NOT']);
|
|
544
|
+
const VALUE_LITERALS = new Set(['true', 'false', 'null']);
|
|
545
|
+
const SPECIAL_CHARS = /[\s(){}\[\],"']/;
|
|
546
|
+
const PROPERTY_KEY = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
|
|
547
|
+
const NUMBER_LITERAL = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
548
|
+
|
|
549
|
+
// Common URL schemes — `http://...`, `mailto:rich@...` etc. should be searched as text,
|
|
550
|
+
// not auto-promoted to property filters.
|
|
551
|
+
const URL_SCHEMES = new Set([
|
|
552
|
+
'http',
|
|
553
|
+
'https',
|
|
554
|
+
'ftp',
|
|
555
|
+
'ftps',
|
|
556
|
+
'file',
|
|
557
|
+
'mailto',
|
|
558
|
+
'tel',
|
|
559
|
+
'sms',
|
|
560
|
+
'data',
|
|
561
|
+
'javascript',
|
|
562
|
+
'ws',
|
|
563
|
+
'wss',
|
|
564
|
+
'ssh',
|
|
565
|
+
'git',
|
|
566
|
+
]);
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Normalize raw user input into a form the lezer grammar can parse.
|
|
570
|
+
* - Bare text fragments (e.g. `foo`) are wrapped in quotes so they parse as TextFilter.
|
|
571
|
+
* - Property values that aren't already quoted/numeric/boolean (e.g. `from:rich@dxos.org`) are quoted.
|
|
572
|
+
* - Tags, type filters, operators, parens, braces, quoted strings, and assignments pass through unchanged.
|
|
573
|
+
*/
|
|
574
|
+
export const normalizeInput = (input: string): string => {
|
|
575
|
+
const out: string[] = [];
|
|
576
|
+
let pos = 0;
|
|
577
|
+
while (pos < input.length) {
|
|
578
|
+
const currentChar = input[pos];
|
|
579
|
+
|
|
580
|
+
// Whitespace.
|
|
581
|
+
if (/\s/.test(currentChar)) {
|
|
582
|
+
out.push(currentChar);
|
|
583
|
+
pos++;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Quoted string (already a valid TextFilter / Value).
|
|
588
|
+
// Single-quote only opens a string if the previous char isn't a word-char,
|
|
589
|
+
// so apostrophes inside barewords (e.g. `don't`, `O'Connor`) stay attached.
|
|
590
|
+
const prevChar = pos > 0 ? input[pos - 1] : '';
|
|
591
|
+
const isStringOpener =
|
|
592
|
+
currentChar === '"' || (currentChar === "'" && (pos === 0 || !/[a-zA-Z0-9_]/.test(prevChar)));
|
|
593
|
+
if (isStringOpener) {
|
|
594
|
+
const quoteChar = currentChar;
|
|
595
|
+
let scanIndex = pos + 1;
|
|
596
|
+
while (scanIndex < input.length && input[scanIndex] !== quoteChar) {
|
|
597
|
+
if (input[scanIndex] === '\\' && scanIndex + 1 < input.length) {
|
|
598
|
+
scanIndex += 2;
|
|
599
|
+
} else {
|
|
600
|
+
scanIndex++;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
out.push(input.slice(pos, Math.min(scanIndex + 1, input.length)));
|
|
604
|
+
pos = scanIndex + 1;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Object/array literals: pass through (contents already structured).
|
|
609
|
+
if (currentChar === '{' || currentChar === '[') {
|
|
610
|
+
const closeChar = currentChar === '{' ? '}' : ']';
|
|
611
|
+
let depth = 1;
|
|
612
|
+
let scanIndex = pos + 1;
|
|
613
|
+
while (scanIndex < input.length && depth > 0) {
|
|
614
|
+
const innerChar = input[scanIndex];
|
|
615
|
+
if (innerChar === '"' || innerChar === "'") {
|
|
616
|
+
const quoteChar = innerChar;
|
|
617
|
+
scanIndex++;
|
|
618
|
+
while (scanIndex < input.length && input[scanIndex] !== quoteChar) {
|
|
619
|
+
if (input[scanIndex] === '\\' && scanIndex + 1 < input.length) {
|
|
620
|
+
scanIndex += 2;
|
|
621
|
+
} else {
|
|
622
|
+
scanIndex++;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
scanIndex++;
|
|
626
|
+
} else if (innerChar === currentChar) {
|
|
627
|
+
depth++;
|
|
628
|
+
scanIndex++;
|
|
629
|
+
} else if (innerChar === closeChar) {
|
|
630
|
+
depth--;
|
|
631
|
+
scanIndex++;
|
|
632
|
+
} else {
|
|
633
|
+
scanIndex++;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
out.push(input.slice(pos, scanIndex));
|
|
637
|
+
pos = scanIndex;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Single-char structural tokens. `}` / `]` only reach here when unmatched —
|
|
642
|
+
// pass them through so the lezer parser can produce a clear error rather than spinning.
|
|
643
|
+
if (
|
|
644
|
+
currentChar === '(' ||
|
|
645
|
+
currentChar === ')' ||
|
|
646
|
+
currentChar === '=' ||
|
|
647
|
+
currentChar === ',' ||
|
|
648
|
+
currentChar === '}' ||
|
|
649
|
+
currentChar === ']'
|
|
650
|
+
) {
|
|
651
|
+
out.push(currentChar);
|
|
652
|
+
pos++;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Relations.
|
|
657
|
+
if ((currentChar === '-' && input[pos + 1] === '>') || (currentChar === '<' && input[pos + 1] === '-')) {
|
|
658
|
+
out.push(input.slice(pos, pos + 2));
|
|
659
|
+
pos += 2;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// NOT prefix (`!`) — single token.
|
|
664
|
+
if (currentChar === '!') {
|
|
665
|
+
out.push(currentChar);
|
|
666
|
+
pos++;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Tag.
|
|
671
|
+
if (currentChar === '#') {
|
|
672
|
+
let scanIndex = pos + 1;
|
|
673
|
+
while (scanIndex < input.length && /[a-zA-Z0-9_-]/.test(input[scanIndex])) {
|
|
674
|
+
scanIndex++;
|
|
675
|
+
}
|
|
676
|
+
out.push(input.slice(pos, scanIndex));
|
|
677
|
+
pos = scanIndex;
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Bareword: scan until next whitespace or special char.
|
|
682
|
+
let scanIndex = pos;
|
|
683
|
+
while (scanIndex < input.length) {
|
|
684
|
+
const innerChar = input[scanIndex];
|
|
685
|
+
if (innerChar === '"') {
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
if (innerChar === "'" && scanIndex > pos && !/[a-zA-Z0-9_]/.test(input[scanIndex - 1])) {
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
if (innerChar === "'" && scanIndex === pos) {
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
if (innerChar !== "'" && SPECIAL_CHARS.test(innerChar)) {
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
if (innerChar === '-' && input[scanIndex + 1] === '>') {
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
if (innerChar === '<' && input[scanIndex + 1] === '-') {
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
scanIndex++;
|
|
704
|
+
}
|
|
705
|
+
// Defensive: if no characters were consumed, advance one to avoid infinite loops.
|
|
706
|
+
if (scanIndex === pos) {
|
|
707
|
+
out.push(currentChar);
|
|
708
|
+
pos++;
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
const token = input.slice(pos, scanIndex);
|
|
712
|
+
pos = scanIndex;
|
|
713
|
+
|
|
714
|
+
// Operators.
|
|
715
|
+
if (KEYWORDS.has(token.toUpperCase())) {
|
|
716
|
+
out.push(token);
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Property/type filter (`key:value`).
|
|
721
|
+
const colonIdx = token.indexOf(':');
|
|
722
|
+
if (colonIdx > 0) {
|
|
723
|
+
const key = token.slice(0, colonIdx);
|
|
724
|
+
const rest = token.slice(colonIdx + 1);
|
|
725
|
+
|
|
726
|
+
// type:typename — leave for grammar's TypeFilter (Identifier value).
|
|
727
|
+
if (key === 'type') {
|
|
728
|
+
out.push(token);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// URLs (`http://...`, `mailto:rich@...`) and URL-paths starting with `//`
|
|
733
|
+
// are searched as text rather than auto-promoted to property filters.
|
|
734
|
+
const isUrlScheme = URL_SCHEMES.has(key.toLowerCase()) || rest.startsWith('//');
|
|
735
|
+
if (!isUrlScheme && PROPERTY_KEY.test(key)) {
|
|
736
|
+
if (rest.length === 0) {
|
|
737
|
+
// Trailing colon while typing — pass through.
|
|
738
|
+
out.push(token);
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const firstValueChar = rest[0];
|
|
742
|
+
if (firstValueChar === '"' || firstValueChar === "'") {
|
|
743
|
+
// Already quoted.
|
|
744
|
+
out.push(token);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (firstValueChar === '{' || firstValueChar === '[') {
|
|
748
|
+
// Object/array literal value.
|
|
749
|
+
out.push(token);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
if (VALUE_LITERALS.has(rest) || NUMBER_LITERAL.test(rest)) {
|
|
753
|
+
// Boolean / null / number literal.
|
|
754
|
+
out.push(token);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
out.push(`${key}:"${escapeStringLiteral(rest)}"`);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// URL or unknown key shape — fall through to text.
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Identifier followed by `=` is the LHS of an Assignment — keep as-is.
|
|
765
|
+
if (PROPERTY_KEY.test(token)) {
|
|
766
|
+
let lookahead = pos;
|
|
767
|
+
while (lookahead < input.length && /\s/.test(input[lookahead])) {
|
|
768
|
+
lookahead++;
|
|
769
|
+
}
|
|
770
|
+
if (input[lookahead] === '=') {
|
|
771
|
+
out.push(token);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Bare text fragment: quote so it parses as TextFilter.
|
|
777
|
+
out.push(`"${escapeStringLiteral(token)}"`);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return out.join('');
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
/** Escape a raw value into a string literal body (without surrounding quotes). */
|
|
784
|
+
const escapeStringLiteral = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
785
|
+
|
|
786
|
+
/** Decode `\\` and `\"` escapes inside a string literal body. */
|
|
787
|
+
const unescapeStringLiteral = (literalBody: string): string => {
|
|
788
|
+
let out = '';
|
|
789
|
+
for (let i = 0; i < literalBody.length; i++) {
|
|
790
|
+
const ch = literalBody[i];
|
|
791
|
+
if (ch === '\\' && i + 1 < literalBody.length) {
|
|
792
|
+
out += literalBody[i + 1];
|
|
793
|
+
i++;
|
|
794
|
+
} else {
|
|
795
|
+
out += ch;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return out;
|
|
799
|
+
};
|
package/src/parser/query.grammar
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
@top Query { expression }
|
|
6
6
|
|
|
7
7
|
expression {
|
|
8
|
+
Assignment |
|
|
8
9
|
Filter |
|
|
9
10
|
!Not Not expression |
|
|
10
11
|
expression !ImplicitAnd expression |
|
|
@@ -14,6 +15,10 @@ expression {
|
|
|
14
15
|
"(" expression ")"
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
Assignment {
|
|
19
|
+
Identifier "=" "(" expression ")"
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
Not {
|
|
18
23
|
@specialize<Identifier, "NOT" | "not" | "!">
|
|
19
24
|
}
|
|
@@ -110,7 +115,7 @@ Value {
|
|
|
110
115
|
space { @whitespace+ }
|
|
111
116
|
|
|
112
117
|
"{" "}" "[" "]" "(" ")"
|
|
113
|
-
":" "," "."
|
|
118
|
+
":" "," "." "="
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
@skip { space }
|
|
@@ -120,5 +125,6 @@ Value {
|
|
|
120
125
|
ImplicitAnd @left,
|
|
121
126
|
And @left,
|
|
122
127
|
Or @left,
|
|
123
|
-
Relation @left
|
|
128
|
+
Relation @left,
|
|
129
|
+
Assignment @right
|
|
124
130
|
}
|