All files / lib/compiler/atlas source.js

97.97% Statements 194/198
90.9% Branches 20/22
87.5% Functions 7/8
97.97% Lines 194/198

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 632x 632x 632x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 320x 320x 2560x 2560x 2560x 320x 320x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 841x 841x 233573x 233573x 841x 841x 322x 322x 322x 322x 322x 322x 322x 322x 2381x 2381x 2381x 2381x 10019x 10019x 4763x 10019x 2381x 2381x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 121x 121x 121x 121x 121x 343x 343x 121x 121x 322x 322x 322x 322x 322x 322x 322x 121x 121x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 319x 319x 319x 319x 319x 319x 319x 319x 319x 319x 319x 266x 266x 266x 266x 266x 319x 319x 319x 319x 319x 194x 194x 194x 194x 340x 340x 340x 340x 194x 319x 319x 319x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x 322x         322x 322x  
/**
 * @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 };