From 425e83be02f3c62a3dadb8397d5655534a3ac4a5 Mon Sep 17 00:00:00 2001 From: Eli Barzilay Date: Tue, 4 Aug 2026 12:20:25 -0400 Subject: [PATCH] loader: document the empty `--loader` extension, accept it in bunfig too `--loader` takes an empty extension to map files that have none, but `--help` and the docs imply an extension is required, and bunfig's `[loader]` drops such a key instead of honoring it. Document it in `--help` (rust and zig copies) and in the run/bunfig docs pages, and accept an empty key in bunfig's `[loader]`, matching the CLI. In the zig parser the `continue` that skipped the key also left an uninitialized entry in the loader map, so a bunfig containing one sent extensionless imports through the `file` loader with no error. --- docs/runtime/bunfig.mdx | 2 ++ docs/snippets/cli/run.mdx | 3 +- src/bunfig/bunfig.rs | 6 ++-- src/runtime/cli/Arguments.rs | 2 +- src/runtime/cli/Arguments.zig | 2 +- src/runtime/cli/bunfig.zig | 4 +-- test/cli/run/run-extensionless.test.ts | 49 +++++++++++++++++++++++++- 7 files changed, 58 insertions(+), 10 deletions(-) diff --git a/docs/runtime/bunfig.mdx b/docs/runtime/bunfig.mdx index 9bdc496a4f7b..81a91b4e07fb 100644 --- a/docs/runtime/bunfig.mdx +++ b/docs/runtime/bunfig.mdx @@ -87,6 +87,8 @@ Configure how Bun maps file extensions to loaders. This is useful for loading fi [loader] # when a .bagel file is imported, treat it like a tsx file ".bagel" = "tsx" +# an empty extension maps files that have none, which Bun otherwise treats as tsx +"" = "ts" ``` Bun supports the following loaders: diff --git a/docs/snippets/cli/run.mdx b/docs/snippets/cli/run.mdx index ba4f375f256b..7ea95dffa233 100644 --- a/docs/snippets/cli/run.mdx +++ b/docs/snippets/cli/run.mdx @@ -198,7 +198,8 @@ bun run - Parse files with .ext:loader, e.g. --loader .js:jsx. Valid loaders: js,{" "} + Parse files with .ext:loader, e.g. --loader .js:jsx. An empty extension maps + files that have none, e.g. --loader :ts. Valid loaders: js,{" "} jsx, ts, tsx, json, toml, text,{" "} file, wasm, napi. Alias: -l diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index eca200865bcc..779176ce1394 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -1065,10 +1065,8 @@ impl<'a> Parser<'a> { let key = key_expr .as_string(self.bump) .expect("infallible: type checked"); - if key.is_empty() { - continue; - } - if key[0] != b'.' { + // An empty key maps files with no extension, as `--loader :ts` does. + if !key.is_empty() && key[0] != b'.' { self.add_error( key_expr.loc, b"file extension for loader must start with a '.'", diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 7e8e01ffc65b..0f553befca5e 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -159,7 +159,7 @@ pub(crate) const TRANSPILER_PARAMS_: &[ParamType] = &[ "--feature ... Enable a feature flag for dead-code elimination, e.g. --feature=SUPER_SECRET" ), parse_param!( - "-l, --loader ... Parse files with .ext:loader, e.g. --loader .js:jsx. Valid loaders: js, jsx, ts, tsx, json, toml, text, file, wasm, napi" + "-l, --loader ... Parse files with .ext:loader, e.g. --loader .js:jsx, or :ts for extensionless files. Valid loaders: js, jsx, ts, tsx, json, toml, text, file, wasm, napi" ), parse_param!( "--no-macros Disable macros from being executed in the bundler, transpiler and runtime" diff --git a/src/runtime/cli/Arguments.zig b/src/runtime/cli/Arguments.zig index cda37b9813e2..802b35cd2e97 100644 --- a/src/runtime/cli/Arguments.zig +++ b/src/runtime/cli/Arguments.zig @@ -67,7 +67,7 @@ pub const transpiler_params_ = [_]ParamType{ clap.parseParam("-d, --define ... Substitute K:V while parsing, e.g. --define process.env.NODE_ENV:\"development\". Values are parsed as JSON.") catch unreachable, clap.parseParam("--drop ... Remove function calls, e.g. --drop=console removes all console.* calls.") catch unreachable, clap.parseParam("--feature ... Enable a feature flag for dead-code elimination, e.g. --feature=SUPER_SECRET") catch unreachable, - clap.parseParam("-l, --loader ... Parse files with .ext:loader, e.g. --loader .js:jsx. Valid loaders: js, jsx, ts, tsx, json, toml, text, file, wasm, napi") catch unreachable, + clap.parseParam("-l, --loader ... Parse files with .ext:loader, e.g. --loader .js:jsx, or :ts for extensionless files. Valid loaders: js, jsx, ts, tsx, json, toml, text, file, wasm, napi") catch unreachable, clap.parseParam("--no-macros Disable macros from being executed in the bundler, transpiler and runtime") catch unreachable, clap.parseParam("--jsx-factory Changes the function called when compiling JSX elements using the classic JSX runtime") catch unreachable, clap.parseParam("--jsx-fragment Changes the function called when compiling JSX fragments") catch unreachable, diff --git a/src/runtime/cli/bunfig.zig b/src/runtime/cli/bunfig.zig index d14e80026962..a63b13ccefc7 100644 --- a/src/runtime/cli/bunfig.zig +++ b/src/runtime/cli/bunfig.zig @@ -1188,8 +1188,8 @@ pub const Bunfig = struct { for (properties, 0..) |item, i| { const key = item.key.?.asString(allocator).?; - if (key.len == 0) continue; - if (key[0] != '.') { + // An empty key maps files with no extension, as `--loader :ts` does. + if (key.len > 0 and key[0] != '.') { try this.addError(item.key.?.loc, "file extension for loader must start with a '.'"); } var value = item.value.?; diff --git a/test/cli/run/run-extensionless.test.ts b/test/cli/run/run-extensionless.test.ts index 9541b6cc4943..616e21d44305 100644 --- a/test/cli/run/run-extensionless.test.ts +++ b/test/cli/run/run-extensionless.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { mkdirSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, isWindows, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isWindows, tempDirWithFiles, tmpdirSync } from "harness"; import { join } from "path"; describe.concurrent("run-extensionless", () => { @@ -32,4 +32,51 @@ describe.concurrent("run-extensionless", () => { const stdout = await proc.stdout.text(); expect(stdout).toEqual("hello world\n"); }); + + // `--loader` takes an empty extension, which maps files that have none. `(x: T) => x` + // is a generic arrow to the ts loader and an unclosed tag to the tsx loader, so it parses + // only when the mapping took effect. + test("--loader with an empty extension sets the loader for extensionless files", async () => { + const dir = tmpdirSync(); + mkdirSync(dir, { recursive: true }); + await Bun.write(join(dir, "cool"), "const id = (x: T) => x; console.log(id('hello world'));"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--loader=:ts", join(dir, "./cool")], + cwd: dir, + env: bunEnv, + stdout: "pipe", + }); + const stdout = await proc.stdout.text(); + expect(stdout).toEqual("hello world\n"); + }); + + test("[loader] with an empty extension sets the loader for extensionless files", async () => { + const dir = tempDirWithFiles("bunfig-loader-extensionless", { + cool: "const id = (x: T) => x; console.log(id('hello world'));", + "bunfig.toml": '[loader]\n"" = "ts"\n', + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), join(dir, "./cool")], + cwd: dir, + env: bunEnv, + stdout: "pipe", + }); + const stdout = await proc.stdout.text(); + expect(stdout).toEqual("hello world\n"); + }); + + test("--loader with an empty extension applies to imported extensionless files", async () => { + const dir = tmpdirSync(); + mkdirSync(dir, { recursive: true }); + await Bun.write(join(dir, "dep"), "export const id = (x: T) => x;"); + await Bun.write(join(dir, "main.ts"), "import { id } from './dep'; console.log(id('hello world'));"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--loader=:ts", join(dir, "./main.ts")], + cwd: dir, + env: bunEnv, + stdout: "pipe", + }); + const stdout = await proc.stdout.text(); + expect(stdout).toEqual("hello world\n"); + }); });