#!/usr/bin/env node

import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const JAVA_COMMIT = "cc82115a4e7f53d71d9d905caa2c2dfa4da58899";
const ANTLR_NG_COMMIT = "1f68422ae4bfc62f93343769e144d01f305487b1";
const JAVA_REPOSITORY = "https://github.com/antlr/antlr4.git";
const ANTLR_NG_REPOSITORY = "https://github.com/mike-lischke/antlr-ng.git";
const SUPPORTED_SUITES = new Set([
  "TestAmbigParseTrees",
  "TestATNInterpreter",
  "TestATNLexerInterpreter",
  "TestATNParserPrediction",
  "TestCodeGeneration",
  "TestGrammarParserInterpreter",
  "TestLexerActions",
  "TestParserExec",
  "TestParserInterpreter",
]);
const SHARED_GRAMMAR_PARSER_LEXER =
  "lexer grammar L;\n" +
  "PLUS : '+' ;\n" +
  "MULT : '*' ;\n" +
  "ID : [a-z]+ ;\n" +
  "INT : [0-9]+ ;\n" +
  "WS : [ \\r\\t\\n]+ ;\n";

const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(scriptDirectory, "../..");
const fixturesRoot = resolve(repositoryRoot, "crates/antlr-rust-codegen/tests/codegen-direct/fixtures");
const generatedPath = resolve(
  repositoryRoot,
  "crates/antlr-rust-codegen/tests/codegen-direct/generated/phase-c-runtime-cases.inc.rs",
);
const options = parseArguments(process.argv.slice(2));

verifyCommit(options.javaRoot, JAVA_COMMIT, "Java ANTLR");
verifyCommit(options.antlrNgRoot, ANTLR_NG_COMMIT, "antlr-ng");

const testMap = await load("crates/antlr-rust-codegen/tests/codegen-direct/upstream-test-map.json");
const inventory = await load(
  "crates/antlr-rust-codegen/tests/codegen-direct/upstream-case-inventory.json",
);
const inventoryById = new Map(
  inventory.cases.map((testCase) => [testCase.id, testCase]),
);
const rows = testMap.rows
  .filter(
    (row) =>
      row.owner_phase === "C" &&
      row.disposition === "port" &&
      ["mapped", "done"].includes(row.tdd_state) &&
      row.source_case_ids.some((id) => {
        const testCase = inventoryById.get(id);
        return testCase && SUPPORTED_SUITES.has(testCase.suite);
      }),
  )
  .sort((left, right) => left.logical_id.localeCompare(right.logical_id));

if (rows.length !== 132) {
  throw new Error(`expected 132 supported Phase C rows, found ${rows.length}`);
}

const sourceCache = new Map();
const methodCache = new Map();
const renderedCases = [];

for (const row of rows) {
  const sourceCases = row.source_case_ids.map((id) => {
    const testCase = inventoryById.get(id);
    if (!testCase) {
      throw new Error(`${row.logical_id} references unknown source case ${id}`);
    }
    return testCase;
  });
  const javaCase = sourceCases.find(
    (testCase) => testCase.implementation === "java-antlr",
  );
  const antlrNgCase = sourceCases.find(
    (testCase) => testCase.implementation === "antlr-ng",
  );
  const primaryCase = javaCase ?? antlrNgCase;
  if (!primaryCase || !SUPPORTED_SUITES.has(primaryCase.suite)) {
    throw new Error(`${row.logical_id} has no supported primary source`);
  }

  const javaMethod = javaCase
    ? await sourceMethod(javaCase, options.javaRoot, JAVA_COMMIT, "java")
    : null;
  const antlrNgMethod = antlrNgCase
    ? await sourceMethod(
        antlrNgCase,
        options.antlrNgRoot,
        ANTLR_NG_COMMIT,
        "typescript",
      )
    : null;
  const definition = extractDefinition(
    row.logical_id,
    primaryCase.suite,
    javaMethod ?? antlrNgMethod,
    javaMethod ? "java" : "typescript",
  );

  const fixture = createFixture(
    row,
    sourceCases,
    javaCase,
    antlrNgCase,
    definition,
  );
  if (options.update) {
    await updateFixture(row.logical_id, fixture);
  } else {
    await checkFixture(row.logical_id, fixture);
  }
  renderedCases.push(renderCase(row.logical_id, definition));
}

const generated =
  "// Generated by tools/grammar-frontend/generate-phase-c-runtime-fixtures.mjs.\n" +
  "// Inputs and behavior oracles come from pinned Java ANTLR 4.13.2 cases.\n\n" +
  `${renderedCases.join("\n")}\n`;
if (options.update) {
  await mkdir(dirname(generatedPath), { recursive: true });
  await writeFile(generatedPath, generated, "utf8");
} else {
  await expectFile(generatedPath, generated);
}

console.log(
  `${options.update ? "updated" : "verified"} ${rows.length} Phase C runtime fixtures`,
);

async function sourceMethod(testCase, root, commit, language) {
  const key = `${root}:${testCase.source.path}`;
  let source = sourceCache.get(key);
  if (source === undefined) {
    source = gitText(root, `${commit}:${testCase.source.path}`);
    sourceCache.set(key, source);
  }
  let methods = methodCache.get(key);
  if (methods === undefined) {
    methods =
      language === "java"
        ? extractJavaMethods(source)
        : extractTypeScriptMethods(source);
    methodCache.set(key, methods);
  }
  const method = methods.get(testCase.name);
  if (!method) {
    throw new Error(
      `${testCase.id} cannot locate ${testCase.source.path}#${testCase.name}`,
    );
  }
  return method;
}

function extractDefinition(logicalId, suite, method, language) {
  switch (suite) {
    case "TestATNLexerInterpreter":
      return extractLexerInterpreter(logicalId, method, language);
    case "TestParserInterpreter":
      return extractParserInterpreter(logicalId, method, language, false);
    case "TestGrammarParserInterpreter":
      return extractParserInterpreter(logicalId, method, language, true);
    case "TestATNInterpreter":
      return extractAtnInterpreter(logicalId, method, language);
    case "TestATNParserPrediction":
      return extractParserPrediction(logicalId, method, language);
    case "TestAmbigParseTrees":
      return extractAmbiguousTrees(logicalId, method, language);
    case "TestLexerActions":
      return extractLexerActions(logicalId, method, language);
    case "TestParserExec":
      return extractParserExec(logicalId, method, language);
    case "TestCodeGeneration":
      return extractCodeGeneration(logicalId, method, language);
    default:
      throw new Error(`${logicalId} has unsupported suite ${suite}`);
  }
}

