@dxos/echo-query 0.8.4-main.9be5663bfe → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/echo-query",
3
- "version": "0.8.4-main.9be5663bfe",
3
+ "version": "0.8.4-main.abd8ff62ef",
4
4
  "description": "ECHO queries.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -24,9 +24,6 @@
24
24
  "./api.d.ts": "./dist/query-lite/index.d.ts"
25
25
  },
26
26
  "types": "dist/types/src/index.d.ts",
27
- "typesVersions": {
28
- "*": {}
29
- },
30
27
  "files": [
31
28
  "dist",
32
29
  "src"
@@ -35,22 +32,22 @@
35
32
  "@lezer/common": "^1.2.2",
36
33
  "@lezer/lezer": "^1.1.2",
37
34
  "@lezer/lr": "^1.4.2",
38
- "@dxos/context": "0.8.4-main.9be5663bfe",
39
- "@dxos/debug": "0.8.4-main.9be5663bfe",
40
- "@dxos/echo": "0.8.4-main.9be5663bfe",
41
- "@dxos/echo-protocol": "0.8.4-main.9be5663bfe",
42
- "@dxos/errors": "0.8.4-main.9be5663bfe",
43
- "@dxos/invariant": "0.8.4-main.9be5663bfe",
44
- "@dxos/node-std": "0.8.4-main.9be5663bfe",
45
- "@dxos/util": "0.8.4-main.9be5663bfe",
46
- "@dxos/vendor-quickjs": "0.8.4-main.9be5663bfe"
35
+ "@dxos/debug": "0.8.4-main.abd8ff62ef",
36
+ "@dxos/errors": "0.8.4-main.abd8ff62ef",
37
+ "@dxos/echo-protocol": "0.8.4-main.abd8ff62ef",
38
+ "@dxos/echo": "0.8.4-main.abd8ff62ef",
39
+ "@dxos/invariant": "0.8.4-main.abd8ff62ef",
40
+ "@dxos/node-std": "0.8.4-main.abd8ff62ef",
41
+ "@dxos/context": "0.8.4-main.abd8ff62ef",
42
+ "@dxos/util": "0.8.4-main.abd8ff62ef",
43
+ "@dxos/vendor-quickjs": "0.8.4-main.abd8ff62ef"
47
44
  },
48
45
  "devDependencies": {
49
46
  "@lezer/generator": "^1.7.1",
50
47
  "tsdown": "^0.16.7",
51
- "typescript": "^6.0.2",
52
- "@dxos/random": "0.8.4-main.9be5663bfe",
53
- "@dxos/echo-generator": "0.8.4-main.9be5663bfe"
48
+ "typescript": "^6.0.3",
49
+ "@dxos/echo-generator": "0.8.4-main.abd8ff62ef",
50
+ "@dxos/random": "0.8.4-main.abd8ff62ef"
54
51
  },
