Source: lib/compiler/codegen/expression.js

/**
 * @file expression.js
 * @description Turns a parsed template expression into JavaScript source.
 *
 * ## What moved, and why
 *
 * Avenx used to ship every template expression, computed value and handler to
 * the browser as **source text**, and evaluate it by walking an AST there. That
 * put a parser, an interpreter and a scope walker in every production bundle,
 * and made the cost of reading `count` a recursive dispatch rather than a
 * property access.
 *
 * The parse is a pure function of the source, and the compiler already has the
 * source. So the parse happens here, once, at build time, and what reaches the
 * browser is a closure the engine itself compiled:
 *
 * ```text
 * count * 2        →   ($s) => axGet($s, "count") * 2
 * item.qty         →   ($s) => axRead(axGet($s, "item"), "qty", false)
 * items.filter(i => !i.done)
 *                  →   ($s) => axCall(axRead(axGet($s,"items"), "filter", false),
 *                                     axGet($s,"items"), [(i) => !axRead(i,"done",false)],
 *                                     "items.filter")
 * ```
 *
 * ## The security boundary is unchanged
 *
 * Every gate the interpreter applied is still applied, in the same place and on
 * the same terms — it is simply called rather than interpreted. A member read
 * emits {@link module:lib/core/expression/ops.readMember} with the key already
 * resolved, so `x['const'+'ructor']` and `x.constructor` still arrive at one
 * check as one string. A call emits `callFunction`. A free identifier emits
 * `readIdentifier`, which resolves scope-first and routes an allowed global
 * through the tracer's substitution point.
 *
 * Two checks get *stronger* by moving: naming a restricted global and writing a
 * forbidden static key are now build errors with a source location, rather than
 * runtime refusals a developer only sees when the branch executes.
 *
 * ## No `eval`, by construction
 *
 * The emitted text is written into the component module the bundler links, so
 * the engine compiles it exactly as it compiles the rest of the bundle. Nothing
 * here or downstream calls `eval` or `new Function`, which is what makes "an
 * Avenx page needs no 'unsafe-eval'" a property of the pipeline rather than a
 * claim about it.
 *
 * ## Arrow parameters stop being a runtime concept
 *
 * The interpreter carried a `Frame` chain so a lambda parameter would not
 * resolve against component state. A compiled arrow's parameters are real
 * JavaScript parameters, so the engine's own scoping does that work: this
 * module only has to know which names are lexically bound so it emits the bare
 * name instead of a scope read.
 * @module lib/compiler/codegen/expression
 */

import { parseExpression, parseExpressionProgram, ExpressionParseError } from '../../core/expression/parser.js';
import { ALLOWED_GLOBALS, RESTRICTED_GLOBALS, FORBIDDEN_KEYS } from '../../core/expression/ops.js';

/**
 * The local names the emitted code uses for the runtime primitives.
 *
 * Short, prefixed, and declared in one place so the module emitter and the
 * generator cannot drift. `$s` is the scope; everything else is imported from
 * the runtime.
 * @type {Object<string, string>}
 */
export const RUNTIME_BINDINGS = {
  scope: '$s',
  read: 'axRead',
  write: 'axWrite',
  call: 'axCall',
  construct: 'axNew',
  get: 'axGet',
  set: 'axSet',
  typeofName: 'axTypeof',
  key: 'axKey',
  hasIn: 'axIn',
};

/**
 * The named runtime imports a module containing compiled expressions needs.
 * @type {string[]}
 */
export const RUNTIME_IMPORT_NAMES = [
  RUNTIME_BINDINGS.read,
  RUNTIME_BINDINGS.write,
  RUNTIME_BINDINGS.call,
  RUNTIME_BINDINGS.construct,
  RUNTIME_BINDINGS.get,
  RUNTIME_BINDINGS.set,
  RUNTIME_BINDINGS.typeofName,
  RUNTIME_BINDINGS.key,
  RUNTIME_BINDINGS.hasIn,
];

/**
 * Raised when an expression cannot be compiled.
 */
export class ExpressionCodegenError extends Error {
  /**
   * @param {string} message - What could not be compiled.
   * @param {string} source - The expression source.
   */
  constructor(message, source) {
    super(message);
    this.name = 'ExpressionCodegenError';
    /** @type {string} */
    this.source = source;
  }
}

/**
 * Binary operators that map straight onto JavaScript's own.
 *
 * `in` is absent deliberately: the interpreter coerced the right-hand side with
 * `Object(right)`, and emitting a bare `in` would throw where the interpreter
 * returned false.
 * @type {Set<string>}
 */