function extractLexerInterpreter(logicalId, method, language) {
  const constructor = constructorArguments(method, "LexerGrammar", language)[0];
  const grammar = constructor
    ? evaluateString(
        constructor.arguments[0],
        method,
        constructor.offset,
        language,
      )
    : evaluateAssignedString("grammar", method, method.length, language);
  const checks = calls(method, "checkLexerMatches", language).map((call) => {
    const input = evaluateString(
      call.arguments[1],
      method,
      call.offset,
      language,
    );
    const expected = evaluateString(
      call.arguments[2],
      method,
      call.offset,
      language,
    )
      .split(", ")
      .filter((name) => name !== "EOF" && !lexerRuleSkips(grammar, name));
    expected.push("EOF");
    return { input, expected, errors: [] };
  });
  if (checks.length === 0) {
    const exec = calls(method, "execLexer", language)[0];
    if (!exec) {
      throw new Error(`${logicalId} has no lexer behavior invocation`);
    }
    const inputIndex = language === "java" ? 3 : 2;
    const input = evaluateString(
      exec.arguments[inputIndex],
      method,
      exec.offset,
      language,
    );
    const expectedError = firstExpectedError(method, language);
    checks.push({
      input,
      expected: null,
      errors: expectedError ? [expectedError.trimEnd()] : [],
    });
  }
  return {
    kind: "lexer",
    roots: ["L.g4"],
    files: new Map([["L.g4", grammar]]),
    checks,
  };
}

function extractLexerActions(logicalId, method, language) {
  const exec = calls(method, "execLexer", language)[0];
  if (!exec) {
    throw new Error(`${logicalId} has no execLexer invocation`);
  }
  const grammar = evaluateAssignedString(
    "grammar",
    method,
    exec.offset,
    language,
  );
  const root = evaluateString(exec.arguments[0], method, exec.offset, language);
  const lexerName = evaluateString(
    exec.arguments[2],
    method,
    exec.offset,
    language,
  );
  const input = evaluateString(
    exec.arguments[3],
    method,
    exec.offset,
    language,
  );
  const expected = evaluateAssignedString(
    "expecting",
    method,
    method.length,
    language,
  );
  const expectedTokens = expected
    .split("\n")
    .filter((line) => line.startsWith("[@"));
  if (expectedTokens.length === 0) {
    throw new Error(`${logicalId} has no expected token stream`);
  }
  return {
    kind: "lexer-actions",
    roots: [root],
    files: new Map([[root, grammar]]),
    lexerName,
    input,
    expectedTokens,
    ...lexerActionOracle(logicalId),
  };
}

function lexerActionOracle(logicalId) {
  const action = (rule, actionIndex, position, text) => ({
    rule,
    actionIndex,
    position,
    text,
  });
  const actions = new Map([
    [
      "testlexeractions-testactionexecutedindfa-50e2605da3",
      [action("I", 0, 2, "34"), action("I", 0, 5, "34")],
    ],
    [
      "testlexeractions-testactionevalsatcorrectindex-9f940f5340",
      [action("I", 0, 1, "1"), action("I", 0, 5, "4")],
    ],
    [
      "testlexeractions-testevalmultipleactions-af7a23955f",
      [
        action("HELLO", 0, 6, "hello "),
        action("HELLO", 1, 11, "hello Steve"),
        action("HELLO", 2, 12, "hello Steve\n"),
      ],
    ],
    [
      "testlexeractions-test2actionsin1rule-710bd0bd7b",
      [
        action("I", 0, 1, "1"),
        action("I", 1, 3, "123"),
        action("I", 0, 5, "4"),
        action("I", 1, 6, "45"),
      ],
    ],
    [
      "testlexeractions-testaltactionsin1rule-cd7e8e3c06",
      [
        action("I", 0, 3, "123"),
        action("I", 2, 3, "123"),
        action("I", 1, 6, "ab"),
        action("I", 2, 6, "ab"),
      ],
    ],
    [
      "testlexeractions-testactionpluscommand-5f3025038d",
      [action("I", 0, 2, "34"), action("I", 0, 5, "34")],
    ],
    [
      "testlexeractions-testskipcommand-0be8276f47",
      [action("I", 0, 2, "34"), action("I", 0, 5, "34")],
    ],
    [
      "testlexeractions-testmorecommand-ea46aea2b9",
      [action("I", 0, 2, "34"), action("I", 0, 5, "#10")],
    ],
    ["testlexeractions-testtypecommand-7d068e482d", [action("I", 0, 2, "34")]],
    [
      "testlexeractions-testcombinedcommand-7db8eee992",
      [action("I", 0, 2, "34"), action("I", 0, 5, "#11")],
    ],
  ]);
  if (
    logicalId ===
    "testlexeractions-testfailingpredicateevalisnotcached-ccbdf3d728"
  ) {
    return {
      actions: [],
      predicate: "line-does-not-start-with-item",
      predicates: [
        { rule: "LINE", predicateIndex: 0, position: 13, result: true },
        { rule: "LINE", predicateIndex: 0, position: 32, result: false },
        { rule: "LINE", predicateIndex: 0, position: 46, result: true },
        { rule: "LINE", predicateIndex: 0, position: 57, result: true },
      ],
    };
  }
  return {
    actions: actions.get(logicalId) ?? [],
    predicate: null,
    predicates: [],
  };
}

function extractParserExec(logicalId, method, language) {
  if (logicalId === "testparserexec-fail-element-option-5aa5efff9b") {
    return {
      kind: "parser-fail-option",
      roots: ["T.g4"],
      files: new Map([
        [
          "T.g4",
          evaluateAssignedString(
            "grammarText",
            method,
            method.length,
            language,
          ),
        ],
      ]),
    };
  }
  if (logicalId === "testparserexec-testalternatequotes-39da532222") {
    return {
      kind: "parser-alternate-quotes",
      roots: ["ModeTagsLexer.g4", "ModeTagsParser.g4"],
      files: new Map([
        [
          "ModeTagsLexer.g4",
          evaluateAssignedString(
            "lexerGrammar",
            method,
            method.length,
            language,
          ),
        ],
        [
          "ModeTagsParser.g4",
          evaluateAssignedString(
            "parserGrammar",
            method,
            method.length,
            language,
          ),
        ],
      ]),
    };
  }
  if (
    logicalId === "testparserexec-testfailedpredicateexceptionstate-3ff3f5083a"
  ) {
    return {
      kind: "parser-failed-predicate",
      roots: ["Psl.g4"],
      files: new Map([
        [
          "Psl.g4",
          gitText(
            options.javaRoot,
            `${JAVA_COMMIT}:tool-testsuite/test/org/antlr/v4/test/tool/Psl.g4`,
          ),
        ],
      ]),
      sourceAdaptation:
        "copied the pinned Psl.g4 dependency loaded by the upstream test",
    };
  }

  const exec = calls(method, "execParser", language)[0];
  if (!exec) {
    throw new Error(`${logicalId} has no execParser invocation`);
  }
  const grammar = evaluateAssignedString(
    "grammar",
    method,
    exec.offset,
    language,
  );
  const root = evaluateString(exec.arguments[0], method, exec.offset, language);
  const parserName = evaluateString(
    exec.arguments[2],
    method,
    exec.offset,
    language,
  );
  const lexerName = evaluateString(
    exec.arguments[3],
    method,
    exec.offset,
    language,
  );
  const startRule = evaluateString(
    exec.arguments[4],
    method,
    exec.offset,
    language,
  );
  const input = evaluateString(
    exec.arguments[5],
    method,
    exec.offset,
    language,
  );
  const kinds = new Map([
    [
      "testparserexec-testattributevalueinitialization-65fdb0798a",
      "parser-attribute-initialization",
    ],
    [
      "testparserexec-testcaseinsensitiveincombinedgrammar-6e1c4cb8b9",
      "parser-case-insensitive",
    ],
    [
      "testparserexec-teststartrulewithouteof-31619be2b5",
      "parser-start-without-eof",
    ],
  ]);
  const kind = kinds.get(logicalId);
  if (!kind) {
    throw new Error(`${logicalId} has no parser-exec oracle`);
  }
  return {
    kind,
    roots: [root],
    files: new Map([[root, grammar]]),
    parserName,
    lexerName,
    startRule,
    input,
  };
}