55
52
  "publishConfig": {
56
53
  "access": "public"
@@ -28,7 +28,7 @@ export class QueryBuilder {
28
28
  */
29
29
  validate(input: string): boolean {
30
30
  try {
31
- const tree = this._parser.parse(input);
31
+ const tree = this._parser.parse(normalizeInput(input));
32
32
  return tree.cursor().node.name === 'Query';
33
33
  } catch {
34
34
  return false;
@@ -40,8 +40,9 @@ export class QueryBuilder {
40
40
  */
41
41
  build(input: string): BuildResult {
42
42
  try {
43
- const tree = this._parser.parse(input);
44
- return this.buildQuery(tree, input);
43
+ const normalized = normalizeInput(input);
44
+ const tree = this._parser.parse(normalized);
45
+ return this.buildQuery(tree, normalized);
45
46
  } catch {
46
47
  return {};
47
48
  }
@@ -245,8 +246,9 @@ export class QueryBuilder {
245
246
  let exprEnd = cursor.to;
246
247
 
247
248
  while (cursor.nextSibling() && depth > 0) {
248
- if (cursor.node.name === '(') depth++;
249
- else if (cursor.node.name === ')') {
249
+ if (cursor.node.name === '(') {
250
+ depth++;
251
+ } else if (cursor.node.name === ')') {
250
252
  depth--;
251
253
  if (depth === 0) {
252
254
  exprEnd = cursor.from;
@@ -355,8 +357,8 @@ export class QueryBuilder {
355
357
  cursor.firstChild(); // Move to String node.
356
358
  const text = this._getNodeText(cursor, input);
357
359
  cursor.parent(); // Go back to TextFilter.
358
- // Remove quotes.
359
- return Filter.text(text.slice(1, -1));
360
+ // Remove quotes and decode escapes.
361
+ return Filter.text(unescapeStringLiteral(text.slice(1, -1)));
360
362
  }
361
363
 
362
364
  /**
@@ -479,9 +481,9 @@ export class QueryBuilder {
479
481
 
480
482
  switch (valueType) {
481
483
  case 'String': {
482
- // Remove quotes.
484
+ // Remove quotes and decode escapes.
483
485
  const str = this._getNodeText(cursor, input);
484
- return str.slice(1, -1);
486
+ return unescapeStringLiteral(str.slice(1, -1));
485
487
  }
486
488
 
487
489
  case 'Number':
@@ -537,3 +539,261 @@ export class QueryBuilder {
537
539
  return input.slice(cursor.from, cursor.to);
538
540
  }
539
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
+ };
@@ -3,12 +3,12 @@
3
3
  //
4
4
 
5
5
  import { type Tree } from '@lezer/common';
6
- import { describe, it } from 'vitest';
6
+ import { describe, it, test } from 'vitest';
7
7
 
8
8
  import { Filter, Tag } from '@dxos/echo';
9
9
 
10
10
  import { QueryDSL } from './gen';
11
- import { type BuildResult, QueryBuilder } from './query-builder';
11
+ import { type BuildResult, QueryBuilder, normalizeInput } from './query-builder';
12
12
 
13
13
  // TODO(burdon): Ref/Relation traversal.
14
14
 
@@ -413,4 +413,117 @@ describe('query', () => {
413
413
  expect(result, JSON.stringify({ input, result, expected }, null, 2)).toEqual(expected);
414
414
  });
415
415
  });
416
+
417
+ test('normalizeInput', ({ expect }) => {
418
+ type Test = { input: string; expected: string };
419
+ const tests: Test[] = [
420
+ { input: 'foo', expected: '"foo"' },
421
+ { input: 'foo bar', expected: '"foo" "bar"' },
422
+ { input: 'foo bar', expected: '"foo" "bar"' },
423
+ { input: '"already" bare', expected: '"already" "bare"' },
424
+ { input: 'from:rich@dxos.org', expected: 'from:"rich@dxos.org"' },
425
+ { input: 'from:rich@dxos.org urgent', expected: 'from:"rich@dxos.org" "urgent"' },
426
+ { input: 'name:DXOS', expected: 'name:"DXOS"' },
427
+ { input: 'count:42', expected: 'count:42' },
428
+ { input: 'active:true', expected: 'active:true' },
429
+ { input: 'value:null', expected: 'value:null' },
430
+ { input: 'name:"DXOS"', expected: 'name:"DXOS"' },
431
+ { input: 'type:org.dxos.type.person', expected: 'type:org.dxos.type.person' },
432
+ { input: '#tag', expected: '#tag' },
433
+ { input: '#tag foo', expected: '#tag "foo"' },
434
+ { input: 'foo AND bar', expected: '"foo" AND "bar"' },
435
+ { input: 'foo OR bar', expected: '"foo" OR "bar"' },
436
+ { input: 'NOT foo', expected: 'NOT "foo"' },
437
+ { input: '!foo', expected: '!"foo"' },
438
+ { input: '(foo bar)', expected: '("foo" "bar")' },
439
+ { input: 'x = ( foo )', expected: 'x = ( "foo" )' },
440
+ { input: '{ name: "DXOS" }', expected: '{ name: "DXOS" }' },
441
+ // Apostrophes inside barewords don't open a quoted string.
442
+ { input: "don't", expected: '"don\'t"' },
443
+ { input: "O'Connor", expected: '"O\'Connor"' },
444
+ { input: "don't worry", expected: '"don\'t" "worry"' },
445
+ // Unmatched closing brace/bracket — passed through, no infinite loop.
446
+ { input: 'foo}', expected: '"foo"}' },
447
+ { input: 'foo]bar', expected: '"foo"]"bar"' },
448
+ // Genuine single-quoted string still works.
449
+ { input: "'foo bar'", expected: "'foo bar'" },
450
+ // URLs are searched as text rather than auto-promoted to property filters.
451
+ { input: 'https://dxos.org', expected: '"https://dxos.org"' },
452
+ { input: 'http://example.com/foo', expected: '"http://example.com/foo"' },
453
+ { input: 'mailto:rich@dxos.org', expected: '"mailto:rich@dxos.org"' },
454
+ // Escapes round-trip: backslashes are escaped in the literal body.
455
+ { input: 'foo\\bar', expected: '"foo\\\\bar"' },
456
+ ];
457
+
458
+ for (const { input, expected } of tests) {
459
+ expect(normalizeInput(input), input).toEqual(expected);
460
+ }
461
+ });
462
+
463
+ test('build with property and text fragments', ({ expect }) => {
464
+ const queryBuilder = new QueryBuilder({
465
+ tag_1: Tag.make({ label: 'foo' }),
466
+ });
467
+
468
+ type Test = { input: string; expected: BuildResult };
469
+ const tests: Test[] = [
470
+ // Property filter from `key:value` text input.
471
+ {
472
+ input: 'from:rich@dxos.org',
473
+ expected: { filter: Filter.props({ from: 'rich@dxos.org' }) },
474
+ },
475
+ {
476
+ input: 'name:DXOS',
477
+ expected: { filter: Filter.props({ name: 'DXOS' }) },
478
+ },
479
+ // Bare text fragment becomes text search.
480
+ {
481
+ input: 'urgent',
482
+ expected: { filter: Filter.text('urgent') },
483
+ },
484
+ // Multiple bare fragments are AND-joined.
485
+ {
486
+ input: 'urgent review',
487
+ expected: { filter: Filter.and(Filter.text('urgent'), Filter.text('review')) },
488
+ },
489
+ // Mixed property + text fragment.
490
+ {
491
+ input: 'from:rich@dxos.org urgent',
492
+ expected: {
493
+ filter: Filter.and(Filter.props({ from: 'rich@dxos.org' }), Filter.text('urgent')),
494
+ },
495
+ },
496
+ // Tag + text fragment.
497
+ {
498
+ input: '#foo bar',
499
+ expected: { filter: Filter.and(Filter.tag('tag_1'), Filter.text('bar')) },
500
+ },
501
+ // Three fragments AND-joined.
502
+ {
503
+ input: 'a b c',
504
+ expected: {
505
+ filter: Filter.and(Filter.text('a'), Filter.text('b'), Filter.text('c')),
506
+ },
507
+ },
508
+ // URLs are text-searched, not promoted to property filters.
509
+ {
510
+ input: 'https://dxos.org',
511
+ expected: { filter: Filter.text('https://dxos.org') },
512
+ },
513
+ {
514
+ input: 'mailto:rich@dxos.org',
515
+ expected: { filter: Filter.text('mailto:rich@dxos.org') },
516
+ },
517
+ // Escapes decode back to the original input.
518
+ {
519
+ input: 'foo\\bar',
520
+ expected: { filter: Filter.text('foo\\bar') },
521
+ },
522
+ ];
523
+
524
+ tests.forEach(({ input, expected }) => {
525
+ const result = queryBuilder.build(input);
526
+ expect(result, JSON.stringify({ input, result, expected }, null, 2)).toEqual(expected);
527
+ });
528
+ });
416
529
  });
@@ -573,6 +573,21 @@ class QueryClass implements Query$.Any {
573
573
  options,
574
574
  });
575
575
  }
576
+
577
+ debugLabel(label: string): Query$.Any {
578
+ if (this.ast.type === 'options') {
579
+ return new QueryClass({
580
+ type: 'options',
581
+ query: this.ast.query,
582
+ options: { ...this.ast.options, debugLabel: label },
583
+ });
584
+ }
585
+ return new QueryClass({
586
+ type: 'options',
587
+ query: this.ast,
588
+ options: { debugLabel: label },
589
+ });
590
+ }
576
591
  }
577
592
 
578
593
  export const Query1: typeof Query$ = QueryClass;
@@ -701,6 +716,9 @@ const prettyQuery = (query: QueryAST.Query): string => {
701
716
  if (query.options.deleted !== undefined) {
702
717
  parts.push(`deleted: ${JSON.stringify(query.options.deleted)}`);
703
718
  }
719
+ if (query.options.debugLabel !== undefined) {
720
+ parts.push(`debugLabel: ${JSON.stringify(query.options.debugLabel)}`);
721
+ }
704
722
  return `${prettyQuery(query.query)}.options({ ${parts.join(', ')} })`;
705
723
  }
706
724
  case 'from': {