const DIRECT_BINARY = new Set([
  '+', '-', '*', '/', '%', '**',
  '==', '!=', '===', '!==',
  '<', '>', '<=', '>=',
  '&', '|', '^', '<<', '>>', '>>>',
  'instanceof',
]);

/**
 * Unary operators that map straight onto JavaScript's own.
 * @type {Set<string>}
 */
const DIRECT_UNARY = new Set(['!', '-', '+', '~', 'void']);

/**
 * A lexical scope of names bound by enclosing arrow parameters.
 */
class Bound {
  /**
   * @param {Bound|null} parent - The enclosing binding set.
   * @param {string[]} names - Names this level binds.
   */
  constructor(parent, names) {
    this.parent = parent;
    this.names = new Set(names);
  }

  /**
   * @param {string} name - The name to test.
   * @returns {boolean} Whether any enclosing level binds it.
   */
  has(name) {
    let level = this;
    while (level) {
      if (level.names.has(name)) return true;
      level = level.parent;
    }
    return false;
  }
}

/**
 * Quotes a string as a JavaScript literal.
 * @param {any} value - The value to quote.
 * @returns {string} The literal source.
 */
function quote(value) {
  return JSON.stringify(String(value));
}

/**
 * Emits a literal value.
 * @param {any} value - The literal's value.
 * @returns {string} The literal source.
 */
function emitLiteral(value) {
  if (value === undefined) return 'undefined';
  if (value === null) return 'null';
  if (typeof value === 'number') {
    // `Infinity` and `NaN` have no literal form that survives JSON.
    if (Number.isNaN(value)) return 'NaN';
    if (value === Infinity) return 'Infinity';
    if (value === -Infinity) return '-Infinity';
    return String(value);
  }
  if (typeof value === 'boolean') return String(value);
  return JSON.stringify(value);
}

/**
 * Describes a node for a runtime error message, matching the interpreter.
 * @param {object} node - The node.
 * @returns {string} A short human-readable description.
 */
function describe(node) {
  switch (node.type) {
    case 'Identifier': return node.name;
    case 'Literal': return JSON.stringify(node.value);
    case 'Member':
      return node.computed
        ? `${describe(node.object)}[…]`
        : `${describe(node.object)}.${node.property.value}`;
    case 'Call': return `${describe(node.callee)}(…)`;
    default: return 'expression';
  }
}

/**
 * Generates JavaScript for one AST node.
 * @param {object} node - The AST node.
 * @param {Bound|null} bound - Names bound by enclosing arrow parameters.
 * @param {string} source - The original expression, for error messages.
 * @returns {string} JavaScript source for the node's value.
 */
