tina4-nodejs 3.13.101 → 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.
@@ -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
 
@@ -2622,6 +2622,7 @@ __export(engine_exports, {
2622
2622
  Frond: () => Frond,
2623
2623
  MEMO_CACHE_MAX: () => MEMO_CACHE_MAX,
2624
2624
  TEMPLATE_CACHE_MAX: () => TEMPLATE_CACHE_MAX,
2625
+ expressionFormCache: () => expressionFormCache,
2625
2626
  filterChainCache: () => filterChainCache,
2626
2627
  pathParseCache: () => pathParseCache,
2627
2628
  setFormTokenSessionId: () => setFormTokenSessionId
@@ -3030,62 +3031,58 @@ function splitOutsideQuotes(expr, sep6) {
3030
3031
  parts.push(expr.slice(currentStart));
3031
3032
  return parts;
3032
3033
  }
3033
- function evalExpr(expr, context) {
3034
- expr = expr.trim();
3035
- if (expr.length >= 2) {
3036
- const q = expr[0];
3037
- if ((q === '"' || q === "'") && expr.endsWith(q) && !expr.slice(1, -1).includes(q)) {
3038
- return expr.slice(1, -1);
3039
- }
3040
- }
3041
- if (expr.length >= 2 && expr[0] === "(" && expr.endsWith(")")) {
3042
- let depth = 0;
3043
- let matched = true;
3044
- for (let pi = 0; pi < expr.length; pi++) {
3045
- if (expr[pi] === "(") depth++;
3046
- else if (expr[pi] === ")") depth--;
3047
- if (depth === 0 && pi < expr.length - 1) {
3048
- matched = false;
3049
- break;
3050
- }
3051
- }
3052
- if (matched) {
3053
- return evalExpr(expr.slice(1, -1), context);
3054
- }
3034
+ function parenthesizedInner(expr) {
3035
+ if (expr.length < 2 || expr[0] !== "(" || !expr.endsWith(")")) return null;
3036
+ let depth = 0;
3037
+ for (let index = 0; index < expr.length; index++) {
3038
+ if (expr[index] === "(") depth++;
3039
+ else if (expr[index] === ")") depth--;
3040
+ if (depth === 0 && index < expr.length - 1) return null;
3055
3041
  }
3056
- const ternaryIdx = findTernary(expr);
3057
- if (ternaryIdx !== -1) {
3058
- const condPart = expr.slice(0, ternaryIdx).trim();
3059
- const rest = expr.slice(ternaryIdx + 1);
3060
- const colonIdx = findColon(rest);
3061
- if (colonIdx !== -1) {
3062
- const truePart = rest.slice(0, colonIdx).trim();
3063
- const falsePart = rest.slice(colonIdx + 1).trim();
3064
- const cond = evalExpr(condPart, context);
3065
- return cond ? evalExpr(truePart, context) : evalExpr(falsePart, context);
3066
- }
3042
+ return expr.slice(1, -1);
3043
+ }
3044
+ function evalPrimary(expr, context) {
3045
+ const quote = expr[0];
3046
+ if (expr.length >= 2 && (quote === '"' || quote === "'") && expr.endsWith(quote) && !expr.slice(1, -1).includes(quote)) {
3047
+ return expr.slice(1, -1);
3067
3048
  }
3049
+ const inner = parenthesizedInner(expr);
3050
+ if (inner !== null) return evalExpr(inner, context);
3051
+ return EXPR_NOT_MATCHED;
3052
+ }
3053
+ function evalTernaryExpression(expr, context) {
3054
+ const ternaryIdx = findTernary(expr);
3055
+ if (ternaryIdx === -1) return EXPR_NOT_MATCHED;
3056
+ const rest = expr.slice(ternaryIdx + 1);
3057
+ const colonIdx = findColon(rest);
3058
+ if (colonIdx === -1) return EXPR_NOT_MATCHED;
3059
+ const condition = evalExpr(expr.slice(0, ternaryIdx).trim(), context);
3060
+ const branch = condition ? rest.slice(0, colonIdx) : rest.slice(colonIdx + 1);
3061
+ return evalExpr(branch.trim(), context);
3062
+ }
3063
+ function evalInlineIfExpression(expr, context) {
3068
3064
  const ifIdx = findOutsideQuotes(expr, " if ");
3069
- if (ifIdx >= 0) {
3070
- const elseIdx = findOutsideQuotes(expr, " else ");
3071
- if (elseIdx >= 0 && elseIdx > ifIdx) {
3072
- const valuePart = expr.slice(0, ifIdx).trim();
3073
- const condPart = expr.slice(ifIdx + 4, elseIdx).trim();
3074
- const elsePart = expr.slice(elseIdx + 6).trim();
3075
- const cond = evalExpr(condPart, context);
3076
- return cond ? evalExpr(valuePart, context) : evalExpr(elsePart, context);
3077
- }
3078
- }
3065
+ if (ifIdx < 0) return EXPR_NOT_MATCHED;
3066
+ const elseIdx = findOutsideQuotes(expr, " else ");
3067
+ if (elseIdx < 0 || elseIdx <= ifIdx) return EXPR_NOT_MATCHED;
3068
+ const condition = evalExpr(expr.slice(ifIdx + 4, elseIdx).trim(), context);
3069
+ const branch = condition ? expr.slice(0, ifIdx) : expr.slice(elseIdx + 6);
3070
+ return evalExpr(branch.trim(), context);
3071
+ }
3072
+ function evalCoalesceExpression(expr, context) {
3079
3073
  const qqIdx = findOutsideQuotes(expr, "??");
3080
- if (qqIdx !== -1) {
3081
- const left = expr.slice(0, qqIdx).trim();
3082
- const right = expr.slice(qqIdx + 2).trim();
3083
- const val = evalExpr(left, context);
3084
- if (val === null || val === void 0) {
3085
- return evalExpr(right, context);
3086
- }
3087
- return val;
3074
+ if (qqIdx === -1) return EXPR_NOT_MATCHED;
3075
+ const value = evalExpr(expr.slice(0, qqIdx).trim(), context);
3076
+ return value === null || value === void 0 ? evalExpr(expr.slice(qqIdx + 2).trim(), context) : value;
3077
+ }
3078
+ function evalConditional(expr, context) {
3079
+ for (const evaluator of [evalTernaryExpression, evalInlineIfExpression, evalCoalesceExpression]) {
3080
+ const result = evaluator(expr, context);
3081
+ if (result !== EXPR_NOT_MATCHED) return result;
3088
3082
  }
3083
+ return EXPR_NOT_MATCHED;
3084
+ }
3085
+ function evalConcatOrComparison(expr, context) {
3089
3086
  if (findOutsideQuotes(expr, "~") >= 0) {
3090
3087
  const parts = splitOutsideQuotes(expr, "~");
3091
3088
  if (parts.length > 1) {
@@ -3103,6 +3100,9 @@ function evalExpr(expr, context) {
3103
3100
  return evalComparison(expr, context);
3104
3101
  }
3105
3102
  }
3103
+ return EXPR_NOT_MATCHED;
3104
+ }
3105
+ function evalArithmeticExpression(expr, context) {
3106
3106
  for (const op of [" + ", " - ", " * ", " // ", " / ", " % ", " ** "]) {
3107
3107
  const pos = findOutsideQuotes(expr, op);
3108
3108
  if (pos >= 0) {
@@ -3115,40 +3115,15 @@ function evalExpr(expr, context) {
3115
3115
  let rNum = rVal != null ? Number(rVal) : 0;
3116
3116
  if (isNaN(lNum)) lNum = 0;
3117
3117
  if (isNaN(rNum)) rNum = 0;
3118
- const opS = op.trim();
3119
- const bothInt = Number.isInteger(lNum) && Number.isInteger(rNum) && opS !== "/";
3120
- let result;
3121
- switch (opS) {
3122
- case "+":
3123
- result = lNum + rNum;
3124
- break;
3125
- case "-":
3126
- result = lNum - rNum;
3127
- break;
3128
- case "*":
3129
- result = lNum * rNum;
3130
- break;
3131
- case "//":
3132
- result = rNum !== 0 ? Math.floor(lNum / rNum) : 0;
3133
- break;
3134
- case "/":
3135
- result = rNum !== 0 ? lNum / rNum : 0;
3136
- break;
3137
- case "%":
3138
- result = rNum !== 0 ? lNum % rNum : 0;
3139
- break;
3140
- case "**":
3141
- result = lNum ** rNum;
3142
- break;
3143
- default:
3144
- result = 0;
3145
- }
3146
- return bothInt && Number.isInteger(result) ? result : result;
3118
+ return ARITHMETIC_OPERATIONS[op.trim()](lNum, rNum);
3147
3119
  } catch {
3148
3120
  return null;
3149
3121
  }
3150
3122
  }
3151
3123
  }
3124
+ return EXPR_NOT_MATCHED;
3125
+ }
3126
+ function evalFilterExpression(expr, context) {
3152
3127
  if (findOutsideQuotes(expr, "|") >= 0) {
3153
3128
  const [baseExpr, filters] = parseFilterChain(expr);
3154
3129
  if (filters.length > 0) {
@@ -3167,38 +3142,49 @@ function evalExpr(expr, context) {
3167
3142
  return value;
3168
3143
  }
3169
3144
  }
3170
- const fnMatch = expr.match(FN_CALL_RE);
3171
- if (fnMatch) {
3172
- const fnName = fnMatch[1];
3173
- const rawArgs = fnMatch[2] || "";
3174
- if (fnName.includes(".")) {
3175
- const lastDot = fnName.lastIndexOf(".");
3176
- const objPath = fnName.slice(0, lastDot);
3177
- const methodName = fnName.slice(lastDot + 1);
3178
- const obj = resolveVar(objPath, context);
3179
- if (obj && typeof obj === "object" && methodName in obj) {
3180
- const method = obj[methodName];
3181
- if (typeof method === "function") {
3182
- if (rawArgs.trim()) {
3183
- const parts = splitArgs(rawArgs);
3184
- const evalArgs = parts.map((a) => evalExpr(a.trim(), context));
3185
- return method.apply(obj, evalArgs);
3186
- }
3187
- return method.call(obj);
3188
- }
3189
- }
3190
- } else {
3191
- const fn = context[fnName] ?? resolveVar(fnName, context);
3192
- if (typeof fn === "function") {
3193
- if (rawArgs.trim()) {
3194
- const parts = splitArgs(rawArgs);
3195
- const evalArgs = parts.map((a) => evalExpr(a.trim(), context));
3196
- return fn(...evalArgs);
3197
- }
3198
- return fn();
3199
- }
3145
+ return EXPR_NOT_MATCHED;
3146
+ }
3147
+ function evaluateCallArgs(rawArgs, context) {
3148
+ return rawArgs.trim() ? splitArgs(rawArgs).map((arg) => evalExpr(arg.trim(), context)) : [];
3149
+ }
3150
+ function evalDottedFunction(name, rawArgs, context) {
3151
+ const lastDot = name.lastIndexOf(".");
3152
+ const owner = resolveVar(name.slice(0, lastDot), context);
3153
+ const member = name.slice(lastDot + 1);
3154
+ if (!owner || typeof owner !== "object" || !(member in owner)) {
3155
+ return EXPR_NOT_MATCHED;
3156
+ }
3157
+ const method = owner[member];
3158
+ return typeof method === "function" ? method.apply(owner, evaluateCallArgs(rawArgs, context)) : EXPR_NOT_MATCHED;
3159
+ }
3160
+ function evalFunctionExpression(expr, context) {
3161
+ const match = expr.match(FN_CALL_RE);
3162
+ if (!match) return EXPR_NOT_MATCHED;
3163
+ const name = match[1];
3164
+ const rawArgs = match[2] || "";
3165
+ if (name.includes(".")) return evalDottedFunction(name, rawArgs, context);
3166
+ const fn = context[name] ?? resolveVar(name, context);
3167
+ if (typeof fn === "function") return fn(...evaluateCallArgs(rawArgs, context));
3168
+ return EXPR_NOT_MATCHED;
3169
+ }
3170
+ function evalExpr(expr, context) {
3171
+ expr = expr.trim();
3172
+ const cachedForm = expressionFormCache.get(expr);
3173
+ if (cachedForm !== void 0) {
3174
+ if (cachedForm === -1) return resolveVar(expr, context);
3175
+ const result = EXPR_EVALUATORS[cachedForm](expr, context);
3176
+ return result === EXPR_NOT_MATCHED ? resolveVar(expr, context) : result;
3177
+ }
3178
+ for (let index = 0; index < EXPR_EVALUATORS.length; index++) {
3179
+ const result = EXPR_EVALUATORS[index](expr, context);
3180
+ if (result !== EXPR_NOT_MATCHED) {
3181
+ capCache(expressionFormCache, MEMO_CACHE_MAX);
3182
+ expressionFormCache.set(expr, index);
3183
+ return result;
3200
3184
  }
3201
3185
  }
3186
+ capCache(expressionFormCache, MEMO_CACHE_MAX);
3187
+ expressionFormCache.set(expr, FN_CALL_RE.test(expr) ? EXPR_EVALUATORS.length - 1 : -1);
3202
3188
  return resolveVar(expr, context);
3203
3189
  }
3204
3190
  function findTernary(expr) {
@@ -3654,7 +3640,7 @@ function _generateFormToken(descriptor = "") {
3654
3640
  function _generateFormTokenValue(descriptor = "") {
3655
3641
  return new SafeString(_buildFormTokenJwt(descriptor));
3656
3642
  }
3657
- var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, GATEABLE_TAGS, BLOCK_TAG_ENDS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, EXTENDS_RE, EXTENDS_RE_GLOBAL, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, MEMO_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
3643
+ var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, GATEABLE_TAGS, BLOCK_TAG_ENDS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, EXTENDS_RE, EXTENDS_RE_GLOBAL, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, MEMO_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, EXPR_NOT_MATCHED, ARITHMETIC_OPERATIONS, EXPR_EVALUATORS, expressionFormCache, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
3658
3644
  var init_engine = __esm({
3659
3645
  "../frond/src/engine.ts"() {
3660
3646
  "use strict";
@@ -3756,6 +3742,25 @@ var init_engine = __esm({
3756
3742
  MEMO_CACHE_MAX = 1024;
3757
3743
  TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
3758
3744
  RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g;
3745
+ EXPR_NOT_MATCHED = Symbol("frond-expression-not-matched");
3746
+ ARITHMETIC_OPERATIONS = {
3747
+ "+": (left, right) => left + right,
3748
+ "-": (left, right) => left - right,
3749
+ "*": (left, right) => left * right,
3750
+ "//": (left, right) => right !== 0 ? Math.floor(left / right) : 0,
3751
+ "/": (left, right) => right !== 0 ? left / right : 0,
3752
+ "%": (left, right) => right !== 0 ? left % right : 0,
3753
+ "**": (left, right) => left ** right
3754
+ };
3755
+ EXPR_EVALUATORS = [
3756
+ evalPrimary,
3757
+ evalConditional,
3758
+ evalConcatOrComparison,
3759
+ evalArithmeticExpression,
3760
+ evalFilterExpression,
3761
+ evalFunctionExpression
3762
+ ];
3763
+ expressionFormCache = /* @__PURE__ */ new Map();
3759
3764
  VarRef = class {
3760
3765
  constructor(name) {
3761
3766
  this.name = name;
@@ -12232,7 +12237,7 @@ var init_metrics = __esm({
12232
12237
  };
12233
12238
  INSTALL_HINT = "update the native tina4 CLI: https://tina4.com/cli";
12234
12239
  SUMMARY_KEYS = ["files_analyzed", "total_functions", "avg_complexity", "avg_maintainability"];
12235
- FILE_KEYS = ["path", "loc", "avg_complexity", "maintainability", "has_tests"];
12240
+ FILE_KEYS = ["path", "loc", "avg_complexity", "maintainability", "has_referencing_test"];
12236
12241
  FUNCTION_KEYS = ["name", "file", "line", "complexity", "loc"];
12237
12242
  }
12238
12243
  });
@@ -36,8 +36,8 @@ export declare const pathParseCache: Map<string, [string[], boolean[]]>;
36
36
  */
37
37
  export declare const TEMPLATE_CACHE_MAX = 256;
38
38
  /**
39
- * Hard cap on every per-expression memo cache — `filterChainCache` and
40
- * `pathParseCache` (ADR-0004, parity with PHP's MEMO_CACHE_MAX and the
39
+ * Hard cap on every per-expression memo cache — `filterChainCache`,
40
+ * `pathParseCache`, and `expressionFormCache` (ADR-0004, parity with PHP's MEMO_CACHE_MAX and the
41
41
  * Python master's `@lru_cache(maxsize=1024)` on the equivalent module-level
42
42
  * parsers). Deliberately higher than TEMPLATE_CACHE_MAX: one entry here is a
43
43
  * small parsed-path array, orders of magnitude smaller than a token list.
@@ -47,6 +47,8 @@ export declare const TEMPLATE_CACHE_MAX = 256;
47
47
  * string, the same order of magnitude as a compiled template.
48
48
  */
49
49
  export declare const MEMO_CACHE_MAX = 1024;
50
+ /** Cached expression dispatcher branch; exported only for cache-bound verification. */
51
+ export declare const expressionFormCache: Map<string, number>;
50
52
  /**
51
53
  * Set the session ID used by formToken() / form_token() for CSRF session binding.
52
54
  */