Source: lib/compiler/atlas/source.js

/**
 * @file source.js
 * @description Reading an Avenx component file without losing where things are.
 *
 * ## Why this exists
 *
 * By the time the compiler validates a template it has already rewritten it:
 * imports are stripped, comments removed, `<state>`/`<computed>`/`<action>`/
 * `<resource>`/`<contract>` blocks deleted, style scoping applied, `data-ax-bind`
 * expanded. Offsets into that string do not point at anything a developer can
 * open in an editor.
 *
 * Atlas reports file and line for every relationship it records, so it cannot
 * use those offsets. Instead it **masks** the original source: declaration
 * blocks and comments are replaced character-for-character with spaces, and
 * newlines are kept. The result is the same length as the file, so an offset
 * into the mask is an offset into the file, and `lineOf` turns it into the
 * line the developer wrote.
 *
 * Masking rather than slicing is what keeps this honest. A slice would need an
 * offset table that has to be maintained in step with every future template
 * transformation; a mask cannot drift, because it never moves anything.
 * @module lib/compiler/atlas/source
 */

/**
 * Regions of a component file that are declarations rather than template.
 *
 * The patterns mirror the ones `extractTemplate` uses to strip the same
 * regions, so the mask and the compiled template agree on what the template
 * is. Order matters only in that block forms must precede self-closing forms.
 * @type {RegExp[]}
 */
const DECLARATION_PATTERNS = [
  /<!--[\s\S]*?-->/g,
  /^[ \t]*import\s+(?:[\s\w$,{}*]*?\s+from\s+)?['"][^'"]*['"];?[ \t]*$/gm,
  /<action\b[\s\S]*?<\/action>/gi,
  /<resource\b[\s\S]*?<\/resource>/gi,
  /<resource\s[^>]*?\/>/gi,
  /<state\s[^>]*?\/>/gi,
  /<computed\s[^>]*?\/>/gi,
  /<(?:contract|@contract)\s[^>]*?\/>/gi,
];

/**
 * Replaces a region with spaces, keeping newlines so line numbers survive.
 * @param {string} text - The region's text.
 * @returns {string} A same-length blank of it.
 */
function blank(text) {
  return text.replace(/[^\n]/g, ' ');
}

/**
 * Blanks out everything in a component file that is not template markup.
 *
 * The returned string has the same length as the input, so any offset into it
 * is an offset into the original file.
 * @param {string} content - The component source.
 * @returns {string} The masked source.
 */
export function maskDeclarations(content) {
  let masked = content;
  for (const pattern of DECLARATION_PATTERNS) {
    pattern.lastIndex = 0;
    masked = masked.replace(pattern, blank);
  }
  return masked;
}

/**
 * Builds an index of line start offsets for fast offset-to-line lookup.
 *
 * Component files are small, but every binding, handler and directive asks for
 * a line, so scanning the string per lookup would be quadratic in the number
 * of bindings.
 * @param {string} content - The source.
 * @returns {number[]} Offsets at which each line begins.
 */
export function lineIndex(content) {
  const starts = [0];
  for (let i = 0; i < content.length; i++) {
    if (content[i] === '\n') starts.push(i + 1);
  }
  return starts;
}

/**
 * Converts an offset into a 1-based line and column.
 * @param {number[]} starts - The index from {@link lineIndex}.
 * @param {number} offset - An offset into the same source.
 * @returns {{line: number, column: number}} The position.
 */
export function positionAt(starts, offset) {
  if (!(offset >= 0)) return { line: 1, column: 1 };
  let low = 0;
  let high = starts.length - 1;
  while (low < high) {
    const mid = Math.ceil((low + high) / 2);
    if (starts[mid] <= offset) low = mid;
    else high = mid - 1;
  }
  return { line: low + 1, column: offset - starts[low] + 1 };
}

/**
 * Finds the 1-based line a declaration sits on.
 *
 * Used for the declarations Atlas records by name rather than by offset — a
 * `<state>` key, a `<computed>`, an `<action>` — where the name is what the
 * developer would search for.
 * @param {string} content - The file contents.
 * @param {RegExp} pattern - What to look for.
 * @returns {number|null} The line, or null when the pattern does not match.
 */
export function lineOf(content, pattern) {
  pattern.lastIndex = 0;
  const match = pattern.exec(content);
  if (!match) return null;
  let line = 1;
  for (let i = 0; i < match.index; i++) {
    if (content[i] === '\n') line++;
  }
  return line;
}

/**
 * Escapes a declared name for use inside a regular expression.
 * @param {string} name - The name.
 * @returns {string} The escaped name.
 */
export function escapeName(name) {
  return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
 * Finds the line of every named declaration in one pass.
 *
 * The obvious implementation asks `lineOf` for each name in turn, which
 * rescans the whole file per declaration and counts newlines from the start
 * each time — quadratic in a component with many declarations, for a fact that
 * one pass can produce.
 * @param {string} content - The component source.
 * @returns {{state: Map<string, number>, computed: Map<string, number>, action: Map<string, number>, resource: Map<string, number>}}
 *   Declaration lines, keyed by name.
 */
export function declarationLines(content) {
  const starts = lineIndex(content);
  const result = {
    state: new Map(),
    computed: new Map(),
    action: new Map(),
    resource: new Map(),
  };

  const named = /<(computed|action|resource)\s+[^>]*?name\s*=\s*["']([^"']+)["']/gi;
  let match;
  while ((match = named.exec(content)) !== null) {
    const kind = match[1].toLowerCase();
    if (!result[kind].has(match[2])) {
      result[kind].set(match[2], positionAt(starts, match.index).line);
    }
  }

  // State keys share one tag, so each attribute is located within it.
  const stateTag = /<state\s([^>]*?)\/>/gi;
  let tagMatch;
  while ((tagMatch = stateTag.exec(content)) !== null) {
    const attrsOffset = tagMatch.index + tagMatch[0].indexOf(tagMatch[1]);
    const attr = /([A-Za-z_$][\w$-]*)\s*=\s*["']/g;
    let attrMatch;
    while ((attrMatch = attr.exec(tagMatch[1])) !== null) {
      if (!result.state.has(attrMatch[1])) {
        result.state.set(attrMatch[1], positionAt(starts, attrsOffset + attrMatch.index).line);
      }
    }
  }

  return result;
}

/**
 * Locates the line of a `<state>` key.
 *
 * State keys share one tag, so the attribute is what is searched for rather
 * than the tag. When the key cannot be found — an unusual formatting — the
 * `<state>` tag's own line is a truthful fallback.
 * @param {string} content - The component source.
 * @param {string} key - The state key.
 * @returns {number|null} The line, or null.
 */
export function stateKeyLine(content, key) {
  const attr = lineOf(content, new RegExp(`\\b${escapeName(key)}\\s*=\\s*["']`));
  if (attr !== null) return attr;
  return lineOf(content, /<state\b/);
}

export default { maskDeclarations, lineIndex, positionAt, lineOf, stateKeyLine, escapeName };