function emit(node, bound, source) {
  const B = RUNTIME_BINDINGS;

  switch (node.type) {
    case 'Literal':
      return emitLiteral(node.value);

    case 'Identifier': {
      if (bound && bound.has(node.name)) {
        return node.name;
      }
      // A restricted global can never resolve, so refusing here turns a runtime
      // sandbox violation into a build error that names the file and the line.
      if (RESTRICTED_GLOBALS.has(node.name)) {
        throw new ExpressionCodegenError(
          `"${node.name}" is a restricted global and cannot be used in a template expression. ` +
            'Move the browser API into a component action and call that instead.',
          source,
        );
      }
      return `${B.get}(${B.scope}, ${quote(node.name)})`;
    }

    case 'TemplateLiteral': {
      let out = '""';
      for (let i = 0; i < node.quasis.length; i++) {
        if (node.quasis[i] !== '') {
          out += ` + ${JSON.stringify(node.quasis[i])}`;
        }
        if (i < node.expressions.length) {
          out += ` + String(${emit(node.expressions[i], bound, source)})`;
        }
      }
      return `(${out})`;
    }

    case 'ArrayLiteral': {
      const items = node.elements.map((element) =>
        element.type === 'Spread'
          ? `...${emit(element.argument, bound, source)}`
          : emit(element, bound, source),
      );
      return `[${items.join(', ')}]`;
    }

    case 'ObjectLiteral': {
      const parts = [];
      for (const property of node.properties) {
        if (property.kind === 'spread') {
          parts.push(`...${emit(property.value, bound, source)}`);
          continue;
        }
        const value = emit(property.value, bound, source);
        if (property.key.computed) {
          // A computed key never sets the prototype, but the interpreter
          // refused a forbidden one outright and that behaviour is preserved.
          parts.push(`[${B.key}(${emit(property.key.node, bound, source)})]: ${value}`);
          continue;
        }
        const name = String(property.key.node.value);
        if (FORBIDDEN_KEYS.has(name)) {
          throw new ExpressionCodegenError(
            `Defining property "${name}" is blocked for security reasons.`,
            source,
          );
        }
        parts.push(`${quote(name)}: ${value}`);
      }
      return `({${parts.join(', ')}})`;
    }

    case 'Member': {
      const object = emit(node.object, bound, source);
      const key = node.computed
        ? emit(node.property, bound, source)
        : quote(node.property.value);
      if (!node.computed && FORBIDDEN_KEYS.has(String(node.property.value))) {
        throw new ExpressionCodegenError(
          `Access to property "${node.property.value}" is blocked for security reasons.`,
          source,
        );
      }
      return `${B.read}(${object}, ${key}, ${node.optional ? 'true' : 'false'})`;
    }

    case 'Call':
      return emitCall(node, bound, source);

    case 'New': {
      const args = emitArguments(node.args, bound, source);
      return `${B.construct}(${emit(node.callee, bound, source)}, ${args}, ${quote(describe(node.callee))})`;
    }

    case 'Unary': {
      if (node.operator === 'typeof' && node.argument.type === 'Identifier' && !(bound && bound.has(node.argument.name))) {
        return `${B.typeofName}(${B.scope}, ${quote(node.argument.name)})`;
      }
      const argument = emit(node.argument, bound, source);
      if (node.operator === 'typeof') {
        return `(typeof ${argument})`;
      }
      if (DIRECT_UNARY.has(node.operator)) {
        return `(${node.operator === 'void' ? 'void ' : node.operator}${argument})`;
      }
      throw new ExpressionCodegenError(`Unsupported operator "${node.operator}"`, source);
    }

    case 'Binary': {
      const left = emit(node.left, bound, source);
      const right = emit(node.right, bound, source);
      if (node.operator === 'in') {
        return `${B.hasIn}(${left}, ${right})`;
      }
      if (DIRECT_BINARY.has(node.operator)) {
        return `(${left} ${node.operator} ${right})`;
      }
      throw new ExpressionCodegenError(`Unsupported operator "${node.operator}"`, source);
    }

    case 'Logical':
      return `(${emit(node.left, bound, source)} ${node.operator} ${emit(node.right, bound, source)})`;

    case 'Conditional':
      return `(${emit(node.test, bound, source)} ? ${emit(node.consequent, bound, source)} : ${emit(node.alternate, bound, source)})`;

    case 'Assignment':
      return emitAssignment(node, bound, source);

    case 'Update':
      return emitUpdate(node, bound, source);

    case 'Arrow': {
      const inner = new Bound(bound, node.params);
      const body = emit(node.body, inner, source);
      return `((${node.params.join(', ')}) => ${body})`;
    }

    case 'Sequence':
      return `(${node.expressions.map((expression) => emit(expression, bound, source)).join(', ')})`;

    case 'Spread':
      throw new ExpressionCodegenError('A spread element is not valid here', source);

    default:
      throw new ExpressionCodegenError(`Unsupported expression node "${node.type}"`, source);
  }
}

/**
 * Emits an argument list, including spreads.
 * @param {object[]} args - Argument nodes.
 * @param {Bound|null} bound - Enclosing bindings.
 * @param {string} source - The original expression.
 * @returns {string} An array literal of the arguments.
 */
function emitArguments(args, bound, source) {
  const parts = args.map((arg) =>
    arg.type === 'Spread' ? `...${emit(arg.argument, bound, source)}` : emit(arg, bound, source),
  );
  return `[${parts.join(', ')}]`;
}

/**
 * Emits a call, preserving the receiver for a method call.
 *
 * The receiver has to be evaluated once and used twice — as the object of the
 * member read and as `this` — so a method call binds it to a temporary rather
 * than emitting the object expression twice, which would run its side effects
 * twice.
 * @param {object} node - The Call node.
 * @param {Bound|null} bound - Enclosing bindings.
 * @param {string} source - The original expression.
 * @returns {string} JavaScript source for the call.
 */
