diff --git a/package.json b/package.json index 151eec79..1a4c6429 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "examples": "node scripts/run-examples.js", "typecheck": "tsc --noEmit", "check": "tsc --noEmit && npm run test:fast", + "test:parity": "npm run build && node --import tsx --test tests/ast-parity.test.ts", "verify": "bash scripts/verify.sh", "verify:quick": "bash scripts/verify.sh --quick", "format": "prettier --write .", diff --git a/src/chad-native.ts b/src/chad-native.ts index fbaf6de9..95fe7644 100644 --- a/src/chad-native.ts +++ b/src/chad-native.ts @@ -1,5 +1,6 @@ import { compileNative, + parseFileToAST, setSkipSemanticAnalysis, setEmitLLVMOnly, setTargetCpu, @@ -65,6 +66,7 @@ parser.addSubcommandInGroup("init", "Generate starter project", "Project"); parser.addSubcommandInGroup("clean", "Remove the .build directory", "Project"); parser.addSubcommandInGroup("ir", "Emit LLVM IR only", "Advanced"); parser.addSubcommandInGroup("target", "Manage cross-compilation target SDKs", "Advanced"); +parser.addSubcommandInGroup("ast-dump", "Dump parsed AST as JSON", "Advanced"); parser.addFlag("version", "", "Show version"); parser.addScopedOption("output", "o", "Specify output file", "", "build,run,ir"); @@ -245,6 +247,22 @@ if (command === "watch") { process.exit(0); } +if (command === "ast-dump") { + const inputFile = parser.getPositional(0); + if (inputFile.length === 0) { + console.log("chad: error: no input files"); + process.exit(1); + throw new Error("unreachable"); + } + if (!fs.existsSync(inputFile)) { + console.log("chad: error: file not found: " + inputFile); + process.exit(1); + throw new Error("unreachable"); + } + console.log(parseFileToAST(inputFile)); + process.exit(0); +} + if (command.length === 0) { parser.printHelp(); process.exit(0); diff --git a/src/chad-node.ts b/src/chad-node.ts index c2e999bc..e1311230 100644 --- a/src/chad-node.ts +++ b/src/chad-node.ts @@ -19,6 +19,7 @@ import { import { LogLevel, logger } from "./utils/logger.js"; import { runInit } from "./codegen/stdlib/init-templates.js"; import { ArgumentParser } from "./argparse.js"; +import { parseWithTSAPI } from "./parser-ts/index.js"; import * as path from "path"; import * as fs from "fs"; import { execSync, spawn as spawnProc, ChildProcess } from "child_process"; @@ -37,6 +38,7 @@ parser.addSubcommandInGroup( parser.addSubcommandInGroup("clean", "Remove the .build directory", "Project"); parser.addSubcommandInGroup("ir", "Emit LLVM IR only", "Advanced"); parser.addSubcommandInGroup("target", "Manage cross-compilation target SDKs", "Advanced"); +parser.addSubcommandInGroup("ast-dump", "Dump parsed AST as JSON", "Advanced"); parser.addFlag("version", "", "Show version"); parser.addScopedOption("output", "o", "Specify output file", "", "build,run,ir"); @@ -238,6 +240,22 @@ if (command === "watch") { }); } +if (command === "ast-dump") { + const inputFile = parser.getPositional(0); + if (!inputFile) { + console.error("chad: error: no input files"); + process.exit(1); + } + if (!fs.existsSync(inputFile)) { + console.error(`chad: error: file not found: ${inputFile}`); + process.exit(1); + } + const code = fs.readFileSync(inputFile, "utf8"); + const ast = parseWithTSAPI(code, { filename: inputFile }); + process.stdout.write(JSON.stringify(ast) + "\n"); + process.exit(0); +} + if (command.length === 0) { parser.printHelp(); process.exit(0); @@ -249,7 +267,8 @@ if ( command !== "ir" && command !== "init" && command !== "watch" && - command !== "target" + command !== "target" && + command !== "ast-dump" ) { if (command.endsWith(".ts") || command.endsWith(".js")) { console.error(`chad: error: missing command. did you mean 'chad build ${command}'?`); diff --git a/src/native-compiler-lib.ts b/src/native-compiler-lib.ts index fb587b64..2785f144 100644 --- a/src/native-compiler-lib.ts +++ b/src/native-compiler-lib.ts @@ -5,7 +5,7 @@ import { parseSource } from "./parser-native/index.js"; import { transformTree } from "./parser-native/transformer.js"; import { LLVMGenerator, LLVMGeneratorOptions, SemaSymbolData } from "./codegen/llvm-generator.js"; import { SemanticAnalyzer } from "./analysis/semantic-analyzer.js"; -import { AST, ImportDeclaration } from "./ast/types.js"; +import { AST, ImportDeclaration, FunctionNode, ClassNode, ClassMethod } from "./ast/types.js"; import { TargetInfo } from "./target-types.js"; declare const child_process: { @@ -172,6 +172,69 @@ function tripleToTargetName(triple: string): string { return osName + "-" + archName; } +export function parseFileToAST(inputFile: string): string { + __gc_disable(); + const absPath = path.resolve(inputFile); + const code = fs.readFileSync(absPath); + const tree = parseSource(code); + const ast = transformTree(tree); + + // JSON.stringify(ast) doesn't work for interface-typed objects in native ChadScript — + // it falls to the number path and treats pointers as doubles. Access fields explicitly. + // JSON.stringify(string[]) is also unimplemented; iterate manually instead. + let out = '{"imports":['; + let ii = 0; + while (ii < ast.imports.length) { + if (ii > 0) out = out + ","; + const imp = ast.imports[ii] as ImportDeclaration; + let specJson = "["; + let si = 0; + while (si < imp.specifiers.length) { + if (si > 0) specJson = specJson + ","; + specJson = specJson + JSON.stringify(imp.specifiers[si]); + si = si + 1; + } + specJson = specJson + "]"; + out = out + '{"source":' + JSON.stringify(imp.source) + ',"specifiers":' + specJson + "}"; + ii = ii + 1; + } + out = out + '],"functions":['; + let fi = 0; + while (fi < ast.functions.length) { + if (fi > 0) out = out + ","; + const fn = ast.functions[fi] as FunctionNode; + let paramsJson = "["; + let pi = 0; + while (pi < fn.params.length) { + if (pi > 0) paramsJson = paramsJson + ","; + paramsJson = paramsJson + JSON.stringify(fn.params[pi]); + pi = pi + 1; + } + paramsJson = paramsJson + "]"; + out = out + '{"name":' + JSON.stringify(fn.name) + ',"params":' + paramsJson + "}"; + fi = fi + 1; + } + out = out + '],"classes":['; + let ci = 0; + while (ci < ast.classes.length) { + if (ci > 0) out = out + ","; + const cls = ast.classes[ci] as ClassNode; + let methodsJson = "["; + let mi = 0; + while (mi < cls.methods.length) { + if (mi > 0) methodsJson = methodsJson + ","; + const m = cls.methods[mi] as ClassMethod; + methodsJson = methodsJson + JSON.stringify(m.name); + mi = mi + 1; + } + methodsJson = methodsJson + "]"; + out = out + '{"name":' + JSON.stringify(cls.name) + ',"methods":' + methodsJson + "}"; + ci = ci + 1; + } + out = out + "]}"; + return out; +} + export function compileNative(inputFile: string, outputFile: string): void { const execDir = path.dirname(path.resolve(process.argv0)); const installedLibDir = execDir + "/lib"; diff --git a/tests/ast-parity.test.ts b/tests/ast-parity.test.ts new file mode 100644 index 00000000..89d84808 --- /dev/null +++ b/tests/ast-parity.test.ts @@ -0,0 +1,61 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { execSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { discoverTests } from "./test-discovery.js"; +import { parseWithTSAPI } from "../src/parser-ts/index.js"; +import type { + AST, + ImportDeclaration, + FunctionNode, + ClassNode, + ClassMethod, +} from "../src/ast/types.js"; + +const hasNative = fs.existsSync(".build/chad"); + +// Extract the same structural summary produced by the native ast-dump command. +// Both sides must emit identical JSON for the parity check to be meaningful. +function extractSummary(ast: AST): unknown { + return { + imports: ast.imports.map((imp: ImportDeclaration) => ({ + source: imp.source, + specifiers: imp.specifiers, + })), + functions: ast.functions.map((fn: FunctionNode) => ({ + name: fn.name, + params: fn.params, + })), + classes: ast.classes.map((cls: ClassNode) => ({ + name: cls.name, + methods: cls.methods.map((m: ClassMethod) => m.name), + })), + }; +} + +const cases = discoverTests().filter((tc) => !tc.compileError); + +describe("Parser parity (TS vs native)", { skip: !hasNative }, () => { + describe("Structural AST equivalence", { concurrency: 16 }, () => { + for (const tc of cases) { + it(tc.description, () => { + const fixturePath = path.resolve(tc.fixture); + const code = fs.readFileSync(fixturePath, "utf8"); + + const jsAst = parseWithTSAPI(code, { filename: tc.fixture }); + const jsSummary = extractSummary(jsAst); + + let nativeOut: string; + try { + nativeOut = execSync(`.build/chad ast-dump ${tc.fixture}`, { encoding: "utf8" }); + } catch (e: any) { + throw new Error(`Native ast-dump failed for ${tc.fixture}: ${e.stderr ?? e.message}`); + } + const nativeSummary = JSON.parse(nativeOut); + + assert.deepStrictEqual(nativeSummary, jsSummary, `Parser divergence in ${tc.fixture}`); + }); + } + }); +});