function extractCodeGeneration(logicalId, method, language) {
  const grammar = evaluateAssignedString("g", method, method.length, language);
  const kinds = new Map([
    [
      "testcodegeneration-assigntokennamestostringliteralsingeneratedparserrulecontexts-983a5b92b8",
      "codegen-single-literal-accessor",
    ],
    [
      "testcodegeneration-assigntokennamestostringliteralarraysingeneratedparserrulecontexts-cd2030bed9",
      "codegen-repeated-literal-accessor",
    ],
    [
      "testcodegeneration-csharpaccessleveloption-3bd1318d28",
      "codegen-access-level",
    ],
    [
      "testcodegeneration-testargdecl-ced2e031e1",
      "codegen-argument-declaration",
    ],
  ]);
  const kind = kinds.get(logicalId);
  if (!kind) {
    throw new Error(`${logicalId} has no code-generation oracle`);
  }
  return {
    kind,
    roots: ["T.g4"],
    files: new Map([["T.g4", grammar]]),
  };
}
function extractParserInterpreter(logicalId, method, language, grammarParser) {
  const parserConstructor = constructorArguments(
    method,
    "Grammar",
    language,
  )[0];
  if (!parserConstructor) {
    throw new Error(`${logicalId} has no parser Grammar constructor`);
  }
  const parserGrammar = evaluateString(
    parserConstructor.arguments[0],
    method,
    parserConstructor.offset,
    language,
  );
  let lexerGrammar;
  if (grammarParser) {
    lexerGrammar = SHARED_GRAMMAR_PARSER_LEXER;
  } else {
    const lexerConstructor = constructorArguments(
      method,
      "LexerGrammar",
      language,
    )[0];
    if (!lexerConstructor) {
      throw new Error(`${logicalId} has no LexerGrammar constructor`);
    }
    lexerGrammar = evaluateString(
      lexerConstructor.arguments[0],
      method,
      lexerConstructor.offset,
      language,
    );
  }
  const checks = calls(method, "testInterp", language).map((call) => {
    const shift = grammarParser && language === "typescript" ? -1 : 0;
    const startRule = evaluateString(
      call.arguments[2 + shift],
      method,
      call.offset,
      language,
    );
    const input = evaluateString(
      call.arguments[3 + shift],
      method,
      call.offset,
      language,
    );
    const tree = evaluateString(
      call.arguments[4 + shift],
      method,
      call.offset,
      language,
    );
    return { startRule, input, tree, intervals: [] };
  });
  if (checks.length === 0) {
    throw new Error(`${logicalId} has no testInterp invocation`);
  }
  const intervalAssertions = extractIntervalAssertions(method, language);
  for (const assertion of intervalAssertions) {
    const check = checks[assertion.checkIndex];
    if (!check) {
      throw new Error(`${logicalId} interval assertion has no parse check`);
    }
    check.intervals.push({
      path: assertion.path,
      expected: assertion.expected,
    });
  }
  return {
    kind: grammarParser ? "grammar-parser" : "parser",
    roots: ["L.g4", "T.g4"],
    files: new Map([
      ["L.g4", lexerGrammar],
      ["T.g4", adaptParserGrammar(parserGrammar, "L")],
    ]),
    checks,
  };
}

function extractAtnInterpreter(logicalId, method, language) {
  const grammars = pairedGrammars(logicalId, method, language);
  const expectedErrors = extractExpectedAltErrors(method, language);
  const checks = calls(method, "checkMatchedAlt", language).map((call) => {
    const error = expectedErrors.get(call.offset);
    return {
      input: evaluateString(call.arguments[2], method, call.offset, language),
      expected: error
        ? {
            kind: "error",
            tokenIndex: error.tokenIndex,
            tokenType: error.tokenType,
          }
        : {
            kind: "alternative",
            alternative: evaluateInteger(
              call.arguments[3],
              method,
              call.offset,
              language,
            ),
          },
    };
  });
  if (checks.length === 0) {
    throw new Error(`${logicalId} has no checkMatchedAlt invocation`);
  }
  return {
    kind: "parser-alt",
    roots: ["L.g4", "T.g4"],
    files: new Map([
      ["L.g4", grammars.lexer],
      ["T.g4", adaptParserGrammar(grammars.parser, "L")],
    ]),
    checks,
  };
}

function extractParserPrediction(logicalId, method, language) {
  const combined = constructorArguments(method, "Grammar", language)[0];
  const lexer = constructorArguments(method, "LexerGrammar", language)[0];
  if (!lexer && combined) {
    const grammar = evaluateString(
      combined.arguments[0],
      method,
      combined.offset,
      language,
    );
    const lr = extractLeftRecursiveAltAssertions(method, language);
    if (!lr) {
      throw new Error(`${logicalId} combined prediction case lacks LR oracle`);
    }
    return {
      kind: "left-recursion-alts",
      roots: ["T.g4"],
      files: new Map([["T.g4", grammar]]),
      rule: "e",
      ...lr,
    };
  }
  const grammars = pairedGrammars(logicalId, method, language);
  const predictions = calls(method, "checkPredictedAlt", language).map(
    (call) => ({
      decision: evaluateInteger(
        call.arguments[2],
        method,
        call.offset,
        language,
      ),
      input: evaluateString(call.arguments[3], method, call.offset, language),
      expected: evaluateInteger(
        call.arguments[4],
        method,
        call.offset,
        language,
      ),
    }),
  );
  const dfaChecks = calls(method, "checkDFAConstruction", language).map(
    (call) => {
      const inputs = evaluateStringArray(
        call.arguments[3],
        method,
        call.offset,
        language,
      );
      const expected = evaluateStringArray(
        call.arguments[4],
        method,
        call.offset,
        language,
      );
      if (inputs.length !== expected.length) {
        throw new Error(
          `${logicalId} has ${inputs.length} DFA inputs but ${expected.length} expected states`,
        );
      }
      return {
        decision: evaluateInteger(
          call.arguments[2],
          method,
          call.offset,
          language,
        ),
        inputs,
        expected,
      };
    },
  );
  if (predictions.length === 0) {
    throw new Error(`${logicalId} has no prediction invocation`);
  }
  return {
    kind: "prediction",
    roots: ["L.g4", "T.g4"],
    files: new Map([
      ["L.g4", grammars.lexer],
      ["T.g4", adaptParserGrammar(grammars.parser, "L")],
    ]),
    predictions,
    dfaChecks,
  };
}

