"use strict"; const vscode = require("vscode"); const COLLECTION_NAME = "wrnexus"; const WRN_LANGUAGE_ID = "wrn"; const TOP_LEVEL_PATTERN = /^\s*((?:global|page)\s+store|page|component|layout)\s+([A-Za-z_$][\w$]*)\s*\{/; const VALID_TOP_LEVEL_KINDS = new Set([ "page", "component", "layout", "global store", "page store", ]); const VALID_LIFECYCLE_HOOKS = new Set(["mount", "update", "unmount"]); const ROOT_MEMBER_NAMES = [ "layout", "runtime", "hydrate", "client", "types", "props", "outputs", "state", "persist", "computed", "effect", "watch", "lifecycle", "view", "seo", "security", "load", "action", "api", "ssr", "realtime", "style", "functions", ]; const VALID_MEMBERS = { page: new Set(ROOT_MEMBER_NAMES), component: new Set(ROOT_MEMBER_NAMES), layout: new Set(ROOT_MEMBER_NAMES), "global store": new Set(ROOT_MEMBER_NAMES), "page store": new Set(ROOT_MEMBER_NAMES), }; function createDiagnostic( document, startOffset, endOffset, message, severity = vscode.DiagnosticSeverity.Error, code, ) { const diagnostic = new vscode.Diagnostic( new vscode.Range(document.positionAt(startOffset), document.positionAt(endOffset)), message, severity, ); diagnostic.source = "WRNexus"; if (code) { diagnostic.code = code; } return diagnostic; } function lineDiagnostic( document, lineNumber, message, severity = vscode.DiagnosticSeverity.Error, code, ) { const line = document.lineAt(lineNumber); const diagnostic = new vscode.Diagnostic(line.range, message, severity); diagnostic.source = "WRNexus"; if (code) { diagnostic.code = code; } return diagnostic; } function maskLeadingTrivia(source) { const masked = [...source]; let offset = 0; const importPattern = /import\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?["'][^"'\r\n]+["']\s*;?/y; while (offset < source.length) { if (/\s/u.test(source[offset])) { offset += 1; continue; } importPattern.lastIndex = offset; const importStatement = importPattern.exec(source); if (importStatement) { const end = importPattern.lastIndex; while (offset < end) { if (source[offset] !== "\n" && source[offset] !== "\r") masked[offset] = " "; offset += 1; } continue; } if (!source.startsWith("//", offset)) break; while (offset < source.length && source[offset] !== "\n") { masked[offset] = " "; offset += 1; } } return masked.join(""); } function findTopLevelDeclaration(document, source) { const sourceWithoutLeadingTrivia = maskLeadingTrivia(source); const match = TOP_LEVEL_PATTERN.exec(sourceWithoutLeadingTrivia); if (!match) { const firstMeaningfulLine = sourceWithoutLeadingTrivia .split(/\r?\n/) .findIndex((line) => line.trim().length > 0); return { diagnostic: lineDiagnostic( document, Math.max(0, firstMeaningfulLine), "A .wrn file must start with `page`, `component`, or `layout`.", vscode.DiagnosticSeverity.Error, "wrn-invalid-root", ), }; } return { kind: match[1], name: match[2], match, }; } function validateBalancedCharacters(document, source) { const diagnostics = []; const stack = []; let quote = null; let escaped = false; const pairs = { "}": "{", "]": "[", ")": "(", }; for (let index = 0; index < source.length; index += 1) { const character = source[index]; if (quote !== null) { if (escaped) { escaped = false; continue; } if (character === "\\") { escaped = true; continue; } if (character === quote) { quote = null; } continue; } if (source.startsWith("//", index)) { const lineEnd = source.indexOf("\n", index + 2); index = lineEnd === -1 ? source.length : lineEnd; continue; } // Apostrophes in rendered copy (for example, "person's name") are text, // not the beginning of a WRN/HTML quoted value. if ( character === "'" && /[\p{L}\p{N}]/u.test(source[index - 1] ?? "") && /[\p{L}\p{N}]/u.test(source[index + 1] ?? "") ) { continue; } if (character === '"' || character === "'") { quote = character; continue; } if (source.startsWith("", index + 4); if (commentEnd === -1) { diagnostics.push( createDiagnostic( document, index, Math.min(source.length, index + 4), "Unclosed HTML comment.", vscode.DiagnosticSeverity.Error, "wrn-unclosed-comment", ), ); break; } index = commentEnd + 2; continue; } if (character === "{" || character === "[" || character === "(") { stack.push({ character, offset: index, }); continue; } if (character === "}" || character === "]" || character === ")") { const expectedOpening = pairs[character]; const opening = stack.pop(); if (!opening || opening.character !== expectedOpening) { diagnostics.push( createDiagnostic( document, index, index + 1, `Unexpected \`${character}\`.`, vscode.DiagnosticSeverity.Error, "wrn-unexpected-closing", ), ); } } } for (const opening of stack) { const expectedClosing = opening.character === "{" ? "}" : opening.character === "[" ? "]" : ")"; diagnostics.push( createDiagnostic( document, opening.offset, opening.offset + 1, `Missing closing \`${expectedClosing}\`.`, vscode.DiagnosticSeverity.Error, "wrn-missing-closing", ), ); } if (quote !== null) { diagnostics.push( createDiagnostic( document, Math.max(0, source.length - 1), source.length, `Unclosed ${quote === '"' ? "double" : "single"} quote.`, vscode.DiagnosticSeverity.Error, "wrn-unclosed-string", ), ); } return diagnostics; } function maskHtmlComments(source) { const masked = [...source]; let index = 0; while (index < source.length) { if (!source.startsWith("", index + 4); const end = commentEnd === -1 ? source.length : commentEnd + 3; for (let cursor = index; cursor < end; cursor += 1) { if (source[cursor] !== "\n" && source[cursor] !== "\r") masked[cursor] = " "; } index = end; } return masked.join(""); } function maskTemplateExpressions(source) { const masked = [...source]; let index = 0; while (index < source.length) { if (source[index] !== "{") { index += 1; continue; } const start = index; let depth = 0; let quote = null; let escaped = false; while (index < source.length) { const character = source[index]; if (quote !== null) { if (escaped) escaped = false; else if (character === "\\") escaped = true; else if (character === quote) quote = null; index += 1; continue; } if (character === '"' || character === "'" || character === "`") { quote = character; index += 1; continue; } if (character === "{") depth += 1; else if (character === "}") { depth -= 1; index += 1; if (depth === 0) break; continue; } index += 1; } for (let cursor = start; cursor < index; cursor += 1) { if (source[cursor] !== "\n" && source[cursor] !== "\r") masked[cursor] = " "; } } return masked.join(""); } function findViewRanges(source) { const ranges = []; const pattern = /\bview\s*\{/g; let match; while ((match = pattern.exec(source)) !== null) { const openingBrace = source.indexOf("{", match.index); const closingBrace = findMatchingBrace(source, openingBrace); if (closingBrace === -1) break; ranges.push({ start: openingBrace + 1, end: closingBrace }); pattern.lastIndex = closingBrace + 1; } return ranges; } function validateHtmlTags(document, source) { const diagnostics = []; const voidElements = new Set([ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", ]); for (const range of findViewRanges(source)) { const fragment = source.slice(range.start, range.end); // JavaScript-style `//` is valid rendered text inside
/. Only
    // HTML comments and WRN expressions are masked before tag validation.
    const cleaned = maskTemplateExpressions(maskHtmlComments(fragment));
    const stack = [];
    const tagPattern = /<\/?([A-Za-z][A-Za-z0-9_$:.-]*)(?:\s[\s\S]*?)?\/?>/g;
    let match;

    while ((match = tagPattern.exec(cleaned)) !== null) {
      const completeTag = match[0];
      const tagName = match[1];
      const normalizedName = /^[a-z]/.test(tagName) ? tagName.toLowerCase() : tagName;
      const lowerTag = tagName.toLowerCase();
      const absoluteStart = range.start + match.index;
      const isClosing = completeTag.startsWith("$/.test(completeTag);
      const isVoid = voidElements.has(lowerTag);

      if (isClosing) {
        const matchingIndex = stack.findLastIndex((item) => item.normalizedName === normalizedName);
        if (matchingIndex === -1) {
          diagnostics.push(
            createDiagnostic(
              document,
              absoluteStart,
              absoluteStart + completeTag.length,
              `Unexpected closing tag .`,
              vscode.DiagnosticSeverity.Error,
              "wrn-unexpected-html-close",
            ),
          );
          continue;
        }

        const last = stack.at(-1);
        if (last.normalizedName !== normalizedName) {
          diagnostics.push(
            createDiagnostic(
              document,
              absoluteStart,
              absoluteStart + completeTag.length,
              `Mismatched closing tag . Expected .`,
              vscode.DiagnosticSeverity.Error,
              "wrn-mismatched-html-tag",
            ),
          );
        }
        stack.splice(matchingIndex);
        continue;
      }

      if (!isSelfClosing && !isVoid) {
        stack.push({
          tagName,
          normalizedName,
          offset: absoluteStart,
          length: completeTag.length,
        });
      }
    }

    for (const tag of stack) {
      diagnostics.push(
        createDiagnostic(
          document,
          tag.offset,
          tag.offset + tag.length,
          `Missing closing tag .`,
          vscode.DiagnosticSeverity.Error,
          "wrn-missing-html-close",
        ),
      );
    }
  }

  return diagnostics;
}

function getRootBodyRange(source, rootMatch) {
  const openingBrace = rootMatch.index + rootMatch[0].lastIndexOf("{");

  const closingBrace = findMatchingBrace(source, openingBrace);

  return {
    start: openingBrace + 1,
    end: closingBrace === -1 ? source.length : closingBrace,
  };
}

function findMatchingBrace(source, openingBrace) {
  let depth = 0;
  let quote = null;
  let escaped = false;

  for (let index = openingBrace; index < source.length; index += 1) {
    const character = source[index];

    if (quote !== null) {
      if (escaped) {
        escaped = false;
        continue;
      }

      if (character === "\\") {
        escaped = true;
        continue;
      }

      if (character === quote) {
        quote = null;
      }

      continue;
    }

    if (character === '"' || character === "'") {
      quote = character;
      continue;
    }

    if (source.startsWith("", index + 4);

      if (commentEnd === -1) {
        return -1;
      }

      index = commentEnd + 2;
      continue;
    }

    if (character === "{") {
      depth += 1;
      continue;
    }

    if (character === "}") {
      depth -= 1;

      if (depth === 0) {
        return index;
      }
    }
  }

  return -1;
}

function skipWhitespace(source, index, end) {
  while (index < end && /\s/.test(source[index])) {
    index += 1;
  }

  return index;
}

function readIdentifier(source, index, end) {
  if (index >= end || !/[A-Za-z_$]/.test(source[index])) {
    return null;
  }

  const start = index;
  index += 1;

  while (index < end && /[A-Za-z0-9_$-]/.test(source[index])) {
    index += 1;
  }

  return {
    name: source.slice(start, index),
    start,
    end: index,
  };
}

function findRootMembers(source, bodyStart, bodyEnd) {
  const members = [];

  let index = bodyStart;
  let depth = 0;
  let quote = null;
  let escaped = false;

  const skipLine = () => {
    while (index < bodyEnd && source[index] !== "\n" && source[index] !== "\r") {
      index += 1;
    }
  };

  while (index < bodyEnd) {
    const character = source[index];

    if (quote !== null) {
      if (escaped) {
        escaped = false;
        index += 1;
        continue;
      }

      if (character === "\\") {
        escaped = true;
        index += 1;
        continue;
      }

      if (character === quote) {
        quote = null;
      }

      index += 1;
      continue;
    }

    if (character === '"' || character === "'") {
      quote = character;
      index += 1;
      continue;
    }

    if (source.startsWith("//", index)) {
      skipLine();
      continue;
    }

    if (source.startsWith("/*", index)) {
      const end = source.indexOf("*/", index + 2);
      index = end === -1 ? bodyEnd : end + 2;
      continue;
    }

    if (source.startsWith("", index + 4);

      index = end === -1 ? bodyEnd : end + 3;

      continue;
    }

    if (character === "{") {
      depth += 1;
      index += 1;
      continue;
    }

    if (character === "}") {
      depth = Math.max(0, depth - 1);
      index += 1;
      continue;
    }

    if (depth === 0 && /[A-Za-z_$]/.test(character)) {
      const identifier = readIdentifier(source, index, bodyEnd);

      if (!identifier) {
        index += 1;
        continue;
      }

      index = identifier.end;

      members.push({
        name: identifier.name,
        start: identifier.start,
        end: identifier.end,
      });

      const assignmentMembers = new Set(["layout", "runtime", "hydrate", "state"]);

      if (assignmentMembers.has(identifier.name)) {
        skipLine();
        continue;
      }

      if (identifier.name === "client") {
        const next = skipWhitespace(source, index, bodyEnd);

        if (source[next] === "=") {
          skipLine();
          continue;
        }
      }

      const blockMembers = new Set([
        "types",
        "props",
        "computed",
        "effect",
        "watch",
        "lifecycle",
        "view",
        "seo",
        "security",
        "load",
        "action",
        "api",
        "ssr",
        "client",
        "realtime",
        "style",
        "functions",
      ]);

      if (blockMembers.has(identifier.name)) {
        let cursor = index;
        let parenthesisDepth = 0;
        let bracketDepth = 0;
        let memberQuote = null;
        let memberEscaped = false;

        while (cursor < bodyEnd) {
          const current = source[cursor];

          if (memberQuote !== null) {
            if (memberEscaped) {
              memberEscaped = false;
            } else if (current === "\\") {
              memberEscaped = true;
            } else if (current === memberQuote) {
              memberQuote = null;
            }

            cursor += 1;
            continue;
          }

          if (current === '"' || current === "'") {
            memberQuote = current;
            cursor += 1;
            continue;
          }

          if (current === "(") parenthesisDepth += 1;
          if (current === ")") parenthesisDepth = Math.max(0, parenthesisDepth - 1);
          if (current === "[") bracketDepth += 1;
          if (current === "]") bracketDepth = Math.max(0, bracketDepth - 1);

          if (current === "{" && parenthesisDepth === 0 && bracketDepth === 0) {
            index = cursor;
            break;
          }

          cursor += 1;
        }

        if (cursor >= bodyEnd) index = bodyEnd;
      }

      continue;
    }

    index += 1;
  }

  return members;
}

function findNamedBlocks(source, bodyStart, bodyEnd, blockName) {
  const blocks = [];
  let index = bodyStart;

  while (index < bodyEnd) {
    index = skipWhitespace(source, index, bodyEnd);

    const identifier = readIdentifier(source, index, bodyEnd);

    if (!identifier) {
      index += 1;
      continue;
    }

    index = identifier.end;

    if (identifier.name !== blockName) {
      const possibleBrace = skipWhitespace(source, index, bodyEnd);

      if (source[possibleBrace] === "{") {
        const end = findMatchingBrace(source, possibleBrace);

        index = end === -1 ? bodyEnd : end + 1;
      }

      continue;
    }

    const openingBrace = skipWhitespace(source, index, bodyEnd);

    if (source[openingBrace] !== "{") {
      blocks.push({
        name: blockName,
        nameStart: identifier.start,
        nameEnd: identifier.end,
        openingBrace: -1,
        closingBrace: -1,
      });

      continue;
    }

    const closingBrace = findMatchingBrace(source, openingBrace);

    blocks.push({
      name: blockName,
      nameStart: identifier.start,
      nameEnd: identifier.end,
      openingBrace,
      closingBrace,
    });

    index = closingBrace === -1 ? bodyEnd : closingBrace + 1;
  }

  return blocks;
}

function findStateDeclarations(source, bodyStart, bodyEnd) {
  const states = new Map();
  let index = bodyStart;
  let depth = 0;
  let quote = null;
  let escaped = false;

  while (index < bodyEnd) {
    const character = source[index];

    if (quote !== null) {
      if (escaped) {
        escaped = false;
      } else if (character === "\\") {
        escaped = true;
      } else if (character === quote) {
        quote = null;
      }

      index += 1;
      continue;
    }

    if (character === '"' || character === "'") {
      quote = character;
      index += 1;
      continue;
    }

    if (source.startsWith("", index + 4);

      index = end === -1 ? bodyEnd : end + 3;

      continue;
    }

    if (character === "{") {
      depth += 1;
      index += 1;
      continue;
    }

    if (character === "}") {
      depth = Math.max(0, depth - 1);
      index += 1;
      continue;
    }

    if (
      depth === 0 &&
      source.startsWith("state", index) &&
      !/[A-Za-z0-9_$]/.test(source[index - 1] || "") &&
      !/[A-Za-z0-9_$]/.test(source[index + 5] || "")
    ) {
      let cursor = skipWhitespace(source, index + 5, bodyEnd);

      const state = readIdentifier(source, cursor, bodyEnd);

      if (state) {
        states.set(state.name, state);
        index = state.end;
        continue;
      }
    }

    index += 1;
  }

  return states;
}

function findWatchDeclarations(source, bodyStart, bodyEnd) {
  const watches = [];
  let index = bodyStart;
  let depth = 0;
  let quote = null;
  let escaped = false;

  while (index < bodyEnd) {
    const character = source[index];

    if (quote !== null) {
      if (escaped) {
        escaped = false;
      } else if (character === "\\") {
        escaped = true;
      } else if (character === quote) {
        quote = null;
      }

      index += 1;
      continue;
    }

    if (character === '"' || character === "'") {
      quote = character;
      index += 1;
      continue;
    }

    if (source.startsWith("", index + 4);

      index = end === -1 ? bodyEnd : end + 3;

      continue;
    }

    if (character === "{") {
      depth += 1;
      index += 1;
      continue;
    }

    if (character === "}") {
      depth = Math.max(0, depth - 1);
      index += 1;
      continue;
    }

    if (
      depth === 0 &&
      source.startsWith("watch", index) &&
      !/[A-Za-z0-9_$]/.test(source[index - 1] || "") &&
      !/[A-Za-z0-9_$]/.test(source[index + 5] || "")
    ) {
      const watchStart = index;
      let cursor = skipWhitespace(source, index + 5, bodyEnd);

      const watchedState = readIdentifier(source, cursor, bodyEnd);

      if (!watchedState) {
        watches.push({
          watchStart,
          watchEnd: index + 5,
          state: null,
          openingBrace: -1,
          closingBrace: -1,
        });

        index += 5;
        continue;
      }

      cursor = skipWhitespace(source, watchedState.end, bodyEnd);

      const openingBrace = source[cursor] === "{" ? cursor : -1;

      const closingBrace = openingBrace === -1 ? -1 : findMatchingBrace(source, openingBrace);

      watches.push({
        watchStart,
        watchEnd: index + 5,
        state: watchedState,
        openingBrace,
        closingBrace,
      });

      index = closingBrace === -1 ? watchedState.end : closingBrace + 1;

      continue;
    }

    index += 1;
  }

  return watches;
}

function validateRootMembers(document, source, rootKind, rootMatch) {
  const diagnostics = [];
  const allowed = VALID_MEMBERS[rootKind];

  if (!allowed) {
    return diagnostics;
  }

  const bodyRange = getRootBodyRange(source, rootMatch);

  const members = findRootMembers(source, bodyRange.start, bodyRange.end);

  for (const member of members) {
    if (allowed.has(member.name)) {
      continue;
    }

    diagnostics.push(
      createDiagnostic(
        document,
        member.start,
        member.end,
        `Unknown ${rootKind} member \`${member.name}\`.`,
        vscode.DiagnosticSeverity.Error,
        "wrn-unknown-member",
      ),
    );
  }

  return diagnostics;
}

function validateLifecycleBlocks(document, source, rootKind, rootMatch) {
  if (rootKind !== "component" && rootKind !== "layout") {
    return [];
  }

  const diagnostics = [];
  const bodyRange = getRootBodyRange(source, rootMatch);

  const blocks = findNamedBlocks(source, bodyRange.start, bodyRange.end, "lifecycle");

  if (blocks.length > 1) {
    for (const block of blocks.slice(1)) {
      diagnostics.push(
        createDiagnostic(
          document,
          block.nameStart,
          block.nameEnd,
          "Only one `lifecycle { ... }` block is allowed.",
          vscode.DiagnosticSeverity.Error,
          "wrn-duplicate-lifecycle",
        ),
      );
    }
  }

  for (const block of blocks) {
    if (block.openingBrace === -1) {
      diagnostics.push(
        createDiagnostic(
          document,
          block.nameStart,
          block.nameEnd,
          "`lifecycle` must be followed by a block.",
          vscode.DiagnosticSeverity.Error,
          "wrn-invalid-lifecycle",
        ),
      );

      continue;
    }

    const lifecycleEnd = block.closingBrace === -1 ? bodyRange.end : block.closingBrace;

    let index = block.openingBrace + 1;
    const seenHooks = new Set();

    while (index < lifecycleEnd) {
      index = skipWhitespace(source, index, lifecycleEnd);

      if (index >= lifecycleEnd) {
        break;
      }

      const hook = readIdentifier(source, index, lifecycleEnd);

      if (!hook) {
        index += 1;
        continue;
      }

      index = hook.end;

      if (!VALID_LIFECYCLE_HOOKS.has(hook.name)) {
        diagnostics.push(
          createDiagnostic(
            document,
            hook.start,
            hook.end,
            `Unknown lifecycle hook \`${hook.name}\`. Use \`mount\`, \`update\`, or \`unmount\`.`,
            vscode.DiagnosticSeverity.Error,
            "wrn-invalid-lifecycle-hook",
          ),
        );
      } else if (seenHooks.has(hook.name)) {
        diagnostics.push(
          createDiagnostic(
            document,
            hook.start,
            hook.end,
            `Duplicate lifecycle hook \`${hook.name}\`.`,
            vscode.DiagnosticSeverity.Error,
            "wrn-duplicate-lifecycle-hook",
          ),
        );
      } else {
        seenHooks.add(hook.name);
      }

      const openingBrace = skipWhitespace(source, index, lifecycleEnd);

      if (source[openingBrace] !== "{") {
        diagnostics.push(
          createDiagnostic(
            document,
            hook.start,
            hook.end,
            `Lifecycle hook \`${hook.name}\` must be followed by a block.`,
            vscode.DiagnosticSeverity.Error,
            "wrn-invalid-lifecycle-hook",
          ),
        );

        index = hook.end;
        continue;
      }

      const closingBrace = findMatchingBrace(source, openingBrace);

      index = closingBrace === -1 ? lifecycleEnd : closingBrace + 1;
    }
  }

  return diagnostics;
}

function validateWatchBlocks(document, source, rootKind, rootMatch) {
  if (rootKind !== "component" && rootKind !== "layout") {
    return [];
  }

  const diagnostics = [];
  const bodyRange = getRootBodyRange(source, rootMatch);

  const states = findStateDeclarations(source, bodyRange.start, bodyRange.end);

  const watches = findWatchDeclarations(source, bodyRange.start, bodyRange.end);

  for (const watch of watches) {
    if (!watch.state) {
      diagnostics.push(
        createDiagnostic(
          document,
          watch.watchStart,
          watch.watchEnd,
          "`watch` must be followed by a declared state name.",
          vscode.DiagnosticSeverity.Error,
          "wrn-invalid-watch",
        ),
      );

      continue;
    }

    if (!states.has(watch.state.name)) {
      diagnostics.push(
        createDiagnostic(
          document,
          watch.state.start,
          watch.state.end,
          `Cannot watch undeclared state \`${watch.state.name}\`.`,
          vscode.DiagnosticSeverity.Error,
          "wrn-unknown-watch-state",
        ),
      );
    }

    if (watch.openingBrace === -1) {
      diagnostics.push(
        createDiagnostic(
          document,
          watch.state.start,
          watch.state.end,
          `Watcher for \`${watch.state.name}\` must be followed by a block.`,
          vscode.DiagnosticSeverity.Error,
          "wrn-invalid-watch",
        ),
      );
    }
  }

  return diagnostics;
}

function validateRequiredView(document, source, rootKind, rootMatch) {
  const bodyRange = getRootBodyRange(source, rootMatch);

  const body = source.slice(bodyRange.start, bodyRange.end);

  if (/\bview\s*\{/.test(body)) {
    return [];
  }

  return [
    createDiagnostic(
      document,
      rootMatch.index,
      rootMatch.index + rootMatch[0].length,
      `The ${rootKind} \`${rootMatch[2]}\` does not contain a \`view { ... }\` block.`,
      vscode.DiagnosticSeverity.Warning,
      "wrn-missing-view",
    ),
  ];
}

function validateLayoutUsage(document, source, rootKind, rootMatch) {
  const diagnostics = [];

  if (rootKind !== "page") {
    const bodyRange = getRootBodyRange(source, rootMatch);
    const layoutMember = findRootMembers(source, bodyRange.start, bodyRange.end).find(
      (member) => member.name === "layout",
    );

    if (layoutMember) {
      diagnostics.push(
        createDiagnostic(
          document,
          layoutMember.start,
          layoutMember.end,
          '`layout = "..."` is only valid inside a page.',
          vscode.DiagnosticSeverity.Error,
          "wrn-invalid-layout-member",
        ),
      );
    }
  }

  return diagnostics;
}

function validateV060Features(document, source) {
  const diagnostics = [];
  const duplicateRuntimeFunctions = new Map();
  for (const match of source.matchAll(
    /\b(client|server|shared)\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
  )) {
    const key = `${match[1]}:${match[2]}`;
    if (duplicateRuntimeFunctions.has(key)) {
      diagnostics.push(
        createDiagnostic(
          document,
          match.index,
          match.index + match[0].length,
          `Duplicate ${match[1]} function '${match[2]}'.`,
          vscode.DiagnosticSeverity.Error,
          "WRN-FUNCTION-DUPLICATE",
        ),
      );
    } else duplicateRuntimeFunctions.set(key, match.index);
  }
  for (const match of source.matchAll(/\$emit\s*\(/g)) {
    diagnostics.push(
      createDiagnostic(
        document,
        match.index,
        match.index + match[0].length,
        "Use typed output.name(payload) instead of deprecated $emit().",
        vscode.DiagnosticSeverity.Warning,
        "WRN-OUTPUT-LEGACY-EMIT",
      ),
    );
  }
  for (const match of source.matchAll(/\bevent\.detail\b/g)) {
    diagnostics.push(
      createDiagnostic(
        document,
        match.index,
        match.index + match[0].length,
        "Component output handlers receive payload directly.",
        vscode.DiagnosticSeverity.Warning,
        "WRN-OUTPUT-LEGACY-DETAIL",
      ),
    );
  }
  for (const match of source.matchAll(
    /\bserver\s+(?:async\s+)?function\b[\s\S]*?\b(window|document|localStorage|navigator)\b/g,
  )) {
    const offset = match.index + match[0].lastIndexOf(match[1]);
    diagnostics.push(
      createDiagnostic(
        document,
        offset,
        offset + match[1].length,
        `Browser API '${match[1]}' is unavailable in a server function.`,
        vscode.DiagnosticSeverity.Error,
        "WRN-SERVER-BROWSER-API",
      ),
    );
  }
  return diagnostics;
}

function validateDocument(document) {
  if (document.languageId !== WRN_LANGUAGE_ID) {
    return [];
  }

  const source = document.getText();

  if (!source.trim()) {
    return [];
  }

  const diagnostics = [];
  const declaration = findTopLevelDeclaration(document, source);

  if (declaration.diagnostic) {
    diagnostics.push(declaration.diagnostic);

    diagnostics.push(...validateBalancedCharacters(document, source));

    return diagnostics;
  }

  if (!VALID_TOP_LEVEL_KINDS.has(declaration.kind)) {
    diagnostics.push(
      createDiagnostic(
        document,
        declaration.match.index,
        declaration.match.index + declaration.match[0].length,
        `Unsupported WRN declaration \`${declaration.kind}\`.`,
        vscode.DiagnosticSeverity.Error,
        "wrn-invalid-kind",
      ),
    );

    return diagnostics;
  }

  diagnostics.push(...validateBalancedCharacters(document, source));

  diagnostics.push(...validateHtmlTags(document, source));

  diagnostics.push(...validateRootMembers(document, source, declaration.kind, declaration.match));

  diagnostics.push(
    ...validateLifecycleBlocks(document, source, declaration.kind, declaration.match),
  );

  diagnostics.push(...validateWatchBlocks(document, source, declaration.kind, declaration.match));

  diagnostics.push(...validateRequiredView(document, source, declaration.kind, declaration.match));

  diagnostics.push(...validateLayoutUsage(document, source, declaration.kind, declaration.match));

  diagnostics.push(...validateV060Features(document, source));

  return diagnostics;
}

function registerDiagnostics(context) {
  const collection = vscode.languages.createDiagnosticCollection(COLLECTION_NAME);

  const timers = new Map();

  const update = (document) => {
    if (document.languageId !== WRN_LANGUAGE_ID) {
      return;
    }

    const key = document.uri.toString();
    const previousTimer = timers.get(key);

    if (previousTimer) {
      clearTimeout(previousTimer);
      timers.delete(key);
    }

    const configuration = vscode.workspace.getConfiguration("wrnexus", document.uri);

    if (!configuration.get("diagnostics.enable", true)) {
      collection.delete(document.uri);
      return;
    }

    const timer = setTimeout(() => {
      timers.delete(key);

      collection.set(document.uri, validateDocument(document));
    }, 150);

    timers.set(key, timer);
  };

  for (const document of vscode.workspace.textDocuments) {
    update(document);
  }

  context.subscriptions.push(
    collection,

    vscode.workspace.onDidOpenTextDocument(update),

    vscode.workspace.onDidChangeTextDocument((event) => {
      update(event.document);
    }),

    vscode.workspace.onDidSaveTextDocument(update),

    vscode.workspace.onDidChangeConfiguration((event) => {
      if (!event.affectsConfiguration("wrnexus.diagnostics.enable")) {
        return;
      }

      for (const document of vscode.workspace.textDocuments) {
        update(document);
      }
    }),

    vscode.workspace.onDidCloseTextDocument((document) => {
      const key = document.uri.toString();
      const timer = timers.get(key);

      if (timer) {
        clearTimeout(timer);
        timers.delete(key);
      }

      collection.delete(document.uri);
    }),

    {
      dispose() {
        for (const timer of timers.values()) {
          clearTimeout(timer);
        }

        timers.clear();
      },
    },
  );
}

module.exports = {
  findTopLevelDeclaration,
  maskLeadingTrivia,
  registerDiagnostics,
  validateBalancedCharacters,
  validateDocument,
  validateHtmlTags,
  validateLifecycleBlocks,
  validateLayoutUsage,
  validateRootMembers,
  validateWatchBlocks,
};