function emitCall(node, bound, source) {
  const B = RUNTIME_BINDINGS;
  const { callee } = node;
  const args = emitArguments(node.args, bound, source);

  if (callee.type === 'Member') {
    const object = emit(callee.object, bound, source);
    const key = callee.computed
      ? emit(callee.property, bound, source)
      : quote(callee.property.value);
    if (!callee.computed && FORBIDDEN_KEYS.has(String(callee.property.value))) {
      throw new ExpressionCodegenError(
        `Access to property "${callee.property.value}" is blocked for security reasons.`,
        source,
      );
    }
    const description = quote(
      callee.computed ? `${describe(callee.object)}[…]` : `${describe(callee.object)}.${callee.property.value}`,
    );

    // `(o => …)(object)` keeps the receiver in one evaluation. The optional
    // chain short-circuits before the read, exactly as `?.` does.
    const short = callee.optional ? '$o === null || $o === undefined ? undefined : ' : '';
    const inner = `${B.call}(${B.read}($o, ${key}, ${callee.optional ? 'true' : 'false'}), $o, ${args}, ${description})`;
    const called = node.optional
      ? `(($f) => $f === null || $f === undefined ? undefined : ${B.call}($f, $o, ${args}, ${description}))(${B.read}($o, ${key}, ${callee.optional ? 'true' : 'false'}))`
      : inner;
    return `(($o) => ${short}${called})(${object})`;
  }

  const fn = emit(callee, bound, source);
  const description = quote(describe(callee));
  if (node.optional) {
    return `(($f) => $f === null || $f === undefined ? undefined : ${B.call}($f, undefined, ${args}, ${description}))(${fn})`;
  }
  return `${B.call}(${fn}, undefined, ${args}, ${description})`;
}

/**
 * Emits an assignment, routing writes through the guarded primitives.
 * @param {object} node - The Assignment node.
 * @param {Bound|null} bound - Enclosing bindings.
 * @param {string} source - The original expression.
 * @returns {string} JavaScript source for the assignment.
 */
function emitAssignment(node, bound, source) {
  const B = RUNTIME_BINDINGS;
  const { target, operator } = node;
  const value = emit(node.value, bound, source);

  if (target.type === 'Identifier') {
    if (bound && bound.has(target.name)) {
      // A lambda parameter is an ordinary JavaScript binding.
      return `(${target.name} ${operator} ${value})`;
    }
    const name = quote(target.name);
    const current = `${B.get}(${B.scope}, ${name})`;
    if (operator === '=') {
      return `${B.set}(${B.scope}, ${name}, ${value})`;
    }
    if (operator === '&&=') {
      return `(($c) => $c ? ${B.set}(${B.scope}, ${name}, ${value}) : $c)(${current})`;
    }
    if (operator === '||=') {
      return `(($c) => $c ? $c : ${B.set}(${B.scope}, ${name}, ${value}))(${current})`;
    }
    if (operator === '??=') {
      return `(($c) => $c !== null && $c !== undefined ? $c : ${B.set}(${B.scope}, ${name}, ${value}))(${current})`;
    }
    return `${B.set}(${B.scope}, ${name}, (${current} ${operator.slice(0, -1)} ${value}))`;
  }

  const object = emit(target.object, bound, source);
  const key = target.computed ? emit(target.property, bound, source) : quote(target.property.value);
  if (!target.computed && FORBIDDEN_KEYS.has(String(target.property.value))) {
    throw new ExpressionCodegenError(
      `Writing to property "${target.property.value}" is blocked for security reasons.`,
      source,
    );
  }

  // Object and key are each evaluated once and reused, so `a[i++] += 1` does
  // not advance `i` twice.
  const current = `${B.read}($o, $k, false)`;
  if (operator === '=') {
    return `(($o, $k) => ${B.write}($o, $k, ${value}))(${object}, ${key})`;
  }
  if (operator === '&&=') {
    return `(($o, $k) => { const $c = ${current}; return $c ? ${B.write}($o, $k, ${value}) : $c; })(${object}, ${key})`;
  }
  if (operator === '||=') {
    return `(($o, $k) => { const $c = ${current}; return $c ? $c : ${B.write}($o, $k, ${value}); })(${object}, ${key})`;
  }
  if (operator === '??=') {
    return `(($o, $k) => { const $c = ${current}; return $c !== null && $c !== undefined ? $c : ${B.write}($o, $k, ${value}); })(${object}, ${key})`;
  }
  return `(($o, $k) => ${B.write}($o, $k, (${current} ${operator.slice(0, -1)} ${value})))(${object}, ${key})`;
}

/**
 * Emits `++` / `--`, preserving prefix and postfix value semantics.
 * @param {object} node - The Update node.
 * @param {Bound|null} bound - Enclosing bindings.
 * @param {string} source - The original expression.
 * @returns {string} JavaScript source for the update.
 */
