All files / lib/core/expression compile.js

85.1% Statements 160/188
86.95% Branches 20/23
66.66% Functions 6/9
85.1% Lines 160/188

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x 504x                     504x 504x 504x 504x 504x 2x 2x 2x 504x 504x 504x 504x 504x 504x     504x 504x 504x 504x 504x 504x 504x 504x 504x 2x 2x 504x 504x 504x 504x 504x 504x 504x 2296x 2296x 1x 1x 2296x 1411x 1411x 884x 884x 884x 884x 884x 2296x 5x 5x 5x 5x 5x     2296x 504x 504x 504x 504x 504x 504x 504x 186x 186x 186x 5x 5x 186x 58x 58x 123x 123x 123x 123x 123x 186x 30x 30x 30x 30x 30x     186x 504x 504x 504x 504x 504x 504x 504x 4x 4x 4x             4x 504x 504x 504x 504x 504x 504x 504x 2x 2x 504x 504x 504x 504x 504x 504x 504x 504x 504x              
/**
 * @file compile.js
 * @description Turns expression source into a reusable compiled accessor.
 *
 * One parse per unique expression, cached, then every evaluation walks the AST.
 * That is the same cost profile the previous implementation had — it compiled a
 * `Function` once per unique source and cached that — minus the `eval`.
 *
 * ## The fallback, and why it is visible
 *
 * The parser covers the expression language templates use, not all of
 * ECMAScript: statements, `async`/`await`, destructuring and regular-expression
 * literals are out of scope, and action bodies are arbitrary JavaScript.
 * Anything the parser cannot read is handed to the legacy evaluator, which
 * still uses `new Function`.
 *
 * That fallback is recorded rather than silent. {@link getFallbackReport} lists
 * every expression that took it, so "does this application still need
 * `unsafe-eval`?" has an answer that comes from the code rather than from a
 * claim — and `avenx check` can print it. A framework that quietly degraded
 * here would be making exactly the kind of promise this work exists to stop
 * making.
 * @module lib/core/expression/compile
 */
 
import { parseExpression, parseExpressionProgram, ExpressionParseError } from './parser.js';
import { evaluate } from './evaluator.js';
import { LruCache } from '../utils/LruCache.js';
 
/**
 * Compiled ASTs by source text.
 * @type {LruCache}
 */
let astCache = new LruCache(1000);
 
/**
 * Sources the parser could not read, and why.
 * @type {Map<string, string>}
 */
const fallbacks = new Map();
 
/**
 * A sentinel stored for sources known to be unparseable, so a failing
 * expression is not re-parsed on every evaluation.
 * @type {object}
 */
const UNPARSEABLE = { unparseable: true };
 
/**
 * Sets the compiled-expression cache capacity.
 * @param {number} capacity - Positive integer limit.
 */
export function setExpressionCacheCapacity(capacity) {
  if (typeof capacity !== 'number' || capacity <= 0 || !Number.isInteger(capacity)) {
    throw new Error('Expression cache capacity must be a positive integer');
  }
  const previous = astCache;
  astCache = new LruCache(capacity);
  for (const [key, value] of previous.cache.entries()) {
    if (astCache.size >= capacity) break;
    astCache.set(key, value);
  }
}
 
/**
 * Empties the compiled-expression cache and the fallback report.
 */
export function clearExpressionCache() {
  astCache.clear();
  fallbacks.clear();
}
 
/**
 * Current cache occupancy, for diagnostics and tests.
 * @returns {{size: number, fallbacks: number}} Cache statistics.
 */
export function getExpressionCacheStats() {
  return { size: astCache.size, fallbacks: fallbacks.size };
}
 
/**
 * Every expression that could not be parsed, with the reason.
 *
 * An application whose report is empty evaluates entirely through the AST
 * evaluator and needs no `'unsafe-eval'` for its expressions.
 * @returns {Array<{source: string, reason: string}>} The fallback list.
 */
export function getFallbackReport() {
  return [...fallbacks.entries()].map(([source, reason]) => ({ source, reason }));
}
 
/**
 * Parses an expression, returning null when it is outside the language.
 * @param {string} source - The expression source.
 * @returns {object|null} The AST, or null when it cannot be parsed.
 */
export function compileExpression(source) {
  const cached = astCache.get(source);
  if (cached === UNPARSEABLE) {
    return null;
  }
  if (cached) {
    return cached;
  }
 
  try {
    const ast = parseExpression(source);
    astCache.set(source, ast);
    return ast;
  } catch (error) {
    if (error instanceof ExpressionParseError) {
      astCache.set(source, UNPARSEABLE);
      fallbacks.set(source, error.message);
      return null;
    }
    throw error;
  }
}
 
/**
 * Parses a statement body as a run of expressions, or returns null.
 * @param {string} source - The statement source.
 * @returns {object|null} A Program AST, or null when it is not expression-only.
 */
export function compileStatements(source) {
  const key = `stmt\u0000${source}`;
  const cached = astCache.get(key);
  if (cached === UNPARSEABLE) {
    return null;
  }
  if (cached) {
    return cached;
  }
 
  try {
    const ast = parseExpressionProgram(source);
    astCache.set(key, ast);
    return ast;
  } catch (error) {
    if (error instanceof ExpressionParseError) {
      astCache.set(key, UNPARSEABLE);
      fallbacks.set(source, error.message);
      return null;
    }
    throw error;
  }
}
 
/**
 * Why an expression could not be parsed.
 * @param {string} source - The expression source.
 * @returns {string} The parser's reason, or a generic message.
 */
export function describeParseFailure(source) {
  if (fallbacks.has(source)) {
    return fallbacks.get(source);
  }
  try {
    parseExpression(source);
    return 'the expression parsed on a second attempt';
  } catch (error) {
    return error.message;
  }
}
 
/**
 * Whether an expression can be evaluated without `new Function`.
 * @param {string} source - The expression source.
 * @returns {boolean} True when the AST evaluator can handle it.
 */
export function isCompilable(source) {
  return compileExpression(source) !== null;
}
 
/**
 * Evaluates an expression against a scope.
 * @param {string} source - The expression source.
 * @param {object} scope - The evaluation scope.
 * @returns {{handled: boolean, value: any}} Whether the AST evaluator ran, and
 *   the value it produced. `handled: false` means the caller should fall back.
 */
export function runExpression(source, scope) {
  const ast = compileExpression(source);
  if (!ast) {
    return { handled: false, value: undefined };
  }
  return { handled: true, value: evaluate(ast, scope) };
}