function extractAmbiguousTrees(logicalId, method, language) {
  const grammars = pairedGrammars(logicalId, method, language);
  const forced = calls(method, "testInterpAtSpecificAlt", language).map(
    (call) => ({
      startRule: evaluateString(
        call.arguments[2],
        method,
        call.offset,
        language,
      ),
      decision: 0,
      inputIndex: 0,
      input: evaluateString(call.arguments[4], method, call.offset, language),
      alternatives: [
        {
          alt: evaluateInteger(
            call.arguments[3],
            method,
            call.offset,
            language,
          ),
          tree: evaluateString(
            call.arguments[5],
            method,
            call.offset,
            language,
          ),
        },
      ],
    }),
  );
  const ambiguous = calls(method, "testAmbiguousTrees", language).map(
    (call) => {
      const startRule = evaluateString(
        call.arguments[2],
        method,
        call.offset,
        language,
      );
      const input = evaluateString(
        call.arguments[3],
        method,
        call.offset,
        language,
      );
      const decision = evaluateInteger(
        call.arguments[4],
        method,
        call.offset,
        language,
      );
      const overall = evaluateString(
        call.arguments[6],
        method,
        call.offset,
        language,
      );
      const trees = evaluateStringArray(
        call.arguments[7],
        method,
        call.offset,
        language,
      );
      return {
        startRule,
        decision,
        inputIndex: 0,
        input,
        overall,
        alternatives: trees.map((tree, index) => ({
          alt: index + 1,
          tree,
        })),
      };
    },
  );
  const checks = [...forced, ...ambiguous];
  if (checks.length === 0) {
    throw new Error(`${logicalId} has no ambiguity invocation`);
  }
  return {
    kind: "ambiguous",
    roots: ["L.g4", "T.g4"],
    files: new Map([
      ["L.g4", grammars.lexer],
      ["T.g4", adaptParserGrammar(grammars.parser, "L")],
    ]),
    checks,
  };
}

function pairedGrammars(logicalId, method, language) {
  const lexer = constructorArguments(method, "LexerGrammar", language)[0];
  const parser = constructorArguments(method, "Grammar", language)[0];
  if (!lexer || !parser) {
    throw new Error(`${logicalId} lacks paired lexer/parser grammars`);
  }
  return {
    lexer: evaluateString(lexer.arguments[0], method, lexer.offset, language),
    parser: evaluateString(
      parser.arguments[0],
      method,
      parser.offset,
      language,
    ),
  };
}

function createFixture(row, sourceCases, javaCase, antlrNgCase, definition) {
  const sources = sourceCases.map((testCase) => ({
    source_case_id: testCase.id,
    repository:
      testCase.implementation === "java-antlr"
        ? JAVA_REPOSITORY
        : ANTLR_NG_REPOSITORY,
    commit:
      testCase.implementation === "java-antlr" ? JAVA_COMMIT : ANTLR_NG_COMMIT,
    path: testCase.source.path,
    case: testCase.name,
    source_sha256: testCase.source.sha256,
  }));
  return {
    files: definition.files,
    manifest: {
      schema_version: 1,
      roots: definition.roots,
      logical_ids: [row.logical_id],
      upstream_tests: sources,
      java_antlr_test: javaCase
        ? sources.find((source) => source.source_case_id === javaCase.id)
        : null,
      antlr_ng_test: antlrNgCase
        ? sources.find((source) => source.source_case_id === antlrNgCase.id)
        : null,
      expected: "success",
      phase_c_runtime_oracle: {
        kind: definition.kind,
        compatibility_verdict: "Java ANTLR 4.13.2",
        source_adaptation:
          definition.sourceAdaptation ??
          (definition.roots.includes("T.g4") &&
          definition.roots.includes("L.g4")
            ? "added options { tokenVocab=L; } to express the upstream in-memory LexerGrammar dependency as a source-set edge"
            : null),
      },
    },
  };
}

async function updateFixture(logicalId, fixture) {
  const directory = resolve(fixturesRoot, logicalId);
  await rm(directory, { recursive: true, force: true });
  await mkdir(directory, { recursive: true });
  for (const [path, contents] of fixture.files) {
    await mkdir(dirname(resolve(directory, path)), { recursive: true });
    await writeFile(resolve(directory, path), contents, "utf8");
  }
  const manifest = {
    ...fixture.manifest,
    files: Object.fromEntries(
      [...fixture.files.entries()]
        .sort(([left], [right]) => left.localeCompare(right))
        .map(([path, contents]) => [path, digest(contents)]),
    ),
  };
  await writeFile(
    resolve(directory, "fixture.json"),
    `${JSON.stringify(manifest, null, 2)}\n`,
    "utf8",
  );
}

async function checkFixture(logicalId, fixture) {
  const directory = resolve(fixturesRoot, logicalId);
  for (const [path, contents] of fixture.files) {
    await expectFile(resolve(directory, path), contents);
  }
  const actual = JSON.parse(
    await readFile(resolve(directory, "fixture.json"), "utf8"),
  );
  for (const key of [
    "schema_version",
    "roots",
    "logical_ids",
    "upstream_tests",
    "java_antlr_test",
    "antlr_ng_test",
    "expected",
    "phase_c_runtime_oracle",
  ]) {
    if (JSON.stringify(actual[key]) !== JSON.stringify(fixture.manifest[key])) {
      throw new Error(`${logicalId} fixture field ${key} differs`);
    }
  }
  for (const [path, expectedHash] of Object.entries(actual.files ?? {})) {
    const actualHash = digest(await readFile(resolve(directory, path)));
    if (actualHash !== expectedHash) {
      throw new Error(`${logicalId} fixture hash differs for ${path}`);
    }
  }
}

