diff --git a/docs/bundler/macros.mdx b/docs/bundler/macros.mdx index c4becbccb82a..426e4cfa363a 100644 --- a/docs/bundler/macros.mdx +++ b/docs/bundler/macros.mdx @@ -116,6 +116,8 @@ Macros run synchronously in the transpiler during the visiting phase, after the Bun's bundler is multi-threaded, so macros execute in parallel in multiple spawned JavaScript "workers". +`bun test --changed`, `bun install --analyze`, `bun add --analyze` and `bun create ` first scan your files to find what they import. That scan does not run macros. It follows a macro import to its file like an ordinary import, and it does not see an `import()` or a `require()` whose specifier is the result of a macro. Macros still run afterwards, when the selected tests or the generated project run. + ## Dead code elimination The bundler performs dead code elimination after running and inlining macros. Given the following macro: diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 1c20cf221d97..1472afa5325d 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2548,6 +2548,7 @@ pub mod parse_worker { || !(task.experimental_decorators || task.emit_decorator_metadata); opts.features.unwrap_commonjs_packages = topts.unwrap_commonjs_packages; opts.features.no_macros = topts.no_macros; + opts.features.macro_imports_are_plain = worker_ctx.scan_only; // Modeled as // `Option>` on both sides, so we deep-clone (small — // CLI-supplied flag set). PERF: retype diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index eb72b4d389c5..b56f1ee09ccc 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -132,6 +132,9 @@ pub struct BundleV2<'a> { /// If false we can skip TLA validation and propagation. pub(crate) has_any_top_level_await_modules: bool, + /// No code is printed: the caller reads the graph of the bundle (its files, imports and exports). + pub(crate) scan_only: bool, + /// Barrel optimization: tracks which exports have been requested from each /// module encountered during barrel BFS. Keys are source indices. Values /// track requested export names for deduplication and cycle detection. @@ -3314,6 +3317,7 @@ pub mod bv2_impl { drain_defer_task: DeferredBatchTask::default(), asynchronous: false, has_any_top_level_await_modules: false, + scan_only: false, requested_exports: Vec::new(), }); if let Some(bo) = bake_options { @@ -4457,6 +4461,7 @@ pub mod bv2_impl { alloc, )?; this.unique_key = generate_unique_key(); + this.scan_only = fetcher.is_some(); // Wrap so every exit path (incl. `?`) hits the cleanup below. let result = (|| -> Result { @@ -4596,6 +4601,7 @@ pub mod bv2_impl { ) -> Result>, Error> { let mut this = BundleV2::init(transpiler, None, alloc, event_loop, false, None, alloc)?; this.unique_key = generate_unique_key(); + this.scan_only = true; if this.transpiler.log().has_errors() { return Err(crate::Error::BuildFailed); diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index bd0f56678462..d795bc9ff159 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -4109,12 +4109,20 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O pub(crate) fn process_import_statement( &mut self, stmt_: S::Import, - path: ParsedPath<'a>, + mut path: ParsedPath<'a>, loc: bun_ast::Loc, was_originally_bare_import: bool, ) -> Result { - let is_macro = + let mut is_macro = Self::ALLOW_MACROS && (path.is_macro || crate::Macro::is_macro_path(path.text)); + if is_macro && self.options.features.macro_imports_are_plain { + // `MacroContext::call` resolves the path without the `macro:` prefix, so the ordinary import gets that path. + path.text = path + .text + .strip_prefix(crate::Macro::NAMESPACE_WITH_COLON) + .unwrap_or(path.text); + is_macro = false; + } let mut stmt = stmt_; if is_macro { let id = self.add_import_record(ImportKind::Stmt, path.loc, path.text); diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index b9ca9e1745df..5bd39fcc7e7a 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -278,6 +278,7 @@ impl<'a> Options<'a> { inlining: f.inlining, inject_jest_globals: f.inject_jest_globals, no_macros: f.no_macros, + macro_imports_are_plain: f.macro_imports_are_plain, commonjs_named_exports: f.commonjs_named_exports, minify_syntax: f.minify_syntax, minify_identifiers: f.minify_identifiers, diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 845a1db56b5f..d684d471afe1 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -194,6 +194,8 @@ pub mod Runtime { pub inject_jest_globals: bool, pub no_macros: bool, + /// A macro import is an ordinary import, and no macro runs. The output is then not what the program means. + pub macro_imports_are_plain: bool, pub commonjs_named_exports: bool, @@ -286,6 +288,7 @@ pub mod Runtime { inlining: false, inject_jest_globals: false, no_macros: false, + macro_imports_are_plain: false, commonjs_named_exports: true, minify_syntax: false, minify_identifiers: false, @@ -357,6 +360,7 @@ pub mod Runtime { // Takes `Wyhash` (NOT `Wyhash11`). pub(crate) fn hash_for_runtime_transpiler(&self, hasher: &mut Wyhash) { debug_assert!(self.runtime_transpiler_cache.is_some()); + debug_assert!(!self.macro_imports_are_plain); let bools: [bool; 17] = [ self.top_level_await, diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index c428e48a372c..d768b5ef3097 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -1,6 +1,6 @@ import { escapeHTML } from "bun" with { type: "macro" }; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { existsSync } from "node:fs"; import path from "node:path"; import defaultMacro, { @@ -777,3 +777,80 @@ describe("macro VM env loader", () => { expect(withMacro).toEqual(withoutMacro); }); }); + +// `bun create ` bundles the component to learn what it reaches, and prints no code. It reads a macro +// import as an ordinary import: the file of the macro is a file of the component, and the macro is not called. +// Windows: `bun create ` does not reach its install step there. +describe.concurrent.todoIf(isWindows)("bun create ", () => { + // The registry has no packages: `bun create` prints what it installs, and then its install step fails. + async function create(files: Record, entry: string) { + using dir = tempDir("macro-create-component", files); + using registry = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "create", entry], + cwd: String(dir), + env: { + ...bunEnv, + BUN_CONFIG_REGISTRY: registry.url.href, + // `bun create` exits without freeing the bundler. The ASAN lane would turn that into exit code 134. + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=0"].filter(Boolean).join(":"), + }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { + install: stdout.split("\n").find(line => line.includes("--only-missing install")) ?? "", + created: stdout.split("\n").filter(line => line.startsWith(" create ")).length, + macroRan: existsSync(path.join(String(dir), "MACRO_RAN")), + stderr, + exitCode, + }; + } + + test.each([ + ["an import attribute", `"./macro.ts" with { type: "macro" }`], + ["the macro: prefix", `"macro:./macro.ts"`], + ])("does not call a macro imported with %s, and installs what its file imports", async (_, specifier) => { + const { install, macroRan, stderr, exitCode } = await create( + { + "App.tsx": `import { one } from ${specifier};\nexport default function App() { return
{one()}
; }\n`, + "macro.ts": `import "bar";\nimport { writeFileSync } from "node:fs";\nimport { join } from "node:path";\nexport function one() {\n writeFileSync(join(import.meta.dir, "MACRO_RAN"), "");\n return 1;\n}\n`, + // Installed, so that a call of the macro could load its file. + "node_modules/bar/package.json": JSON.stringify({ name: "bar", version: "1.0.0" }), + "node_modules/bar/index.js": "", + }, + "./App.tsx", + ); + expect({ install, macroRan }, stderr).toEqual({ + install: expect.stringContaining(" install -- bar "), + macroRan: false, + }); + expect(exitCode).toBe(1); + }); + + test("picks the template from the class names in the file of a macro, as in any file of the component", async () => { + const { install, stderr } = await create( + { + "App.tsx": `import { one } from "./macro.tsx" with { type: "macro" };\nexport default function App() { return
{one()}
; }\n`, + "macro.tsx": `//
\nexport function one() {\n return 1;\n}\n`, + }, + "./App.tsx", + ); + expect(install, stderr).toContain(" install -- bun-plugin-tailwind tailwindcss "); + }); + + test("fails for a macro import that is never called and names a missing export", async () => { + // As for an ordinary import. A TypeScript file drops an import that it never uses, so this is a .jsx file. + const { install, created, stderr, exitCode } = await create( + { + "App.jsx": `import { missing } from "./macro.js" with { type: "macro" };\nexport default function App() { return
hi
; }\n`, + "macro.js": `export function one() {\n return 1;\n}\n`, + }, + "./App.jsx", + ); + expect(stderr).toContain(`No matching export in "macro.js" for import "missing"`); + expect({ install, created, exitCode }).toEqual({ install: "", created: 0, exitCode: 1 }); + }); +}); diff --git a/test/cli/install/bun-add.test.ts b/test/cli/install/bun-add.test.ts index 8b25f7badc60..a1a8f61d1333 100644 --- a/test/cli/install/bun-add.test.ts +++ b/test/cli/install/bun-add.test.ts @@ -2,6 +2,7 @@ import type { BunLockFile } from "bun"; import { $, file, spawn } from "bun"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, setDefaultTimeout, test } from "bun:test"; import { randomBytes } from "crypto"; +import { existsSync } from "fs"; import { access, appendFile, copyFile, mkdir, readlink, rm, writeFile } from "fs/promises"; import { bunExe, @@ -294,19 +295,23 @@ it("bun add --only-missing should not install existing package", async () => { } }); -// Writes `source` to entry-point.ts and returns what `bun --analyze` adds to package.json. `urls` collects +// Writes `source` to `entry` and returns what `bun --analyze` adds to package.json. `urls` collects // its registry requests. async function analyze( command: "add" | "install", source: string, - { packageJson = { name: "foo" }, urls = [] }: { packageJson?: object; urls?: string[] } = {}, + { + packageJson = { name: "foo" }, + urls = [], + entry = "entry-point.ts", + }: { packageJson?: object; urls?: string[]; entry?: string } = {}, ) { setHandler(dummyRegistry(urls)); await writeFile(join(package_dir, "package.json"), JSON.stringify({ version: "0.0.1", ...packageJson })); - await writeFile(join(package_dir, "entry-point.ts"), source); + await writeFile(join(package_dir, entry), source); // The registry has no tarball for most of these names. await using proc = spawn({ - cmd: [bunExe(), command, "--analyze", "--lockfile-only", "./entry-point.ts"], + cmd: [bunExe(), command, "--analyze", "--lockfile-only", `./${entry}`], cwd: package_dir, stdout: "pipe", stderr: "pipe", @@ -405,6 +410,69 @@ it("bun add --analyze uses the nearest package.json with a name above the import expect(urls).toEqual([`${root_url}/bar`]); }); +// `--analyze` bundles the entry point to learn what it imports. It reads a macro import as an ordinary import: the +// file of the macro is a file of the bundle, and the macro is not called. +it.each( + (["add", "install"] as const).flatMap(command => + [ + ["an import attribute", `"./macro.ts" with { type: "macro" }`], + ["the macro: prefix", `"macro:./macro.ts"`], + ].map(([form, specifier]) => [command, form, specifier] as const), + ), +)("bun %s --analyze adds what the file of a macro imported with %s imports", async (command, _, specifier) => { + // `bar` is not installed, so a call of the macro would fail to load its file. + await writeFile(join(package_dir, "macro.ts"), `import "bar";\nexport function one() {\n return 1;\n}\n`); + const source = `import { one } from ${specifier};\nconsole.log(one());\n`; + expect(await analyze(command, source)).toEqual({ added: ["bar"], errors: [], exitCode: 0 }); +}); + +it.each(["add", "install"] as const)( + "bun %s --analyze does not call a macro, and does not add a package that only its result names", + async command => { + await writeFile( + join(package_dir, "macro.ts"), + `import { writeFileSync } from "node:fs";\nimport { join } from "node:path";\nexport function name(n: string) {\n writeFileSync(join(import.meta.dir, "MACRO_RAN"), "");\n return n;\n}\n`, + ); + const source = `import "baz";\nimport { name } from "./macro.ts" with { type: "macro" };\nawait import(name("bar"));\nrequire(name("qux"));\n`; + expect(await analyze(command, source)).toEqual({ added: ["baz"], errors: [], exitCode: 0 }); + expect(existsSync(join(package_dir, "MACRO_RAN"))).toBe(false); + }, +); + +// The bundle is linked before `--analyze` reads it, so the file of a macro is checked as the file of an ordinary +// import is. A TypeScript file drops an import that it never uses, so these are .js files. +it.each([ + [ + "names an export that its file does not have", + `export function one() {\n return 1;\n}\n`, + `No matching export in "macro.js" for import "missing"`, + ], + ["names a file that does not parse", `export function missing( {\n return 1;\n}\n`, `Expected "}" but found "1"`], +])("bun add --analyze fails for a macro import that is never called and %s", async (_, macro, error) => { + await writeFile(join(package_dir, "macro.js"), macro); + const source = `import { missing } from "./macro.js" with { type: "macro" };\nimport "baz";\n`; + expect(await analyze("add", source, { entry: "entry-point.js" })).toEqual({ + added: [], + errors: expect.arrayContaining([expect.stringContaining(error)]), + exitCode: 1, + }); +}); + +it("bun add --analyze fails for a require() that reaches a macro whose file has a top-level await", async () => { + // The program runs: for the runtime the file of the macro is not a dependency of the module that calls the macro. + await writeFile(join(package_dir, "macro.ts"), `await 0;\nexport function one() {\n return 1;\n}\n`); + await writeFile( + join(package_dir, "calls.ts"), + `import { one } from "./macro.ts" with { type: "macro" };\nexport const value = one();\n`, + ); + const source = `import "baz";\nconsole.log(require("./calls.ts").value);\n`; + expect(await analyze("add", source)).toEqual({ + added: [], + errors: [expect.stringContaining(`the transitive dependency "macro.ts" contains a top-level await`)], + exitCode: 1, + }); +}); + it("bun add --analyze should scan dependencies", async () => { const urls: string[] = []; setHandler(dummyRegistry(urls)); diff --git a/test/cli/test/test-changed.test.ts b/test/cli/test/test-changed.test.ts index abea2aadcd25..670dad5d5fd0 100644 --- a/test/cli/test/test-changed.test.ts +++ b/test/cli/test/test-changed.test.ts @@ -1,7 +1,7 @@ import { spawnSync } from "bun"; import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isWindows, tempDir, tmpdirSync } from "harness"; -import { appendFileSync, writeFileSync } from "node:fs"; +import { appendFileSync, existsSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; // Each case spawns a full `bun test` process; give the concurrent group @@ -131,6 +131,117 @@ describe.concurrent("bun test --changed", () => { expect(exitCode).toBe(0); }); + // The scan is made only for its import graph. It reads a macro import as an ordinary import: the file of the + // macro is a module of the graph, and no macro runs. + const macroImports = [ + ["an import attribute", `"./src/macro" with { type: "macro" }`], + ["the macro: prefix", `"macro:./src/macro"`], + ] as const; + const macroNames = ["calls.test.ts", "plain.test.ts"]; + const macroFixture = (specifier: string) => ({ + "package.json": JSON.stringify({ name: "changed-macro", type: "module" }), + // A call of the macro leaves a file behind. + "src/macro.ts": `import { writeFileSync } from "node:fs";\nimport { join } from "node:path";\nimport { base } from "./base";\nexport function one() {\n writeFileSync(join(import.meta.dir, "MACRO_RAN"), "");\n return base;\n}\n`, + "src/base.ts": `export const base = 1;\n`, + "calls.test.ts": `import { test, expect } from "bun:test";\nimport { one } from ${specifier};\ntest("calls", () => expect(one()).toBe(1));\n`, + "plain.test.ts": `import { test, expect } from "bun:test";\ntest("plain", () => expect(1).toBe(1));\n`, + "README.md": "hello\n", + }); + + test.each( + macroImports.flatMap(([form, specifier]) => + [ + ["the file of the macro", "macro.ts"], + ["a file that the macro imports", "base.ts"], + ].map(([what, changed]) => [form, what, specifier, changed] as const), + ), + )("a macro imported with %s: a change to %s selects the test that calls it", async (_, __, specifier, changed) => { + using dir = tempDir("test-changed-macro", macroFixture(specifier)); + initRepo(String(dir)); + + appendFileSync(join(String(dir), "src", changed), "// touched\n"); + + const { stderr, exitCode } = await runTestChanged(String(dir)); + expect(ranFiles(stderr, macroNames)).toEqual(["calls.test.ts"]); + expect(exitCode).toBe(0); + }); + + test.each(macroImports)("a macro imported with %s is not called by the scan", async (_, specifier) => { + using dir = tempDir("test-changed-macro-scan", macroFixture(specifier)); + initRepo(String(dir)); + + // No test is selected, so only the scan could call the macro. + appendFileSync(join(String(dir), "README.md"), "more\n"); + + const { stderr, exitCode } = await runTestChanged(String(dir)); + expect({ + ran: ranFiles(stderr, macroNames), + macroRan: existsSync(join(String(dir), "src", "MACRO_RAN")), + }).toEqual({ ran: [], macroRan: false }); + expect(stderr).toContain("no test files are affected"); + expect(exitCode).toBe(0); + }); + + test("a macro that needs the preload does not hide the other imports of its caller", async () => { + // The scan runs before the preload. A macro that it called would throw here, and a file with an error has no + // import records. + using dir = tempDir("test-changed-macro-preload", { + "package.json": JSON.stringify({ name: "changed-macro-preload", type: "module" }), + "bunfig.toml": `[test]\npreload = ["./preload.ts"]\n`, + "preload.ts": `globalThis.REGION = "eu";\n`, + "src/region.ts": `export function region() {\n if (!globalThis.REGION) throw new Error("the preload did not run");\n return globalThis.REGION;\n}\n`, + "src/dep.ts": `export const dep = 1;\n`, + "calls.test.ts": `import { test, expect } from "bun:test";\nimport { dep } from "./src/dep";\nimport { region } from "./src/region" with { type: "macro" };\ntest("calls", () => expect(region() + dep).toBe("eu1"));\n`, + "plain.test.ts": `import { test, expect } from "bun:test";\ntest("plain", () => expect(1).toBe(1));\n`, + }); + initRepo(String(dir)); + + appendFileSync(join(String(dir), "src", "dep.ts"), "// touched\n"); + + const { stderr, exitCode } = await runTestChanged(String(dir)); + expect(ranFiles(stderr, macroNames)).toEqual(["calls.test.ts"]); + expect(exitCode).toBe(0); + }); + + test("a file that only the result of a macro names does not select the test", async () => { + // The scan runs no macro, so it does not see a specifier that a macro computes. + using dir = tempDir("test-changed-macro-result", { + "package.json": JSON.stringify({ name: "changed-macro-result", type: "module" }), + "src/paths.ts": `export function imported() {\n return "./src/imported.ts";\n}\nexport function required() {\n return "./src/required.ts";\n}\n`, + "src/imported.ts": `export const value = 1;\n`, + "src/required.ts": `export const value = 2;\n`, + "calls.test.ts": `import { test, expect } from "bun:test";\nimport { imported, required } from "./src/paths" with { type: "macro" };\ntest("calls", async () => {\n expect((await import(imported())).value + require(required()).value).toBe(3);\n});\n`, + }); + initRepo(String(dir)); + + appendFileSync(join(String(dir), "src", "imported.ts"), "// touched\n"); + appendFileSync(join(String(dir), "src", "required.ts"), "// touched\n"); + + const { stderr, exitCode } = await runTestChanged(String(dir)); + expect(ranFiles(stderr, macroNames)).toEqual([]); + expect(stderr).toContain("no test files are affected"); + expect(exitCode).toBe(0); + }); + + test("the scan removes one macro: prefix, as a call of the macro does", async () => { + // With the prefix more than once, the rest names no file: not for the call of the macro, and not for the scan. + const import_ = (prefixes: number) => + `import { test, expect } from "bun:test";\nimport { one } from "${Buffer.alloc(prefixes * "macro:".length, "macro:").toString()}./src/macro";\ntest("calls", () => expect(one()).toBe(1));\n`; + using dir = tempDir("test-changed-macro-prefixes", { + "package.json": JSON.stringify({ name: "changed-macro-prefixes", type: "module" }), + "src/macro.ts": `export function one() {\n return 1;\n}\n`, + "twice.test.ts": import_(2), + "often.test.ts": import_(20_000), + }); + initRepo(String(dir)); + + appendFileSync(join(String(dir), "src", "macro.ts"), "// touched\n"); + + const { stderr, exitCode } = await runTestChanged(String(dir)); + expect(stderr).toContain("no test files are affected"); + expect(exitCode).toBe(0); + }); + test("change to a file no test imports runs nothing", async () => { using dir = tempDir("test-changed-unrelated", fixture); initRepo(String(dir)); @@ -408,6 +519,8 @@ describe.skipIf(isWindows)("bun test --changed --watch", () => { "dep-b.ts": `export const B = 2;\n`, "wa.test.ts": `import { test, expect } from "bun:test";\nimport { A } from "./dep-a";\ntest("wa", () => expect(A).toBe(1));\n`, "wb.test.ts": `import { test, expect } from "bun:test";\nimport { B } from "./dep-b";\ntest("wb", () => expect(B).toBe(2));\n`, + "macro-c.ts": `export function C() {\n return 3;\n}\n`, + "wc.test.ts": `import { test, expect } from "bun:test";\nimport { C } from "./macro-c" with { type: "macro" };\ntest("wc", () => expect(C()).toBe(3));\n`, }); initRepo(String(dir)); @@ -438,6 +551,7 @@ describe.skipIf(isWindows)("bun test --changed --watch", () => { await waitFor("Ran 0 tests"); expect(buf).not.toContain("wa.test.ts:"); expect(buf).not.toContain("wb.test.ts:"); + expect(buf).not.toContain("wc.test.ts:"); // Touch dep-a.ts: watcher restarts, --changed now sees an uncommitted // change to dep-a.ts and should run only wa.test.ts. Sync on the @@ -457,6 +571,14 @@ describe.skipIf(isWindows)("bun test --changed --watch", () => { const afterB = buf.slice(before2); expect(ranFiles(afterB, ["wa.test.ts", "wb.test.ts"])).toEqual(["wb.test.ts"]); + // Touch macro-c.ts: no run has loaded wc.test.ts, so the file of its macro + // is watched only because the scan made it a module of the graph. + const before3 = buf.length; + appendFileSync(join(String(dir), "macro-c.ts"), "// touched\n"); + await waitFor("Ran 1 test across 1 file", before3); + const afterC = buf.slice(before3); + expect(ranFiles(afterC, ["wa.test.ts", "wb.test.ts", "wc.test.ts"])).toEqual(["wc.test.ts"]); + proc.kill(); reader.releaseLock(); }, 60_000);