@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.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/dist/lib/neutral/index.mjs +917 -0
  3. package/dist/lib/neutral/index.mjs.map +7 -0
  4. package/dist/lib/neutral/meta.json +1 -0
  5. package/dist/query-lite/index.d.ts +9975 -0
  6. package/dist/query-lite/index.d.ts.map +1 -0
  7. package/dist/query-lite/index.js +548 -0
  8. package/dist/query-lite/index.js.map +1 -0
  9. package/dist/types/src/index.d.ts +1 -0
  10. package/dist/types/src/index.d.ts.map +1 -1
  11. package/dist/types/src/parser/gen/index.d.ts.map +1 -1
  12. package/dist/types/src/parser/gen/query.terms.d.ts +1 -1
  13. package/dist/types/src/parser/gen/query.terms.d.ts.map +1 -1
  14. package/dist/types/src/parser/query-builder.d.ts +21 -5
  15. package/dist/types/src/parser/query-builder.d.ts.map +1 -1
  16. package/dist/types/src/query-lite/index.d.ts +2 -0
  17. package/dist/types/src/query-lite/index.d.ts.map +1 -0
  18. package/dist/types/src/query-lite/query-lite.d.ts +8 -0
  19. package/dist/types/src/query-lite/query-lite.d.ts.map +1 -0
  20. package/dist/types/src/sandbox/index.d.ts +2 -0
  21. package/dist/types/src/sandbox/index.d.ts.map +1 -0
  22. package/dist/types/src/sandbox/query-sandbox.d.ts +21 -0
  23. package/dist/types/src/sandbox/query-sandbox.d.ts.map +1 -0
  24. package/dist/types/src/sandbox/query-sandbox.test.d.ts +2 -0
  25. package/dist/types/src/sandbox/query-sandbox.test.d.ts.map +1 -0
  26. package/dist/types/src/sandbox/quickjs.d.ts +8 -0
  27. package/dist/types/src/sandbox/quickjs.d.ts.map +1 -0
  28. package/dist/types/src/sandbox/quickjs.test.d.ts +2 -0
  29. package/dist/types/src/sandbox/quickjs.test.d.ts.map +1 -0
  30. package/dist/types/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +25 -14
  32. package/src/env.d.ts +8 -0
  33. package/src/index.ts +1 -0
  34. package/src/parser/gen/query.terms.ts +24 -23
  35. package/src/parser/gen/query.ts +8 -8
  36. package/src/parser/query-builder.ts +359 -30
  37. package/src/parser/query.grammar +8 -2
  38. package/src/parser/query.test.ts +212 -43
  39. package/src/query-lite/index.ts +5 -0
  40. package/src/query-lite/query-lite.ts +744 -0
  41. package/src/sandbox/index.ts +5 -0
  42. package/src/sandbox/query-sandbox.test.ts +53 -0
  43. package/src/sandbox/query-sandbox.ts +72 -0
  44. package/src/sandbox/quickjs.test.ts +67 -0
  45. package/src/sandbox/quickjs.ts +33 -0
  46. package/dist/lib/browser/index.mjs +0 -505
  47. package/dist/lib/browser/index.mjs.map +0 -7
  48. package/dist/lib/browser/meta.json +0 -1
  49. package/dist/lib/node-esm/index.mjs +0 -505
  50. package/dist/lib/node-esm/index.mjs.map +0 -7
  51. package/dist/lib/node-esm/meta.json +0 -1
  52. package/dist/types/src/search.test.d.ts +0 -2
  53. package/dist/types/src/search.test.d.ts.map +0 -1
  54. 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
- constructor(private readonly _parser: Parser = QueryDSL.Parser.configure({ strict: true })) {}
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): Filter.Any | null {
41
+ build(input: string): BuildResult {
36
42
  try {
37
- const tree = this._parser.parse(input);
38
- return this.buildQuery(tree, input);
43
+ const normalized = normalizeInput(input);
44
+ const tree = this._parser.parse(normalized);
45
+ return this.buildQuery(tree, normalized);
39
46
  } catch {
40
- return null;
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): Filter.Any {
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 Filter.nothing();
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
- return this._parseBinaryExpression(cursor, input);
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
- return this._parseExpression(cursor, input);
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 === '(') depth++;
193
- else if (cursor.node.name === ')') {
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
- filters.push(this.buildQuery(subTree, subInput));
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
- filters.push(this._parseExpression(cursor, input));
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
- filters.push(this._parseExpression(cursor, input));
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
- result = this._parseTagFilter(cursor, input);
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
- const tag = this._getNodeText(cursor, input);
402
- return Filter.tag(tag.slice(1));
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
+ };
@@ -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
  }