function renderCase(logicalId, definition) {
  const name = rustIdentifier(logicalId);
  const roots = renderRustSlice(definition.roots);
  switch (definition.kind) {
    case "lexer":
      return `lexer_case!(\n    ${name},\n    ${rustString(logicalId)},\n    ${roots},\n    ${renderLexerChecks(definition.checks)},\n);`;
    case "parser":
    case "grammar-parser":
      return `parser_case!(\n    ${name},\n    ${rustString(logicalId)},\n    ${roots},\n    ${definition.kind === "grammar-parser"},\n    ${renderParserChecks(definition.checks)},\n);`;
    case "parser-alt":
      return `parser_alt_case!(\n    ${name},\n    ${rustString(logicalId)},\n    ${roots},\n    ${renderAltChecks(definition.checks)},\n);`;
    case "prediction":
      return `prediction_case!(\n    ${name},\n    ${rustString(logicalId)},\n    ${roots},\n    ${renderPredictionChecks(definition.predictions)},\n    ${renderDfaChecks(definition.dfaChecks)},\n);`;
    case "left-recursion-alts":
      return `left_recursion_alts_case!(\n    ${name},\n    ${rustString(logicalId)},\n    ${roots},\n    ${rustString(definition.rule)},\n    ${renderRustSlice(definition.primary)},\n    ${renderRustSlice(definition.recursive)},\n);`;
    case "ambiguous":
      return `ambiguous_case!(\n    ${name},\n    ${rustString(logicalId)},\n    ${roots},\n    ${renderAmbiguousChecks(definition.checks)},\n);`;
    case "lexer-actions":
      return `lexer_action_case!(\n    ${name},\n    ${rustString(logicalId)},\n    ${roots},\n    ${rustString(definition.lexerName)},\n    ${rustString(definition.input)},\n    ${renderRustSlice(definition.expectedTokens)},\n    ${renderActionEvents(definition.actions)},\n    ${renderPredicateKind(definition.predicate)},\n    ${renderPredicateEvents(definition.predicates)},\n);`;
    case "parser-case-insensitive":
      return renderToolCase(
        name,
        logicalId,
        roots,
        `ToolCase::ParserRuntime { lexer: ${rustString(definition.lexerName)}, parser: ${rustString(definition.parserName)}, start_rule: ${rustString(definition.startRule)}, input: ${rustString(definition.input)} }`,
      );
    case "parser-start-without-eof":
      return renderToolCase(
        name,
        logicalId,
        roots,
        `ToolCase::ParserStartWithoutEof { lexer: ${rustString(definition.lexerName)}, parser: ${rustString(definition.parserName)}, start_rule: ${rustString(definition.startRule)}, input: ${rustString(definition.input)} }`,
      );
    case "parser-alternate-quotes":
      return renderToolCase(
        name,
        logicalId,
        roots,
        "ToolCase::ParserAlternateQuotes",
      );
    case "parser-attribute-initialization":
      return renderToolCase(
        name,
        logicalId,
        roots,
        "ToolCase::ParserAttributeInitialization",
      );
    case "parser-failed-predicate":
      return renderToolCase(
        name,
        logicalId,
        roots,
        "ToolCase::ParserFailedPredicate",
      );
    case "parser-fail-option":
      return renderToolCase(
        name,
        logicalId,
        roots,
        "ToolCase::ParserFailOption",
      );
    case "codegen-single-literal-accessor":
      return renderToolCase(
        name,
        logicalId,
        roots,
        "ToolCase::CodegenLiteralAccessor { repeated: false }",
      );
    case "codegen-repeated-literal-accessor":
      return renderToolCase(
        name,
        logicalId,
        roots,
        "ToolCase::CodegenLiteralAccessor { repeated: true }",
      );
    case "codegen-access-level":
      return renderToolCase(
        name,
        logicalId,
        roots,
        "ToolCase::CodegenAccessLevel",
      );
    case "codegen-argument-declaration":
      return renderToolCase(
        name,
        logicalId,
        roots,
        "ToolCase::CodegenArgumentDeclaration",
      );
    default:
      throw new Error(`${logicalId} cannot render kind ${definition.kind}`);
  }
}

function renderToolCase(name, logicalId, roots, toolCase) {
  return `tool_case!(\n    ${name},\n    ${rustString(logicalId)},\n    ${roots},\n    ${toolCase},\n);`;
}

function renderLexerChecks(checks) {
  return `&[\n${checks
    .map(
      (check) =>
        `        LexerCheck { input: ${rustString(check.input)}, expected: ${
          check.expected === null
            ? "None"
            : `Some(${renderRustSlice(check.expected)})`
        }, errors: ${renderRustSlice(check.errors)} },`,
    )
    .join("\n")}\n    ]`;
}

function renderParserChecks(checks) {
  return `&[\n${checks
    .map(
      (check) =>
        `        ParserCheck { start_rule: ${rustString(check.startRule)}, input: ${rustString(check.input)}, tree: ${rustString(check.tree)}, intervals: ${renderIntervals(check.intervals)} },`,
    )
    .join("\n")}\n    ]`;
}

function renderIntervals(intervals) {
  return `&[${intervals
    .map(
      (interval) =>
        `IntervalCheck { path: ${renderRustSlice(interval.path)}, expected: ${rustString(interval.expected)} }`,
    )
    .join(", ")}]`;
}

function renderAltChecks(checks) {
  return `&[\n${checks
    .map(
      (check) =>
        `        AltCheck { input: ${rustString(check.input)}, expected: ${
          check.expected.kind === "alternative"
            ? `AltExpectation::Alternative(${check.expected.alternative})`
            : `AltExpectation::NoViableAlt { token_index: ${check.expected.tokenIndex}, token_type: ${check.expected.tokenType} }`
        } },`,
    )
    .join("\n")}\n    ]`;
}

function renderPredictionChecks(checks) {
  return `&[\n${checks
    .map(
      (check) =>
        `        PredictionCheck { decision: ${check.decision}, input: ${rustString(check.input)}, expected: ${check.expected} },`,
    )
    .join("\n")}\n    ]`;
}

function renderDfaChecks(checks) {
  return `&[\n${checks
    .map(
      (check) =>
        `        DfaCheck { decision: ${check.decision}, inputs: ${renderRustSlice(check.inputs)}, expected: ${renderRustSlice(check.expected)} },`,
    )
    .join("\n")}\n    ]`;
}

function renderAmbiguousChecks(checks) {
  return `&[\n${checks
    .map(
      (check) =>
        `        AmbiguousCheck { start_rule: ${rustString(check.startRule)}, decision: ${check.decision}, input_index: ${check.inputIndex}, input: ${rustString(check.input)}, overall: ${check.overall === undefined ? "None" : `Some(${rustString(check.overall)})`}, alternatives: &[${check.alternatives.map((alternative) => `(${alternative.alt}, ${rustString(alternative.tree)})`).join(", ")}] },`,
    )
    .join("\n")}\n    ]`;
}

function renderActionEvents(events) {
  return `&[\n${events
    .map(
      (event) =>
        `        ExpectedLexerAction { rule: ${rustString(event.rule)}, action_index: ${event.actionIndex}, position: ${event.position}, text: ${rustString(event.text)} },`,
    )
    .join("\n")}\n    ]`;
}

function renderPredicateKind(predicate) {
  switch (predicate) {
    case null:
      return "LexerPredicateKind::AlwaysTrue";
    case "line-does-not-start-with-item":
      return "LexerPredicateKind::LineDoesNotStartWithItem";
    default:
      throw new Error(`unsupported lexer predicate oracle ${predicate}`);
  }
}

function renderPredicateEvents(events) {
  return `&[\n${events
    .map(
      (event) =>
        `        ExpectedLexerPredicate { rule: ${rustString(event.rule)}, predicate_index: ${event.predicateIndex}, position: ${event.position}, result: ${event.result} },`,
    )
    .join("\n")}\n    ]`;
}

