tina4-nodejs 3.13.100 → 3.13.103

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.
@@ -501,62 +501,59 @@ function splitOutsideQuotes(expr, sep2) {
501
501
  parts.push(expr.slice(currentStart));
502
502
  return parts;
503
503
  }
504
- function evalExpr(expr, context) {
505
- expr = expr.trim();
506
- if (expr.length >= 2) {
507
- const q = expr[0];
508
- if ((q === '"' || q === "'") && expr.endsWith(q) && !expr.slice(1, -1).includes(q)) {
509
- return expr.slice(1, -1);
510
- }
504
+ var EXPR_NOT_MATCHED = Symbol("frond-expression-not-matched");
505
+ function parenthesizedInner(expr) {
506
+ if (expr.length < 2 || expr[0] !== "(" || !expr.endsWith(")")) return null;
507
+ let depth = 0;
508
+ for (let index = 0; index < expr.length; index++) {
509
+ if (expr[index] === "(") depth++;
510
+ else if (expr[index] === ")") depth--;
511
+ if (depth === 0 && index < expr.length - 1) return null;
511
512
  }
512
- if (expr.length >= 2 && expr[0] === "(" && expr.endsWith(")")) {
513
- let depth = 0;
514
- let matched = true;
515
- for (let pi = 0; pi < expr.length; pi++) {
516
- if (expr[pi] === "(") depth++;
517
- else if (expr[pi] === ")") depth--;
518
- if (depth === 0 && pi < expr.length - 1) {
519
- matched = false;
520
- break;
521
- }
522
- }
523
- if (matched) {
524
- return evalExpr(expr.slice(1, -1), context);
525
- }
513
+ return expr.slice(1, -1);
514
+ }
515
+ function evalPrimary(expr, context) {
516
+ const quote = expr[0];
517
+ if (expr.length >= 2 && (quote === '"' || quote === "'") && expr.endsWith(quote) && !expr.slice(1, -1).includes(quote)) {
518
+ return expr.slice(1, -1);
526
519
  }
520
+ const inner = parenthesizedInner(expr);
521
+ if (inner !== null) return evalExpr(inner, context);
522
+ return EXPR_NOT_MATCHED;
523
+ }
524
+ function evalTernaryExpression(expr, context) {
527
525
  const ternaryIdx = findTernary(expr);
528
- if (ternaryIdx !== -1) {
529
- const condPart = expr.slice(0, ternaryIdx).trim();
530
- const rest = expr.slice(ternaryIdx + 1);
531
- const colonIdx = findColon(rest);
532
- if (colonIdx !== -1) {
533
- const truePart = rest.slice(0, colonIdx).trim();
534
- const falsePart = rest.slice(colonIdx + 1).trim();
535
- const cond = evalExpr(condPart, context);
536
- return cond ? evalExpr(truePart, context) : evalExpr(falsePart, context);
537
- }
538
- }
526
+ if (ternaryIdx === -1) return EXPR_NOT_MATCHED;
527
+ const rest = expr.slice(ternaryIdx + 1);
528
+ const colonIdx = findColon(rest);
529
+ if (colonIdx === -1) return EXPR_NOT_MATCHED;
530
+ const condition = evalExpr(expr.slice(0, ternaryIdx).trim(), context);
531
+ const branch = condition ? rest.slice(0, colonIdx) : rest.slice(colonIdx + 1);
532
+ return evalExpr(branch.trim(), context);
533
+ }
534
+ function evalInlineIfExpression(expr, context) {
539
535
  const ifIdx = findOutsideQuotes(expr, " if ");
540
- if (ifIdx >= 0) {
541
- const elseIdx = findOutsideQuotes(expr, " else ");
542
- if (elseIdx >= 0 && elseIdx > ifIdx) {
543
- const valuePart = expr.slice(0, ifIdx).trim();
544
- const condPart = expr.slice(ifIdx + 4, elseIdx).trim();
545
- const elsePart = expr.slice(elseIdx + 6).trim();
546
- const cond = evalExpr(condPart, context);
547
- return cond ? evalExpr(valuePart, context) : evalExpr(elsePart, context);
548
- }
549
- }
536
+ if (ifIdx < 0) return EXPR_NOT_MATCHED;
537
+ const elseIdx = findOutsideQuotes(expr, " else ");
538
+ if (elseIdx < 0 || elseIdx <= ifIdx) return EXPR_NOT_MATCHED;
539
+ const condition = evalExpr(expr.slice(ifIdx + 4, elseIdx).trim(), context);
540
+ const branch = condition ? expr.slice(0, ifIdx) : expr.slice(elseIdx + 6);
541
+ return evalExpr(branch.trim(), context);
542
+ }
543
+ function evalCoalesceExpression(expr, context) {
550
544
  const qqIdx = findOutsideQuotes(expr, "??");
551
- if (qqIdx !== -1) {
552
- const left = expr.slice(0, qqIdx).trim();
553
- const right = expr.slice(qqIdx + 2).trim();
554
- const val = evalExpr(left, context);
555
- if (val === null || val === void 0) {
556
- return evalExpr(right, context);
557
- }
558
- return val;
545
+ if (qqIdx === -1) return EXPR_NOT_MATCHED;
546
+ const value = evalExpr(expr.slice(0, qqIdx).trim(), context);
547
+ return value === null || value === void 0 ? evalExpr(expr.slice(qqIdx + 2).trim(), context) : value;
548
+ }
549
+ function evalConditional(expr, context) {
550
+ for (const evaluator of [evalTernaryExpression, evalInlineIfExpression, evalCoalesceExpression]) {
551
+ const result = evaluator(expr, context);
552
+ if (result !== EXPR_NOT_MATCHED) return result;
559
553
  }
554
+ return EXPR_NOT_MATCHED;
555
+ }
556
+ function evalConcatOrComparison(expr, context) {
560
557
  if (findOutsideQuotes(expr, "~") >= 0) {
561
558
  const parts = splitOutsideQuotes(expr, "~");
562
559
  if (parts.length > 1) {
@@ -574,6 +571,18 @@ function evalExpr(expr, context) {
574
571
  return evalComparison(expr, context);
575
572
  }
576
573
  }
574
+ return EXPR_NOT_MATCHED;
575
+ }
576
+ var ARITHMETIC_OPERATIONS = {
577
+ "+": (left, right) => left + right,
578
+ "-": (left, right) => left - right,
579
+ "*": (left, right) => left * right,
580
+ "//": (left, right) => right !== 0 ? Math.floor(left / right) : 0,
581
+ "/": (left, right) => right !== 0 ? left / right : 0,
582
+ "%": (left, right) => right !== 0 ? left % right : 0,
583
+ "**": (left, right) => left ** right
584
+ };
585
+ function evalArithmeticExpression(expr, context) {
577
586
  for (const op of [" + ", " - ", " * ", " // ", " / ", " % ", " ** "]) {
578
587
  const pos = findOutsideQuotes(expr, op);
579
588
  if (pos >= 0) {
@@ -586,40 +595,15 @@ function evalExpr(expr, context) {
586
595
  let rNum = rVal != null ? Number(rVal) : 0;
587
596
  if (isNaN(lNum)) lNum = 0;
588
597
  if (isNaN(rNum)) rNum = 0;
589
- const opS = op.trim();
590
- const bothInt = Number.isInteger(lNum) && Number.isInteger(rNum) && opS !== "/";
591
- let result;
592
- switch (opS) {
593
- case "+":
594
- result = lNum + rNum;
595
- break;
596
- case "-":
597
- result = lNum - rNum;
598
- break;
599
- case "*":
600
- result = lNum * rNum;
601
- break;
602
- case "//":
603
- result = rNum !== 0 ? Math.floor(lNum / rNum) : 0;
604
- break;
605
- case "/":
606
- result = rNum !== 0 ? lNum / rNum : 0;
607
- break;
608
- case "%":
609
- result = rNum !== 0 ? lNum % rNum : 0;
610
- break;
611
- case "**":
612
- result = lNum ** rNum;
613
- break;
614
- default:
615
- result = 0;
616
- }
617
- return bothInt && Number.isInteger(result) ? result : result;
598
+ return ARITHMETIC_OPERATIONS[op.trim()](lNum, rNum);
618
599
  } catch {
619
600
  return null;
620
601
  }
621
602
  }
622
603
  }
604
+ return EXPR_NOT_MATCHED;
605
+ }
606
+ function evalFilterExpression(expr, context) {
623
607
  if (findOutsideQuotes(expr, "|") >= 0) {
624
608
  const [baseExpr, filters] = parseFilterChain(expr);
625
609
  if (filters.length > 0) {
@@ -638,38 +622,58 @@ function evalExpr(expr, context) {
638
622
  return value;
639
623
  }
640
624
  }
641
- const fnMatch = expr.match(FN_CALL_RE);
642
- if (fnMatch) {
643
- const fnName = fnMatch[1];
644
- const rawArgs = fnMatch[2] || "";
645
- if (fnName.includes(".")) {
646
- const lastDot = fnName.lastIndexOf(".");
647
- const objPath = fnName.slice(0, lastDot);
648
- const methodName = fnName.slice(lastDot + 1);
649
- const obj = resolveVar(objPath, context);
650
- if (obj && typeof obj === "object" && methodName in obj) {
651
- const method = obj[methodName];
652
- if (typeof method === "function") {
653
- if (rawArgs.trim()) {
654
- const parts = splitArgs(rawArgs);
655
- const evalArgs = parts.map((a) => evalExpr(a.trim(), context));
656
- return method.apply(obj, evalArgs);
657
- }
658
- return method.call(obj);
659
- }
660
- }
661
- } else {
662
- const fn = context[fnName] ?? resolveVar(fnName, context);
663
- if (typeof fn === "function") {
664
- if (rawArgs.trim()) {
665
- const parts = splitArgs(rawArgs);
666
- const evalArgs = parts.map((a) => evalExpr(a.trim(), context));
667
- return fn(...evalArgs);
668
- }
669
- return fn();
670
- }
625
+ return EXPR_NOT_MATCHED;
626
+ }
627
+ function evaluateCallArgs(rawArgs, context) {
628
+ return rawArgs.trim() ? splitArgs(rawArgs).map((arg) => evalExpr(arg.trim(), context)) : [];
629
+ }
630
+ function evalDottedFunction(name, rawArgs, context) {
631
+ const lastDot = name.lastIndexOf(".");
632
+ const owner = resolveVar(name.slice(0, lastDot), context);
633
+ const member = name.slice(lastDot + 1);
634
+ if (!owner || typeof owner !== "object" || !(member in owner)) {
635
+ return EXPR_NOT_MATCHED;
636
+ }
637
+ const method = owner[member];
638
+ return typeof method === "function" ? method.apply(owner, evaluateCallArgs(rawArgs, context)) : EXPR_NOT_MATCHED;
639
+ }
640
+ function evalFunctionExpression(expr, context) {
641
+ const match = expr.match(FN_CALL_RE);
642
+ if (!match) return EXPR_NOT_MATCHED;
643
+ const name = match[1];
644
+ const rawArgs = match[2] || "";
645
+ if (name.includes(".")) return evalDottedFunction(name, rawArgs, context);
646
+ const fn = context[name] ?? resolveVar(name, context);
647
+ if (typeof fn === "function") return fn(...evaluateCallArgs(rawArgs, context));
648
+ return EXPR_NOT_MATCHED;
649
+ }
650
+ var EXPR_EVALUATORS = [
651
+ evalPrimary,
652
+ evalConditional,
653
+ evalConcatOrComparison,
654
+ evalArithmeticExpression,
655
+ evalFilterExpression,
656
+ evalFunctionExpression
657
+ ];
658
+ var expressionFormCache = /* @__PURE__ */ new Map();
659
+ function evalExpr(expr, context) {
660
+ expr = expr.trim();
661
+ const cachedForm = expressionFormCache.get(expr);
662
+ if (cachedForm !== void 0) {
663
+ if (cachedForm === -1) return resolveVar(expr, context);
664
+ const result = EXPR_EVALUATORS[cachedForm](expr, context);
665
+ return result === EXPR_NOT_MATCHED ? resolveVar(expr, context) : result;
666
+ }
667
+ for (let index = 0; index < EXPR_EVALUATORS.length; index++) {
668
+ const result = EXPR_EVALUATORS[index](expr, context);
669
+ if (result !== EXPR_NOT_MATCHED) {
670
+ capCache(expressionFormCache, MEMO_CACHE_MAX);
671
+ expressionFormCache.set(expr, index);
672
+ return result;
671
673
  }
672
674
  }
675
+ capCache(expressionFormCache, MEMO_CACHE_MAX);
676
+ expressionFormCache.set(expr, FN_CALL_RE.test(expr) ? EXPR_EVALUATORS.length - 1 : -1);
673
677
  return resolveVar(expr, context);
674
678
  }
675
679
  function findTernary(expr) {
@@ -342,8 +342,8 @@ export const pathParseCache = new Map<string, [string[], boolean[]]>();
342
342
  export const TEMPLATE_CACHE_MAX = 256;
343
343
 
344
344
  /**
345
- * Hard cap on every per-expression memo cache — `filterChainCache` and
346
- * `pathParseCache` (ADR-0004, parity with PHP's MEMO_CACHE_MAX and the
345
+ * Hard cap on every per-expression memo cache — `filterChainCache`,
346
+ * `pathParseCache`, and `expressionFormCache` (ADR-0004, parity with PHP's MEMO_CACHE_MAX and the
347
347
  * Python master's `@lru_cache(maxsize=1024)` on the equivalent module-level
348
348
  * parsers). Deliberately higher than TEMPLATE_CACHE_MAX: one entry here is a
349
349
  * small parsed-path array, orders of magnitude smaller than a token list.
@@ -750,76 +750,71 @@ function splitOutsideQuotes(expr: string, sep: string): string[] {
750
750
  return parts;
751
751
  }
752
752
 
753
- function evalExpr(expr: string, context: Record<string, unknown>): unknown {
754
- expr = expr.trim();
753
+ const EXPR_NOT_MATCHED = Symbol("frond-expression-not-matched");
755
754
 
756
- // String literal early-return: if the entire expression is a single quoted
757
- // string with no unescaped matching quotes inside, return its content.
758
- if (expr.length >= 2) {
759
- const q = expr[0];
760
- if ((q === '"' || q === "'") && expr.endsWith(q) && !expr.slice(1, -1).includes(q)) {
761
- return expr.slice(1, -1);
762
- }
755
+ type ExprResult = unknown | typeof EXPR_NOT_MATCHED;
756
+
757
+ function parenthesizedInner(expr: string): string | null {
758
+ if (expr.length < 2 || expr[0] !== "(" || !expr.endsWith(")")) return null;
759
+ let depth = 0;
760
+ for (let index = 0; index < expr.length; index++) {
761
+ if (expr[index] === "(") depth++;
762
+ else if (expr[index] === ")") depth--;
763
+ if (depth === 0 && index < expr.length - 1) return null;
763
764
  }
765
+ return expr.slice(1, -1);
766
+ }
764
767
 
765
- // Parenthesized sub-expression: (expr) strip parens and evaluate inner
766
- if (expr.length >= 2 && expr[0] === "(" && expr.endsWith(")")) {
767
- let depth = 0;
768
- let matched = true;
769
- for (let pi = 0; pi < expr.length; pi++) {
770
- if (expr[pi] === "(") depth++;
771
- else if (expr[pi] === ")") depth--;
772
- if (depth === 0 && pi < expr.length - 1) {
773
- matched = false;
774
- break;
775
- }
776
- }
777
- if (matched) {
778
- return evalExpr(expr.slice(1, -1), context);
779
- }
768
+ function evalPrimary(expr: string, context: Record<string, unknown>): ExprResult {
769
+ const quote = expr[0];
770
+ if (expr.length >= 2 && (quote === '"' || quote === "'") &&
771
+ expr.endsWith(quote) && !expr.slice(1, -1).includes(quote)) {
772
+ return expr.slice(1, -1);
780
773
  }
774
+ const inner = parenthesizedInner(expr);
775
+ if (inner !== null) return evalExpr(inner, context);
776
+ return EXPR_NOT_MATCHED;
777
+ }
781
778
 
782
- // Ternary: condition ? true_val : false_val
783
- // Match carefully to handle nested ternaries
779
+ function evalTernaryExpression(expr: string, context: Record<string, unknown>): ExprResult {
784
780
  const ternaryIdx = findTernary(expr);
785
- if (ternaryIdx !== -1) {
786
- const condPart = expr.slice(0, ternaryIdx).trim();
787
- const rest = expr.slice(ternaryIdx + 1);
788
- const colonIdx = findColon(rest);
789
- if (colonIdx !== -1) {
790
- const truePart = rest.slice(0, colonIdx).trim();
791
- const falsePart = rest.slice(colonIdx + 1).trim();
792
- const cond = evalExpr(condPart, context);
793
- return cond ? evalExpr(truePart, context) : evalExpr(falsePart, context);
794
- }
795
- }
781
+ if (ternaryIdx === -1) return EXPR_NOT_MATCHED;
782
+ const rest = expr.slice(ternaryIdx + 1);
783
+ const colonIdx = findColon(rest);
784
+ if (colonIdx === -1) return EXPR_NOT_MATCHED;
785
+ const condition = evalExpr(expr.slice(0, ternaryIdx).trim(), context);
786
+ const branch = condition ? rest.slice(0, colonIdx) : rest.slice(colonIdx + 1);
787
+ return evalExpr(branch.trim(), context);
788
+ }
796
789
 
797
- // Jinja2-style inline if: value if condition else other_value — quote-aware
790
+ function evalInlineIfExpression(expr: string, context: Record<string, unknown>): ExprResult {
798
791
  const ifIdx = findOutsideQuotes(expr, " if ");
799
- if (ifIdx >= 0) {
800
- const elseIdx = findOutsideQuotes(expr, " else ");
801
- if (elseIdx >= 0 && elseIdx > ifIdx) {
802
- const valuePart = expr.slice(0, ifIdx).trim();
803
- const condPart = expr.slice(ifIdx + 4, elseIdx).trim();
804
- const elsePart = expr.slice(elseIdx + 6).trim();
805
- const cond = evalExpr(condPart, context);
806
- return cond ? evalExpr(valuePart, context) : evalExpr(elsePart, context);
807
- }
808
- }
792
+ if (ifIdx < 0) return EXPR_NOT_MATCHED;
793
+ const elseIdx = findOutsideQuotes(expr, " else ");
794
+ if (elseIdx < 0 || elseIdx <= ifIdx) return EXPR_NOT_MATCHED;
795
+ const condition = evalExpr(expr.slice(ifIdx + 4, elseIdx).trim(), context);
796
+ const branch = condition ? expr.slice(0, ifIdx) : expr.slice(elseIdx + 6);
797
+ return evalExpr(branch.trim(), context);
798
+ }
809
799
 
810
- // Null coalescing: value ?? "default"
800
+ function evalCoalesceExpression(expr: string, context: Record<string, unknown>): ExprResult {
811
801
  const qqIdx = findOutsideQuotes(expr, "??");
812
- if (qqIdx !== -1) {
813
- const left = expr.slice(0, qqIdx).trim();
814
- const right = expr.slice(qqIdx + 2).trim();
815
- const val = evalExpr(left, context);
816
- if (val === null || val === undefined) {
817
- return evalExpr(right, context);
818
- }
819
- return val;
802
+ if (qqIdx === -1) return EXPR_NOT_MATCHED;
803
+ const value = evalExpr(expr.slice(0, qqIdx).trim(), context);
804
+ return value === null || value === undefined
805
+ ? evalExpr(expr.slice(qqIdx + 2).trim(), context)
806
+ : value;
807
+ }
808
+
809
+ function evalConditional(expr: string, context: Record<string, unknown>): ExprResult {
810
+ for (const evaluator of [evalTernaryExpression, evalInlineIfExpression, evalCoalesceExpression]) {
811
+ const result = evaluator(expr, context);
812
+ if (result !== EXPR_NOT_MATCHED) return result;
820
813
  }
814
+ return EXPR_NOT_MATCHED;
815
+ }
821
816
 
822
- // String concatenation with ~
817
+ function evalConcatOrComparison(expr: string, context: Record<string, unknown>): ExprResult {
823
818
  if (findOutsideQuotes(expr, "~") >= 0) {
824
819
  const parts = splitOutsideQuotes(expr, "~");
825
820
  if (parts.length > 1) {
@@ -829,19 +824,6 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
829
824
  }).join("");
830
825
  }
831
826
  }
832
-
833
- // Comparison/logical operators -> evalComparison, the SAME evaluator {% if %}
834
- // uses, so a condition means the same thing in a condition and in an output
835
- // expression.
836
- //
837
- // The LEADING unary `not` needs its own check: every operator below is matched
838
- // WITH surrounding spaces, so `not x` (nothing to its left) matched none of
839
- // them, fell through to the variable-resolution tail, and was looked up as a
840
- // variable literally named "not x" -- found nothing, rendered EMPTY.
841
- // `{% if not x %}` and `x and not y` always worked; only the standalone
842
- // `{{ not x }}` was dropped, and before booleans rendered lowercase a dropped
843
- // expression and `false -> ''` looked identical, which is why it survived.
844
- // Fixed in 3.13.87 alongside the boolean contract.
845
827
  if (expr.startsWith("not ")) {
846
828
  return evalComparison(expr, context);
847
829
  }
@@ -850,8 +832,20 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
850
832
  return evalComparison(expr, context);
851
833
  }
852
834
  }
835
+ return EXPR_NOT_MATCHED;
836
+ }
837
+
838
+ const ARITHMETIC_OPERATIONS: Record<string, (left: number, right: number) => number> = {
839
+ "+": (left, right) => left + right,
840
+ "-": (left, right) => left - right,
841
+ "*": (left, right) => left * right,
842
+ "//": (left, right) => right !== 0 ? Math.floor(left / right) : 0,
843
+ "/": (left, right) => right !== 0 ? left / right : 0,
844
+ "%": (left, right) => right !== 0 ? left % right : 0,
845
+ "**": (left, right) => left ** right,
846
+ };
853
847
 
854
- // Arithmetic operators: +, -, *, //, /, %, ** (lowest to highest precedence)
848
+ function evalArithmeticExpression(expr: string, context: Record<string, unknown>): ExprResult {
855
849
  for (const op of [" + ", " - ", " * ", " // ", " / ", " % ", " ** "]) {
856
850
  const pos = findOutsideQuotes(expr, op);
857
851
  if (pos >= 0) {
@@ -864,35 +858,16 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
864
858
  let rNum = rVal != null ? Number(rVal) : 0;
865
859
  if (isNaN(lNum)) lNum = 0;
866
860
  if (isNaN(rNum)) rNum = 0;
867
- const opS = op.trim();
868
- // Preserve int type when both operands are int-like (except for / which returns float)
869
- const bothInt = Number.isInteger(lNum) && Number.isInteger(rNum) && opS !== "/";
870
- let result: number;
871
- switch (opS) {
872
- case "+": result = lNum + rNum; break;
873
- case "-": result = lNum - rNum; break;
874
- case "*": result = lNum * rNum; break;
875
- case "//": result = rNum !== 0 ? Math.floor(lNum / rNum) : 0; break;
876
- case "/": result = rNum !== 0 ? lNum / rNum : 0; break;
877
- case "%": result = rNum !== 0 ? lNum % rNum : 0; break;
878
- case "**": result = lNum ** rNum; break;
879
- default: result = 0;
880
- }
881
- return bothInt && Number.isInteger(result) ? result : result;
861
+ return ARITHMETIC_OPERATIONS[op.trim()](lNum, rNum);
882
862
  } catch {
883
863
  return null;
884
864
  }
885
865
  }
886
866
  }
867
+ return EXPR_NOT_MATCHED;
868
+ }
887
869
 
888
- // Filter pipe: value | filter1 | filter2(args). In Twig `|` binds TIGHTER than
889
- // concat (`~`), comparisons and arithmetic, but looser than member/function
890
- // access — so it is resolved here, AFTER those operators have had a chance to
891
- // split the expression. Handling it inside the expression evaluator (not only
892
- // at the {{ }} output layer) makes filters work at any nesting depth: concat
893
- // operands, ternary branches, and parenthesised sub-expressions. This fixes
894
- // both the `|`-vs-`~` precedence bug and the "pipe inside parens returns empty"
895
- // defect. (#171)
870
+ function evalFilterExpression(expr: string, context: Record<string, unknown>): ExprResult {
896
871
  if (findOutsideQuotes(expr, "|") >= 0) {
897
872
  const [baseExpr, filters] = parseFilterChain(expr);
898
873
  if (filters.length > 0) {
@@ -919,43 +894,66 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
919
894
  return value;
920
895
  }
921
896
  }
897
+ return EXPR_NOT_MATCHED;
898
+ }
922
899
 
923
- // Function call: name("arg1", "arg2") supports dotted names like user.t("key")
924
- const fnMatch = expr.match(FN_CALL_RE);
925
- if (fnMatch) {
926
- const fnName = fnMatch[1];
927
- const rawArgs = fnMatch[2] || "";
900
+ function evaluateCallArgs(rawArgs: string, context: Record<string, unknown>): unknown[] {
901
+ return rawArgs.trim() ? splitArgs(rawArgs).map(arg => evalExpr(arg.trim(), context)) : [];
902
+ }
928
903
 
929
- // Dotted function name: resolve object, then call method
930
- if (fnName.includes(".")) {
931
- const lastDot = fnName.lastIndexOf(".");
932
- const objPath = fnName.slice(0, lastDot);
933
- const methodName = fnName.slice(lastDot + 1);
934
- const obj = resolveVar(objPath, context);
935
- if (obj && typeof obj === "object" && methodName in (obj as Record<string, unknown>)) {
936
- const method = (obj as Record<string, unknown>)[methodName];
937
- if (typeof method === "function") {
938
- if (rawArgs.trim()) {
939
- const parts = splitArgs(rawArgs);
940
- const evalArgs = parts.map(a => evalExpr(a.trim(), context));
941
- return method.apply(obj, evalArgs);
942
- }
943
- return method.call(obj);
944
- }
945
- }
946
- } else {
947
- const fn = context[fnName] ?? resolveVar(fnName, context);
948
- if (typeof fn === "function") {
949
- if (rawArgs.trim()) {
950
- const parts = splitArgs(rawArgs);
951
- const evalArgs = parts.map(a => evalExpr(a.trim(), context));
952
- return fn(...evalArgs);
953
- }
954
- return fn();
955
- }
904
+ function evalDottedFunction(name: string, rawArgs: string, context: Record<string, unknown>): ExprResult {
905
+ const lastDot = name.lastIndexOf(".");
906
+ const owner = resolveVar(name.slice(0, lastDot), context);
907
+ const member = name.slice(lastDot + 1);
908
+ if (!owner || typeof owner !== "object" || !(member in (owner as Record<string, unknown>))) {
909
+ return EXPR_NOT_MATCHED;
910
+ }
911
+ const method = (owner as Record<string, unknown>)[member];
912
+ return typeof method === "function"
913
+ ? method.apply(owner, evaluateCallArgs(rawArgs, context))
914
+ : EXPR_NOT_MATCHED;
915
+ }
916
+
917
+ function evalFunctionExpression(expr: string, context: Record<string, unknown>): ExprResult {
918
+ const match = expr.match(FN_CALL_RE);
919
+ if (!match) return EXPR_NOT_MATCHED;
920
+ const name = match[1];
921
+ const rawArgs = match[2] || "";
922
+ if (name.includes(".")) return evalDottedFunction(name, rawArgs, context);
923
+ const fn = context[name] ?? resolveVar(name, context);
924
+ if (typeof fn === "function") return fn(...evaluateCallArgs(rawArgs, context));
925
+ return EXPR_NOT_MATCHED;
926
+ }
927
+
928
+ const EXPR_EVALUATORS = [
929
+ evalPrimary,
930
+ evalConditional,
931
+ evalConcatOrComparison,
932
+ evalArithmeticExpression,
933
+ evalFilterExpression,
934
+ evalFunctionExpression,
935
+ ] as const;
936
+ /** Cached expression dispatcher branch; exported only for cache-bound verification. */
937
+ export const expressionFormCache = new Map<string, number>();
938
+
939
+ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
940
+ expr = expr.trim();
941
+ const cachedForm = expressionFormCache.get(expr);
942
+ if (cachedForm !== undefined) {
943
+ if (cachedForm === -1) return resolveVar(expr, context);
944
+ const result = EXPR_EVALUATORS[cachedForm](expr, context);
945
+ return result === EXPR_NOT_MATCHED ? resolveVar(expr, context) : result;
946
+ }
947
+ for (let index = 0; index < EXPR_EVALUATORS.length; index++) {
948
+ const result = EXPR_EVALUATORS[index](expr, context);
949
+ if (result !== EXPR_NOT_MATCHED) {
950
+ capCache(expressionFormCache, MEMO_CACHE_MAX);
951
+ expressionFormCache.set(expr, index);
952
+ return result;
956
953
  }
957
954
  }
958
-
955
+ capCache(expressionFormCache, MEMO_CACHE_MAX);
956
+ expressionFormCache.set(expr, FN_CALL_RE.test(expr) ? EXPR_EVALUATORS.length - 1 : -1);
959
957
  return resolveVar(expr, context);
960
958
  }
961
959