function emitUpdate(node, bound, source) {
  const B = RUNTIME_BINDINGS;
  const { argument, operator, prefix } = node;
  const delta = operator === '++' ? '+ 1' : '- 1';

  if (argument.type === 'Identifier') {
    if (bound && bound.has(argument.name)) {
      return `(${prefix ? `${operator}${argument.name}` : `${argument.name}${operator}`})`;
    }
    const name = quote(argument.name);
    return (
      `(($c) => { const $n = $c ${delta}; ${B.set}(${B.scope}, ${name}, $n); return ${prefix ? '$n' : '$c'}; })` +
      `(Number(${B.get}(${B.scope}, ${name})))`
    );
  }

  const object = emit(argument.object, bound, source);
  const key = argument.computed ? emit(argument.property, bound, source) : quote(argument.property.value);
  if (!argument.computed && FORBIDDEN_KEYS.has(String(argument.property.value))) {
    throw new ExpressionCodegenError(
      `Writing to property "${argument.property.value}" is blocked for security reasons.`,
      source,
    );
  }
  return (
    `(($o, $k) => { const $c = Number(${B.read}($o, $k, false)); const $n = $c ${delta}; ` +
    `${B.write}($o, $k, $n); return ${prefix ? '$n' : '$c'}; })(${object}, ${key})`
  );
}

/**
 * Compiles one expression to a scope-taking arrow function.
 * @param {string} source - The expression source.
 * @returns {string} JavaScript source for `($s) => value`.
 * @throws {ExpressionCodegenError} When the expression cannot be compiled.
 */
export function compileExpressionToSource(source) {
  let ast;
  try {
    ast = parseExpression(source);
  } catch (error) {
    if (error instanceof ExpressionParseError) {
      throw new ExpressionCodegenError(error.message, source);
    }
    throw error;
  }
  return `(${RUNTIME_BINDINGS.scope}) => (${emit(ast, null, source)})`;
}

/**
 * Compiles a run of expression statements to a scope-taking arrow function.
 *
 * Used for inline event handlers and any action body that is a sequence of
 * expressions rather than real statement syntax. The value is discarded, which
 * is what the interpreter did for a `Program`.
 * @param {string} source - The statement source.
 * @returns {string} JavaScript source for `($s) => { … }`.
 * @throws {ExpressionCodegenError} When the source cannot be compiled.
 */
export function compileStatementsToSource(source) {
  let ast;
  try {
    ast = parseExpressionProgram(source);
  } catch (error) {
    if (error instanceof ExpressionParseError) {
      throw new ExpressionCodegenError(error.message, source);
    }
    throw error;
  }
  const body = ast.body.map((statement) => `${emit(statement, null, source)};`).join(' ');
  return `(${RUNTIME_BINDINGS.scope}) => { ${body} }`;
}

/**
 * Compiles an expression, returning null rather than throwing.
 * @param {string} source - The expression source.
 * @returns {{code: string}|{error: string}} The generated source, or the reason.
 */
export function tryCompileExpression(source) {
  try {
    return { code: compileExpressionToSource(source) };
  } catch (error) {
    return { error: error.message };
  }
}

/**
 * Compiles a statement run, returning null rather than throwing.
 * @param {string} source - The statement source.
 * @returns {{code: string}|{error: string}} The generated source, or the reason.
 */
export function tryCompileStatements(source) {
  try {
    return { code: compileStatementsToSource(source) };
  } catch (error) {
    return { error: error.message };
  }
}

/**
 * The import statement a module holding a compiled class body needs.
 *
 * One function rather than a literal repeated at each framing site. There are
 * three -- the CLI's module emitter, the Vite plugin's wrapper, and the tests
 * that frame a bare class body themselves -- and when the generator started
 * emitting calls to the expression primitives, every site that still wrote the
 * import by hand produced a module that threw `axGet is not defined` at its
 * first render. Deriving the line from the same list the generator emits
 * against makes that class of drift impossible.
 * @param {string} base - The base class name to import (`AvenxComponent` or `AvenxPage`).
 * @param {string} [specifier] - Where the base class comes from.
 * @param {string} [opsSpecifier] - Where the primitives come from, when that is
 *   somewhere else. A caller importing the base class straight from its own
 *   module -- a test does -- still has to reach the runtime index for these.
 * @returns {string} The import declaration, or two.
 */
export function runtimeImportStatement(base, specifier = 'avenx-core/runtime', opsSpecifier = specifier) {
  if (opsSpecifier === specifier) {
    return `import { ${[base, ...RUNTIME_IMPORT_NAMES].join(', ')} } from ${JSON.stringify(specifier)};`;
  }
  return [
    `import { ${base} } from ${JSON.stringify(specifier)};`,
    `import { ${RUNTIME_IMPORT_NAMES.join(', ')} } from ${JSON.stringify(opsSpecifier)};`,
  ].join('\n');
}

export { ALLOWED_GLOBALS };