function extractLeftRecursiveAltAssertions(method, language) {
  const values =
    language === "java"
      ? [...method.matchAll(/assertEquals\(\s*"(\[[^"]+\])"/gu)].map(
          (match) => match[1],
        )
      : [
          ...method.matchAll(
            /expect\([^)]*(?:PrimaryAlts|RecursiveOpAlts)[^)]*\)[\s\S]*?toEqual\(\s*(\[[^\]]+\])/gu,
          ),
        ].map((match) => match[1]);
  if (values.length < 2) {
    const arrays = [...method.matchAll(/"\[(?<values>[0-9, ]+)\]"/gu)].map(
      (match) => `[${match.groups.values}]`,
    );
    values.push(...arrays);
  }
  if (values.length < 2) {
    return null;
  }
  return {
    primary: parseNumberArray(values[0]),
    recursive: parseNumberArray(values[1]),
  };
}

function extractIntervalAssertions(method, language) {
  const assertions = [];
  const pattern =
    language === "java"
      ? /assertEquals\(\s*"(?<expected>-?\d+\.\.-?\d+)"\s*,\s*t(?<path>(?:\.getChild\(\d+\))*)\.getSourceInterval\(\)\.toString\(\)\s*\)/gu
      : /expect\(\s*t(?<path>(?:\.getChild\(\d+\)!?)*)\.getSourceInterval\(\)\.toString\(\)\s*\)\.toBe\(\s*"(?<expected>-?\d+\.\.-?\d+)"\s*\)/gu;
  for (const match of method.matchAll(pattern)) {
    const before = method.slice(0, match.index);
    const checkIndex = calls(before, "testInterp", language).length - 1;
    const path = [...match.groups.path.matchAll(/getChild\((\d+)\)/gu)].map(
      (child) => Number.parseInt(child[1], 10),
    );
    assertions.push({
      checkIndex,
      path,
      expected: match.groups.expected,
    });
  }
  return assertions;
}

function firstExpectedError(method, language) {
  const assertions =
    language === "java" ? calls(method, "assertEquals", language) : [];
  if (language === "java") {
    for (const assertion of assertions) {
      if (assertion.arguments[1]?.includes(".errors")) {
        return evaluateString(
          assertion.arguments[0],
          method,
          assertion.offset,
          language,
        );
      }
    }
  }
  const match = /\.toBe\(\s*("(?:\\.|[^"\\])*")\s*\)/gu.exec(method);
  return match ? decodeString(match[1], language) : null;
}

