@coldsmirk/abacus-core 0.2.0 → 0.3.1
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 +54 -3
- package/dist/index.cjs +627 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +189 -3
- package/dist/index.d.ts +189 -3
- package/dist/index.js +618 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -209,11 +209,14 @@ function detectDev() {
|
|
|
209
209
|
return process.env ? process.env.NODE_ENV !== "production" : false;
|
|
210
210
|
}
|
|
211
211
|
//#endregion
|
|
212
|
-
//#region src/condition/
|
|
212
|
+
//#region src/condition/subject.ts
|
|
213
213
|
/**
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
214
|
+
* Shared subject-path guard for the condition compilers. A subject / left-hand
|
|
215
|
+
* path is emitted **verbatim** into ZEN source, so both {@link compileCondition}
|
|
216
|
+
* (which writes that source) and {@link liftConditionTree} (which reads it back)
|
|
217
|
+
* gate paths through this one predicate — a single definition of "what is a safe
|
|
218
|
+
* field path" that cannot drift between the writer and the reader, which matters
|
|
219
|
+
* because the guard is also the compiler's injection defense.
|
|
217
220
|
*/
|
|
218
221
|
const SUBJECT_PATTERN = /^[A-Z_$][\w$]*(?:\.[A-Z_$][\w$]*|\[\d+\])*$/i;
|
|
219
222
|
const ZEN_RESERVED_WORDS = new Set([
|
|
@@ -225,9 +228,15 @@ const ZEN_RESERVED_WORDS = new Set([
|
|
|
225
228
|
"false",
|
|
226
229
|
"null"
|
|
227
230
|
]);
|
|
231
|
+
/**
|
|
232
|
+
* Whether `subject` is a plain identifier path safe to emit verbatim into ZEN
|
|
233
|
+
* source: dotted/indexed segments only, none of which is a ZEN reserved word.
|
|
234
|
+
*/
|
|
228
235
|
function isIdentifierPath(subject) {
|
|
229
236
|
return SUBJECT_PATTERN.test(subject) && (subject.match(/[A-Z_$][\w$]*/gi) ?? []).every((segment) => !ZEN_RESERVED_WORDS.has(segment));
|
|
230
237
|
}
|
|
238
|
+
//#endregion
|
|
239
|
+
//#region src/condition/compile.ts
|
|
231
240
|
/**
|
|
232
241
|
* Serialize a JavaScript value into a ZEN literal. Nullish becomes `null`;
|
|
233
242
|
* numbers / booleans / bigints are emitted verbatim; strings are quoted via
|
|
@@ -420,6 +429,508 @@ const CONDITION_OPERATORS = [
|
|
|
420
429
|
"is_empty",
|
|
421
430
|
"is_not_empty"
|
|
422
431
|
];
|
|
432
|
+
const CONDITION_OPERATOR_ARITIES = {
|
|
433
|
+
eq: "scalar",
|
|
434
|
+
ne: "scalar",
|
|
435
|
+
gt: "scalar",
|
|
436
|
+
gte: "scalar",
|
|
437
|
+
lt: "scalar",
|
|
438
|
+
lte: "scalar",
|
|
439
|
+
contains: "scalar",
|
|
440
|
+
not_contains: "scalar",
|
|
441
|
+
starts_with: "scalar",
|
|
442
|
+
ends_with: "scalar",
|
|
443
|
+
in: "array",
|
|
444
|
+
not_in: "array",
|
|
445
|
+
is_empty: "none",
|
|
446
|
+
is_not_empty: "none"
|
|
447
|
+
};
|
|
448
|
+
/**
|
|
449
|
+
* The arity of `operator`'s right-hand operand (see
|
|
450
|
+
* {@link ConditionOperatorArity}). One definition site next to
|
|
451
|
+
* {@link CONDITION_OPERATORS}, so the tree compiler's arity enforcement and a
|
|
452
|
+
* condition editor's operand controls classify operators identically instead of
|
|
453
|
+
* each keeping a drift-prone copy.
|
|
454
|
+
*/
|
|
455
|
+
function conditionOperatorArity(operator) {
|
|
456
|
+
return CONDITION_OPERATOR_ARITIES[operator];
|
|
457
|
+
}
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region src/condition/compile-tree.ts
|
|
460
|
+
/**
|
|
461
|
+
* Compile a condition tree to a canonical ZEN expression, or `""` when no rule in
|
|
462
|
+
* the tree is compilable (see the module note for the drop semantics).
|
|
463
|
+
*/
|
|
464
|
+
function compileConditionTree(tree) {
|
|
465
|
+
const normalized = normalizeNode(tree);
|
|
466
|
+
return normalized === null ? "" : emitNode(normalized, true);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Prune a node to its compilable core: drop rules {@link compileRule} rejects, drop
|
|
470
|
+
* emptied groups, and collapse a single-surviving-item group to that item (so the
|
|
471
|
+
* emitted shape carries no redundant parentheses). Returns `null` when nothing in
|
|
472
|
+
* the node survives.
|
|
473
|
+
*/
|
|
474
|
+
function normalizeNode(node) {
|
|
475
|
+
if (node.kind === "rule") return compileRule(node) === null ? null : node;
|
|
476
|
+
const items = node.items.map((item) => normalizeNode(item)).filter((item) => item !== null);
|
|
477
|
+
if (items.length === 0) return null;
|
|
478
|
+
if (items.length === 1) return items[0];
|
|
479
|
+
return {
|
|
480
|
+
kind: "group",
|
|
481
|
+
op: node.op,
|
|
482
|
+
items
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Render a normalized node. A rule emits its lowered ZEN; a group joins its items
|
|
487
|
+
* with ` and ` / ` or ` and, unless it is the top-level group, wraps them in
|
|
488
|
+
* parentheses so the tree structure survives ZEN's operator precedence on lift.
|
|
489
|
+
*/
|
|
490
|
+
function emitNode(node, topLevel) {
|
|
491
|
+
if (node.kind === "rule") return compileRule(node);
|
|
492
|
+
const joined = node.items.map((item) => emitNode(item, false)).join(node.op === "and" ? " and " : " or ");
|
|
493
|
+
return topLevel ? joined : `(${joined})`;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Lower a leaf rule to ZEN via {@link compileCondition}, or `null` for a rule the
|
|
497
|
+
* compiler cannot represent. The rule's `right` must match its operator's arity —
|
|
498
|
+
* a single scalar for the comparison / string operators, an array of scalars for
|
|
499
|
+
* `in` / `not_in`, absent for the emptiness operators (the contract
|
|
500
|
+
* {@link ConditionTreeValue} documents); an off-arity rule is non-compilable and
|
|
501
|
+
* drops. Enforcing arity here, not just relying on `compileCondition`, is what
|
|
502
|
+
* keeps every emitted expression liftable: the flat compiler's lax value handling
|
|
503
|
+
* would happily serialize e.g. a missing `right` as a `null` literal, which is not
|
|
504
|
+
* part of the canonical grammar.
|
|
505
|
+
*/
|
|
506
|
+
function compileRule(rule) {
|
|
507
|
+
if (!matchesOperatorArity(rule)) return null;
|
|
508
|
+
return compileCondition({
|
|
509
|
+
kind: "field",
|
|
510
|
+
subject: rule.left,
|
|
511
|
+
operator: rule.operator,
|
|
512
|
+
value: rule.right
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
function matchesOperatorArity(rule) {
|
|
516
|
+
switch (conditionOperatorArity(rule.operator)) {
|
|
517
|
+
case "scalar": return isConditionScalar(rule.right);
|
|
518
|
+
case "array": return isScalarArray(rule.right);
|
|
519
|
+
case "none": return rule.right === void 0;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function isConditionScalar(value) {
|
|
523
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
524
|
+
}
|
|
525
|
+
function isScalarArray(value) {
|
|
526
|
+
return isArray(value) && value.every((item) => isConditionScalar(item));
|
|
527
|
+
}
|
|
528
|
+
//#endregion
|
|
529
|
+
//#region src/condition/lift-tree.ts
|
|
530
|
+
const TWO_CHAR_PUNCTUATION = new Set([
|
|
531
|
+
"==",
|
|
532
|
+
"!=",
|
|
533
|
+
"<=",
|
|
534
|
+
">="
|
|
535
|
+
]);
|
|
536
|
+
const ONE_CHAR_PUNCTUATION = new Set([
|
|
537
|
+
"(",
|
|
538
|
+
")",
|
|
539
|
+
"[",
|
|
540
|
+
"]",
|
|
541
|
+
",",
|
|
542
|
+
".",
|
|
543
|
+
"<",
|
|
544
|
+
">",
|
|
545
|
+
"-"
|
|
546
|
+
]);
|
|
547
|
+
const IDENT_START = /[a-z_$]/i;
|
|
548
|
+
const IDENT_PART = /[\w$]/;
|
|
549
|
+
const DIGIT = /\d/;
|
|
550
|
+
const NUMBER_PATTERN = /^\d+(?:\.\d+)?(?:e[+-]?\d+)?/i;
|
|
551
|
+
const INTEGER_PATTERN = /^\d+$/;
|
|
552
|
+
const COMPARISON_OPERATORS = {
|
|
553
|
+
"==": "eq",
|
|
554
|
+
"!=": "ne",
|
|
555
|
+
">": "gt",
|
|
556
|
+
">=": "gte",
|
|
557
|
+
"<": "lt",
|
|
558
|
+
"<=": "lte"
|
|
559
|
+
};
|
|
560
|
+
const CALL_OPERATORS = {
|
|
561
|
+
contains: "contains",
|
|
562
|
+
startsWith: "starts_with",
|
|
563
|
+
endsWith: "ends_with"
|
|
564
|
+
};
|
|
565
|
+
const MAX_GROUP_DEPTH = 64;
|
|
566
|
+
/**
|
|
567
|
+
* Lift a ZEN expression to a condition tree, or `null` when it is not in the
|
|
568
|
+
* canonical form {@link compileConditionTree} produces (the consumer then keeps the
|
|
569
|
+
* raw expression). Groups nested deeper than 64 parenthesized levels are refused as
|
|
570
|
+
* non-canonical rather than risking parser-stack overflow on adversarial input. The
|
|
571
|
+
* returned root is always a group.
|
|
572
|
+
*/
|
|
573
|
+
function liftConditionTree(expression) {
|
|
574
|
+
const tokens = tokenize(expression);
|
|
575
|
+
if (tokens === null || tokens.length === 0) return null;
|
|
576
|
+
let pos = 0;
|
|
577
|
+
function peek(offset = 0) {
|
|
578
|
+
return tokens[pos + offset];
|
|
579
|
+
}
|
|
580
|
+
function consumePunct(value) {
|
|
581
|
+
const token = peek();
|
|
582
|
+
if (token !== void 0 && token.kind === "punct" && token.value === value) {
|
|
583
|
+
pos += 1;
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
function consumeIdent(value) {
|
|
589
|
+
const token = peek();
|
|
590
|
+
if (token !== void 0 && token.kind === "ident" && token.value === value) {
|
|
591
|
+
pos += 1;
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
function tokensMatchAt(from, expected) {
|
|
597
|
+
for (const [index, element] of expected.entries()) {
|
|
598
|
+
const actual = tokens[from + index];
|
|
599
|
+
const want = element;
|
|
600
|
+
if (actual === void 0 || actual.kind !== want.kind || actual.value !== want.value) return false;
|
|
601
|
+
}
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
function parsePath() {
|
|
605
|
+
const read = readPath(tokens, pos);
|
|
606
|
+
if (read === null) return null;
|
|
607
|
+
pos = read.next;
|
|
608
|
+
return read.path;
|
|
609
|
+
}
|
|
610
|
+
function parseLiteral() {
|
|
611
|
+
const token = peek();
|
|
612
|
+
if (token === void 0) return null;
|
|
613
|
+
if (token.kind === "string") {
|
|
614
|
+
pos += 1;
|
|
615
|
+
return token.value;
|
|
616
|
+
}
|
|
617
|
+
if (token.kind === "number") {
|
|
618
|
+
pos += 1;
|
|
619
|
+
return Number(token.value);
|
|
620
|
+
}
|
|
621
|
+
if (token.kind === "ident") {
|
|
622
|
+
if (token.value === "true") {
|
|
623
|
+
pos += 1;
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
if (token.value === "false") {
|
|
627
|
+
pos += 1;
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
if (token.kind === "punct" && token.value === "-") {
|
|
633
|
+
const digits = peek(1);
|
|
634
|
+
if (digits === void 0 || digits.kind !== "number") return null;
|
|
635
|
+
pos += 2;
|
|
636
|
+
return -Number(digits.value);
|
|
637
|
+
}
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
function parseArray() {
|
|
641
|
+
if (!consumePunct("[")) return null;
|
|
642
|
+
if (isPunct(peek(), "]")) {
|
|
643
|
+
pos += 1;
|
|
644
|
+
return [];
|
|
645
|
+
}
|
|
646
|
+
const first = parseLiteral();
|
|
647
|
+
if (first === null) return null;
|
|
648
|
+
const items = [first];
|
|
649
|
+
while (isPunct(peek(), ",")) {
|
|
650
|
+
pos += 1;
|
|
651
|
+
const next = parseLiteral();
|
|
652
|
+
if (next === null) return null;
|
|
653
|
+
items.push(next);
|
|
654
|
+
}
|
|
655
|
+
return consumePunct("]") ? items : null;
|
|
656
|
+
}
|
|
657
|
+
function parseCall(operator) {
|
|
658
|
+
if (!consumePunct("(")) return null;
|
|
659
|
+
const left = parsePath();
|
|
660
|
+
if (left === null || !consumePunct(",")) return null;
|
|
661
|
+
const right = parseLiteral();
|
|
662
|
+
if (right === null || !consumePunct(")")) return null;
|
|
663
|
+
return {
|
|
664
|
+
kind: "rule",
|
|
665
|
+
left,
|
|
666
|
+
operator,
|
|
667
|
+
right
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
function parseNotIn() {
|
|
671
|
+
const left = parsePath();
|
|
672
|
+
if (left === null || !consumeIdent("in")) return null;
|
|
673
|
+
const right = parseArray();
|
|
674
|
+
if (right === null || !consumePunct(")")) return null;
|
|
675
|
+
return {
|
|
676
|
+
kind: "rule",
|
|
677
|
+
left,
|
|
678
|
+
operator: "not_in",
|
|
679
|
+
right
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
function tryEmptiness() {
|
|
683
|
+
const first = peek();
|
|
684
|
+
if (first === void 0) return null;
|
|
685
|
+
let operator;
|
|
686
|
+
let pathIndex;
|
|
687
|
+
if (first.kind === "ident" && first.value === "not" && isPunct(peek(1), "(")) {
|
|
688
|
+
operator = "is_not_empty";
|
|
689
|
+
pathIndex = pos + 2;
|
|
690
|
+
} else if (first.kind === "punct" && first.value === "(") {
|
|
691
|
+
operator = "is_empty";
|
|
692
|
+
pathIndex = pos + 1;
|
|
693
|
+
} else return null;
|
|
694
|
+
const read = readPath(tokens, pathIndex);
|
|
695
|
+
if (read === null) return null;
|
|
696
|
+
const canonical = compileCondition({
|
|
697
|
+
kind: "field",
|
|
698
|
+
subject: read.path,
|
|
699
|
+
operator,
|
|
700
|
+
value: void 0
|
|
701
|
+
});
|
|
702
|
+
if (canonical === null) return null;
|
|
703
|
+
const expected = tokenize(canonical);
|
|
704
|
+
if (expected === null || !tokensMatchAt(pos, expected)) return null;
|
|
705
|
+
pos += expected.length;
|
|
706
|
+
return {
|
|
707
|
+
kind: "rule",
|
|
708
|
+
left: read.path,
|
|
709
|
+
operator
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
function parseRule() {
|
|
713
|
+
const token = peek();
|
|
714
|
+
if (token === void 0 || token.kind !== "ident") return null;
|
|
715
|
+
if (token.value === "not") {
|
|
716
|
+
const next = peek(1);
|
|
717
|
+
if (next === void 0) return null;
|
|
718
|
+
if (next.kind === "ident" && next.value === "contains" && isPunct(peek(2), "(")) {
|
|
719
|
+
pos += 2;
|
|
720
|
+
return parseCall("not_contains");
|
|
721
|
+
}
|
|
722
|
+
if (next.kind === "punct" && next.value === "(") {
|
|
723
|
+
pos += 2;
|
|
724
|
+
return parseNotIn();
|
|
725
|
+
}
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
if (isPunct(peek(1), "(")) {
|
|
729
|
+
const operator = CALL_OPERATORS[token.value];
|
|
730
|
+
if (operator === void 0) return null;
|
|
731
|
+
pos += 1;
|
|
732
|
+
return parseCall(operator);
|
|
733
|
+
}
|
|
734
|
+
const left = parsePath();
|
|
735
|
+
if (left === null) return null;
|
|
736
|
+
const operatorToken = peek();
|
|
737
|
+
if (operatorToken === void 0) return null;
|
|
738
|
+
if (operatorToken.kind === "ident" && operatorToken.value === "in") {
|
|
739
|
+
pos += 1;
|
|
740
|
+
const right = parseArray();
|
|
741
|
+
return right === null ? null : {
|
|
742
|
+
kind: "rule",
|
|
743
|
+
left,
|
|
744
|
+
operator: "in",
|
|
745
|
+
right
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
if (operatorToken.kind === "punct") {
|
|
749
|
+
const operator = COMPARISON_OPERATORS[operatorToken.value];
|
|
750
|
+
if (operator === void 0) return null;
|
|
751
|
+
pos += 1;
|
|
752
|
+
const right = parseLiteral();
|
|
753
|
+
return right === null ? null : {
|
|
754
|
+
kind: "rule",
|
|
755
|
+
left,
|
|
756
|
+
operator,
|
|
757
|
+
right
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
function parseItem(depth) {
|
|
763
|
+
const emptiness = tryEmptiness();
|
|
764
|
+
if (emptiness !== null) return emptiness;
|
|
765
|
+
if (isPunct(peek(), "(")) {
|
|
766
|
+
if (depth >= MAX_GROUP_DEPTH) return null;
|
|
767
|
+
pos += 1;
|
|
768
|
+
const inner = parseGroup(depth + 1);
|
|
769
|
+
if (inner === null || !consumePunct(")")) return null;
|
|
770
|
+
return asGroup(inner);
|
|
771
|
+
}
|
|
772
|
+
return parseRule();
|
|
773
|
+
}
|
|
774
|
+
function peekJoin() {
|
|
775
|
+
const token = peek();
|
|
776
|
+
if (token !== void 0 && token.kind === "ident" && (token.value === "and" || token.value === "or")) return token.value;
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
function parseGroup(depth) {
|
|
780
|
+
const first = parseItem(depth);
|
|
781
|
+
if (first === null) return null;
|
|
782
|
+
const items = [first];
|
|
783
|
+
let op = null;
|
|
784
|
+
let join = peekJoin();
|
|
785
|
+
while (join !== null) {
|
|
786
|
+
if (op === null) op = join;
|
|
787
|
+
else if (op !== join) return null;
|
|
788
|
+
pos += 1;
|
|
789
|
+
const next = parseItem(depth);
|
|
790
|
+
if (next === null) return null;
|
|
791
|
+
items.push(next);
|
|
792
|
+
join = peekJoin();
|
|
793
|
+
}
|
|
794
|
+
return items.length === 1 ? items[0] : {
|
|
795
|
+
kind: "group",
|
|
796
|
+
op: op ?? "and",
|
|
797
|
+
items
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
const parsed = parseGroup(0);
|
|
801
|
+
if (parsed === null || pos !== tokens.length) return null;
|
|
802
|
+
return asGroup(parsed);
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Read a dotted/indexed identifier path starting at `from`, validated through
|
|
806
|
+
* {@link isIdentifierPath}. Pure over the token array (no cursor) so it can probe a
|
|
807
|
+
* candidate path — the emptiness oracle reads the blob's subject without committing
|
|
808
|
+
* the cursor. Returns the path and the index just past it, or `null`.
|
|
809
|
+
*/
|
|
810
|
+
function readPath(tokens, from) {
|
|
811
|
+
const head = tokens[from];
|
|
812
|
+
if (head === void 0 || head.kind !== "ident") return null;
|
|
813
|
+
let path = head.value;
|
|
814
|
+
let index = from + 1;
|
|
815
|
+
let segment = tokens[index];
|
|
816
|
+
while (segment !== void 0 && segment.kind === "punct" && (segment.value === "." || segment.value === "[")) {
|
|
817
|
+
if (segment.value === ".") {
|
|
818
|
+
const name = tokens[index + 1];
|
|
819
|
+
if (name === void 0 || name.kind !== "ident") return null;
|
|
820
|
+
path += `.${name.value}`;
|
|
821
|
+
index += 2;
|
|
822
|
+
} else {
|
|
823
|
+
const inner = tokens[index + 1];
|
|
824
|
+
if (inner === void 0 || inner.kind !== "number" || !INTEGER_PATTERN.test(inner.value)) return null;
|
|
825
|
+
const close = tokens[index + 2];
|
|
826
|
+
if (close === void 0 || close.kind !== "punct" || close.value !== "]") return null;
|
|
827
|
+
path += `[${inner.value}]`;
|
|
828
|
+
index += 3;
|
|
829
|
+
}
|
|
830
|
+
segment = tokens[index];
|
|
831
|
+
}
|
|
832
|
+
return isIdentifierPath(path) ? {
|
|
833
|
+
path,
|
|
834
|
+
next: index
|
|
835
|
+
} : null;
|
|
836
|
+
}
|
|
837
|
+
function asGroup(node) {
|
|
838
|
+
return node.kind === "group" ? node : {
|
|
839
|
+
kind: "group",
|
|
840
|
+
op: "and",
|
|
841
|
+
items: [node]
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
function isPunct(token, value) {
|
|
845
|
+
return token !== void 0 && token.kind === "punct" && token.value === value;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Split a ZEN expression into tokens, or `null` on an unexpected character or an
|
|
849
|
+
* unterminated string. Whitespace is dropped, so the parser (and the emptiness
|
|
850
|
+
* oracle's token match) is insensitive to spacing. String literals are read raw
|
|
851
|
+
* between matching quotes — ZEN honors no backslash escapes, mirroring the encoder
|
|
852
|
+
* in {@link compileCondition}.
|
|
853
|
+
*/
|
|
854
|
+
function tokenize(input) {
|
|
855
|
+
const tokens = [];
|
|
856
|
+
let index = 0;
|
|
857
|
+
while (index < input.length) {
|
|
858
|
+
const char = input[index];
|
|
859
|
+
if (char === " " || char === " " || char === "\n" || char === "\r") {
|
|
860
|
+
index += 1;
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (char === "'" || char === "\"") {
|
|
864
|
+
const end = input.indexOf(char, index + 1);
|
|
865
|
+
if (end === -1) return null;
|
|
866
|
+
tokens.push({
|
|
867
|
+
kind: "string",
|
|
868
|
+
value: input.slice(index + 1, end)
|
|
869
|
+
});
|
|
870
|
+
index = end + 1;
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
const pair = input.slice(index, index + 2);
|
|
874
|
+
if (TWO_CHAR_PUNCTUATION.has(pair)) {
|
|
875
|
+
tokens.push({
|
|
876
|
+
kind: "punct",
|
|
877
|
+
value: pair
|
|
878
|
+
});
|
|
879
|
+
index += 2;
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
if (ONE_CHAR_PUNCTUATION.has(char)) {
|
|
883
|
+
tokens.push({
|
|
884
|
+
kind: "punct",
|
|
885
|
+
value: char
|
|
886
|
+
});
|
|
887
|
+
index += 1;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
if (DIGIT.test(char)) {
|
|
891
|
+
const match = NUMBER_PATTERN.exec(input.slice(index));
|
|
892
|
+
if (match === null) return null;
|
|
893
|
+
tokens.push({
|
|
894
|
+
kind: "number",
|
|
895
|
+
value: match[0]
|
|
896
|
+
});
|
|
897
|
+
index += match[0].length;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
if (IDENT_START.test(char)) {
|
|
901
|
+
let end = index + 1;
|
|
902
|
+
while (end < input.length && IDENT_PART.test(input[end])) end += 1;
|
|
903
|
+
tokens.push({
|
|
904
|
+
kind: "ident",
|
|
905
|
+
value: input.slice(index, end)
|
|
906
|
+
});
|
|
907
|
+
index = end;
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
return tokens;
|
|
913
|
+
}
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region src/condition/tree-types.ts
|
|
916
|
+
/**
|
|
917
|
+
* The visual condition **tree** model: an arbitrarily nested and/or tree of typed
|
|
918
|
+
* comparison rules that {@link compileConditionTree} serializes to a single ZEN
|
|
919
|
+
* boolean expression and {@link liftConditionTree} reconstructs from one. It is a
|
|
920
|
+
* distinct, self-contained shape from the compiler's flat {@link ConditionInput} —
|
|
921
|
+
* the tree is what a structured builder UI edits, whereas `ConditionInput` is the
|
|
922
|
+
* narrowed per-condition shape the compiler consumes. The two meet only at the
|
|
923
|
+
* leaf: a {@link ConditionTreeRule} lowers to a field {@link ConditionInput} so the
|
|
924
|
+
* operator vocabulary, literal encoding, and injection guard have one owner.
|
|
925
|
+
*/
|
|
926
|
+
/**
|
|
927
|
+
* The tree's operator vocabulary as a runtime list — the compiler's full
|
|
928
|
+
* {@link CONDITION_OPERATORS} set under the tree name. An alias, not a second
|
|
929
|
+
* hand-maintained list, so the tree vocabulary can never drift from the compiler's;
|
|
930
|
+
* it gives tree code and the builder UI one tree-named import for the operators they
|
|
931
|
+
* support.
|
|
932
|
+
*/
|
|
933
|
+
const CONDITION_TREE_OPERATORS = CONDITION_OPERATORS;
|
|
423
934
|
//#endregion
|
|
424
935
|
//#region src/engine/evaluate.ts
|
|
425
936
|
/**
|
|
@@ -726,14 +1237,121 @@ function satisfiesTypeSync(actual, expected) {
|
|
|
726
1237
|
return getEngineSync().satisfies(actual, expected);
|
|
727
1238
|
}
|
|
728
1239
|
//#endregion
|
|
1240
|
+
//#region src/engine/template.ts
|
|
1241
|
+
const HOLE_PATTERN = /\{\{(?<expression>[^{}]*)\}\}/g;
|
|
1242
|
+
/**
|
|
1243
|
+
* Extract every `{{ expression }}` hole from a template document, in source
|
|
1244
|
+
* order. Literal text outside holes is ignored. Pure string scan — no engine
|
|
1245
|
+
* required — matching the `@gorules/lezer-zen-template` hole grammar, so it is
|
|
1246
|
+
* consistent with the editor's highlighting and completion gating.
|
|
1247
|
+
*/
|
|
1248
|
+
function parseTemplateHoles(source) {
|
|
1249
|
+
const holes = [];
|
|
1250
|
+
HOLE_PATTERN.lastIndex = 0;
|
|
1251
|
+
for (let match = HOLE_PATTERN.exec(source); match !== null; match = HOLE_PATTERN.exec(source)) {
|
|
1252
|
+
const inner = match.groups?.expression ?? "";
|
|
1253
|
+
const from = match.index + 2;
|
|
1254
|
+
holes.push({
|
|
1255
|
+
from,
|
|
1256
|
+
to: from + inner.length,
|
|
1257
|
+
expression: inner
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
return holes;
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* The template hole whose inner expression range contains `pos` (boundaries
|
|
1264
|
+
* included, so a caret sitting right after `{{` or right before `}}` counts as
|
|
1265
|
+
* inside), or `null` when `pos` is in literal text. Drives the editor's
|
|
1266
|
+
* hole-scoped completion and hover: intelligence fires inside a hole, nothing in
|
|
1267
|
+
* the surrounding literal text.
|
|
1268
|
+
*/
|
|
1269
|
+
function templateHoleAt(source, pos) {
|
|
1270
|
+
return parseTemplateHoles(source).find((hole) => pos >= hole.from && pos <= hole.to) ?? null;
|
|
1271
|
+
}
|
|
1272
|
+
function hasExpression(hole) {
|
|
1273
|
+
return hole.expression.trim().length > 0;
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Type-check every hole of a template against a `variables` context and merge the
|
|
1277
|
+
* results into one {@link ExpressionAnalysis} whose spans are offset onto the
|
|
1278
|
+
* template document. Each hole is a standard ZEN value expression; literal text
|
|
1279
|
+
* contributes nothing. `rootKind` is the context itself (shared by every hole),
|
|
1280
|
+
* so top-level completion works even in an empty hole.
|
|
1281
|
+
*
|
|
1282
|
+
* Best-effort: a hole that fails to analyze (e.g. mid-edit syntax) is skipped
|
|
1283
|
+
* rather than discarding the spans of its siblings. Throws
|
|
1284
|
+
* {@link ExpressionNotReadyError} if the engine has not loaded — use only behind
|
|
1285
|
+
* a readiness gate.
|
|
1286
|
+
*/
|
|
1287
|
+
function analyzeTemplateSync(variables, source) {
|
|
1288
|
+
if (!isEngineReady()) throw new ExpressionNotReadyError();
|
|
1289
|
+
const spans = [];
|
|
1290
|
+
for (const hole of parseTemplateHoles(source)) {
|
|
1291
|
+
if (!hasExpression(hole)) continue;
|
|
1292
|
+
try {
|
|
1293
|
+
for (const span of analyzeTypesSync(variables, hole.expression, "standard").spans) spans.push({
|
|
1294
|
+
...span,
|
|
1295
|
+
span: [span.span[0] + hole.from, span.span[1] + hole.from]
|
|
1296
|
+
});
|
|
1297
|
+
} catch {}
|
|
1298
|
+
}
|
|
1299
|
+
return {
|
|
1300
|
+
rootKind: variables,
|
|
1301
|
+
spans
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Async {@link analyzeTemplateSync}, loading the engine on first use.
|
|
1306
|
+
*/
|
|
1307
|
+
async function analyzeTemplate(variables, source) {
|
|
1308
|
+
await loadEngine();
|
|
1309
|
+
return analyzeTemplateSync(variables, source);
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Validate every hole of a template and return their syntax diagnostics, each
|
|
1313
|
+
* offset onto the template document (empty holes and literal text produce none).
|
|
1314
|
+
* The single-expression {@link getDiagnosticsSync} yields at most one diagnostic
|
|
1315
|
+
* per hole, so a template with several broken holes surfaces each one.
|
|
1316
|
+
*
|
|
1317
|
+
* Throws {@link ExpressionNotReadyError} if the engine has not loaded — use only
|
|
1318
|
+
* behind a readiness gate.
|
|
1319
|
+
*/
|
|
1320
|
+
function getTemplateDiagnosticsSync(source) {
|
|
1321
|
+
if (!isEngineReady()) throw new ExpressionNotReadyError();
|
|
1322
|
+
const diagnostics = [];
|
|
1323
|
+
for (const hole of parseTemplateHoles(source)) {
|
|
1324
|
+
if (!hasExpression(hole)) continue;
|
|
1325
|
+
const diagnostic = getDiagnosticsSync(hole.expression, "standard");
|
|
1326
|
+
if (diagnostic) diagnostics.push({
|
|
1327
|
+
...diagnostic,
|
|
1328
|
+
from: diagnostic.from + hole.from,
|
|
1329
|
+
to: diagnostic.to + hole.from
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
return diagnostics;
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Async {@link getTemplateDiagnosticsSync}, loading the engine on first use.
|
|
1336
|
+
*/
|
|
1337
|
+
async function getTemplateDiagnostics(source) {
|
|
1338
|
+
await loadEngine();
|
|
1339
|
+
return getTemplateDiagnosticsSync(source);
|
|
1340
|
+
}
|
|
1341
|
+
//#endregion
|
|
729
1342
|
exports.CONDITION_OPERATORS = CONDITION_OPERATORS;
|
|
1343
|
+
exports.CONDITION_TREE_OPERATORS = CONDITION_TREE_OPERATORS;
|
|
730
1344
|
exports.ExpressionError = ExpressionError;
|
|
731
1345
|
exports.ExpressionNotReadyError = ExpressionNotReadyError;
|
|
1346
|
+
exports.analyzeTemplate = analyzeTemplate;
|
|
1347
|
+
exports.analyzeTemplateSync = analyzeTemplateSync;
|
|
732
1348
|
exports.analyzeTypes = analyzeTypes;
|
|
733
1349
|
exports.analyzeTypesSync = analyzeTypesSync;
|
|
734
1350
|
exports.compileBranch = compileBranch;
|
|
735
1351
|
exports.compileCondition = compileCondition;
|
|
1352
|
+
exports.compileConditionTree = compileConditionTree;
|
|
736
1353
|
exports.compileGroup = compileGroup;
|
|
1354
|
+
exports.conditionOperatorArity = conditionOperatorArity;
|
|
737
1355
|
exports.configureEngine = configureEngine;
|
|
738
1356
|
exports.configureExpressionMessages = configureExpressionMessages;
|
|
739
1357
|
exports.enMessages = enMessages;
|
|
@@ -748,14 +1366,19 @@ exports.getDiagnosticsSync = getDiagnosticsSync;
|
|
|
748
1366
|
exports.getEngineError = getEngineError;
|
|
749
1367
|
exports.getEngineSync = getEngineSync;
|
|
750
1368
|
exports.getExpressionMessages = getExpressionMessages;
|
|
1369
|
+
exports.getTemplateDiagnostics = getTemplateDiagnostics;
|
|
1370
|
+
exports.getTemplateDiagnosticsSync = getTemplateDiagnosticsSync;
|
|
751
1371
|
exports.isEngineReady = isEngineReady;
|
|
1372
|
+
exports.liftConditionTree = liftConditionTree;
|
|
752
1373
|
exports.loadEngine = loadEngine;
|
|
1374
|
+
exports.parseTemplateHoles = parseTemplateHoles;
|
|
753
1375
|
exports.registerExpressionLocale = registerExpressionLocale;
|
|
754
1376
|
exports.resetEngine = resetEngine;
|
|
755
1377
|
exports.satisfiesType = satisfiesType;
|
|
756
1378
|
exports.satisfiesTypeSync = satisfiesTypeSync;
|
|
757
1379
|
exports.selectBranch = selectBranch;
|
|
758
1380
|
exports.selectBranchWith = selectBranchWith;
|
|
1381
|
+
exports.templateHoleAt = templateHoleAt;
|
|
759
1382
|
exports.toZenLiteral = toZenLiteral;
|
|
760
1383
|
exports.zhCNMessages = zhCNMessages;
|
|
761
1384
|
|