@coldsmirk/abacus-core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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/compile.ts
212
+ //#region src/condition/subject.ts
213
213
  /**
214
- * A subject must be a plain identifier path (`amount`, `user.age`, `items[0]`).
215
- * It is emitted verbatim into ZEN source, so anything else is rejected to keep
216
- * the condition compiler from being an expression-injection sink.
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