/**
* @file validateExpressions.js
* @description Checks template expressions at build time, not at render time.
*
* Template interpolations, computed values and directive bindings are evaluated
* by Avenx's own expression evaluator, which covers the expression language and
* refuses anything outside it. Without this pass a developer finds that out
* when the component renders — possibly in production, possibly on a branch a
* test never took.
*
* The compiler already holds every one of those expressions as source text.
* Parsing them here means an unsupported expression fails the build, with the
* file, the line and the reason, instead of becoming an AVX_R32 at runtime.
*
* Action bodies are deliberately not checked. They are statement JavaScript and
* always have been; `if`, `for` and `return` are not expressions, and the
* runtime runs them as statements.
* @module lib/compiler/validateExpressions
*/
import { compileExpression, describeParseFailure } from '../core/expression/compile.js';
import { createInterpolationRegex } from '../core/utils/templateUtils.js';
import { createLineIndex } from './parser/tokenizer.js';
import { AvenxErrorCodes } from '../core/runtime/AvenxError.js';
import { TemplateValidationError } from './errors/TemplateValidationError.js';
/**
* Attributes whose value is an expression rather than a statement.
*
* `data-ax-event` holds handler statements and is excluded: a handler may
* legitimately be a statement sequence.
* @type {string[]}
*/
const EXPRESSION_ATTRIBUTES = [
'data-ax-for',
'data-ax-key',
'data-ax-show',
'data-ax-html',
'data-ax-class',
'data-ax-style',
'data-ax-if',
];
/**
* Finds every expression in a template, with its source offset.
*
* Interpolations and directive attribute values only. Reading them out of the
* rewritten template rather than the original source means the offsets point at
* a string the developer never wrote, so the location is reported against the
* original file by searching for the expression text — imprecise for a repeated
* expression, and honest about it: the message names the expression itself,
* which is what a developer searches for.
* @param {string} template - The rewritten template.
* @returns {Array<{source: string, kind: string}>} The expressions found.
*/
export function collectTemplateExpressions(template) {
/** @type {Array<{source: string, kind: string}>} */
const found = [];
if (typeof template !== 'string' || template === '') {
return found;
}
const interpolation = createInterpolationRegex();
let match;
while ((match = interpolation.exec(template)) !== null) {
const source = (match[1] !== undefined ? match[1] : match[2] || '').trim();
if (source) {
found.push({ source, kind: 'interpolation' });
}
}
for (const name of EXPRESSION_ATTRIBUTES) {
const attribute = new RegExp(`${name}="([^"]*)"`, 'g');
let attrMatch;
while ((attrMatch = attribute.exec(template)) !== null) {
const raw = attrMatch[1];
// A directive value may be written as an interpolation --
// data-ax-style="{{ { fontWeight: x } }}" -- in which case the braces are
// the interpolation, not the expression. Those are already collected by
// the interpolation pass above; parsing the wrapper as an expression
// would reject perfectly valid markup.
//
// `{% %}` is the same thing inside a loop body, where the escaping pass
// rewrites `{{ }}` so the enclosing render does not evaluate it per
// template instead of per item. The interpolation pass does not see
// those, so skipping them here is what stops a valid directive inside a
// `<@for>` being reported as a malformed expression.
if (raw.includes('{{') || raw.includes('{%')) {
continue;
}
const source = raw
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/&/g, '&')
.trim();
if (source) {
found.push({ source, kind: name });
}
}
}
return found;
}
/**
* Validates every expression a component evaluates as an expression.
* @param {object} unit - The component being compiled.
* @param {string} unit.name - The component name.
* @param {string} unit.filePath - Absolute path to the component file.
* @param {string} unit.content - The original source, for locating errors.
* @param {string} unit.template - The rewritten template.
* @param {Object<string, string>} unit.computed - Computed expressions by name.
* @returns {TemplateValidationError[]} One error per unsupported expression.
*/
export function validateComponentExpressions({ name, filePath, content, template, computed }) {
/** @type {TemplateValidationError[]} */
const errors = [];
const lines = content ? createLineIndex(content) : null;
/**
* Records an error for one unsupported expression.
* @param {string} source - The expression source.
* @param {string} where - What kind of binding it is.
*/
const reject = (source, where) => {
const error = new TemplateValidationError(
AvenxErrorCodes.EXPRESSION_UNSUPPORTED,
source,
`${describeParseFailure(source)} (in ${where} of <${name}>)`,
);
if (content && lines) {
const index = content.indexOf(source);
if (index >= 0) {
error.setLocation({ source: content, index, filename: filePath });
}
}
errors.push(error);
};
for (const [key, expression] of Object.entries(computed || {})) {
if (typeof expression !== 'string' || expression.trim() === '') continue;
if (!compileExpression(expression)) {
reject(expression, `<computed name="${key}">`);
}
}
for (const { source, kind } of collectTemplateExpressions(template)) {
if (!compileExpression(source)) {
reject(source, kind === 'interpolation' ? 'a template interpolation' : `a ${kind} binding`);
}
}
return errors;
}