/**
* @file BridgeParser.js
* @description Static analysis of Avenx bridge modules.
*
* A bridge is a normal ES module whose default export is a `bridge({...})`
* call. Because consumers reach it through an `import`, the compiler can read
* the whole picture from source alone: which bridges exist, what each one
* declares, which events it emits, and who imports it.
*
* This module answers those questions without a full JavaScript parser. It
* scans the `bridge({...})` argument with a brace/string-aware walker and reads
* only the top level of the object literal, which is all the declaration
* surface a bridge has.
*/
import fs from 'fs';
import path from 'path';
/**
* Module specifiers that resolve to the Avenx runtime.
* @type {RegExp}
*/
const RUNTIME_SPECIFIER = /^(avenx-core(\/(runtime|core))?|.*\/lib\/core(\/index\.js)?)$/;
/**
* Derives a bridge's name from its file name.
* `user-prefs.bridge.js` becomes `userPrefs`.
* @param {string} filePath - Path to the bridge module.
* @returns {string} The bridge name.
*/
export function bridgeNameFromFile(filePath) {
const base = path.basename(filePath).replace(/\.bridge\.js$/i, '');
return base
.split(/[-_.]/)
.filter(Boolean)
.map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
.join('');
}
/**
* Returns the identifier the compiled bundle uses for a bridge instance.
* @param {string} name - The bridge name.
* @returns {string} A valid JavaScript identifier.
*/
export function bridgeBindingName(name) {
return `__avx_bridge_${String(name).replace(/[^\w$]/g, '_')}`;
}
/**
* Walks source from an opening brace to its match, ignoring braces that appear
* inside strings, template literals or comments.
* @param {string} source - The source text.
* @param {number} openIndex - Index of the opening `{`.
* @returns {{ end: number, topLevelCommas: number[] }} The index of the matching
* `}` (or -1) and the offsets of commas at depth 1.
*/
function scanObjectLiteral(source, openIndex) {
let depth = 0;
let index = openIndex;
const topLevelCommas = [];
while (index < source.length) {
const char = source[index];
const next = source[index + 1];
// Comments
if (char === '/' && next === '/') {
const lineEnd = source.indexOf('\n', index);
index = lineEnd === -1 ? source.length : lineEnd;
continue;
}
if (char === '/' && next === '*') {
const blockEnd = source.indexOf('*/', index + 2);
index = blockEnd === -1 ? source.length : blockEnd + 2;
continue;
}
// Quoted strings
if (char === '"' || char === "'") {
index++;
while (index < source.length) {
if (source[index] === '\\') {
index += 2;
continue;
}
if (source[index] === char) break;
index++;
}
index++;
continue;
}
// Template literals, including ${...} substitutions
if (char === '`') {
index++;
while (index < source.length) {
if (source[index] === '\\') {
index += 2;
continue;
}
if (source[index] === '`') break;
if (source[index] === '$' && source[index + 1] === '{') {
let inner = 1;
index += 2;
while (index < source.length && inner > 0) {
if (source[index] === '{') inner++;
else if (source[index] === '}') inner--;
index++;
}
continue;
}
index++;
}
index++;
continue;
}
if (char === '{' || char === '[' || char === '(') {
depth++;
} else if (char === '}' || char === ']' || char === ')') {
depth--;
if (depth === 0) {
return { end: index, topLevelCommas };
}
} else if (char === ',' && depth === 1) {
topLevelCommas.push(index);
}
index++;
}
return { end: -1, topLevelCommas };
}
/**
* Matches the `: atomic(` that marks a bridge action as transactional.
* @type {RegExp}
*/
const ATOMIC_VALUE = /^\s*:\s*atomic\s*\(/;
/**
* Reads the member name that starts a top-level object-literal entry.
* @param {string} segment - Source of one entry, starting after `{` or `,`.
* @returns {{ name: string, kind: ('getter'|'action'|'property'), atomic: boolean= }|null}
* The parsed member, or null when the segment is not a recognisable declaration.
*/
function parseMember(segment) {
const match = segment.match(
/^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/|\s)*(?:(get|set)\s+)?(?:async\s+)?\*?\s*(?:(['"])([^'"]+)\2|([A-Za-z_$][\w$]*))\s*([:(])/,
);
if (!match) {
return null;
}
const accessor = match[1];
const name = match[3] !== undefined ? match[3] : match[4];
const delimiter = match[5];
if (accessor === 'get') {
return { name, kind: 'getter' };
}
if (accessor === 'set') {
// Setters are not part of the Bridge surface; report them as properties so
// the caller can reject them with a clear message.
return { name, kind: 'property' };
}
if (delimiter === '(') {
return { name, kind: 'action' };
}
// `addQty: atomic(function (id, n) { ... })` is an action wearing a wrapper.
// Without this it reads as a plain property, which the Bridge API rejects
// and which would hide the body from Atlas.
if (delimiter === ':' && ATOMIC_VALUE.test(segment.slice(segment.indexOf(name) + name.length))) {
return { name, kind: 'action', atomic: true };
}
return { name, kind: 'property' };
}
/**
* Splits the top level of an object literal into member declarations.
* @param {string} source - The full source text.
* @param {number} openIndex - Index of the literal's opening `{`.
* @returns {{ members: Array<{name: string, kind: string, valueStart: number}>, end: number }}
* The declared members and the index of the closing brace.
*/
function parseObjectMembers(source, openIndex) {
const { end, topLevelCommas } = scanObjectLiteral(source, openIndex);
if (end === -1) {
return { members: [], end };
}
const boundaries = [openIndex, ...topLevelCommas];
const members = [];
for (let i = 0; i < boundaries.length; i++) {
const start = boundaries[i] + 1;
const stop = i + 1 < boundaries.length ? boundaries[i + 1] : end;
const segment = source.slice(start, stop);
const member = parseMember(segment);
if (member) {
members.push({ ...member, valueStart: start + segment.indexOf(member.name) });
}
}
return { members, end };
}
/**
* Skips past an `atomic(` wrapper following a member name.
* @param {string} source - The module source.
* @param {number} valueStart - Offset of the member's name.
* @returns {number} The offset just inside the wrapper's `(`, or -1 when the
* member is not wrapped.
*/
function unwrapAtomic(source, valueStart) {
const match = /^(?:['"][^'"]*['"]|[A-Za-z_$][\w$]*)\s*:\s*atomic\s*\(/.exec(source.slice(valueStart));
return match ? valueStart + match[0].length : -1;
}
/**
* Locates the body brace of a function expression that begins at an offset.
*
* The wrapper accepts every shape a bridge action is written in — `function
* (a, b) {`, `async function (a) {`, `(a, b) => {`, `a => {` — so the brace is
* found by depth rather than by matching each form. Parentheses and brackets
* opened by the parameter list raise the depth, so the first `{` seen at depth
* zero is the body.
* @param {string} source - The module source.
* @param {number} from - Where to start scanning.
* @returns {{start: number, end: number}|null} The body's inner span, exclusive
* of the braces, or null when no body could be located.
*/
function wrappedFunctionBodySpan(source, from) {
let depth = 0;
for (let i = from; i < source.length; i++) {
const char = source[i];
if (char === '(' || char === '[') {
depth++;
} else if (char === ')' || char === ']') {
if (depth === 0) return null;
depth--;
} else if (char === '{' && depth === 0) {
const { end } = scanObjectLiteral(source, i);
return end === -1 ? null : { start: i + 1, end };
}
}
return null;
}
/**
* Locates the body of a member declared as a function.
*
* An action (`addQty(id, n) { ... }`) and a getter (`get total() { ... }`)
* both put their code between the brace that follows their parameter list and
* its match. Atlas reads those bodies to record what a bridge action writes
* and what a getter reads; without a span it would have to re-parse the module.
* @param {string} source - The module source.
* @param {number} valueStart - Offset of the member's name.
* @returns {{start: number, end: number}|null} The body's inner span, exclusive
* of the braces, or null when the member has no function body.
*/
export function memberBodySpan(source, valueStart) {
const unwrapped = unwrapAtomic(source, valueStart);
if (unwrapped !== -1) {
return wrappedFunctionBodySpan(source, unwrapped);
}
const paren = source.indexOf('(', valueStart);
if (paren === -1) return null;
const { end: parenEnd } = scanObjectLiteral(source, paren);
if (parenEnd === -1) return null;
let i = parenEnd + 1;
while (i < source.length && /\s/.test(source[i])) i++;
if (source[i] !== '{') return null;
const { end: braceEnd } = scanObjectLiteral(source, i);
if (braceEnd === -1) return null;
return { start: i + 1, end: braceEnd };
}
/**
* Reads a member's parameter list.
*
* Slicing from the member name to the body and taking everything between the
* first `(` and the last `)` works for `addQty(id, n) {`, and breaks the
* moment a wrapper puts a paren in front of it: for
* `addQty: atomic(function (id, n) {` it yields `function (id, n`. Scanning
* backwards from the body brace instead finds the same parameter list in
* every shape, wrapper or not, including an arrow's.
*
* Parameters matter more than they look. `addBridgeUnit` hands them to the
* analyser as locals; without them every parameter reads as an unknown
* identifier, and an unknown identifier blocks a diagnostic.
* @param {string} source - The module source.
* @param {number} bodyStart - Offset just inside the body's `{`.
* @returns {string} The parameter text, without its parentheses. Empty when
* the member takes no parameters.
*/
export function memberParams(source, bodyStart) {
let i = bodyStart - 2; // step back over the `{`
while (i >= 0 && /\s/.test(source[i])) i--;
// An arrow sits between the parameter list and the body.
if (i >= 1 && source[i] === '>' && source[i - 1] === '=') {
i -= 2;
while (i >= 0 && /\s/.test(source[i])) i--;
}
if (i < 0) return '';
if (source[i] !== ')') {
// `n => { ... }` — one parameter, written without parentheses.
const end = i;
while (i >= 0 && /[\w$]/.test(source[i])) i--;
const name = source.slice(i + 1, end + 1);
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : '';
}
let depth = 0;
for (let j = i; j >= 0; j--) {
const char = source[j];
if (char === ')') {
depth++;
} else if (char === '(') {
depth--;
if (depth === 0) return source.slice(j + 1, i);
}
}
return '';
}
/**
* Finds the `bridge(` call that produces the module's default export.
* @param {string} source - The module source.
* @returns {number} Index of the `{` opening the definition object, or -1.
*/
function findDefinitionBrace(source) {
const callRegex = /(^|[^\w$.])bridge\s*\(/g;
let match;
while ((match = callRegex.exec(source)) !== null) {
const parenIndex = source.indexOf('(', match.index + match[0].length - 1);
let cursor = parenIndex + 1;
while (cursor < source.length && /\s/.test(source[cursor])) cursor++;
if (source[cursor] === '{') {
return cursor;
}
}
return -1;
}
/**
* Extracts every event name emitted with a literal string, e.g. `this.emit('login')`.
* @param {string} source - The module source.
* @returns {string[]} Unique event names in source order.
*/
export function extractEmittedEvents(source) {
const events = new Set();
const regex = /\bemit\s*\(\s*(['"`])([^'"`\\]+)\1/g;
let match;
while ((match = regex.exec(source)) !== null) {
events.add(match[2]);
}
return [...events];
}
/**
* Extracts `<identifier>.on('event', ...)` subscriptions from source.
* @param {string} source - The source to scan.
* @returns {Array<{ target: string, event: string }>} The subscriptions found.
*/
export function extractSubscriptions(source) {
const found = [];
const regex = /\b([A-Za-z_$][\w$]*)\s*\.\s*on\s*\(\s*(['"`])([^'"`\\]+)\2/g;
let match;
while ((match = regex.exec(source)) !== null) {
found.push({ target: match[1], event: match[3] });
}
return found;
}
/**
* Replaces comments with spaces, preserving length and line structure.
* @param {string} source - The module source.
* @returns {string} The source with comments blanked.
*/
function blankComments(source) {
let out = '';
let i = 0;
while (i < source.length) {
const ch = source[i];
if (ch === '/' && source[i + 1] === '/') {
let j = i;
while (j < source.length && source[j] !== '\n') j++;
out += ' '.repeat(j - i);
i = j;
continue;
}
if (ch === '/' && source[i + 1] === '*') {
let j = i + 2;
while (j < source.length && !(source[j] === '*' && source[j + 1] === '/')) j++;
j = Math.min(j + 2, source.length);
out += source.slice(i, j).replace(/[^\n]/g, ' ');
i = j;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') {
const quote = ch;
let j = i + 1;
while (j < source.length && source[j] !== quote) {
if (source[j] === '\\') j++;
j++;
}
j = Math.min(j + 1, source.length);
out += source.slice(i, j);
i = j;
continue;
}
out += ch;
i++;
}
return out;
}
/**
* Parses the import statements of a module.
* @param {string} source - The module source.
* @returns {Array<{ statement: string, specifier: string, defaultName: string|null, named: string[] }>}
* One entry per import statement.
*/
export function parseImports(source) {
const imports = [];
// Comments are blanked out first, keeping every offset and newline, so a
// documented example — the scaffolded bridge contains one — cannot be read
// as a real dependency. Before this, a bridge whose JSDoc showed how to
// import it declared an import of itself.
const scannable = blankComments(source);
const regex = /import\s+(?:([\s\S]*?)\s+from\s+)?['"]([^'"]+)['"];?/g;
let match;
while ((match = regex.exec(scannable)) !== null) {
const clause = (match[1] || '').trim();
const specifier = match[2];
let defaultName = null;
const named = [];
if (clause) {
const namedMatch = clause.match(/\{([\s\S]*?)\}/);
if (namedMatch) {
for (const part of namedMatch[1].split(',')) {
const cleaned = part.trim();
if (cleaned) {
named.push(cleaned.split(/\s+as\s+/)[0].trim());
}
}
}
const beforeBrace = clause.split('{')[0].replace(/,\s*$/, '').trim();
if (beforeBrace && !beforeBrace.startsWith('*')) {
defaultName = beforeBrace;
}
}
imports.push({ statement: source.slice(match.index, match.index + match[0].length), specifier, defaultName, named });
}
return imports;
}
/**
* Resolves a relative import specifier to a bridge module path, if it is one.
* @param {string} fromFile - The importing file.
* @param {string} specifier - The import specifier.
* @returns {string|null} Absolute path to the `.bridge.js` file, or null.
*/
export function resolveBridgeSpecifier(fromFile, specifier) {
if (!specifier || !specifier.startsWith('.')) {
return null;
}
const base = path.resolve(path.dirname(fromFile), specifier);
// A bridge import is explicit: the specifier names a `.bridge.js` module.
// Anything else (a child component, a helper) is not a bridge, and guessing
// otherwise would misread ordinary imports.
if (base.endsWith('.bridge.js')) {
return base;
}
if (base.endsWith('.bridge')) {
return `${base}.js`;
}
return null;
}
/**
* Collects the bridge imports of any module (component, page, bridge or main).
* @param {string} filePath - Absolute path to the importing file.
* @param {string} source - Its source text.
* @returns {Array<{ local: string, specifier: string, resolved: string }>} One entry
* per default-imported bridge.
*/
export function findBridgeImports(filePath, source) {
const results = [];
for (const entry of parseImports(source)) {
const resolved = resolveBridgeSpecifier(filePath, entry.specifier);
if (resolved && entry.defaultName) {
results.push({ local: entry.defaultName, specifier: entry.specifier, resolved });
}
}
return results;
}
/**
* Analyses a bridge module.
*
* A bridge module imports `bridge` from the Avenx runtime and exports the
* definition object it returns. Anything else in a `*.bridge.js` file is not a
* bridge, and is reported as such so the caller can raise a build error rather
* than emit a module the runtime cannot use.
* @param {string} filePath - Absolute path to the `.bridge.js` file.
* @param {string} source - The module source.
* @returns {object|null} A descriptor of the bridge, or null when the module is
* not built on the `bridge()` factory.
*/
export function analyzeBridge(filePath, source) {
const name = bridgeNameFromFile(filePath);
const imports = parseImports(source);
const importsFactory = imports.some(
(entry) => entry.named.includes('bridge') && RUNTIME_SPECIFIER.test(entry.specifier),
);
const braceIndex = findDefinitionBrace(source);
if (!importsFactory || braceIndex === -1) {
return null;
}
const descriptor = {
filePath,
name,
binding: bridgeBindingName(name),
stateKeys: [],
actions: [],
getters: [],
hasSetup: false,
events: [],
bridgeImports: [],
/**
* Actions declared with the `atomic()` wrapper, in declaration order.
* @type {string[]}
*/
atomicActions: [],
/**
* Where each member sits in the source, for consumers that need more than
* its name. Additive: nothing that only reads `stateKeys`, `getters` or
* `actions` is affected.
* @type {Array<{name: string, kind: string, valueStart: number, body: {start: number, end: number}|null}>}
*/
members: [],
};
const { members } = parseObjectMembers(source, braceIndex);
for (const member of members) {
if (member.kind === 'getter') {
descriptor.getters.push(member.name);
descriptor.members.push({ ...member, body: memberBodySpan(source, member.valueStart) });
continue;
}
if (member.name === 'setup') {
descriptor.hasSetup = true;
descriptor.members.push({ ...member, body: memberBodySpan(source, member.valueStart) });
continue;
}
if (member.name === 'state' && member.kind === 'property') {
const stateBrace = source.indexOf('{', member.valueStart + 'state'.length);
if (stateBrace !== -1) {
const { members: stateMembers } = parseObjectMembers(source, stateBrace);
descriptor.stateKeys = stateMembers.map((entry) => entry.name);
for (const entry of stateMembers) {
descriptor.members.push({ ...entry, kind: 'state', body: null });
}
}
continue;
}
if (member.kind === 'action') {
descriptor.actions.push(member.name);
if (member.atomic) {
descriptor.atomicActions.push(member.name);
}
descriptor.members.push({ ...member, body: memberBodySpan(source, member.valueStart) });
}
}
descriptor.events = extractEmittedEvents(source);
for (const entry of imports) {
if (RUNTIME_SPECIFIER.test(entry.specifier)) {
continue;
}
const resolved = resolveBridgeSpecifier(filePath, entry.specifier);
if (resolved && entry.defaultName) {
descriptor.bridgeImports.push({ local: entry.defaultName, specifier: entry.specifier, resolved });
}
// Anything else -- an npm package, a local helper -- used to be collected
// as an unsupported import and fail the build with AVX_C09, because the
// concatenator had no way to inline it. The bundler resolves it like any
// other specifier, so the only imports that fail now are the ones that
// resolve to nothing, and they fail with the reason.
}
return descriptor;
}
/**
* Reads and analyses a bridge module from disk.
* @param {string} filePath - Absolute path to the `.bridge.js` file.
* @param {function(string): string} [transform] - Optional source transform, used
* by the compiler to substitute environment variables.
* @returns {object|null} The descriptor, or null when the file cannot be read or
* is not built on the `bridge()` factory.
*/
export function analyzeBridgeFile(filePath, transform = (value) => value) {
if (!fs.existsSync(filePath)) {
return null;
}
return analyzeBridge(filePath, transform(fs.readFileSync(filePath, 'utf-8')));
}
/**
* Every member a consumer may legitimately read from a bridge instance.
* @param {object} descriptor - A bridge descriptor.
* @returns {string[]} The declared member names.
*/
export function declaredMembers(descriptor) {
return [...descriptor.stateKeys, ...descriptor.getters, ...descriptor.actions, 'on', '$dispose', '$name'];
}
/**
* Suggests the closest known name for a mistyped one, using edit distance.
* @param {string} name - The unknown name.
* @param {string[]} known - Candidate names.
* @returns {string} A " Did you mean ...?" fragment, or an empty string.
*/
export function suggestName(name, known) {
if (!name || !Array.isArray(known) || known.length === 0) {
return '';
}
const distance = (a, b) => {
const rows = [];
for (let i = 0; i <= a.length; i++) rows[i] = [i];
for (let j = 0; j <= b.length; j++) rows[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
rows[i][j] = Math.min(
rows[i - 1][j] + 1,
rows[i][j - 1] + 1,
rows[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
);
}
}
return rows[a.length][b.length];
};
let best = null;
let bestScore = Infinity;
const lower = name.toLowerCase();
for (const candidate of known) {
const score = distance(lower, String(candidate).toLowerCase());
if (score < bestScore) {
bestScore = score;
best = candidate;
}
}
// Only suggest when the names are genuinely close.
return best !== null && bestScore <= Math.max(2, Math.floor(name.length / 3))
? ` Did you mean "${best}"?`
: '';
}