Source: lib/core/expression/compile.js

/**
 * @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) };
}