function extractExpectedAltErrors(method, language) {
  const errors = new Map();
  if (language !== "java") {
    return errors;
  }

  const masked = maskCode(method);
  for (const match of masked.matchAll(/\btry\s*\{/gu)) {
    const tryOpen = masked.indexOf("{", match.index);
    const tryClose = matchingDelimiter(masked, tryOpen, "{", "}");
    const catchMatch =
      /\s*catch\s*\(\s*NoViableAltException\s+(?<name>[A-Za-z_$][\w$]*)\s*\)\s*\{/uy;
    catchMatch.lastIndex = tryClose + 1;
    const caught = catchMatch.exec(masked);
    if (!caught) {
      continue;
    }
    const catchOpen = masked.indexOf("{", caught.index);
    const catchClose = matchingDelimiter(masked, catchOpen, "{", "}");
    const catchBody = method.slice(catchOpen + 1, catchClose);
    const variable = escapeRegExp(caught.groups.name);
    const tokenIndex = assertionInteger(
      catchBody,
      new RegExp(
        `assertEquals\\(\\s*(-?\\d+)\\s*,\\s*${variable}\\.getOffendingToken\\(\\)\\.getTokenIndex\\(\\)\\s*\\)`,
        "u",
      ),
      "offending token index",
    );
    const tokenType = assertionInteger(
      catchBody,
      new RegExp(
        `assertEquals\\(\\s*(-?\\d+)\\s*,\\s*${variable}\\.getOffendingToken\\(\\)\\.getType\\(\\)\\s*\\)`,
        "u",
      ),
      "offending token type",
    );
    const checked = calls(
      method.slice(tryOpen + 1, tryClose),
      "checkMatchedAlt",
      language,
    );
    if (checked.length !== 1) {
      throw new Error(
        `expected one checkMatchedAlt call in NoViableAltException try block, found ${checked.length}`,
      );
    }
    const absoluteOffset = tryOpen + 1 + checked[0].offset;
    errors.set(absoluteOffset, { tokenIndex, tokenType });
  }
  return errors;
}

function assertionInteger(source, pattern, label) {
  const match = pattern.exec(source);
  if (!match) {
    throw new Error(`cannot extract ${label} assertion`);
  }
  return Number.parseInt(match[1], 10);
}

function lexerRuleSkips(grammar, name) {
  const masked = maskCode(grammar);
  const declaration = new RegExp(
    `(?:^|\\n)\\s*(?:fragment\\s+)?${escapeRegExp(name)}\\b`,
    "u",
  ).exec(masked);
  if (!declaration) {
    return false;
  }
  const bodyStart = topLevelDelimiter(masked, declaration.index, ":");
  const bodyEnd = topLevelDelimiter(masked, bodyStart + 1, ";");
  return /->\s*skip\b/u.test(masked.slice(bodyStart + 1, bodyEnd));
}

function topLevelDelimiter(source, start, delimiter) {
  const stack = [];
  const matching = new Map([
    [")", "("],
    ["]", "["],
    ["}", "{"],
  ]);
  for (let index = start; index < source.length; index += 1) {
    const current = source[index];
    if ("([{".includes(current)) {
      stack.push(current);
    } else if (matching.has(current)) {
      if (stack.pop() !== matching.get(current)) {
        throw new Error("unbalanced grammar rule");
      }
    } else if (current === delimiter && stack.length === 0) {
      return index;
    }
  }
  throw new Error(`cannot find top-level ${delimiter}`);
}

function adaptParserGrammar(grammar, lexerName) {
  if (/\btokenVocab\s*=/u.test(grammar)) {
    return grammar;
  }
  const declaration =
    /^\s*parser\s+grammar\s+[A-Za-z_]\w*\s*;\s*(?:\r?\n)?/u.exec(grammar);
  if (!declaration) {
    throw new Error(`cannot adapt parser grammar: ${grammar.slice(0, 80)}`);
  }
  const rest = grammar.slice(declaration[0].length);
  const options = /^\s*options\s*\{/u.exec(rest);
  if (options) {
    const open = rest.indexOf("{", options.index);
    return (
      grammar.slice(0, declaration[0].length + open + 1) +
      ` tokenVocab=${lexerName};` +
      rest.slice(open + 1)
    );
  }
  return (
    grammar.slice(0, declaration[0].length) +
    `options { tokenVocab=${lexerName}; }\n` +
    rest
  );
}

function constructorArguments(text, className, language) {
  const result = [];
  const masked = maskCode(text);
  const pattern = new RegExp(`\\bnew\\s+${className}\\s*\\(`, "gu");
  for (const match of masked.matchAll(pattern)) {
    const open = masked.indexOf("(", match.index);
    const close = matchingDelimiter(masked, open, "(", ")");
    result.push({
      offset: match.index,
      arguments: splitArguments(
        text.slice(open + 1, close),
        masked.slice(open + 1, close),
      ),
      language,
    });
  }
  return result;
}

function calls(text, name, language) {
  const result = [];
  const masked = maskCode(text);
  const pattern = new RegExp(`\\b${escapeRegExp(name)}\\s*\\(`, "gu");
  for (const match of masked.matchAll(pattern)) {
    const open = masked.indexOf("(", match.index);
    const close = matchingDelimiter(masked, open, "(", ")");
    result.push({
      offset: match.index,
      arguments: splitArguments(
        text.slice(open + 1, close),
        masked.slice(open + 1, close),
      ),
      language,
    });
  }
  return result;
}

function evaluateString(
  expression,
  source,
  before,
  language,
  seen = new Set(),
) {
  const trimmed = expression.trim();
  if (
    trimmed.startsWith('"') ||
    trimmed.startsWith("`") ||
    /^(?:new\s+StringBuilder|String\.fromCodePoint|Utils\.join)/u.test(trimmed)
  ) {
    return stringExpression(trimmed, language);
  }
  if (/^[A-Za-z_$][\w$]*$/u.test(trimmed)) {
    if (seen.has(trimmed)) {
      throw new Error(`cyclic string binding ${trimmed}`);
    }
    seen.add(trimmed);
    return evaluateString(
      lastAssignment(source, trimmed, before),
      source,
      before,
      language,
      seen,
    );
  }
  return stringExpression(trimmed, language);
}

function evaluateAssignedString(name, source, before, language) {
  return evaluateString(name, source, before, language);
}

function evaluateInteger(expression, source, before, language) {
  const trimmed = expression.trim();
  if (/^-?\d+$/u.test(trimmed)) {
    return Number.parseInt(trimmed, 10);
  }
  const assigned = lastAssignment(source, trimmed, before).trim();
  if (!/^-?\d+$/u.test(assigned)) {
    throw new Error(`unsupported integer expression: ${assigned}`);
  }
  return Number.parseInt(assigned, 10);
}

function evaluateStringArray(expression, source, before, language) {
  const trimmed = expression.trim();
  const resolved = /^[A-Za-z_$][\w$]*$/u.test(trimmed)
    ? lastAssignment(source, trimmed, before)
    : trimmed;
  const masked = maskCode(resolved);
  const opening = language === "java" ? "{" : "[";
  const closing = language === "java" ? "}" : "]";
  const open = masked.indexOf(opening);
  if (open < 0) {
    throw new Error(`unsupported string-array expression: ${resolved}`);
  }
  const close = matchingDelimiter(masked, open, opening, closing);
  return splitArguments(
    resolved.slice(open + 1, close),
    masked.slice(open + 1, close),
  )
    .map((element) => element.trim())
    .filter(Boolean)
    .map((element) => stringExpression(element, language));
}

function lastAssignment(source, variable, before) {
  const masked = maskCode(source);
  const pattern = new RegExp(
    `(?:(?:String|const|let)\\s+)?\\b${escapeRegExp(variable)}\\s*=`,
    "gu",
  );
  const assignments = [...masked.matchAll(pattern)].filter(
    (match) => match.index < before,
  );
  const assignment = assignments.at(-1);
  if (!assignment) {
    throw new Error(`cannot find assignment for ${variable}`);
  }
  const start = assignment.index + assignment[0].length;
  const end = statementEnd(masked, start);
  return source.slice(start, end);
}

function statementEnd(masked, start) {
  const stack = [];
  const matching = new Map([
    [")", "("],
    ["]", "["],
    ["}", "{"],
  ]);
  for (let index = start; index < masked.length; index += 1) {
    const current = masked[index];
    if ("([{".includes(current)) {
      stack.push(current);
    } else if (matching.has(current)) {
      if (stack.pop() !== matching.get(current)) {
        throw new Error("unbalanced assignment expression");
      }
    } else if (current === ";" && stack.length === 0) {
      return index;
    }
  }
  throw new Error("unterminated assignment");
}

function stringExpression(expression, language) {
  const values = stringLiterals(expression, language, true);
  let result = values.join("");
  for (const match of expression.matchAll(
    /(?:appendCodePoint|String\.fromCodePoint)\(\s*(0x[0-9a-f]+|\d+)\s*\)/giu,
  )) {
    result += String.fromCodePoint(Number.parseInt(match[1], 0));
  }
  if (/Utils\.join/u.test(expression)) {
    const separator = values.at(-1);
    return values.slice(0, -1).join(separator);
  }
  return result;
}

function stringLiterals(expression, language, allowEmpty = false) {
  const quotes =
    language === "typescript" ? new Set(['"', "'", "`"]) : new Set(['"']);
  const values = sourceStringLiterals(expression, quotes).map((literal) =>
    decodeString(literal, language),
  );
  if (values.length === 0 && !allowEmpty) {
    throw new Error(`expression has no string literals: ${expression}`);
  }
  return values;
}

function sourceStringLiterals(source, quotes) {
  const literals = [];
  for (let index = 0; index < source.length; index += 1) {
    if (source[index] === "/" && source[index + 1] === "/") {
      index = source.indexOf("\n", index + 2);
      if (index < 0) {
        break;
      }
      continue;
    }
    if (source[index] === "/" && source[index + 1] === "*") {
      const close = source.indexOf("*/", index + 2);
      if (close < 0) {
        throw new Error("unterminated block comment in string expression");
      }
      index = close + 1;
      continue;
    }
    const quote = source[index];
    if (!quotes.has(quote)) {
      continue;
    }
    const start = index;
    for (index += 1; index < source.length; index += 1) {
      if (source[index] === "\\") {
        index += 1;
      } else if (source[index] === quote) {
        literals.push(source.slice(start, index + 1));
        break;
      }
    }
    if (index >= source.length) {
      throw new Error(`unterminated ${quote} literal in string expression`);
    }
  }
  return literals;
}

function decodeString(literal, language) {
  const quote = literal[0];
  let result = "";
  for (let index = 1; index < literal.length - 1; index += 1) {
    const current = literal[index];
    if (current !== "\\") {
      result += current;
      continue;
    }
    const escaped = literal[++index];
    const simple = {
      b: "\b",
      f: "\f",
      n: "\n",
      r: "\r",
      t: "\t",
      v: "\v",
      '"': '"',
      "'": "'",
      "`": "`",
      "\\": "\\",
    };
    if (Object.hasOwn(simple, escaped)) {
      result += simple[escaped];
    } else if (escaped === "u") {
      if (literal[index + 1] === "{") {
        const close = literal.indexOf("}", index + 2);
        const digits = literal.slice(index + 2, close);
        result += String.fromCodePoint(Number.parseInt(digits, 16));
        index = close;
      } else {
        while (literal[index + 1] === "u" && language === "java") {
          index += 1;
        }
        const digits = literal.slice(index + 1, index + 5);
        result += String.fromCodePoint(Number.parseInt(digits, 16));
        index += 4;
      }
    } else {
      throw new Error(
        `unsupported ${language} string escape \\${escaped} in ${quote} literal`,
      );
    }
  }
  return result;
}

function extractJavaMethods(text) {
  const methods = new Map();
  const masked = maskCode(text);
  const pattern =
    /@Test\s+(?:public\s+)?void\s+(?<name>[$\w]+)\s*\([^)]*\)[^{]*\{/gu;
  for (const match of masked.matchAll(pattern)) {
    const open = masked.indexOf("{", match.index);
    const close = matchingDelimiter(masked, open, "{", "}");
    methods.set(match.groups.name, text.slice(open + 1, close));
  }
  return methods;
}

function extractTypeScriptMethods(text) {
  const methods = new Map();
  const masked = maskCode(text);
  const pattern = /\bit(?:\.skip)?\(\s*"(?<name>[^"]+)"/gu;
  for (const match of text.matchAll(pattern)) {
    const arrow = masked.indexOf("=>", match.index);
    const open = masked.indexOf("{", arrow);
    const close = matchingDelimiter(masked, open, "{", "}");
    methods.set(match.groups.name, text.slice(open + 1, close));
  }
  return methods;
}

function splitArguments(text, masked) {
  const result = [];
  let start = 0;
  const stack = [];
  const matching = new Map([
    [")", "("],
    ["]", "["],
    ["}", "{"],
  ]);
  for (let index = 0; index < masked.length; index += 1) {
    const current = masked[index];
    if ("([{".includes(current)) {
      stack.push(current);
    } else if (matching.has(current)) {
      if (stack.pop() !== matching.get(current)) {
        throw new Error("unbalanced argument expression");
      }
    } else if (current === "," && stack.length === 0) {
      result.push(text.slice(start, index));
      start = index + 1;
    }
  }
  if (stack.length !== 0) {
    throw new Error("unterminated argument expression");
  }
  result.push(text.slice(start));
  return result;
}

function matchingDelimiter(text, open, opening, closing) {
  if (open < 0 || text[open] !== opening) {
    throw new Error(`cannot find opening ${opening}`);
  }
  let depth = 0;
  for (let index = open; index < text.length; index += 1) {
    if (text[index] === opening) {
      depth += 1;
    } else if (text[index] === closing && --depth === 0) {
      return index;
    }
  }
  throw new Error(`unterminated ${opening}${closing} block`);
}

function maskCode(text) {
  const output = [...text];
  let state = "code";
  for (let index = 0; index < text.length; index += 1) {
    const current = text[index];
    const next = text[index + 1];
    if (state === "code") {
      if (current === "/" && next === "/") {
        output[index] = output[index + 1] = " ";
        state = "line-comment";
        index += 1;
      } else if (current === "/" && next === "*") {
        output[index] = output[index + 1] = " ";
        state = "block-comment";
        index += 1;
      } else if (current === '"' || current === "'" || current === "`") {
        output[index] = " ";
        state =
          current === '"'
            ? "string"
            : current === "'"
              ? "character"
              : "template";
      }
    } else if (state === "line-comment") {
      if (current === "\n") {
        state = "code";
      } else {
        output[index] = " ";
      }
    } else if (state === "block-comment") {
      if (current === "*" && next === "/") {
        output[index] = output[index + 1] = " ";
        state = "code";
        index += 1;
      } else if (current !== "\n") {
        output[index] = " ";
      }
    } else if (current === "\\") {
      output[index] = " ";
      if (text[index + 1] !== "\n") {
        output[index + 1] = " ";
      }
      index += 1;
    } else if (
      (state === "string" && current === '"') ||
      (state === "character" && current === "'") ||
      (state === "template" && current === "`")
    ) {
      output[index] = " ";
      state = "code";
    } else if (current !== "\n") {
      output[index] = " ";
    }
  }
  return output.join("");
}

function parseArguments(args) {
  const result = {
    update: null,
    javaRoot:
      process.env.ANTLR4_TOOL_ROOT ?? resolve(repositoryRoot, "target/antlr-cleanroom/antlr4-4.13.2-tool"),
    antlrNgRoot:
      process.env.ANTLR_NG_ROOT ?? resolve(repositoryRoot, "target/antlr-cleanroom/antlr-ng-1f68422"),
  };
  for (let index = 0; index < args.length; index += 1) {
    const argument = args[index];
    switch (argument) {
      case "--update":
        result.update = true;
        break;
      case "--check":
        result.update = false;
        break;
      case "--java-root":
        result.javaRoot = resolve(requiredValue(args, ++index, argument));
        break;
      case "--antlr-ng-root":
        result.antlrNgRoot = resolve(requiredValue(args, ++index, argument));
        break;
      default:
        throw new Error(`unknown argument: ${argument}`);
    }
  }
  if (result.update === null) {
    throw new Error(
      "usage: generate-phase-c-runtime-fixtures.mjs --check|--update",
    );
  }
  return result;
}

function requiredValue(args, index, option) {
  const value = args[index];
  if (value === undefined) {
    throw new Error(`${option} requires a value`);
  }
  return value;
}

function verifyCommit(root, expected, label) {
  const result = spawnSync("git", ["rev-parse", "HEAD"], {
    cwd: root,
    encoding: "utf8",
  });
  if (result.status !== 0 || result.stdout.trim() !== expected) {
    throw new Error(
      `${label} root must be at ${expected}; found ${result.stdout.trim() || result.stderr.trim()}`,
    );
  }
}

function gitText(root, object) {
  const result = spawnSync("git", ["show", object], {
    cwd: root,
    encoding: "utf8",
    maxBuffer: 32 * 1024 * 1024,
  });
  if (result.status !== 0) {
    throw new Error(`git show ${object} failed: ${result.stderr}`);
  }
  return result.stdout;
}

async function load(path) {
  return JSON.parse(await readFile(resolve(repositoryRoot, path), "utf8"));
}

async function expectFile(path, expected) {
  const actual = await readFile(path, "utf8");
  if (actual !== expected) {
    throw new Error(`${relative(repositoryRoot, path)} differs`);
  }
}

function digest(value) {
  return createHash("sha256").update(value).digest("hex");
}

function rustIdentifier(value) {
  return value.replaceAll("-", "_").replaceAll(/[^a-zA-Z0-9_]/gu, "_");
}

function rustString(value) {
  return JSON.stringify(value)
    .replaceAll("\u2028", "\\u{2028}")
    .replaceAll("\u2029", "\\u{2029}");
}

function renderRustSlice(values) {
  return `&[${values
    .map((value) =>
      typeof value === "string" ? rustString(value) : String(value),
    )
    .join(", ")}]`;
}

function parseNumberArray(value) {
  return value
    .slice(1, -1)
    .split(",")
    .map((part) => Number.parseInt(part.trim(), 10));
}

function escapeRegExp(value) {
  return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
