Source: lib/compiler/parser/declarations.js

/**
 * @file declarations.js
 * @description Extracts Avenx declaration tags from a component source.
 *
 * This is the single place that decides what a component declares. Both the
 * reader (what `<state>` says) and the remover (what leaves the template) come
 * from one scan over one set of source ranges, so the two can no longer
 * disagree — which is the class of bug that let a multi-line `<state>` tag be
 * parsed correctly and stripped incorrectly at the same time.
 *
 * The declarations recognised here are the component's *header*: `<state>`,
 * `<computed>`, `<action>`, `<resource>` and `<contract>`. Structural
 * directives (`<@for>`, `<@suspense>`, `<@deadlock>`, `<@css />`) stay in the
 * template and are handled by the template pipeline; they describe rendering,
 * not declaration.
 * @module lib/compiler/parser/declarations
 */

import { scanTags, stripRanges, createLineIndex } from './tokenizer.js';

/**
 * The declaration tags that are lifted out of the template.
 * @type {Set<string>}
 */
const DECLARATION_TAGS = new Set(['state', 'computed', 'action', 'resource', 'contract', '@contract']);

/**
 * Contract names the compiler understands.
 * @type {string[]}
 */
export const VALID_CONTRACTS = ['static', 'pure', 'deterministic', 'isolated'];

/**
 * Coerces a declared attribute value to a JavaScript value.
 *
 * `<state count="0" />` should produce the number `0`, not the string `"0"`,
 * and `<state items="[1,2]" />` should produce an array. The order matters:
 * literal keywords first, then numbers, then JSON, then the raw string.
 * @param {string} raw - The attribute value as written.
 * @returns {any} The coerced value.
 */
export function coerceValue(raw) {
  const trimmed = String(raw).trim();
  if (trimmed === 'true') return true;
  if (trimmed === 'false') return false;
  if (trimmed === 'null') return null;
  if (trimmed !== '' && !isNaN(trimmed)) return Number(trimmed);

  try {
    return JSON.parse(raw);
  } catch {
    try {
      return JSON.parse(String(raw).replace(/'/g, '"'));
    } catch {
      return raw;
    }
  }
}

/**
 * Reads an attribute that may carry Vue-style `:` binding syntax.
 *
 * `<resource :handler="x" :pollInterval="5000" />` and the unprefixed form mean
 * the same thing to Avenx; the colon is accepted because developers arriving
 * from Vue write it out of habit.
 * @param {Object<string,string>} attrs - The parsed attribute map.
 * @param {string} name - The attribute name without a prefix.
 * @returns {string|undefined} The value, or undefined when absent.
 */
function readBindable(attrs, name) {
  if (attrs[name] !== undefined) return attrs[name];
  return attrs[`:${name}`];
}

/**
 * @typedef {object} DeclarationSet
 * @property {Array<{name: string, value: any, line: number, column: number}>} state
 *   Declared state keys, in source order.
 * @property {number} stateTagCount - How many `<state>` tags were written.
 * @property {number} secondStateTagOffset - Source offset of a duplicate
 *   `<state>` tag, or -1.
 * @property {Array<{name: string, expression: string, line: number, column: number}>} computed
 *   Declared computed values.
 * @property {Array<{name: string, body: string, atomic: boolean, onConflict: (string|undefined),
 *   line: number, column: number, tagOffset: number, bodyOffset: number}>} actions
 *   Declared actions with their modifiers.
 * @property {Array<{name: string, handler: string, pollInterval: (number|null),
 *   line: number, column: number}>} resources - Declared resources.
 * @property {Set<string>} contracts - Declared compiler contracts.
 * @property {string} template - The source with every declaration removed.
 * @property {Array<{start: number, end: number}>} ranges - Removed source ranges.
 */

/**
 * Reads every declaration in a component source.
 * @param {string} source - The component source text.
 * @returns {DeclarationSet} The declarations and the remaining template.
 */
export function parseDeclarations(source) {
  const tags = scanTags(source, DECLARATION_TAGS);
  const lines = createLineIndex(source);

  /** @type {DeclarationSet} */
  const result = {
    state: [],
    stateTagCount: 0,
    secondStateTagOffset: -1,
    computed: [],
    actions: [],
    resources: [],
    contracts: new Set(),
    template: '',
    ranges: [],
  };

  for (const tag of tags) {
    const pos = lines.at(tag.start);
    result.ranges.push({ start: tag.start, end: tag.end });

    switch (tag.lowerName) {
      case 'state':
        result.stateTagCount++;
        if (result.stateTagCount === 2) {
          result.secondStateTagOffset = tag.start;
        }
        // Only the first <state> tag contributes keys, matching the documented
        // "one state declaration per component" rule. Later tags are still
        // removed from the template so a duplicate cannot leak into the DOM,
        // and the caller reports AVX_W28 for them.
        if (result.stateTagCount === 1) {
          for (const [name, value] of Object.entries(tag.attrs)) {
            const offset = tag.attrOffsets[name];
            const at = lines.at(offset ? offset.start : tag.start);
            result.state.push({ name, value: coerceValue(value), line: at.line, column: at.column });
          }
        }
        break;

      case 'computed': {
        const name = tag.attrs.name;
        const expression = tag.attrs.value;
        if (name && expression !== undefined) {
          const offset = tag.attrOffsets.value;
          const at = offset ? lines.at(offset.valueStart) : pos;
          result.computed.push({ name, expression, line: at.line, column: at.column });
        }
        break;
      }

      case 'action': {
        const name = tag.attrs.name;
        if (name) {
          const atomic =
            tag.attrs.atomic !== undefined &&
            (tag.valueless.has('atomic') || tag.attrs.atomic === '' || tag.attrs.atomic === 'true');
          const bodyStart = tag.bodyStart >= 0 ? tag.bodyStart : tag.openEnd;
          result.actions.push({
            name,
            body: (tag.body || '').trim(),
            atomic,
            onConflict: tag.attrs.onConflict,
            line: pos.line,
            column: pos.column,
            tagOffset: tag.start,
            bodyOffset: bodyStart,
          });
        }
        break;
      }

      case 'resource': {
        const name = readBindable(tag.attrs, 'name');
        if (!name) break;
        const rawPoll = readBindable(tag.attrs, 'pollInterval');
        const pollInterval = rawPoll === undefined ? null : Number(rawPoll);

        let handler;
        if (tag.body !== null) {
          handler = tag.body.trim();
          // A single-expression body is the common shorthand. `return` is added
          // only when the body is unambiguously one expression.
          if (!handler.startsWith('return') && !handler.includes(';')) {
            handler = `return ${handler};`;
          }
        } else if (readBindable(tag.attrs, 'handler') !== undefined) {
          handler = readBindable(tag.attrs, 'handler');
        } else {
          break;
        }

        result.resources.push({
          name,
          handler,
          pollInterval: pollInterval !== null && !isNaN(pollInterval) && pollInterval > 0 ? pollInterval : null,
          line: pos.line,
          column: pos.column,
        });
        break;
      }

      case 'contract':
      case '@contract':
        for (const [rawName, value] of Object.entries(tag.attrs)) {
          const name = rawName.toLowerCase();
          if (!VALID_CONTRACTS.includes(name)) continue;
          if (tag.valueless.has(rawName) || value === 'true' || value === '') {
            result.contracts.add(name);
          }
        }
        break;

      default:
        break;
    }
  }

  result.template = stripRanges(source, result.ranges);
  return result;
}