diff --git a/.prettierignore b/.prettierignore index 5ed4b7aad..b0eaf397c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,7 @@ package-lock.json # Auto-generated files packages/format/src/schemas/yamls.ts +packages/bugc/src/examples/generated.ts # Solidity fixtures are compiler inputs; this repo has no Solidity Prettier parser. packages/conformance/test/fixtures/solc/**/*.sol diff --git a/eslint.config.js b/eslint.config.js index a80e6d847..f375f13f1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -72,6 +72,7 @@ export default tseslint.config( "**/*.config.js", "**/*.config.ts", "packages/format/src/schemas/yamls.ts", + "packages/bugc/src/examples/generated.ts", "packages/web/.docusaurus/", "packages/web/build/", ], diff --git a/package.json b/package.json index 58c4647fb..31e535ccd 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "packages/*" ], "scripts": { - "build": "yarn --cwd packages/format prepare:yamls && tsc --build packages/format packages/pointers packages/evm packages/bugc packages/conformance packages/programs-react packages/pointers-react", + "build": "yarn --cwd packages/format prepare:yamls && yarn --cwd packages/bugc prepare:examples && tsc --build packages/format packages/pointers packages/evm packages/bugc packages/conformance packages/programs-react packages/pointers-react", "bundle": "tsx ./bin/bundle-schema.ts", "test": "vitest", "test:coverage": "vitest run --coverage", diff --git a/packages/bugc/.gitignore b/packages/bugc/.gitignore new file mode 100644 index 000000000..df29bf777 --- /dev/null +++ b/packages/bugc/.gitignore @@ -0,0 +1,2 @@ +# Auto-generated from the canonical examples/*.bug files at build time. +src/examples/generated.ts diff --git a/packages/bugc/bin/generate-examples.js b/packages/bugc/bin/generate-examples.js new file mode 100644 index 000000000..989a83e91 --- /dev/null +++ b/packages/bugc/bin/generate-examples.js @@ -0,0 +1,68 @@ +// Generates src/examples/generated.ts from the canonical `.bug` example +// files under packages/bugc/examples. Bundlers like webpack (used by the +// docs site) can't glob raw `.bug` files the way Vite can, so we surface +// the sources as an importable module of string literals — the same +// pattern as @ethdebug/format's generated schemas/yamls.ts. + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const examplesRoot = path.resolve(__dirname, "../examples"); + +// Walk examplesRoot recursively, collecting `.bug` sources keyed by their +// path relative to examplesRoot (POSIX separators, so keys are stable +// across platforms). +const readExamples = (directory) => { + const sources = {}; + const entries = fs.readdirSync(directory, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + Object.assign(sources, readExamples(fullPath)); + } else if (entry.isFile() && entry.name.endsWith(".bug")) { + const relativePath = path + .relative(examplesRoot, fullPath) + .split(path.sep) + .join("/"); + sources[relativePath] = fs.readFileSync(fullPath, "utf8"); + } + } + + return sources; +}; + +// Sort keys so the generated output is deterministic. +const collected = readExamples(examplesRoot); +const exampleSources = {}; +for (const key of Object.keys(collected).sort()) { + exampleSources[key] = collected[key]; +} + +const output = `// THIS FILE GETS AUTO-GENERATED AS PART OF THIS PACKAGE'S BUILD PROCESS +// Please do not modify it directly or allow it to get checked into source control. + +export type ExampleSourcesByPath = { + [path: string]: string; +}; + +export const exampleSources: ExampleSourcesByPath = ${JSON.stringify( + exampleSources, + undefined, + 2, +)}; +`; + +const outputDir = path.resolve(__dirname, "../src/examples"); +const outputPath = path.join(outputDir, "generated.ts"); +const tempPath = outputPath + ".tmp"; + +fs.mkdirSync(outputDir, { recursive: true }); + +// Write to a temp file, then rename atomically to avoid race conditions. +fs.writeFileSync(tempPath, output); +fs.renameSync(tempPath, outputPath); diff --git a/packages/bugc/package.json b/packages/bugc/package.json index 84723504b..932b73d82 100644 --- a/packages/bugc/package.json +++ b/packages/bugc/package.json @@ -5,6 +5,16 @@ "type": "module", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + }, + "./examples": { + "types": "./dist/src/examples/index.d.ts", + "default": "./dist/src/examples/index.js" + } + }, "files": [ "dist", "bin" @@ -54,7 +64,8 @@ "#test/*": "./dist/test/*.js" }, "scripts": { - "build": "tsc", + "prepare:examples": "node ./bin/generate-examples.js", + "build": "yarn prepare:examples && tsc", "build:watch": "tsc --watch --preserveWatchOutput", "watch": "tsc --watch --preserveWatchOutput", "test": "vitest run", @@ -64,7 +75,7 @@ "typecheck": "tsc --noEmit", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", - "prepare": "tsc" + "prepare": "yarn prepare:examples && tsc" }, "keywords": [ "ethereum", diff --git a/packages/bugc/src/examples/annotations.test.ts b/packages/bugc/src/examples/annotations.test.ts new file mode 100644 index 000000000..5ca3e6c0d --- /dev/null +++ b/packages/bugc/src/examples/annotations.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; + +import { stripTestAnnotations } from "./annotations.js"; + +describe("stripTestAnnotations", () => { + it("removes a /*@test*/ block sitting on its own lines", () => { + const source = [ + "create {", + " value = 1;", + " /*@test value-set", + " variables:", + " value:", + " value: 1", + " */", + "}", + "", + ].join("\n"); + + expect(stripTestAnnotations(source)).toBe( + ["create {", " value = 1;", "}", ""].join("\n"), + ); + }); + + it("removes a /**@test*/ JSDoc-style block", () => { + const source = [ + "create {", + " value = 1;", + " /**@test value-set", + " * variables:", + " * value:", + " * value: 1", + " */", + "}", + "", + ].join("\n"); + + expect(stripTestAnnotations(source)).toBe( + ["create {", " value = 1;", "}", ""].join("\n"), + ); + }); + + it("collapses the blank lines left around removed blocks", () => { + const source = [ + " lastSender = msg.sender;", + "", + " /*@test a", + " variables: { x: 1 }", + " */", + "", + " /*@test b", + " variables: { y: 2 }", + " */", + " return;", + "", + ].join("\n"); + + expect(stripTestAnnotations(source)).toBe( + [" lastSender = msg.sender;", "", " return;", ""].join("\n"), + ); + }); + + it("strips // @wip, @skip, and @expect-* directive lines", () => { + const source = [ + "// @wip", + "// @skip needs work", + "// @expect-parse-error", + "// @expect-bytecode-error", + "name Thing;", + "", + ].join("\n"); + + expect(stripTestAnnotations(source)).toBe(["name Thing;", ""].join("\n")); + }); + + it("leaves ordinary // comments and code untouched", () => { + const source = [ + "code {", + " // a normal comment", + " x = 1; // trailing comment", + "}", + "", + ].join("\n"); + + expect(stripTestAnnotations(source)).toBe(source); + }); + + it("does not touch block comments that are not @test", () => { + const source = ["/* just a comment */", "name Thing;", ""].join("\n"); + + expect(stripTestAnnotations(source)).toBe(source); + }); + + it("normalizes to a single trailing newline and no leading blanks", () => { + const source = ["", "", "name Thing;", "", "", ""].join("\n"); + + expect(stripTestAnnotations(source)).toBe(["name Thing;", ""].join("\n")); + }); + + it("removes an inline @test block but keeps the code before it", () => { + const source = [" x = 1; /*@test t\n variables: {}\n */", ""].join( + "\n", + ); + + expect(stripTestAnnotations(source)).toBe([" x = 1;", ""].join("\n")); + }); +}); diff --git a/packages/bugc/src/examples/annotations.ts b/packages/bugc/src/examples/annotations.ts new file mode 100644 index 000000000..fedf4a36c --- /dev/null +++ b/packages/bugc/src/examples/annotations.ts @@ -0,0 +1,38 @@ +/** + * The canonical `.bug` example files double as bugc's behavioral test + * fixtures, so they carry inline test metadata: `/*@test … *\/` YAML + * blocks and `// @wip` / `// @skip` / `// @expect-*` directive lines. + * That metadata is noise when the same source is shown in an editor. + * + * `stripTestAnnotations` removes it, leaving clean, display-ready BUG + * source. The canonical files stay the single source of truth; consumers + * that render examples (the playground, the docs widget) strip on the way + * out. + */ + +// Matches a `/*@test … *\/` or `/**@test … *\/` block, together with any +// indentation on the line it opens and the trailing newline — so a block +// alone on its line disappears without leaving a blank behind. +const TEST_BLOCK = /[ \t]*\/\*\*?@test\b[\s\S]*?\*\/[ \t]*\n?/g; + +// Matches a whole-line `// @wip` / `// @skip …` / `// @expect-*` directive. +const DIRECTIVE_LINE = + /^[ \t]*\/\/[ \t]*@(?:wip|skip|expect-[a-z-]+)\b.*(?:\n|$)/gm; + +/** + * Strip bugc test annotations from BUG source, yielding display-ready text. + */ +export function stripTestAnnotations(source: string): string { + const stripped = source + .replace(TEST_BLOCK, "") + .replace(DIRECTIVE_LINE, "") + // Trim whitespace an inline removal may have left at a line's end. + .replace(/[ \t]+$/gm, "") + // Collapse the blank runs that removals leave behind to one blank line. + .replace(/\n{3,}/g, "\n\n") + // No leading blank lines; end with exactly one trailing newline. + .replace(/^\n+/, "") + .replace(/\s+$/, ""); + + return stripped === "" ? "" : stripped + "\n"; +} diff --git a/packages/bugc/src/examples/index.test.ts b/packages/bugc/src/examples/index.test.ts new file mode 100644 index 000000000..8c1bc00a0 --- /dev/null +++ b/packages/bugc/src/examples/index.test.ts @@ -0,0 +1,79 @@ +/** + * These tests guard the example subpath export: that the generated source + * map is populated, and — crucially — that stripping the test annotations + * is purely cosmetic. Because the strip only removes comments, a stripped + * example must compile to byte-identical bytecode as its raw counterpart; + * if it doesn't, the strip has eaten real code. + */ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import { exampleSources, examplePaths, stripTestAnnotations } from "./index.js"; + +// A representative set of examples that compile cleanly. Kept small so the +// test stays fast; the point is to exercise strip/compile equivalence, not +// to re-test the whole example corpus (test/examples covers that). +const COMPILABLE = [ + "basic/minimal.bug", + "basic/functions.bug", + "basic/conditionals.bug", + "basic/array-length.bug", + "intermediate/arrays.bug", + "intermediate/mappings.bug", +]; + +const hex = (bytes?: Uint8Array): string => + bytes ? Buffer.from(bytes).toString("hex") : ""; + +describe("example sources", () => { + it("surfaces the canonical .bug files keyed by relative path", () => { + expect(examplePaths.length).toBeGreaterThan(0); + expect(examplePaths).toEqual(Object.keys(exampleSources)); + expect(exampleSources["basic/minimal.bug"]).toContain("name Minimal;"); + }); + + it("keeps the raw sources' test annotations intact", () => { + // The raw map is the test fixtures verbatim — annotations included. + const anyHasTestBlock = examplePaths.some((p) => + exampleSources[p].includes("@test"), + ); + expect(anyHasTestBlock).toBe(true); + }); +}); + +describe("stripTestAnnotations on real examples", () => { + for (const path of COMPILABLE) { + it(`yields annotation-free source for ${path}`, () => { + const clean = stripTestAnnotations(exampleSources[path]); + expect(clean).not.toContain("@test"); + expect(clean).not.toMatch(/\/\/\s*@(?:wip|skip|expect-)/); + }); + + it(`compiles ${path} to the same bytecode raw and stripped`, async () => { + const raw = exampleSources[path]; + const clean = stripTestAnnotations(raw); + + const rawResult = await compile({ + to: "bytecode", + source: raw, + optimizer: { level: 0 }, + }); + const cleanResult = await compile({ + to: "bytecode", + source: clean, + optimizer: { level: 0 }, + }); + + expect(rawResult.success).toBe(true); + expect(cleanResult.success).toBe(true); + if (!rawResult.success || !cleanResult.success) return; + + expect(hex(cleanResult.value.bytecode.runtime)).toBe( + hex(rawResult.value.bytecode.runtime), + ); + expect(hex(cleanResult.value.bytecode.create)).toBe( + hex(rawResult.value.bytecode.create), + ); + }); + } +}); diff --git a/packages/bugc/src/examples/index.ts b/packages/bugc/src/examples/index.ts new file mode 100644 index 000000000..1597c7ee7 --- /dev/null +++ b/packages/bugc/src/examples/index.ts @@ -0,0 +1,30 @@ +/** + * Canonical BUG example sources, surfaced for editors and playgrounds. + * + * The `.bug` files under `packages/bugc/examples` are the single source of + * truth: bugc's behavioral tests read them from disk, and this module + * exposes the same sources as an importable string map so bundlers (webpack, + * Vite) can ship them without globbing the filesystem. + * + * The raw sources carry bugc's test annotations (`/*@test … *\/` blocks and + * `// @wip` / `// @skip` / `// @expect-*` directives). Call + * {@link stripTestAnnotations} to get display-ready source for an editor. + * Which examples to show, and how to label them, is left to each consumer. + */ + +import { exampleSources } from "./generated.js"; + +export { exampleSources } from "./generated.js"; +export type { ExampleSourcesByPath } from "./generated.js"; +export { stripTestAnnotations } from "./annotations.js"; + +/** Relative paths of every canonical example (e.g. `"basic/minimal.bug"`). */ +export const examplePaths: string[] = Object.keys(exampleSources); + +/** A single BUG example: its canonical path and raw source. */ +export interface BugExample { + /** Path relative to `packages/bugc/examples` (e.g. `"basic/minimal.bug"`). */ + path: string; + /** Raw source, including bugc's inline test annotations. */ + source: string; +}