Source: lib/compiler/BridgeParser.js

/**
 * @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}"?`
    : '';
}