From 64c765f52127b96d778af787eb3cdae3250fd342 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:20:09 +0000 Subject: [PATCH 1/9] Honor tsconfig verbatimModuleSyntax, preserveValueImports and importsNotUsedAsValues A TypeScript import that no value uses is removed. tsconfig.json can ask to keep it, and nothing read those settings. The parser now takes two flags from the tsconfig.json that governs a file: KEEP_STMT (`importsNotUsedAsValues: "preserve" | "error"`) keeps the statement as a bare import, and KEEP_VALUES (`preserveValueImports`) keeps the unused names. `verbatimModuleSyntax` sets both. The `extends` merge inherits each setting. The runtime and a bundle link what they print, so there the unused names go and the statement stays. Only `bun build --no-bundle` prints the names. Each file takes the flags of the tsconfig.json nearest to it. A file in node_modules, a declaration file, and a file that becomes CommonJS at runtime take none. A TypeScript clause that binds no value (`import {} from "x"`, `import { type T } from "x"`, `export {} from "x"`) is type-only and is dropped unless KEEP_STMT is set. `S::Import::has_items_clause` records the clause so that it prints as `import {} from "x"` when kept. `parse_import_clause` follows esbuild's table for `{ type as ... }`. The record of a statement that binds no value, or that sits in a `declare` block, is created unused, and only the import scanner makes it live. The bun:test global injection and the module type detection do not see it. --- docs/runtime/transpiler.mdx | 2 + docs/typescript.mdx | 36 ++ packages/bun-types/bun.d.ts | 16 + src/ast/s.rs | 5 + src/bundler/ParseTask.rs | 5 + src/bundler/bundle_v2.rs | 22 ++ src/bundler/options.rs | 6 + src/bundler/transpiler.rs | 13 +- src/js_parser/lower/lower_esm_exports_hmr.rs | 1 + src/js_parser/p.rs | 15 + src/js_parser/parse/parse_entry.rs | 45 ++- src/js_parser/parse/parse_import_export.rs | 13 - src/js_parser/parse/parse_stmt.rs | 76 +++- src/js_parser/parser.rs | 40 +- src/js_parser/scan/scan_imports.rs | 97 +++-- src/js_printer/lib.rs | 11 +- src/jsc/ModuleLoader.rs | 2 + src/jsc/RuntimeTranspilerCache.rs | 4 +- src/jsc/RuntimeTranspilerStore.rs | 8 +- src/jsc/VirtualMachine.rs | 1 + src/react_compiler/imports.rs | 1 + src/resolver/resolver.rs | 41 +- src/resolver/result.rs | 31 +- src/resolver/tsconfig_json.rs | 61 ++- src/runtime/api/JSTranspiler.rs | 15 + src/runtime/jsc_hooks.rs | 38 +- test/bundler/esbuild/tsconfig.test.ts | 272 +++++++++---- .../transpiler/runtime-transpiler.test.ts | 359 ++++++++++++++++++ test/bundler/transpiler/transpiler.test.js | 120 ++++++ test/cli/run/transpiler-cache.test.ts | 24 ++ test/integration/bun-types/fixture/bun.ts | 16 + 31 files changed, 1226 insertions(+), 170 deletions(-) diff --git a/docs/runtime/transpiler.mdx b/docs/runtime/transpiler.mdx index 1c27654541db..5df132641b43 100644 --- a/docs/runtime/transpiler.mdx +++ b/docs/runtime/transpiler.mdx @@ -214,6 +214,8 @@ interface TranspilerOptions { // Whether to remove unused imports from transpiled file // Default: false + // With tsconfig `verbatimModuleSyntax: true`, the unused names go + // and the statement stays as `import "module"` trimUnusedImports?: boolean, // Whether to enable a set of JSX optimizations diff --git a/docs/typescript.mdx b/docs/typescript.mdx index ca2159a4582e..10c386635f4f 100644 --- a/docs/typescript.mdx +++ b/docs/typescript.mdx @@ -54,6 +54,42 @@ Running `bun init` in a new directory generates this `tsconfig.json` for you. bun init ``` +## `verbatimModuleSyntax` + +With `"verbatimModuleSyntax": true`, Bun keeps every import that has no `type` modifier. The imported module runs even when your code uses none of its names. + +```ts index.ts icon="/icons/typescript.svg" +import { unused } from "./register-plugin"; // runs ./register-plugin +import { type Options } from "./options"; // runs ./options +import type { Config } from "./config"; // removed +``` + +Without the option, Bun removes all three imports, because no value uses them. + +- `bun run`, `bun test`, and `bun build` keep the statement and remove the names that nothing uses. `import { unused } from "./register-plugin"` becomes `import "./register-plugin"`, so a type that you import without `type` still loads. +- `bun build --no-bundle` prints the unused names too, and prints `import { type Options } from "./options"` as `import {} from "./options"`. +- Bun reads the option from the `tsconfig.json` nearest to each file. Bun does not apply the option to files in `node_modules` or to declaration files (`.d.ts`). +- `bun run` does not apply the option to a file that uses `module.exports`. That file runs as CommonJS, which cannot hold an `import` statement. + +The module of an import that Bun keeps must exist. `import { type Handler } from "aws-lambda"` fails when only `@types/aws-lambda` is installed. Write `import type` for an import that you need only for its types. [`bun check`](/runtime/check) reports a type that you import without `type`. + +The two settings that `verbatimModuleSyntax` replaced still work: `"importsNotUsedAsValues": "preserve"` keeps the statement, and `"preserveValueImports": true` keeps the unused names. + +To run a project without this behavior, point Bun at a `tsconfig.json` that turns the option off. + +```json tsconfig.run.json icon="file-json" +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "verbatimModuleSyntax": false + } +} +``` + +```sh terminal icon="terminal" +bun --tsconfig-override=tsconfig.run.json index.ts +``` + ## Type checking Bun runs TypeScript files without checking their types. Use [`bun check`](/runtime/check) to type check your project. diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 59aa530c8e58..c5586542aaab 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -2938,6 +2938,22 @@ declare module "bun" { jsxFragmentFactory?: string; jsxImportSource?: string; useDefineForClassFields?: boolean; + /** + * Keep an import that no value uses, so that the module it names still runs. Only + * `import type` and `export type` are removed. With `trimUnusedImports` or + * `treeShaking`, `Bun.Transpiler` removes the unused names and keeps the statement + * as `import "module"`. + */ + verbatimModuleSyntax?: boolean; + /** + * Keep a value import that nothing uses. + * @deprecated Use `verbatimModuleSyntax` + */ + preserveValueImports?: boolean; + /** + * `"preserve"` and `"error"` keep an import that no value uses as `import "module"`. + * @deprecated Use `verbatimModuleSyntax` + */ importsNotUsedAsValues?: "remove" | "preserve" | "error"; /** moduleSuffixes is not supported yet */ moduleSuffixes?: any; diff --git a/src/ast/s.rs b/src/ast/s.rs index a5517d2f02bb..f71901d738c8 100644 --- a/src/ast/s.rs +++ b/src/ast/s.rs @@ -214,6 +214,10 @@ pub struct Import { /// proposal. Only valid with a namespace import (`star_name_loc` is set, /// `default_name`/`items` are empty). pub phase_defer: bool, // = false + /// A TypeScript source had a `{ ... }` clause here. Read only while `items` is + /// empty, where it tells `import {} from 'path'` (type-only to TypeScript, printed + /// with its braces when kept) from `import 'path'`. Never set for JavaScript. + pub has_items_clause: bool, // = false } impl Default for Import { @@ -226,6 +230,7 @@ impl Default for Import { import_record_index: u32::MAX, is_single_line: false, phase_defer: false, + has_items_clause: false, } } } diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 1c20cf221d97..238f8cb13fb1 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -109,6 +109,7 @@ pub struct ParseTask { pub(crate) known_target: options::Target, pub(crate) module_type: options::ModuleType, + pub(crate) unused_import_flags_ts: options::TSUnusedImportFlags, pub(crate) emit_decorator_metadata: bool, pub(crate) experimental_decorators: bool, pub(crate) use_define_for_class_fields: bool, @@ -292,6 +293,7 @@ impl ParseTask { jsx: resolve_result.jsx.clone(), source_index, module_type: resolve_result.module_type, + unused_import_flags_ts: resolve_result.flags.unused_import_flags_ts(), emit_decorator_metadata: resolve_result.flags.emit_decorator_metadata(), experimental_decorators: resolve_result.flags.experimental_decorators(), use_define_for_class_fields: resolve_result.flags.use_define_for_class_fields(), @@ -348,6 +350,7 @@ impl Default for ParseTask { stage: ParseTaskStage::NeedsSourceCode, known_target: options::Target::default(), module_type: options::ModuleType::Unknown, + unused_import_flags_ts: options::TSUnusedImportFlags::empty(), emit_decorator_metadata: false, experimental_decorators: false, use_define_for_class_fields: true, @@ -634,6 +637,7 @@ pub mod parse_worker { }, stage: ParseTaskStage::NeedsSourceCode, module_type: options::ModuleType::Unknown, + unused_import_flags_ts: options::TSUnusedImportFlags::empty(), emit_decorator_metadata: false, experimental_decorators: false, use_define_for_class_fields: true, @@ -2548,6 +2552,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.unused_import_flags_ts = task.unused_import_flags_ts; // 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 201b32587eeb..da06270acc1f 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -238,6 +238,21 @@ impl<'a> BundleV2<'a> { self.graph.path_to_source_index_map(target) } + /// `ParseTask::unused_import_flags_ts` for a task that no resolver result describes: the + /// setting of the tsconfig.json of the top-level directory, and none inside node_modules. + pub(crate) fn root_unused_import_flags_ts( + &mut self, + target: options::Target, + path: &crate::bun_fs::Path<'_>, + ) -> options::TSUnusedImportFlags { + if path.is_node_module() { + return options::TSUnusedImportFlags::empty(); + } + self.transpiler_for_target(target) + .options + .unused_import_flags_ts + } + pub(crate) fn transpiler_for_target(&mut self, target: options::Target) -> &mut Transpiler<'a> { // SAFETY: all three pointers are live for `'a` (set in `init`); the // `client_transpiler` arm is only reached when bake populated it. @@ -4218,6 +4233,7 @@ pub mod bv2_impl { } else { self.transpiler_for_target(known_target).options.jsx.clone() }; + let unused_import_flags_ts = self.root_unused_import_flags_ts(known_target, &task_path); // SAFETY: arena (`self.graph.heap`) outlives the bundle pass; coerce the // `&mut ParseTask` to `*mut` immediately so the `&self` borrow from // `arena()` ends before we take `&mut self` below. @@ -4228,6 +4244,7 @@ pub mod bv2_impl { jsx, source_index: bun_ast::Index::init(source_index.get()), module_type, + unused_import_flags_ts, emit_decorator_metadata: false, // TODO package_version: bun_ast::StoreStr::EMPTY, loader: Some(loader), @@ -5427,6 +5444,10 @@ pub mod bv2_impl { ..Default::default() }) .expect("unreachable"); + let unused_import_flags_ts = this.root_unused_import_flags_ts( + resolve.import_record.original_target, + &path, + ); let task_val = ParseTask { // SAFETY: `from_mut(this)` is the live bundle (write provenance); // outlives the task. @@ -5450,6 +5471,7 @@ pub mod bv2_impl { .clone(), source_index: bun_ast::Index::init(source_index.get()), module_type: options::ModuleType::Unknown, + unused_import_flags_ts, loader: Some(loader), known_target: resolve.import_record.original_target, is_entry_point: resolve.import_record.kind diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 2fe2aff4ccc3..dbf8a6ca2dce 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -76,6 +76,7 @@ pub(crate) fn validate_path( // the SAME nominal type and `ParseTask::run_with_source_code` can hand // `&transpiler.options.allow_unresolved` straight through. pub use bun_js_parser::options::AllowUnresolved; +pub use bun_js_parser::options::TSUnusedImportFlags; // Canonical defs live in `bun_resolver::options` (lower tier; resolver is the // runtime consumer of `.patterns`/`.abs_paths`/`.node_modules`). Re-export so @@ -1220,6 +1221,9 @@ pub struct BundleOptions<'a> { pub allow_runtime: bool, pub trim_unused_imports: Option, + /// Of the tsconfig.json of the top-level directory, for a source that no directory holds. + /// A file takes `Resolver::unused_import_flags_ts` of its own directory. + pub unused_import_flags_ts: TSUnusedImportFlags, pub(crate) mark_builtins_as_external: bool, pub server_components: bool, pub hot_module_reloading: bool, @@ -1477,6 +1481,7 @@ impl<'a> BundleOptions<'a> { auto_import_jsx: self.auto_import_jsx, allow_runtime: self.allow_runtime, trim_unused_imports: self.trim_unused_imports, + unused_import_flags_ts: self.unused_import_flags_ts, mark_builtins_as_external: self.mark_builtins_as_external, server_components: self.server_components, hot_module_reloading: self.hot_module_reloading, @@ -1746,6 +1751,7 @@ impl<'a> BundleOptions<'a> { auto_import_jsx: true, allow_runtime: true, trim_unused_imports: None, + unused_import_flags_ts: TSUnusedImportFlags::empty(), mark_builtins_as_external: false, server_components: false, hot_module_reloading: false, diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index c5ef7f1b8fd3..50a5c4cf742b 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -615,6 +615,8 @@ impl<'a> Transpiler<'a> { // Most of the time, this will already be cached let top_level_dir = self.fs().top_level_dir(); if let Ok(Some(root_dir)) = self.resolver.read_dir_info(top_level_dir) { + self.options.unused_import_flags_ts = + self.resolver.unused_import_flags_ts(&root_dir); if let Some(tsconfig) = self.resolver.tsconfig_json(&root_dir) { // If we don't explicitly pass JSX, try to get it from the root tsconfig if self.options.transform_options.jsx.is_none() { @@ -881,6 +883,8 @@ pub struct ParseOptions<'a, 'b> { pub replace_exports: bun_collections::StringArrayHashMap, pub inject_jest_globals: bool, pub set_breakpoint_on_first_line: bool, + /// `Resolver::unused_import_flags_ts` for the directory of `path`. + pub unused_import_flags_ts: options::TSUnusedImportFlags, pub emit_decorator_metadata: bool, pub experimental_decorators: bool, pub use_define_for_class_fields: bool, @@ -888,6 +892,8 @@ pub struct ParseOptions<'a, 'b> { pub dont_bundle_twice: bool, pub allow_commonjs: bool, + /// The output is text that Bun does not link (`bun build --no-bundle`). + pub transform_only: bool, /// `"type"` from `package.json`. Used to make sure the parser defaults /// to CommonJS or ESM based on what the package.json says, when it /// doesn't otherwise know from reading the source code. @@ -1467,7 +1473,7 @@ impl<'a> Transpiler<'a> { keep_names: true, ignore_dce_annotations: self.options.ignore_dce_annotations, keep_directives: false, - preserve_unused_imports_ts: false, + unused_import_flags_ts: this_parse.unused_import_flags_ts, use_define_for_class_fields: this_parse.use_define_for_class_fields, suppress_warnings_about_weird_code: true, features: js_ast::RuntimeFeatures::default(), @@ -1481,7 +1487,7 @@ impl<'a> Transpiler<'a> { module_type: to_parser_module_type(this_parse.module_type), jsc_builtin_syntax: false, output_format: p_opts::Format::Esm, - transform_only: self.options.transform_only, + transform_only: this_parse.transform_only, import_meta_main_value: None, lower_import_meta_main_for_node_js: false, framework: None, @@ -2838,6 +2844,7 @@ impl<'a> Transpiler<'a> { // the option fields needed for `ParseOptions` first. let jsx = jsx_pragma_from_resolver(&resolve_result.jsx); let dirname_fd = resolve_result.dirname_fd; + let unused_import_flags_ts = resolve_result.flags.unused_import_flags_ts(); let emit_decorator_metadata = resolve_result.flags.emit_decorator_metadata(); let experimental_decorators = resolve_result.flags.experimental_decorators(); let use_define_for_class_fields = @@ -2868,6 +2875,7 @@ impl<'a> Transpiler<'a> { macro_remappings, macro_js_ctx: default_macro_js_value(), jsx, + unused_import_flags_ts, emit_decorator_metadata, experimental_decorators, use_define_for_class_fields, @@ -2878,6 +2886,7 @@ impl<'a> Transpiler<'a> { remove_cjs_module_wrapper: false, dont_bundle_twice: false, allow_commonjs: false, + transform_only: self.options.transform_only, module_type: options::ModuleType::Unknown, runtime_transpiler_cache: None, keep_json_and_toml_as_one_statement: false, diff --git a/src/js_parser/lower/lower_esm_exports_hmr.rs b/src/js_parser/lower/lower_esm_exports_hmr.rs index 68cd881bd02a..73aeb11dba01 100644 --- a/src/js_parser/lower/lower_esm_exports_hmr.rs +++ b/src/js_parser/lower/lower_esm_exports_hmr.rs @@ -501,6 +501,7 @@ impl<'a> ConvertESMExportsForHmr<'a> { namespace_ref, star_name_loc, phase_defer: false, + has_items_clause: false, }, loc, )); diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index d981eb71862d..2cc7dc8872d7 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -2490,6 +2490,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O default_name: None, star_name_loc: bun_ast::Loc::EMPTY, phase_defer: false, + has_items_clause: false, }, bun_ast::Loc::default(), ); @@ -2660,6 +2661,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O default_name: None, star_name_loc: bun_ast::Loc::EMPTY, phase_defer: false, + has_items_clause: false, }, bun_ast::Loc::EMPTY, ) @@ -4113,12 +4115,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // Only assert above; do not actually pop. } + /// `starts_unused`: the record is created with `IS_UNUSED` set, and stays so unless + /// `ImportScanner::scan` keeps the statement. pub(crate) fn process_import_statement( &mut self, stmt_: S::Import, path: ParsedPath<'a>, loc: bun_ast::Loc, was_originally_bare_import: bool, + starts_unused: bool, ) -> Result { let is_macro = Self::ALLOW_MACROS && (path.is_macro || crate::Macro::is_macro_path(path.text)); @@ -4251,6 +4256,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.import_records.items_mut()[stmt.import_record_index as usize] .flags .set(bun_ast::ImportRecordFlags::PHASE_DEFER, stmt.phase_defer); + self.import_records.items_mut()[stmt.import_record_index as usize] + .flags + .set(bun_ast::ImportRecordFlags::IS_UNUSED, starts_unused); if let Some(star) = stmt.star_name_loc.to_nullable() { let name = self.load_name_from_ref(stmt.namespace_ref); @@ -9615,6 +9623,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O opts.features.emit_decorator_metadata = false; } + // Every parse passes here, so this is where a declaration file loses the flags. + if !opts.unused_import_flags_ts.is_empty() + && options::TSUnusedImportFlags::is_declaration_file(source.path.text) + { + opts.unused_import_flags_ts = options::TSUnusedImportFlags::empty(); + } + let unwrap_all_requires = 'brk: { if opts.bundle && opts.output_format != options::Format::Cjs { // `bun_paths::fs::Path<'static>` is the diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index d1cf892e5f49..4e9caea2459a 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -152,7 +152,9 @@ pub struct Options<'a> { /// Keeps every directive in the AST, in its original position and with its original text. /// Otherwise `"use strict"` is only recorded on its scope, and `minify_syntax` drops the rest. pub keep_directives: bool, - pub preserve_unused_imports_ts: bool, + /// From the tsconfig.json that governs the file. Empty for a file inside node_modules + /// and for a declaration file. + pub unused_import_flags_ts: options::TSUnusedImportFlags, pub use_define_for_class_fields: bool, pub suppress_warnings_about_weird_code: bool, pub features: RuntimeFeatures, @@ -173,6 +175,8 @@ pub struct Options<'a> { pub jsc_builtin_syntax: bool, pub output_format: options::Format, + /// The output is text for another tool (`bun build --no-bundle`) and Bun does not + /// link it. Off by default: a new parse site gets output that links. pub transform_only: bool, /// Used for inlining the state of import.meta.main during visiting @@ -210,7 +214,7 @@ impl<'a> Default for Options<'a> { keep_names: true, ignore_dce_annotations: false, keep_directives: false, - preserve_unused_imports_ts: false, + unused_import_flags_ts: options::TSUnusedImportFlags::empty(), use_define_for_class_fields: true, suppress_warnings_about_weird_code: true, features: RuntimeFeatures::default(), @@ -262,7 +266,7 @@ impl<'a> Options<'a> { keep_names: self.keep_names, ignore_dce_annotations: self.ignore_dce_annotations, keep_directives: self.keep_directives, - preserve_unused_imports_ts: self.preserve_unused_imports_ts, + unused_import_flags_ts: self.unused_import_flags_ts, use_define_for_class_fields: self.use_define_for_class_fields, suppress_warnings_about_weird_code: self.suppress_warnings_about_weird_code, features: RuntimeFeatures { @@ -341,6 +345,10 @@ impl<'a> Options<'a> { if self.ts { hasher.update(b"TS"); + if !self.unused_import_flags_ts.is_empty() { + hasher.update(b"ts_unused_imports="); + hasher.update(&[self.unused_import_flags_ts.bits()]); + } } else { hasher.update(b"NO_TS"); } @@ -356,6 +364,18 @@ impl<'a> Options<'a> { self.features.hash_for_runtime_transpiler(hasher); } + /// `KEEP_VALUES` prints an unused import name only in transform output. The runtime + /// and a bundle link what is printed, so there the name goes and the statement stays. + pub(crate) fn keeps_unused_import_names_ts(&self) -> bool { + self.ts + && self.transform_only + && self + .unused_import_flags_ts + .contains(options::TSUnusedImportFlags::KEEP_VALUES) + && !self.bundle + && !self.features.minify_identifiers + } + // Used to determine if `joinWithComma` should be called in `visitStmts`. We do this // to avoid changing line numbers too much to make source mapping more readable pub(crate) fn runtime_merge_adjacent_expression_statements(&self) -> bool { @@ -371,7 +391,7 @@ impl<'a> Options<'a> { keep_names: true, ignore_dce_annotations: false, keep_directives: false, - preserve_unused_imports_ts: false, + unused_import_flags_ts: options::TSUnusedImportFlags::empty(), use_define_for_class_fields: true, suppress_warnings_about_weird_code: true, features: RuntimeFeatures::default(), @@ -593,8 +613,9 @@ impl<'a> Parser<'a> { } } - // - if TS { + // With a tsconfig.json setting that keeps unused imports, a statement that + // survives the parse survives the transform too, and the guess below is wrong. + if TS && p.options.unused_import_flags_ts.is_empty() { for import_record in p.import_records.items_mut() { // Mark everything as unused // Except: @@ -1540,6 +1561,7 @@ impl<'a> Parser<'a> { items: bun_ast::StoreSlice::EMPTY, is_single_line: false, phase_defer: false, + has_items_clause: false, }, ns_loc, ); @@ -2090,10 +2112,12 @@ impl<'a> Parser<'a> { } for item in p.import_records.items() { - // skip if they did import it - if item.path.text == b"bun:test" - || item.path.text == b"@jest/globals" - || item.path.text == b"vitest" + // skip if they did import it. An unused record is not an import: its + // statement binds no value, or a `declare` block holds it. + if !item.flags.contains(ImportRecordFlags::IS_UNUSED) + && (item.path.text == b"bun:test" + || item.path.text == b"@jest/globals" + || item.path.text == b"vitest") { if let Some(cache) = p.options.features.runtime_transpiler_cache_mut() { // If we rewrote import paths, we need to disable the runtime transpiler cache @@ -2248,6 +2272,7 @@ impl<'a> Parser<'a> { star_name_loc: bun_ast::Loc::EMPTY, is_single_line: false, phase_defer: false, + has_items_clause: false, }, bun_ast::Loc::EMPTY, ); diff --git a/src/js_parser/parse/parse_import_export.rs b/src/js_parser/parse/parse_import_export.rs index f862d5adde3e..50101dd10cc5 100644 --- a/src/js_parser/parse/parse_import_export.rs +++ b/src/js_parser/parse/parse_import_export.rs @@ -118,9 +118,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut items = bun_alloc::ArenaVec::::new_in(p.arena); p.lexer.expect(T::TOpenBrace)?; let mut is_single_line = !p.lexer.has_newline_before; - // this variable should not exist if we're not in a typescript file - // Declared unconditionally — dead-store elim removes it when !TS. - let mut had_type_only_imports = false; while p.lexer.token != T::TCloseBrace { // The alias may be a keyword; @@ -160,11 +157,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if p.lexer.token == T::TIdentifier { // "import { type as as as } from 'mod'" // "import { type as as foo } from 'mod'" - had_type_only_imports = true; p.lexer.next()?; } else { // "import { type as as } from 'mod'" - items.push(ClauseItem { alias: alias.into(), alias_loc, @@ -173,8 +168,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }); } } else if p.lexer.token == T::TIdentifier { - had_type_only_imports = true; - // "import { type as xxx } from 'mod'" original_name = p.lexer.identifier; name = LocRef { @@ -220,7 +213,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // An import where the name is a keyword must have an alias p.lexer.expected_string(b"\"as\"")?; } - had_type_only_imports = true; } } else { if p.lexer.is_contextual_keyword(b"as") { @@ -280,11 +272,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(ImportClause { items: items.into_bump_slice_mut(), is_single_line, - had_type_only_imports: if TYPESCRIPT { - had_type_only_imports - } else { - false - }, }) } diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 856983870baf..0f35913fdfad 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1257,6 +1257,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // TODO: import assertions // path.assertions ); + // A `declare` block is dropped whole, and its records with it. + if opts.is_typescript_declare { + p.import_records.items_mut()[import_record_index as usize] + .flags + .insert(ImportRecordFlags::IS_UNUSED); + } if path.is_macro { p.log().add_error( @@ -1303,14 +1309,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.lexer.expect_or_insert_semicolon()?; - if Self::IS_TYPESCRIPT_ENABLED { - // export {type Foo} from 'bar'; - // -> - // nothing - // https://www.typescriptlang.org/play?useDefineForClassFields=true&esModuleInterop=false&declaration=false&target=99&isolatedModules=false&ts=4.5.4#code/KYDwDg9gTgLgBDAnmYcDeAxCEC+cBmUEAtnAOQBGAhlGQNwBQQA - if export_clause.clauses.is_empty() && export_clause.had_type_only_exports { - return Ok(p.s(S::TypeScript {}, loc)); - } + // export {type Foo} from 'bar'; + // export {} from 'bar'; + // -> + // nothing, or `export {} from 'bar'` when tsconfig keeps the statement + // https://www.typescriptlang.org/play?useDefineForClassFields=true&esModuleInterop=false&declaration=false&target=99&isolatedModules=false&ts=4.5.4#code/KYDwDg9gTgLgBDAnmYcDeAxCEC+cBmUEAtnAOQBGAhlGQNwBQQA + let valueless_clause = + Self::IS_TYPESCRIPT_ENABLED && export_clause.clauses.is_empty(); + if valueless_clause && p.options.unused_import_flags_ts.drops_valueless_clause() + { + return Ok(p.s(S::TypeScript {}, loc)); } if parsed_path.is_macro { @@ -1329,6 +1337,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let import_record_index = p.add_import_record(ImportKind::Stmt, parsed_path.loc, parsed_path.text); + // As for an import: see `starts_unused` in `t_import`. + if opts.is_typescript_declare || (valueless_clause && !SCAN_ONLY) { + p.import_records.items_mut()[import_record_index as usize] + .flags + .insert(ImportRecordFlags::IS_UNUSED); + } let path_name = fs::PathName::init(parsed_path.text); let namespace_ref = { use std::io::Write as _; @@ -1369,7 +1383,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // -> // nothing // https://www.typescriptlang.org/play?useDefineForClassFields=true&esModuleInterop=false&declaration=false&target=99&isolatedModules=false&ts=4.5.4#code/KYDwDg9gTgLgBDAnmYcDeAxCEC+cBmUEAtnAOQBGAhlGQNwBQQA - if export_clause.clauses.is_empty() && export_clause.had_type_only_exports { + if export_clause.clauses.is_empty() + && export_clause.had_type_only_exports + && p.options.unused_import_flags_ts.drops_valueless_clause() + { return Ok(p.s(S::TypeScript {}, loc)); } } @@ -1476,13 +1493,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return Err(crate::Error::SyntaxError); } let import_clause = p.parse_import_clause()?; - if Self::IS_TYPESCRIPT_ENABLED { - if import_clause.had_type_only_imports && import_clause.items.is_empty() { - p.lexer.expect_contextual_keyword(b"from")?; - let _ = p.parse_path()?; - p.lexer.expect_or_insert_semicolon()?; - return Ok(p.s(S::TypeScript {}, loc)); - } + // "import {} from 'mod'" + // "import { type Foo } from 'mod'" + if Self::IS_TYPESCRIPT_ENABLED + && import_clause.items.is_empty() + && p.options.unused_import_flags_ts.drops_valueless_clause() + { + p.lexer.expect_contextual_keyword(b"from")?; + let _ = p.parse_path()?; + p.lexer.expect_or_insert_semicolon()?; + return Ok(p.s(S::TypeScript {}, loc)); } stmt = S::Import { @@ -1492,6 +1512,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // moved into the AST node here; no other &mut alias exists. items: import_clause.items.into(), is_single_line: import_clause.is_single_line, + has_items_clause: Self::IS_TYPESCRIPT_ENABLED, ..Default::default() }; p.lexer.expect_contextual_keyword(b"from")?; @@ -1554,7 +1575,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let path = p.parse_path()?; p.lexer.expect_or_insert_semicolon()?; - return p.process_import_statement(stmt, path, loc, false); + return p.process_import_statement( + stmt, + path, + loc, + false, + opts.is_typescript_declare, + ); } if Self::IS_TYPESCRIPT_ENABLED { @@ -1654,6 +1681,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O T::TOpenBrace => { let import_clause = p.parse_import_clause()?; + // "import d, {} from 'mod'" + // "import d, { type T } from 'mod'" + // The default name decides the statement. The empty clause + // goes with the same test as in the `{` arm above. + stmt.has_items_clause = Self::IS_TYPESCRIPT_ENABLED + && !(import_clause.items.is_empty() + && p.options.unused_import_flags_ts.drops_valueless_clause()); // SAFETY: sole owner — fresh arena slice from parse_import_clause, // moved into the AST node here; no other &mut alias exists. stmt.items = import_clause.items.into(); @@ -1677,7 +1711,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let path = p.parse_path()?; p.lexer.expect_or_insert_semicolon()?; - p.process_import_statement(stmt, path, loc, was_originally_bare_import) + // Nothing may act on these two records before `ImportScanner::scan` decides. A + // `declare` block is dropped whole, so its statement never reaches the scanner. A + // clause with no value binding stays only because tsconfig keeps the statement. + let valueless_clause = + stmt.has_items_clause && stmt.items.is_empty() && stmt.default_name.is_none(); + let starts_unused = opts.is_typescript_declare || (valueless_clause && !SCAN_ONLY); + p.process_import_statement(stmt, path, loc, was_originally_bare_import, starts_unused) } /// Out-of-line tail for the (uncommon) `label: stmt` form reached from diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index b37f40c8f55a..92228e478f44 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -108,6 +108,45 @@ pub mod options { } } } + bitflags::bitflags! { + /// What a TypeScript file does with an import that no value uses. tsconfig + /// `verbatimModuleSyntax` sets both bits. `importsNotUsedAsValues: "preserve" | "error"` + /// sets `KEEP_STMT` and `preserveValueImports` sets `KEEP_VALUES`. + /// + /// | input | neither | `KEEP_STMT` | `KEEP_VALUES` | both | + /// |-------------------------------------|----------------|----------------|---------------------------------|---------------------------------| + /// | `import 'foo'` | `import 'foo'` | `import 'foo'` | `import 'foo'` | `import 'foo'` | + /// | `import * as unused from 'foo'` | (removed) | `import 'foo'` | `import * as unused from 'foo'` | `import * as unused from 'foo'` | + /// | `import { unused } from 'foo'` | (removed) | `import 'foo'` | `import { unused } from 'foo'` | `import { unused } from 'foo'` | + /// | `import { type unused } from 'foo'` | (removed) | `import 'foo'` | (removed) | `import {} from 'foo'` | + /// | `import {} from 'foo'` | (removed) | `import 'foo'` | (removed) | `import {} from 'foo'` | + /// + /// The `KEEP_VALUES` columns are what a transform prints (`Options::transform_only`). + /// The runtime and a bundle link what is printed, and a type that was imported + /// without `type` has no export to link to. There `KEEP_VALUES` keeps the statement + /// and the unused names go, so those two columns read `import 'foo'`. + #[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] + pub struct TSUnusedImportFlags: u8 { + /// Keep the statement, for the side effects of the module, when no binding is left. + const KEEP_STMT = 1 << 0; + /// Keep a value binding that nothing uses. + const KEEP_VALUES = 1 << 1; + } + } + impl TSUnusedImportFlags { + /// A TypeScript import or `export from` whose clause binds no value + /// (`import {} from 'x'`, `import { type T } from 'x'`, `export {} from 'x'`) is + /// type-only. It goes unless the statement itself is kept. + #[inline] + pub const fn drops_valueless_clause(self) -> bool { + !self.contains(Self::KEEP_STMT) + } + + /// The settings do not reach a declaration file: TypeScript emits nothing for one. + pub fn is_declaration_file(path: &[u8]) -> bool { + path.ends_with(b".d.ts") || path.ends_with(b".d.mts") || path.ends_with(b".d.cts") + } + } /// Port of `bake.Framework` (src/runtime/bake/mod.rs:129) — TYPE_ONLY /// parser-side mirror. The full struct lives in `bun_runtime::bake` (a /// higher tier we cannot depend on here); the parser only consumes the @@ -1358,7 +1397,6 @@ pub(crate) struct ImportClause<'a> { /// (`S::Import.items: StoreSlice`). pub(crate) items: &'a mut [js_ast::ClauseItem], pub(crate) is_single_line: bool, - pub(crate) had_type_only_imports: bool, } pub struct PropertyOpts { diff --git a/src/js_parser/scan/scan_imports.rs b/src/js_parser/scan/scan_imports.rs index 73ad31de4498..56128f580d6b 100644 --- a/src/js_parser/scan/scan_imports.rs +++ b/src/js_parser/scan/scan_imports.rs @@ -1,6 +1,7 @@ #![warn(unused_must_use)] use crate::lower::lower_esm_exports_hmr::ConvertESMExportsForHmr; use crate::p::P; +use crate::parser::options::TSUnusedImportFlags; use crate::parser::{ImportItemForNamespaceMap, Ref}; use bun_ast::{self as js_ast, Expr, G, LocRef, S, Stmt, Symbol}; use bun_ast::{ImportRecord, import_record}; @@ -80,19 +81,21 @@ impl<'a> ImportScanner<'a> { continue; } - // The official TypeScript compiler always removes unused imported - // symbols. However, we deliberately deviate from the official - // TypeScript compiler's behavior doing this in a specific scenario: - // we are not bundling, symbol renaming is off, and the tsconfig.json - // "importsNotUsedAsValues" setting is present and is not set to - // "remove". - // - // This exists to support the use case of compiling partial modules for - // compile-to-JavaScript languages such as Svelte. These languages try - // to reference imports in ways that are impossible for esbuild to know - // about when esbuild is only given a partial module to compile. Here - // is an example of some Svelte code that might use esbuild to convert - // TypeScript to JavaScript: + // A file that becomes CommonJS at runtime cannot hold an import statement + // (`reject_import_statements`), so the tsconfig.json settings keep nothing + // there: its unused imports go as they do with no setting. + let unused_import_flags = if will_transform_to_common_js { + TSUnusedImportFlags::empty() + } else { + p.options.unused_import_flags_ts + }; + + // We implement TypeScript's "preserveValueImports" tsconfig.json setting + // to support the use case of compiling partial modules for compile-to- + // JavaScript languages such as Svelte. These languages try to reference + // imports in ways that are impossible for TypeScript and esbuild to know + // about when they are only given a partial module to compile. Here is an + // example of some Svelte code that contains a TypeScript snippet: // // - //
- //

Hello {name}!

- // - //
- // - // Tools that use esbuild to compile TypeScript code inside a Svelte - // file like this only give esbuild the contents of the `, + "serve.ts": ` + import page from "./index.html"; + using server = Bun.serve({ port: 0, development: true, routes: { "/": page } }); + const html = await (await fetch(server.url)).text(); + const src = html.match(/src="(\\/_bun\\/client\\/[^"]+)"/)![1]; + const script = await (await fetch(new URL(src, server.url))).text(); + const modules = ["effect", "type-only", "types", "erased"]; + console.log(modules.filter(name => script.includes('console.log("' + name + '")')).join(" ")); + `, + }); + const { stdout, exitCode } = await run(String(dir), ["serve.ts"]); + expect(stdout).toBe("effect type-only types\n"); + expect(exitCode).toBe(0); + }); }); // The tsconfig.json nearest to a file decides, wherever bun starts. A file in node_modules @@ -501,6 +521,72 @@ describe("tsconfig unused imports", () => { stderr: "", exitCode: 0, }); + const build = await run(String(dir), [ + "build", + "--target=bun", + "--tsconfig-override=on.json", + "static.ts", + "--outfile=out.js", + ]); + expect(build.stderr).toBe(""); + expect(await run(String(dir), ["out.js"])).toEqual({ + stdout: "app effect\napp\nnested effect\nnested\nlib effect\nlib\ndep\ndep-with-tsconfig\n", + stderr: "", + exitCode: 0, + }); + }); + + // A plugin's source has no tsconfig.json of its own: the one of the working directory + // decides. A source for a path in node_modules takes none. + test.concurrent("a plugin's source takes the tsconfig.json of the working directory", async () => { + const names = ["onLoad", "namespace", "module", "dependency"]; + using dir = tempDir("ts-unused-imports-plugin", { + "tsconfig.json": verbatim, + ...Object.fromEntries(names.map(name => [`effect-${name}.ts`, effect(name)])), + "file.virtual": "", + "node_modules/dep/package.json": JSON.stringify({ name: "dep", main: "./file.virtual" }), + "node_modules/dep/file.virtual": "", + "sources.ts": ` + const source = (name: string) => ({ + contents: "import { unused } from " + JSON.stringify(import.meta.dir + "/effect-" + name) + '; console.log("' + name + '");', + loader: "ts" as const, + }); + export function setup(build: Bun.PluginBuilder) { + build.onLoad({ filter: /\\.virtual$/ }, args => source(args.path.includes("node_modules") ? "dependency" : "onLoad")); + build.onResolve({ filter: /^ns:/ }, args => ({ path: args.path.slice(3), namespace: "ns" })); + build.onLoad({ filter: /.*/, namespace: "ns" }, () => source("namespace")); + } + export const virtualModule = () => source("module"); + `, + "preload.ts": ` + import { setup, virtualModule } from "./sources"; + Bun.plugin({ + name: "sources", + setup(build) { + setup(build); + build.module("virtual:module", virtualModule); + }, + }); + `, + "runtime.ts": `import "./file.virtual"; import "ns:source"; import "virtual:module"; import "dep";`, + "entry.ts": `import "./file.virtual"; import "ns:source"; import "dep";`, + "bundle.ts": ` + import { setup } from "./sources"; + const build = await Bun.build({ entrypoints: ["./entry.ts"], target: "bun", plugins: [{ name: "sources", setup }] }); + await Bun.write("out.mjs", await build.outputs[0].text()); + await import("./out.mjs"); + `, + }); + expect(await run(String(dir), ["--preload=./preload.ts", "runtime.ts"])).toEqual({ + stdout: "onLoad effect\nonLoad\nnamespace effect\nnamespace\nmodule effect\nmodule\ndependency\n", + stderr: "", + exitCode: 0, + }); + expect(await run(String(dir), ["bundle.ts"])).toEqual({ + stdout: "onLoad effect\nonLoad\nnamespace effect\nnamespace\ndependency\n", + stderr: "", + exitCode: 0, + }); }); }); From d748f8560d2e1244db4a2b6b9aa5fa3d040aef47 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:04:45 +0000 Subject: [PATCH 4/9] Mark an import that only tsconfig keeps, and read the flags from the real directory - `ImportRecordFlags::KEPT_BY_TSCONFIG` marks a TypeScript import that no value uses and that tsconfig.json keeps. It replaces the provisional `IS_UNUSED` of a kept clause with no value name. The `bun:test` global injection ignores such a record, and the transpiler cache is dropped for every `vitest` or `@jest/globals` record. - A module statement inside a `declare` block creates no import record. - The bundler reads the flags from the directory that a file is loaded from (its real path), through `Resolver::unused_import_flags_ts`, as the runtime does. A `Bun.build({ files })` entry and the second parse of a "use client" file take flags too. - A package that auto-install put in the global cache takes no flags. With auto-install on, the flags keep no import of a bare specifier. - A resolve error for a kept import has a note in place of the `bun install` hint. - `replace_exports` drops a kept statement only when a deleted export used one of its names. - `TSConfigJSON::unused_import_flags` computes the flags on read. `bun repl` takes the top-level flags. --- src/ast/import_record.rs | 3 + src/bundler/ParseTask.rs | 4 ++ src/bundler/ServerComponentParseTask.rs | 1 + src/bundler/bundle_v2.rs | 74 ++++++++++++++++++++----- src/bundler/transpiler.rs | 2 +- src/js_parser/p.rs | 8 +-- src/js_parser/parse/parse_entry.rs | 20 ++++--- src/js_parser/parse/parse_stmt.rs | 59 +++++++++++--------- src/js_parser/parser.rs | 8 +++ src/js_parser/scan/scan_imports.rs | 56 ++++++++++--------- src/resolver/dir_info.rs | 9 +++ src/resolver/resolver.rs | 68 +++++++++++++++++++---- src/resolver/result.rs | 2 +- src/resolver/tsconfig_json.rs | 37 +++++-------- src/runtime/cli/repl.rs | 1 + src/runtime/jsc_hooks.rs | 17 +++++- 16 files changed, 253 insertions(+), 116 deletions(-) diff --git a/src/ast/import_record.rs b/src/ast/import_record.rs index aa4160ec68c4..96517a535b87 100644 --- a/src/ast/import_record.rs +++ b/src/ast/import_record.rs @@ -107,6 +107,9 @@ bitflags::bitflags! { /// `IS_UNUSED` for its own reasons (an unused TypeScript import, a macro /// import), and those records must never be resolved. const IS_BARREL_DEFERRED = 1 << 18; + + /// No value uses this TypeScript import: its statement stays because tsconfig.json keeps it. + const KEPT_BY_TSCONFIG = 1 << 19; } } diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 238f8cb13fb1..2482d7f56219 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -210,6 +210,9 @@ pub(crate) struct Success { /// The package name from package.json, used for barrel optimization. pub(crate) package_name: ast::StoreStr, + + /// What this parse took, for a second parse of the same file ("use client"). + pub(crate) unused_import_flags_ts: options::TSUnusedImportFlags, } pub(crate) struct ResultError { @@ -2791,6 +2794,7 @@ pub mod parse_worker { side_effects: task.side_effects, loader, package_name: task.package_name, + unused_import_flags_ts: task.unused_import_flags_ts, // Hash the files in here so that we do it in parallel. content_hash_for_additional_file: unique_key_for_additional_file.content_hash, diff --git a/src/bundler/ServerComponentParseTask.rs b/src/bundler/ServerComponentParseTask.rs index 02e76cd73c8c..9137a562ca2c 100644 --- a/src/bundler/ServerComponentParseTask.rs +++ b/src/bundler/ServerComponentParseTask.rs @@ -198,6 +198,7 @@ fn task_callback( unique_key_for_additional_file: bun_ast::StoreStr::EMPTY, content_hash_for_additional_file: 0, package_name: bun_ast::StoreStr::EMPTY, + unused_import_flags_ts: crate::options::TSUnusedImportFlags::empty(), }) } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 83ea9b3bd04c..0ee50a73c293 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -252,6 +252,16 @@ impl<'a> BundleV2<'a> { .unused_import_flags_ts } + /// A `files` entry is a source that no directory holds, like a plugin's. + fn set_file_map_result_flags( + &mut self, + target: options::Target, + result: &mut bun_resolver::Result, + ) { + let flags = self.root_unused_import_flags_ts(target, &result.path_pair.primary); + result.flags.set_unused_import_flags_ts(flags); + } + pub(crate) fn transpiler_for_target(&mut self, target: options::Target) -> &mut Transpiler<'a> { // SAFETY: all three pointers are live for `'a` (set in `init`); the // `client_transpiler` arm is only reached when bake populated it. @@ -1903,6 +1913,18 @@ pub mod bv2_impl { unsafe { (*p).into_static() } } + /// The last error is for an import that only tsconfig.json keeps: `bun install` is no remedy. + #[cold] + pub(crate) fn note_import_kept_by_tsconfig(log: &mut bun_ast::Log) { + if let Some(msg) = log.msgs.last_mut() { + msg.notes = Box::new([bun_ast::range_data( + None, + bun_ast::Range::NONE, + "No value uses this import. tsconfig.json keeps it (\"verbatimModuleSyntax\"), so the module must exist. Write \"import type\" to drop it.", + )]); + } + } + /// Logs resolver errors that `resolve()` returns without writing to any /// log so `has_errors()` actually fires. Returns `true` when `err` is one /// of those; shared by `run_resolver` and `resolve_import_records`. @@ -2712,7 +2734,8 @@ pub mod bv2_impl { &import_record.source_file, &import_record.specifier, ) { - let file_map_result = _file_map_result; + let mut file_map_result = _file_map_result; + self.set_file_map_result_flags(target, &mut file_map_result); let mut path_primary = file_map_result.path_pair.primary; // reshaped for borrowck — `get_or_put` borrows `*self` mutably via // `self.graph`; capture the slot as `*mut u32` so subsequent `self.*` calls @@ -2828,6 +2851,7 @@ pub mod bv2_impl { } let handles_import_errors; + let kept_by_tsconfig; // reshaped for borrowck — `log_for_resolution_failures` borrows // `&mut self`; the returned log is backed by either a DevServer-owned slot or // `*self.transpiler.log` (both raw-pointer-derived), so detach the lifetime @@ -2849,6 +2873,9 @@ pub mod bv2_impl { handles_import_errors = record .flags .contains(bun_ast::ImportRecordFlags::HANDLES_IMPORT_ERRORS); + kept_by_tsconfig = record + .flags + .contains(bun_ast::ImportRecordFlags::KEPT_BY_TSCONFIG); // Disable failing packages from being printed. // This may cause broken code to write. @@ -2893,8 +2920,13 @@ pub mod bv2_impl { source, import_record.range, format_args!( - "Could not resolve: \"{}\". Maybe you need to \"bun install\"?", - bstr::BStr::new(path_to_use) + "Could not resolve: \"{}\"{}", + bstr::BStr::new(path_to_use), + if kept_by_tsconfig { + "" + } else { + ". Maybe you need to \"bun install\"?" + }, ), path_to_use, import_record.kind, @@ -2913,6 +2945,9 @@ pub mod bv2_impl { import_record.kind, ); } + if kept_by_tsconfig { + note_import_kept_by_tsconfig(log); + } } } else { log_unhandled_resolve_error( @@ -3602,14 +3637,13 @@ pub mod bv2_impl { // Check FileMap first for in-memory entry points if let Some(file_map) = self.file_map { - if let Some(file_map_result) = file_map.resolve(self.arena(), b"", entry_point) + if let Some(mut file_map_result) = + file_map.resolve(self.arena(), b"", entry_point) { - let _ = self.enqueue_entry_item( - &mut { file_map_result }, - true, - self.transpiler.options.target, - None, - )?; + let target = self.transpiler.options.target; + self.set_file_map_result_flags(target, &mut file_map_result); + let _ = + self.enqueue_entry_item(&mut file_map_result, true, target, None)?; continue; } } @@ -4188,6 +4222,7 @@ pub mod bv2_impl { loader: Loader, known_target: options::Target, module_type: options::ModuleType, + unused_import_flags_ts: options::TSUnusedImportFlags, ) -> Result { let source_index = Index::init(u32::try_from(self.graph.ast.len()).expect("int cast")); let _ = self.graph.ast.append(JSAst::empty_in(self.graph.heap)); // OOM/capacity: fire-and-forget @@ -4232,7 +4267,6 @@ pub mod bv2_impl { } else { self.transpiler_for_target(known_target).options.jsx.clone() }; - let unused_import_flags_ts = self.root_unused_import_flags_ts(known_target, &task_path); // SAFETY: arena (`self.graph.heap`) outlives the bundle pass; coerce the // `&mut ParseTask` to `*mut` immediately so the `&self` borrow from // `arena()` ends before we take `&mut self` below. @@ -6924,6 +6958,7 @@ pub mod bv2_impl { file_map.resolve(self.arena(), source.path.text, import_record.path.text) { let mut file_map_result = _file_map_result; + self.set_file_map_result_flags(target, &mut file_map_result); let mut path_primary = file_map_result.path_pair.primary; let import_record_loader = import_record.loader.unwrap_or_else(|| { Fs::Path::init(path_primary.text) @@ -7055,6 +7090,9 @@ pub mod bv2_impl { && !self.transpiler.options.ignore_module_resolution_errors { last_error = Some(err.into()); + let kept_by_tsconfig = import_record + .flags + .contains(bun_ast::ImportRecordFlags::KEPT_BY_TSCONFIG); if is_package_path(import_record.path.text) { if ctx.target == Target::Browser && options::is_node_builtin(import_record.path.text) @@ -7107,8 +7145,13 @@ pub mod bv2_impl { Some(source), import_record.range, format_args!( - "Could not resolve: \"{}\". Maybe you need to \"bun install\"?", - bstr::BStr::new(&import_record.path.text) + "Could not resolve: \"{}\"{}", + bstr::BStr::new(&import_record.path.text), + if kept_by_tsconfig { + "" + } else { + ". Maybe you need to \"bun install\"?" + }, ), import_record.path.text, import_record.kind, @@ -7153,6 +7196,9 @@ pub mod bv2_impl { import_record.kind, ); } + if kept_by_tsconfig { + note_import_kept_by_tsconfig(log); + } } } else { let report = !import_record @@ -8058,6 +8104,7 @@ pub mod bv2_impl { source_loader, Target::ServerComponentsSsr, source_module_type, + result.unused_import_flags_ts, ) .expect("oom"); @@ -8082,6 +8129,7 @@ pub mod bv2_impl { source_loader, Target::Browser, source_module_type, + result.unused_import_flags_ts, ) .expect("oom"); diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 50a5c4cf742b..07966f687e08 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -892,7 +892,7 @@ pub struct ParseOptions<'a, 'b> { pub dont_bundle_twice: bool, pub allow_commonjs: bool, - /// The output is text that Bun does not link (`bun build --no-bundle`). + /// `bun build --no-bundle`: the output is printed, and whatever runs it links it. pub transform_only: bool, /// `"type"` from `package.json`. Used to make sure the parser defaults /// to CommonJS or ESM based on what the package.json says, when it diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 3d37fcdb30e6..5c42fae4f566 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -4115,14 +4115,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // Only assert above; do not actually pop. } - /// `starts_unused`: the record is `IS_UNUSED` until `ImportScanner::scan` keeps the statement. + /// `binds_no_value`: a TypeScript clause with no value name, which only tsconfig.json keeps. pub(crate) fn process_import_statement( &mut self, stmt_: S::Import, path: ParsedPath<'a>, loc: bun_ast::Loc, was_originally_bare_import: bool, - starts_unused: bool, + binds_no_value: bool, ) -> Result { let is_macro = Self::ALLOW_MACROS && (path.is_macro || crate::Macro::is_macro_path(path.text)); @@ -4255,10 +4255,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.import_records.items_mut()[stmt.import_record_index as usize] .flags .set(bun_ast::ImportRecordFlags::PHASE_DEFER, stmt.phase_defer); - if Self::IS_TYPESCRIPT_ENABLED && starts_unused { + if Self::IS_TYPESCRIPT_ENABLED && binds_no_value { self.import_records.items_mut()[stmt.import_record_index as usize] .flags - .insert(bun_ast::ImportRecordFlags::IS_UNUSED); + .insert(bun_ast::ImportRecordFlags::KEPT_BY_TSCONFIG); } if let Some(star) = stmt.star_name_loc.to_nullable() { diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 3388105acd8a..bf368685336f 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -174,7 +174,7 @@ pub struct Options<'a> { pub jsc_builtin_syntax: bool, pub output_format: options::Format, - /// The output is text that Bun does not link (`bun build --no-bundle`). Off by default. + /// `bun build --no-bundle`: the output is printed, and whatever runs it links it. Off by default. pub transform_only: bool, /// Used for inlining the state of import.meta.main during visiting @@ -362,7 +362,7 @@ impl<'a> Options<'a> { self.features.hash_for_runtime_transpiler(hasher); } - /// Only transform output prints a name that `KEEP_VALUES` keeps. Everything else is linked. + /// Only transform output prints a name that `KEEP_VALUES` keeps: it must be a real export there. pub(crate) fn keeps_unused_import_names_ts(&self) -> bool { self.ts && self.transform_only @@ -2107,12 +2107,11 @@ impl<'a> Parser<'a> { break 'outer; } + let mut imports_test_names = false; for item in p.import_records.items() { - // skip if they did import it - if !item.flags.contains(ImportRecordFlags::IS_UNUSED) - && (item.path.text == b"bun:test" - || item.path.text == b"@jest/globals" - || item.path.text == b"vitest") + if item.path.text == b"bun:test" + || item.path.text == b"@jest/globals" + || item.path.text == b"vitest" { if let Some(cache) = p.options.features.runtime_transpiler_cache_mut() { // If we rewrote import paths, we need to disable the runtime transpiler cache @@ -2121,9 +2120,14 @@ impl<'a> Parser<'a> { } } - break 'outer; + // A statement that binds no value leaves the file on the globals. + imports_test_names |= !item.flags.contains(ImportRecordFlags::KEPT_BY_TSCONFIG); } } + // skip if they did import it + if imports_test_names { + break 'outer; + } // if they didn't use any of the jest globals, don't inject it, I guess. // Iterates the static `Jest::FIELDS` diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 30dd65869bfc..7f7ffddf0217 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1250,6 +1250,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O namespace_ref = p.store_name_in_ref(name); } + // A `declare` block is dropped whole: a statement in it asks for no module. + if Self::IS_TYPESCRIPT_ENABLED && opts.is_typescript_declare { + p.lexer.expect_or_insert_semicolon()?; + p.has_es_module_syntax = true; + return Ok(p.s(S::TypeScript {}, loc)); + } + let import_record_index = p.add_import_record( ImportKind::Stmt, path.loc, @@ -1257,12 +1264,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // TODO: import assertions // path.assertions ); - // A `declare` block is dropped whole, and its records with it. - if Self::IS_TYPESCRIPT_ENABLED && opts.is_typescript_declare { - p.import_records.items_mut()[import_record_index as usize] - .flags - .insert(ImportRecordFlags::IS_UNUSED); - } if path.is_macro { p.log().add_error( @@ -1317,10 +1318,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // nothing // https://www.typescriptlang.org/play?useDefineForClassFields=true&esModuleInterop=false&declaration=false&target=99&isolatedModules=false&ts=4.5.4#code/KYDwDg9gTgLgBDAnmYcDeAxCEC+cBmUEAtnAOQBGAhlGQNwBQQA if valueless_clause - && p.options.unused_import_flags_ts.drops_valueless_clause() + && (p.options.unused_import_flags_ts.drops_valueless_clause() + || p.options.unused_import_flags_ts.skips(parsed_path.text)) { return Ok(p.s(S::TypeScript {}, loc)); } + // A `declare` block is dropped whole: a statement in it asks for no module. + if opts.is_typescript_declare { + p.has_es_module_syntax = true; + return Ok(p.s(S::TypeScript {}, loc)); + } } if parsed_path.is_macro { @@ -1339,13 +1346,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let import_record_index = p.add_import_record(ImportKind::Stmt, parsed_path.loc, parsed_path.text); - // As for an import: see `starts_unused` in `t_import`. - if Self::IS_TYPESCRIPT_ENABLED - && (opts.is_typescript_declare || (valueless_clause && !SCAN_ONLY)) - { + if valueless_clause { p.import_records.items_mut()[import_record_index as usize] .flags - .insert(ImportRecordFlags::IS_UNUSED); + .insert(ImportRecordFlags::KEPT_BY_TSCONFIG); } let path_name = fs::PathName::init(parsed_path.text); let namespace_ref = { @@ -1578,13 +1582,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let path = p.parse_path()?; p.lexer.expect_or_insert_semicolon()?; - return p.process_import_statement( - stmt, - path, - loc, - false, - Self::IS_TYPESCRIPT_ENABLED && opts.is_typescript_declare, - ); + if Self::IS_TYPESCRIPT_ENABLED && opts.is_typescript_declare { + return Ok(p.s(S::TypeScript {}, loc)); + } + return p.process_import_statement(stmt, path, loc, false, false); } if Self::IS_TYPESCRIPT_ENABLED { @@ -1711,14 +1712,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let path = p.parse_path()?; p.lexer.expect_or_insert_semicolon()?; - // Not an import until `ImportScanner::scan` keeps it. It never sees a `declare` block. - let starts_unused = Self::IS_TYPESCRIPT_ENABLED - && (opts.is_typescript_declare - || (!SCAN_ONLY - && stmt.has_items_clause - && stmt.items.is_empty() - && stmt.default_name.is_none())); - p.process_import_statement(stmt, path, loc, was_originally_bare_import, starts_unused) + // A `declare` block is dropped whole: a statement in it asks for no module. + if Self::IS_TYPESCRIPT_ENABLED && opts.is_typescript_declare { + return Ok(p.s(S::TypeScript {}, loc)); + } + let binds_no_value = Self::IS_TYPESCRIPT_ENABLED + && stmt.has_items_clause + && stmt.items.is_empty() + && stmt.default_name.is_none(); + if binds_no_value && p.options.unused_import_flags_ts.skips(path.text) { + return Ok(p.s(S::TypeScript {}, loc)); + } + p.process_import_statement(stmt, path, loc, was_originally_bare_import, binds_no_value) } /// Out-of-line tail for the (uncommon) `label: stmt` form reached from diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 71e86a6e1717..f2565dd08763 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -116,6 +116,8 @@ pub mod options { const KEEP_STMT = 1 << 0; /// `preserveValueImports`: an unused value name stays. `verbatimModuleSyntax` is both. const KEEP_VALUES = 1 << 1; + /// Auto-install answers a bare specifier from the registry, so the two flags above keep no such import. + const NOT_PACKAGES = 1 << 2; } } impl TSUnusedImportFlags { @@ -125,6 +127,12 @@ pub mod options { !self.contains(Self::KEEP_STMT) } + /// `NOT_PACKAGES`, for the specifier of one import. + #[inline] + pub fn skips(self, specifier: &[u8]) -> bool { + self.contains(Self::NOT_PACKAGES) && bun_paths::is_package_path(specifier) + } + /// TypeScript emits nothing for a declaration file, so the settings do not reach one. pub fn is_declaration_file(path: &[u8]) -> bool { path.ends_with(b".d.ts") || path.ends_with(b".d.mts") || path.ends_with(b".d.cts") diff --git a/src/js_parser/scan/scan_imports.rs b/src/js_parser/scan/scan_imports.rs index 9da32718b9c4..0b088cbff1c0 100644 --- a/src/js_parser/scan/scan_imports.rs +++ b/src/js_parser/scan/scan_imports.rs @@ -93,6 +93,10 @@ impl<'a> ImportScanner<'a> { let mut did_remove_star_loc = false; let keep_unused_imports = !p.options.features.trim_unused_imports || (keep_values_ts && p.options.keeps_unused_import_names_ts()); + // A statement that tsconfig keeps still goes with an export deleted on request. + let check_deleted_exports = !unused_import_flags.is_empty() + && p.options.features.replace_exports.count() > 0; + let mut lost_name_to_deleted_export = false; // Without trimming (JavaScript), exports deleted on request // (`replace_exports`) still take the imports that only they used: a binding // goes when its only uses were in them. An import that nothing used stays, for @@ -102,9 +106,14 @@ impl<'a> ImportScanner<'a> { macro_rules! removable { ($ref_:expr) => {{ let index = $ref_.inner_index(); - p.symbols[index as usize].use_count_estimate == 0 + let removable = p.symbols[index as usize].use_count_estimate == 0 && (!only_deleted_exports_uses - || p.used_by_deleted_exports.contains(&index)) + || p.used_by_deleted_exports.contains(&index)); + if removable && check_deleted_exports { + lost_name_to_deleted_export |= + p.used_by_deleted_exports.contains(&index); + } + removable }}; } // TypeScript always trims unused imports. This is important for @@ -119,8 +128,7 @@ impl<'a> ImportScanner<'a> { // TypeScript has a separate definition of unused if is_typescript_enabled - && (p.ts_use_counts[default_name.ref_.inner_index() as usize] != 0 - || keep_values_ts) + && p.ts_use_counts[default_name.ref_.inner_index() as usize] != 0 { is_unused_in_typescript = false; } @@ -137,8 +145,7 @@ impl<'a> ImportScanner<'a> { // TypeScript has a separate definition of unused if is_typescript_enabled - && (p.ts_use_counts[st.namespace_ref.inner_index() as usize] != 0 - || keep_values_ts) + && p.ts_use_counts[st.namespace_ref.inner_index() as usize] != 0 { is_unused_in_typescript = false; } @@ -184,8 +191,7 @@ impl<'a> ImportScanner<'a> { // TypeScript has a separate definition of unused if is_typescript_enabled - && (p.ts_use_counts[ref_.inner_index() as usize] != 0 - || keep_values_ts) + && p.ts_use_counts[ref_.inner_index() as usize] != 0 { is_unused_in_typescript = false; } @@ -246,14 +252,20 @@ impl<'a> ImportScanner<'a> { && st.star_name_loc.is_empty() // SAFETY: arena-owned slice; see above. && st.items.slice().is_empty() - && st.default_name.is_none(); + && st.default_name.is_none() + && (!check_deleted_exports || lost_name_to_deleted_export); // An empty TypeScript clause is an import too: `import {} from 'x'`. + let unused_in_typescript = !keep_unused_imports + && is_typescript_enabled + && (found_imports || had_items_clause) + && is_unused_in_typescript; + // `KEEP_STMT` keeps every statement, `KEEP_VALUES` one that binds a value. + let kept_by_tsconfig = unused_in_typescript + && (!unused_import_flags.drops_valueless_clause() + || (keep_values_ts && found_imports)) + && !unused_import_flags.skips(record!().path.text); if unused_after_deleted_exports - || (!keep_unused_imports - && is_typescript_enabled - && (found_imports || had_items_clause) - && is_unused_in_typescript - && unused_import_flags.drops_valueless_clause()) + || (unused_in_typescript && !kept_by_tsconfig) || (!is_typescript_enabled && p.options.features.trim_unused_imports && found_imports @@ -273,15 +285,15 @@ impl<'a> ImportScanner<'a> { continue; } } + if kept_by_tsconfig { + record!() + .flags + .insert(import_record::Flags::KEPT_BY_TSCONFIG); + } } let _ = did_remove_star_loc; - // The statement stays: a record that `t_import` created unused is live now. - if is_typescript_enabled { - record!().flags.remove(import_record::Flags::IS_UNUSED); - } - let namespace_ref = st.namespace_ref; // `import defer * as ns` must keep its namespace binding // (see the matching guard above): converting it to a @@ -756,12 +768,6 @@ impl<'a> ImportScanner<'a> { js_ast::StmtData::SExportFrom(st) => { p.import_records_for_current_part .push(st.import_record_index); - // `export {} from 'x'` that tsconfig keeps: see the import arm. - if is_typescript_enabled { - p.import_records.items_mut()[st.import_record_index as usize] - .flags - .remove(import_record::Flags::IS_UNUSED); - } // SAFETY: arena-owned slice valid for 'p. let items = st.items.slice(); p.named_imports diff --git a/src/resolver/dir_info.rs b/src/resolver/dir_info.rs index b90cba7326e7..03fc4eff71b0 100644 --- a/src/resolver/dir_info.rs +++ b/src/resolver/dir_info.rs @@ -201,6 +201,13 @@ impl DirInfo { self.flags.contains(Flags::InsideNodeModules) } + /// The project that installs a package did not write its files. + #[inline] + pub(crate) fn is_inside_installed_package(&self) -> bool { + self.flags + .intersects(Flags::InsideNodeModules | Flags::InsideCachedPackage) + } + /// Read-only view of `package_json`. The field stores `NonNull` to preserve /// mut-provenance; callers that only read go through here. #[inline] @@ -386,6 +393,8 @@ bitflags::bitflags! { /// This directory has a node_modules subdirectory const HasNodeModules = 1 << 1; const InsideNodeModules = 1 << 2; + /// Inside a package that auto-install put in the global cache. + const InsideCachedPackage = 1 << 3; } } impl Flags { diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 7efe41266756..a517b1aa0ced 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1221,6 +1221,10 @@ impl<'a> Resolver<'a> { } let _ = self.flush_debug_logs(FlushMode::Success); self.extension_order = original_order; + let mut flags = ResultFlags::default(); + if let Ok(Some(dir_info)) = self.read_dir_info(res.path_pair.primary.name().dir) { + flags.set_unused_import_flags_ts(self.unused_import_flags_ts(&dir_info)); + } return ResultUnion::Success(Result { import_kind: kind, path_pair: res.path_pair, @@ -1228,6 +1232,7 @@ impl<'a> Resolver<'a> { dirname_fd: res.dirname_fd, file_fd: res.file_fd, jsx: self.opts.jsx.clone(), + flags, ..Default::default() }); } @@ -1626,12 +1631,6 @@ impl<'a> Resolver<'a> { } if let Some(tsconfig) = self.enclosing_tsconfig_json(&dir) { - // As `unused_import_flags_ts`, for the file that is parsed. - if primary && !dir.is_inside_node_modules() { - result - .flags - .set_unused_import_flags_ts(tsconfig.unused_import_flags()); - } result.jsx = tsconfig.merge_jsx(core::mem::take(&mut result.jsx)); result.flags.set_emit_decorator_metadata( result.flags.emit_decorator_metadata() || tsconfig.emit_decorator_metadata, @@ -1741,6 +1740,11 @@ impl<'a> Resolver<'a> { path.set_realpath(symlink); } } + + if primary { + let flags = self.unused_import_flags_ts_of_file(&dir, path); + result.flags.set_unused_import_flags_ts(flags); + } } // The extension wins over a package.json "type". @@ -6427,6 +6431,14 @@ impl<'a> Resolver<'a> { info.flags .set_present(DirInfo::Flag::InsideNodeModules, true); } + if parent_.flags.contains(DirInfo::Flag::InsideCachedPackage) { + info.flags + .set_present(DirInfo::Flag::InsideCachedPackage, true); + } + } + if package_id.is_some() { + info.flags + .set_present(DirInfo::Flag::InsideCachedPackage, true); } // Record if this directory has a package.json file @@ -6703,9 +6715,7 @@ impl<'a> Resolver<'a> { } // SAFETY: from `parse_tsconfig` (heap::into_raw) above; the walk is over, so this // is the only pointer to it. - let mut merged_config = unsafe { bun_core::heap::take(merged_config) }; - merged_config.fold_unused_import_flags(); - return Ok(Some(merged_config)); + return Ok(Some(unsafe { bun_core::heap::take(merged_config) })); } Ok(None) } @@ -6753,9 +6763,9 @@ impl<'a> Resolver<'a> { } } - /// Of the nearest tsconfig.json, and none inside node_modules, as esbuild's `tsConfigForDir`. + /// Of the nearest tsconfig.json. An installed package takes none, as in esbuild's `tsConfigForDir`. pub fn unused_import_flags_ts(&mut self, dir_info: &DirInfo::DirInfo) -> TSUnusedImportFlags { - if dir_info.is_inside_node_modules() { + if dir_info.is_inside_installed_package() { return TSUnusedImportFlags::empty(); } // No `Arc` clone of the override per file: this runs for every TypeScript module. @@ -6775,6 +6785,42 @@ impl<'a> Resolver<'a> { }) } + /// Whether the registry can answer a bare specifier that a file in `dir_info` imports. + pub fn may_auto_install(&self, dir_info: &DirInfo::DirInfo) -> bool { + if !self.use_package_manager() { + return false; + } + let global_cache = self.opts.global_cache; + let mut any_node_modules_folder = false; + if global_cache != GlobalCache::force { + any_node_modules_folder = dir_info.has_node_modules(); + let mut parent = dir_info.get_parent(); + while let (false, Some(dir)) = (any_node_modules_folder, parent) { + any_node_modules_folder = dir.has_node_modules(); + parent = dir.get_parent(); + } + } + global_cache.can_use(any_node_modules_folder) + } + + /// `unused_import_flags_ts` of the directory that `path` is loaded from: its real one, as at runtime. + fn unused_import_flags_ts_of_file( + &mut self, + dir_info: &DirInfo::DirInfo, + path: &Path, + ) -> TSUnusedImportFlags { + if !path.is_symlink { + return self.unused_import_flags_ts(dir_info); + } + if path.is_node_module() { + return TSUnusedImportFlags::empty(); + } + match self.read_dir_info(path.name().dir) { + Ok(Some(real_dir_info)) => self.unused_import_flags_ts(&real_dir_info), + _ => TSUnusedImportFlags::empty(), + } + } + /// `DirInfo::tsconfig_json`, for this resolver. pub fn tsconfig_json(&mut self, dir_info: &DirInfo::DirInfo) -> Option { match self.tsconfig_override() { diff --git a/src/resolver/result.rs b/src/resolver/result.rs index 1d19a0fcbddf..5a1ea6ac72bf 100644 --- a/src/resolver/result.rs +++ b/src/resolver/result.rs @@ -234,7 +234,7 @@ impl ResultFlags { flags } #[inline] - pub(crate) fn set_unused_import_flags_ts(&mut self, flags: TSUnusedImportFlags) { + pub fn set_unused_import_flags_ts(&mut self, flags: TSUnusedImportFlags) { self.set( Self::TS_KEEP_UNUSED_IMPORT_STMT, flags.contains(TSUnusedImportFlags::KEEP_STMT), diff --git a/src/resolver/tsconfig_json.rs b/src/resolver/tsconfig_json.rs index 15d694ca584c..f1c33690dbfb 100644 --- a/src/resolver/tsconfig_json.rs +++ b/src/resolver/tsconfig_json.rs @@ -162,8 +162,6 @@ pub struct TSConfigJSON { pub(crate) preserve_imports_not_used_as_values: Option, pub(crate) preserve_value_imports: Option, pub(crate) verbatim_module_syntax: Option, - /// The three settings above, folded by `fold_unused_import_flags` after the `extends` merge. - unused_import_flags: TSUnusedImportFlags, pub emit_decorator_metadata: bool, pub experimental_decorators: bool, @@ -184,7 +182,6 @@ impl Default for TSConfigJSON { preserve_imports_not_used_as_values: None, preserve_value_imports: None, verbatim_module_syntax: None, - unused_import_flags: TSUnusedImportFlags::empty(), emit_decorator_metadata: false, experimental_decorators: false, use_define_for_class_fields: None, @@ -262,27 +259,20 @@ impl TSConfigJSON { } /// What a TypeScript file under this configuration does with an unused import. - #[inline] pub fn unused_import_flags(&self) -> TSUnusedImportFlags { - self.unused_import_flags - } - - /// `verbatimModuleSyntax` is both flags. Call it after `parse` and after the `extends` merge. - pub(crate) fn fold_unused_import_flags(&mut self) { - self.unused_import_flags = if self.verbatim_module_syntax == Some(true) { - TSUnusedImportFlags::all() - } else { - let mut flags = TSUnusedImportFlags::empty(); - flags.set( - TSUnusedImportFlags::KEEP_STMT, - self.preserve_imports_not_used_as_values == Some(true), - ); - flags.set( - TSUnusedImportFlags::KEEP_VALUES, - self.preserve_value_imports == Some(true), - ); - flags - }; + if self.verbatim_module_syntax == Some(true) { + return TSUnusedImportFlags::KEEP_STMT | TSUnusedImportFlags::KEEP_VALUES; + } + let mut flags = TSUnusedImportFlags::empty(); + flags.set( + TSUnusedImportFlags::KEEP_STMT, + self.preserve_imports_not_used_as_values == Some(true), + ); + flags.set( + TSUnusedImportFlags::KEEP_VALUES, + self.preserve_value_imports == Some(true), + ); + flags } /// Support ${configDir}, but avoid allocating when possible. @@ -716,7 +706,6 @@ impl TSConfigJSON { debug_assert!(!result.base_url.is_empty()); } - result.fold_unused_import_flags(); Ok(Some(TSConfigJSON::new(result))) } diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index 4ff19836ca6e..3d786662f538 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -2147,6 +2147,7 @@ impl<'a> Repl<'a> { bun_ast::Loader::Tsx, ); opts.repl_mode = true; + opts.unused_import_flags_ts = vm.transpiler.options.unused_import_flags_ts; opts.features.dead_code_elimination = false; // REPL needs all code opts.features.top_level_await = true; // Enable top-level await in REPL // Keep `lower_using` at its default (true) here even though JavaScriptCore diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 352911a153d9..9acacd5c2b4e 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -4097,6 +4097,9 @@ unsafe fn get_loader_and_virtual_source<'a>( Ok(Some(dir_info)) => { if may_be_typescript { unused_import_flags_ts = resolver.unused_import_flags_ts(&dir_info); + if !unused_import_flags_ts.is_empty() && resolver.may_auto_install(&dir_info) { + unused_import_flags_ts |= TSUnusedImportFlags::NOT_PACKAGES; + } } dir_info.package_json().or(dir_info.enclosing_package_json) } @@ -4609,8 +4612,18 @@ pub(crate) extern "C" fn Bun__transpileVirtualModule( let unused_import_flags_ts = if path.is_node_module() { TSUnusedImportFlags::empty() } else { - // SAFETY: `jsc_vm` is the live per-thread VM. - unsafe { (*jsc_vm).transpiler.options.unused_import_flags_ts } + // SAFETY: `jsc_vm` is the live per-thread VM; `read_dir_info` is re-entrant on the JS thread. + let transpiler = unsafe { &mut (*jsc_vm).transpiler }; + let mut flags = transpiler.options.unused_import_flags_ts; + if !flags.is_empty() + && let Ok(Some(top_level)) = transpiler + .resolver + .read_dir_info(Fs::FileSystem::get().top_level_dir()) + && transpiler.resolver.may_auto_install(&top_level) + { + flags |= TSUnusedImportFlags::NOT_PACKAGES; + } + flags }; let mut extra = TranspileExtra { path, From f104a44925539c52c1c10d1651a7760c1e7e99b9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:19:01 +0000 Subject: [PATCH 5/9] Test the scope, the record mark and the failures of a kept import - `bun test` caches no output for a kept type-only import of `vitest`, and each setting has its own cache key. - A kept import makes a file an ES module in `bun run` and `bun build`. - A workspace package linked into node_modules: `bun run`, one bundle from `bun build`, and `--preserve-symlinks`. - `Bun.build({ files })`, `Bun.build({ tsconfig })`, an override that cannot be read, a package with two entry files, a module with no extension or no directory, a path that a plugin resolved. - The dev server after a save, and its module format in `bun build`. - `bun build --no-bundle` with `--minify-identifiers`, and its output when it runs. - `Bun.Transpiler`: `transform()`, `declare` blocks, `exports.eliminate`. - The evaluation order, the resolve errors and the note of `bun build`. - Auto-install makes no request for a kept import, and a package in the cache takes no setting. --- test/bundler/esbuild/tsconfig.test.ts | 38 +- .../transpiler/runtime-transpiler.test.ts | 410 +++++++++++++++++- test/bundler/transpiler/transpiler.test.js | 45 +- test/cli/run/run-autoinstall.test.ts | 72 +++ test/cli/run/transpiler-cache.test.ts | 47 ++ 5 files changed, 601 insertions(+), 11 deletions(-) diff --git a/test/bundler/esbuild/tsconfig.test.ts b/test/bundler/esbuild/tsconfig.test.ts index 8b4a55af0205..cda31c211eb9 100644 --- a/test/bundler/esbuild/tsconfig.test.ts +++ b/test/bundler/esbuild/tsconfig.test.ts @@ -1,4 +1,4 @@ -import { describe, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { itBundled } from "../expectBundled"; // Tests ported from: @@ -1120,6 +1120,42 @@ import {} from "xyz"; stdout: "side effect\ninline type\nentry 1", }, }); + // The dev server's module format scans the imports of a file with its hot-reload transform, + // which merges the statements of one path. The modules that a file asks for are the same. + for (const [name, tsconfig, modules] of [ + ["VerbatimModuleSyntaxDevServerFormat", `{ "compilerOptions": { "verbatimModuleSyntax": true } }`, "abcdef"], + ["NoSettingDevServerFormat", `{}`, "bc"], + ] as const) { + itBundled(`tsconfig/${name}`, { + format: "internal_bake_dev", + files: { + "/entry.ts": /* ts */ ` + import { unused } from "./a" + import {} from "./b" + import { value } from "./b" + import { other } from "./c" + import { type T } from "./c" + import { type U } from "./d" + export {} from "./e" + export { type V } from "./f" + import type { W } from "./g" + const x: T | U | W = 1 + console.log(value, other, x) + `, + ...Object.fromEntries( + [..."abcdefg"].map(file => [ + `/${file}.ts`, + `console.log("module ${file}"); export const unused = 1, value = 2, other = 3; export type T = number; export type U = T; export type V = T; export type W = T;`, + ]), + ), + "/tsconfig.json": tsconfig, + }, + onAfterBundle(api) { + const output = api.readFile("/out.js"); + expect([..."abcdefg"].filter(file => output.includes(`console.log("module ${file}")`)).join("")).toBe(modules); + }, + }); + } return; itBundled("tsconfig/NestedJSX", { // GENERATED diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index dfc6debd9634..2ca967d4158c 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isWindows, tempDir } from "harness"; +import { symlinkSync } from "node:fs"; import path from "node:path"; test("use strict causes CommonJS", () => { @@ -366,6 +367,17 @@ describe("tsconfig unused imports", () => { }); }); + // `--minify-identifiers` renames every binding, so no unused name is printed. + test.concurrent("bun build --no-bundle --minify-identifiers", async () => { + using dir = tempDir("ts-unused-imports-no-bundle-minify", { ...files, "tsconfig.json": tsconfig }); + const kept = output.split("\n").slice(0, -2); + expect(await run(String(dir), ["build", "--no-bundle", "--minify-identifiers", "entry.ts"])).toEqual({ + stdout: kept.map(name => `import"./${name}";\n`).join("") + `const o = 1;\nconsole.log("entry", o);\n`, + stderr: "", + exitCode: 0, + }); + }); + test.concurrent("extends inherits the setting, and the child can turn it off", async () => { const off = Object.fromEntries( Object.keys(compilerOptions).map(key => [key, key === "importsNotUsedAsValues" ? "remove" : false]), @@ -386,6 +398,52 @@ describe("tsconfig unused imports", () => { }); }); + // `extends` merges each key by itself: `verbatimModuleSyntax: false` in the child does not + // turn off the `importsNotUsedAsValues` of the base. + test.concurrent("extends merges the three settings key by key", async () => { + using dir = tempDir("ts-unused-imports-extends-keys", { + ...files, + "base.json": JSON.stringify({ compilerOptions: { importsNotUsedAsValues: "preserve" } }), + "tsconfig.json": JSON.stringify({ extends: "./base.json", compilerOptions: { verbatimModuleSyntax: false } }), + "own/plain.json": "{}", + "own/tsconfig.json": JSON.stringify({ extends: "./plain.json", compilerOptions: { verbatimModuleSyntax: true } }), + "own/own.ts": `import { unused } from "../effect"; console.log("own");`, + }); + expect(await run(String(dir), ["entry.ts"])).toEqual({ stdout: keepStatement, stderr: "", exitCode: 0 }); + expect(await run(path.join(String(dir), "own"), ["own.ts"])).toEqual({ + stdout: "effect\nown\n", + stderr: "", + exitCode: 0, + }); + }); + + // `bun build --no-bundle` prints a kept name as written. What runs the output links it, so + // the name must be a real export there. `bun run` and a bundle drop the name instead. + test.concurrent("the output of bun build --no-bundle runs when each kept name is an export", async () => { + using dir = tempDir("ts-unused-imports-no-bundle-run", { + "tsconfig.json": verbatim, + "effect.ts": files["effect.ts"], + "types.ts": files["types.ts"], + "value.ts": `import { unused } from "./effect"; console.log("value");`, + "type.ts": `import { SomeType } from "./types"; const x: SomeType = 1; console.log("type", x);`, + }); + const build = await run(String(dir), [ + "build", + "--no-bundle", + "--outdir=out", + "value.ts", + "type.ts", + "effect.ts", + "types.ts", + ]); + expect(build.exitCode).toBe(0); + expect(await run(String(dir), ["out/value.js"])).toEqual({ stdout: "effect\nvalue\n", stderr: "", exitCode: 0 }); + const linked = await run(String(dir), ["out/type.js"]); + expect(linked.stderr).toContain("Export named 'SomeType' not found"); + expect(linked.exitCode).toBe(1); + expect(await run(String(dir), ["type.ts"])).toEqual({ stdout: "types\ntype 1\n", stderr: "", exitCode: 0 }); + }); + test.concurrent("no setting removes the imports", async () => { using dir = tempDir("ts-unused-imports-default", { ...files, "tsconfig.json": "{}" }); expect(await run(String(dir), ["entry.ts"])).toEqual({ stdout: "entry 1\n", stderr: "", exitCode: 0 }); @@ -441,15 +499,25 @@ describe("tsconfig unused imports", () => { "serve.ts": ` import page from "./index.html"; using server = Bun.serve({ port: 0, development: true, routes: { "/": page } }); - const html = await (await fetch(server.url)).text(); - const src = html.match(/src="(\\/_bun\\/client\\/[^"]+)"/)![1]; - const script = await (await fetch(new URL(src, server.url))).text(); const modules = ["effect", "type-only", "types", "erased"]; - console.log(modules.filter(name => script.includes('console.log("' + name + '")')).join(" ")); + async function bundled(marker: string) { + for (;;) { + const html = await (await fetch(server.url)).text(); + const src = html.match(/src="(\\/_bun\\/client\\/[^"]+)"/)![1]; + const script = await (await fetch(new URL(src, server.url))).text(); + if (script.includes(marker)) + return modules.filter(name => script.includes('console.log("' + name + '")')).join(" "); + } + } + console.log(await bundled('"entry"')); + // A save bundles the file again, and asks for it by its real path. + const source = await Bun.file("entry.ts").text(); + await Bun.write("entry.ts", source.replace('"entry"', '"entry saved"')); + console.log(await bundled('"entry saved"')); `, }); const { stdout, exitCode } = await run(String(dir), ["serve.ts"]); - expect(stdout).toBe("effect type-only types\n"); + expect(stdout).toBe("effect type-only types\neffect type-only types\n"); expect(exitCode).toBe(0); }); }); @@ -588,6 +656,228 @@ describe("tsconfig unused imports", () => { exitCode: 0, }); }); + + test.concurrent("a path in node_modules that a plugin resolved takes no setting", async () => { + using dir = tempDir("ts-unused-imports-plugin-resolved", { + "tsconfig.json": verbatim, + "node_modules/dep/package.json": JSON.stringify({ name: "dep", main: "./index.ts" }), + "node_modules/dep/index.ts": importer("dep"), + "node_modules/dep/effect.ts": effect("dep"), + "entry.ts": `import "alias:dep";`, + "bundle.ts": ` + import path from "node:path"; + const build = await Bun.build({ + entrypoints: ["./entry.ts"], + target: "bun", + plugins: [{ + name: "alias", + setup(build) { + build.onResolve({ filter: /^alias:/ }, () => ({ path: path.join(import.meta.dir, "node_modules/dep/index.ts") })); + }, + }], + }); + await Bun.write("out.mjs", await build.outputs[0].text()); + await import("./out.mjs"); + `, + }); + expect(await run(String(dir), ["bundle.ts"])).toEqual({ stdout: "dep\n", stderr: "", exitCode: 0 }); + }); + + // `Bun.build({ files })` hands the bundler a source that no directory holds, as a plugin does. + test.concurrent("a Bun.build({ files }) entry takes the tsconfig.json of the working directory", async () => { + using dir = tempDir("ts-unused-imports-files", { + "tsconfig.json": verbatim, + "disk.ts": `console.log("disk");`, + ...Object.fromEntries( + ["replaced", "memory", "nowhere", "dep"].map(name => [`effect-${name}.ts`, effect(name)]), + ), + "bundle.ts": ` + import path from "node:path"; + const here = import.meta.dir; + const source = (name: string) => + "import { unused } from " + JSON.stringify(path.join(here, "effect-" + name + ".ts")) + '; console.log("' + name + '");'; + const dependency = path.join(here, "node_modules", "dep", "index.ts"); + const build = await Bun.build({ + entrypoints: [path.join(here, "entry.ts")], + target: "bun", + files: { + [path.join(here, "entry.ts")]: + 'import "./disk.ts"; import "./memory.ts"; import "/nowhere/memory.ts"; import ' + JSON.stringify(dependency) + ";", + [path.join(here, "disk.ts")]: source("replaced"), + [path.join(here, "memory.ts")]: source("memory"), + "/nowhere/memory.ts": source("nowhere"), + [dependency]: source("dep"), + }, + }); + await Bun.write("out.mjs", await build.outputs[0].text()); + await import("./out.mjs"); + `, + }); + expect(await run(String(dir), ["bundle.ts"])).toEqual({ + stdout: "replaced effect\nreplaced\nmemory effect\nmemory\nnowhere effect\nnowhere\ndep\n", + stderr: "", + exitCode: 0, + }); + }); + + // The `tsconfig` of one build replaces the tsconfig.json of every directory, for that build only. + test.concurrent("Bun.build({ tsconfig }) decides for that build", async () => { + using dir = tempDir("ts-unused-imports-api-override", { + "tsconfig.json": off, + "on.json": verbatim, + "off.json": off, + "effect.ts": effect("root"), + "a.ts": importer("a"), + "pkg/tsconfig.json": verbatim, + "pkg/effect.ts": effect("pkg"), + "pkg/b.ts": importer("b"), + "build.ts": ` + const build = async (entry: string, tsconfig?: string) => { + const { outputs } = await Bun.build({ entrypoints: [entry], target: "bun", tsconfig }); + return / effect"\\)/.test(await outputs[0].text()); + }; + console.log( + JSON.stringify({ + first: await build("./pkg/b.ts", "./off.json"), + without: [await build("./pkg/b.ts"), await build("./a.ts")], + with: [await build("./a.ts", "./on.json"), await build("./pkg/b.ts", "./off.json")], + atOnce: await Promise.all([ + build("./a.ts", "./on.json"), + build("./a.ts"), + build("./pkg/b.ts", "./off.json"), + build("./pkg/b.ts"), + ]), + }), + ); + await import("./a.ts"); + await import("./pkg/b.ts"); + `, + }); + expect(await run(String(dir), ["build.ts"])).toEqual({ + stdout: + JSON.stringify({ + first: false, + without: [true, false], + with: [true, false], + atOnce: [true, false, false, true], + }) + "\na\npkg effect\nb\n", + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent("an override that cannot be read leaves no setting", async () => { + using dir = tempDir("ts-unused-imports-override-missing", { + "tsconfig.json": verbatim, + "effect.ts": effect("root"), + "entry.ts": importer("entry"), + }); + const { stdout, exitCode } = await run(String(dir), ["--tsconfig-override=nowhere.json", "entry.ts"]); + expect(stdout).toBe("entry\n"); + expect(exitCode).toBe(0); + }); + + // A package with `module` and `main` resolves to two files. The one that is parsed decides. + test.concurrent.each([ + [off, verbatim, "esm\n"], + [verbatim, off, "esm effect\nesm\n"], + ])("a package with two entry files takes the tsconfig.json of the one that is parsed", async (esm, cjs, stdout) => { + using dir = tempDir("ts-unused-imports-dual", { + "tsconfig.json": "{}", + "entry.ts": `import "./pkg";`, + "pkg/package.json": JSON.stringify({ name: "pkg", module: "./esm/index.ts", main: "./cjs/index.ts" }), + "pkg/esm/tsconfig.json": esm, + "pkg/esm/index.ts": importer("esm"), + "pkg/esm/effect.ts": effect("esm"), + "pkg/cjs/tsconfig.json": cjs, + "pkg/cjs/index.ts": importer("cjs"), + "pkg/cjs/effect.ts": effect("cjs"), + }); + const build = await run(String(dir), ["build", "--target=bun", "--outfile=out.js", "entry.ts"]); + expect(build.stderr).toBe(""); + expect(await run(String(dir), ["out.js"])).toEqual({ stdout, stderr: "", exitCode: 0 }); + }); + + // These reach the transpiler with no directory of their own, or with no loader yet. + test.concurrent.each([ + ["./script", "root effect\nscript\n"], + ["extension.cjs", "root effect\nfoo\n"], + ["blob.mjs", "root effect\nblob 1\n"], + ])("a module with no known extension or no directory: %s", async (entry, stdout) => { + using dir = tempDir("ts-unused-imports-fallbacks", { + "tsconfig.json": verbatim, + "effect.ts": effect("root"), + "script": importer("script"), + "module.foo": importer("foo"), + "extension.cjs": `require.extensions[".foo"] = require.extensions[".ts"]; require("./module.foo");`, + "blob.mjs": ` + const source = 'import { unused } from ' + JSON.stringify(import.meta.dir + "/effect.ts") + '; const x: number = 1; console.log("blob", x);'; + await import(URL.createObjectURL(new Blob([source], { type: "application/typescript" }))); + `, + }); + expect(await run(String(dir), [entry])).toEqual({ stdout, stderr: "", exitCode: 0 }); + }); + + // A workspace package is a link in node_modules to a directory of the project. The file is + // loaded from that directory, so its tsconfig.json decides: in `bun run` and in `bun build`, + // by whichever path the file is reached first. With `--preserve-symlinks` the file is loaded + // from node_modules, and an installed package takes no setting. + describe("a workspace package that is linked into node_modules", () => { + const workspace = { + "tsconfig.json": verbatim, + "packages/ui/package.json": JSON.stringify({ + name: "ui", + exports: { ".": "./index.ts", "./button": "./button.ts" }, + }), + "packages/ui/index.ts": `export * from "./button";`, + "packages/ui/button.ts": `import { unused } from "./effect"; export const button = "button";`, + "packages/ui/effect.ts": effect("ui"), + "app/page.ts": `import { button } from "ui/button"; export const page = "page " + button;`, + "app/main.ts": `import { page } from "./page"; import { button } from "ui"; console.log(page, button);`, + }; + function link(dir: string) { + symlinkSync( + path.join(dir, "packages", "ui"), + path.join(dir, "app", "node_modules", "ui"), + isWindows ? "junction" : "dir", + ); + } + const files = { ...workspace, "app/node_modules/.keep": "" }; + const kept = "ui effect\npage button button\n"; + + test.concurrent("bun run", async () => { + using dir = tempDir("ts-unused-imports-workspace-run", files); + link(String(dir)); + expect(await run(path.join(String(dir), "app"), ["main.ts"])).toEqual({ + stdout: kept, + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent("bun build gives one bundle, with the answer of bun run", async () => { + using dir = tempDir("ts-unused-imports-workspace-build", files); + link(String(dir)); + const app = path.join(String(dir), "app"); + const builds = await Promise.all( + Array.from({ length: 4 }, () => run(app, ["build", "--target=bun", "main.ts"])), + ); + const outputs = new Set(builds.map(build => build.stdout)); + expect(outputs.size).toBe(1); + await Bun.write(path.join(app, "out.js"), [...outputs][0]); + expect(await run(app, ["out.js"])).toEqual({ stdout: kept, stderr: "", exitCode: 0 }); + }); + + test.concurrent("--preserve-symlinks", async () => { + using dir = tempDir("ts-unused-imports-workspace-preserve", files); + link(String(dir)); + expect(await run(path.join(String(dir), "app"), ["--preserve-symlinks", "main.ts"])).toEqual({ + stdout: "page button button\n", + stderr: "", + exitCode: 0, + }); + }); + }); }); // A file that assigns `module.exports` becomes CommonJS, which cannot hold an import @@ -612,6 +902,34 @@ describe("tsconfig unused imports", () => { }); }); + // A kept statement is an import statement, so a file that has one is an ES module. `require`, + // `__dirname` and "use strict" do not make it CommonJS, as they do not with `import { T }`. + test.concurrent.each([ + [ + "__dirname", + `import { type T } from "./type-only"; const x: T = 1; console.log(typeof __dirname, x);`, + "string 1", + ], + [ + "require", + `import { type T } from "./type-only"; console.log(typeof require("node:fs").readFileSync);`, + "function", + ], + ["use strict", `"use strict";\nimport {} from "./type-only";\nconsole.log("strict");`, "strict"], + ])("a kept import with %s runs its module in bun run and in bun build", async (_, source, printed) => { + using dir = tempDir("ts-unused-imports-module-kind", { + ...files, + "tsconfig.json": verbatim, + "package.json": JSON.stringify({ name: "app" }), + "main.ts": source, + }); + const stdout = `type-only\n${printed}\n`; + expect(await run(String(dir), ["main.ts"])).toEqual({ stdout, stderr: "", exitCode: 0 }); + const build = await run(String(dir), ["build", "--target=bun", "--outfile=out.js", "main.ts"]); + expect(build.stderr).toBe(""); + expect(await run(String(dir), ["out.js"])).toEqual({ stdout, stderr: "", exitCode: 0 }); + }); + // Each statement asks for its module exactly where tsc emits a statement for it. // `import "./p"` always does. The other five do only under `verbatimModuleSyntax: true`. describe("which statements ask for their module", () => { @@ -680,10 +998,10 @@ describe("tsconfig unused imports", () => { }); // TypeScript emits nothing for a declaration file, so its imports ask for no module. - test.concurrent("a declaration file keeps no import", async () => { + test.concurrent.each(["d.ts", "d.mts", "d.cts"])("a .%s file keeps no import", async extension => { using dir = tempDir("ts-unused-imports-dts", { "tsconfig.json": verbatim, - "globals.d.ts": ` + [`globals.${extension}`]: ` import { Model } from "./models"; import { type Other } from "./missing"; declare global { @@ -691,7 +1009,7 @@ describe("tsconfig unused imports", () => { } `, "models.d.ts": `export interface Model { id: number }`, - "main.ts": `import "./globals.d.ts"; console.log("main");`, + "main.ts": `import "./globals.${extension}"; console.log("main");`, }); expect(await run(String(dir), ["main.ts"])).toEqual({ stdout: "main\n", stderr: "", exitCode: 0 }); const build = await run(String(dir), ["build", "--target=bun", "--outfile=out.js", "main.ts"]); @@ -730,4 +1048,78 @@ describe("tsconfig unused imports", () => { expect(build.stderr).toBe(""); expect(await run(String(dir), ["out.js"])).toEqual({ stdout: "main\n", stderr: "", exitCode: 0 }); }); + + // A kept import is a request for its module, at its place in the file. These programs run + // without the setting. With it they do what the output of tsc does in Node. + describe("what a kept import asks for", () => { + // `./config` and what it imports run before `./setup`, which comes later in the file. + test.concurrent("its module runs at the place of the statement", async () => { + const order = { + "config.ts": `import "./db"; export type Config = { url: string };`, + "db.ts": `console.log("db reads", globalThis.SETUP ?? "nothing");`, + "setup.ts": `globalThis.SETUP = "the setup";`, + "main.ts": `import { type Config } from "./config";\nimport "./setup";\nimport "./db";\nconst config: Config = { url: "" };\nconsole.log("main", config.url);`, + }; + using kept = tempDir("ts-unused-imports-order", { ...order, "tsconfig.json": verbatim }); + expect(await run(String(kept), ["main.ts"])).toEqual({ + stdout: "db reads nothing\nmain \n", + stderr: "", + exitCode: 0, + }); + using removed = tempDir("ts-unused-imports-order-off", { ...order, "tsconfig.json": "{}" }); + expect(await run(String(removed), ["main.ts"])).toEqual({ + stdout: "db reads the setup\nmain \n", + stderr: "", + exitCode: 0, + }); + }); + + // The module must exist. A declaration file is not a module, and `@types/x` is not the package `x`. + test.concurrent.each([ + ["a sibling declaration file", `import { type T } from "./types";`, "./types"], + ["a package that has only its types", `import { type Handler } from "only-types";`, "only-types"], + ["a name that is not marked as a type", `import { Handler } from "only-types";`, "only-types"], + ])("%s does not resolve", async (_, statement, specifier) => { + using dir = tempDir("ts-unused-imports-missing", { + "tsconfig.json": verbatim, + "types.d.ts": `export type T = number;`, + "node_modules/@types/only-types/package.json": JSON.stringify({ + name: "@types/only-types", + types: "index.d.ts", + }), + "node_modules/@types/only-types/index.d.ts": `export type Handler = () => void;`, + "main.ts": `${statement}\nconsole.log("main");`, + }); + const ran = await run(String(dir), ["main.ts"]); + expect(ran.stderr).toContain(`Cannot find ${specifier.startsWith(".") ? "module" : "package"} '${specifier}'`); + expect(ran.exitCode).toBe(1); + const build = await run(String(dir), ["build", "--target=bun", "--outfile=out.js", "main.ts"]); + expect(build.stderr).toContain(`error: Could not resolve: "${specifier}"\n`); + expect(build.stderr).toContain( + `note: No value uses this import. tsconfig.json keeps it ("verbatimModuleSyntax"), so the module must exist. Write "import type" to drop it.`, + ); + expect(build.exitCode).toBe(1); + }); + + test.concurrent("a declaration file that is named in full is a declaration file", async () => { + using dir = tempDir("ts-unused-imports-dts-named", { + "tsconfig.json": verbatim, + "types.d.ts": `export type T = number;`, + "main.ts": `import { type T } from "./types.d.ts";\nconst x: T = 1;\nconsole.log("main", x);`, + }); + expect(await run(String(dir), ["main.ts"])).toEqual({ stdout: "main 1\n", stderr: "", exitCode: 0 }); + }); + + // A value import that fails gets the usual hint, not the note. + test.concurrent("an import that a value uses keeps the hint of bun build", async () => { + using dir = tempDir("ts-unused-imports-missing-used", { + "tsconfig.json": verbatim, + "main.ts": `import { handler } from "not-installed";\nconsole.log(handler);`, + }); + const build = await run(String(dir), ["build", "--target=bun", "--outfile=out.js", "main.ts"]); + expect(build.stderr).toContain(`Could not resolve: "not-installed". Maybe you need to "bun install"?`); + expect(build.stderr).not.toContain("note:"); + expect(build.exitCode).toBe(1); + }); + }); }); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index cbebacd3f1d1..e53d06e926b2 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -1998,7 +1998,7 @@ class Test extends Bar { untrimmed.keepStatement, trimmed.keepStatement, ], - ])("%j", (compilerOptions, expectedUntrimmed, expectedTrimmed) => { + ])("%j", async (compilerOptions, expectedUntrimmed, expectedTrimmed) => { for (const [options, expected] of [ [{}, expectedUntrimmed], [{ trimUnusedImports: true }, expectedTrimmed], @@ -2006,6 +2006,7 @@ class Test extends Bar { ]) { const transpiler = new Bun.Transpiler({ loader: "ts", tsconfig: { compilerOptions }, ...options }); expect(inputs.map(input => transpiler.transformSync(input))).toEqual(expected); + expect(await Promise.all(inputs.map(input => transpiler.transform(input)))).toEqual(expected); // `scan` and `scanImports` report a module exactly when the output asks for it. const requests = expected.map(output => (output ? [{ path: "foo", kind: "import-statement" }] : [])); expect(inputs.map(input => transpiler.scan(input).imports)).toEqual(requests); @@ -2022,6 +2023,48 @@ class Test extends Bar { expect(transpiler.transformSync("import { unused } from 'foo'")).toBe(""); expect(transpiler.transformSync("import {} from 'foo'")).toBe(bare); }); + + // A `declare` block is dropped whole: no statement in it is an import of its module. + it.each([ + {}, + { importsNotUsedAsValues: "preserve" }, + { preserveValueImports: true }, + { verbatimModuleSyntax: true }, + ])("a declare block asks for no module: %j", compilerOptions => { + const blocks = [ + 'declare module "m" { import { T } from "foo"; }', + 'declare module "m" { import { type T } from "foo"; }', + 'declare module "m" { import "foo"; }', + 'declare module "m" { export * from "foo"; }', + 'declare module "m" { export { a } from "foo"; }', + 'declare global { import { T } from "foo"; }', + 'declare namespace N { import { T } from "foo"; }', + ]; + for (const options of [{}, { trimUnusedImports: true }, { treeShaking: true }]) { + const transpiler = new Bun.Transpiler({ loader: "ts", tsconfig: { compilerOptions }, ...options }); + expect(blocks.map(block => transpiler.transformSync(block))).toEqual(blocks.map(() => "")); + expect(blocks.map(block => transpiler.scan(block).imports)).toEqual(blocks.map(() => [])); + expect(blocks.map(block => transpiler.scanImports(block))).toEqual(blocks.map(() => [])); + } + }); + + // `exports.eliminate` deletes an export and the imports that only it used. An import that + // tsconfig keeps goes with a deleted export that used it, and not for the option alone. + it("an export deleted on request takes only the imports that it used", () => { + const source = `import { register } from "./register";\nimport { db } from "./db";\nexport const kept = 1;\nexport const gone = () => db;`; + const transform = exports => + new Bun.Transpiler({ + loader: "ts", + trimUnusedImports: true, + tsconfig: { compilerOptions: { verbatimModuleSyntax: true } }, + ...(exports ? { exports } : {}), + }).transformSync(source); + const used = + 'import"./register";\nimport { db } from "./db";\nexport const kept = 1;\nexport const gone = () => db;\n'; + expect(transform()).toBe(used); + expect(transform({ eliminate: ["absent"] })).toBe(used); + expect(transform({ eliminate: ["gone"] })).toBe('import"./register";\nexport const kept = 1;\n'); + }); }); it("export import Foo = Baz.Bar", () => { diff --git a/test/cli/run/run-autoinstall.test.ts b/test/cli/run/run-autoinstall.test.ts index 95ee83d0ac08..61954db1b8e4 100644 --- a/test/cli/run/run-autoinstall.test.ts +++ b/test/cli/run/run-autoinstall.test.ts @@ -2182,6 +2182,78 @@ describe.concurrent("auto-install does not run the event loop inside", () => { }); }); +// tsconfig `verbatimModuleSyntax` keeps an import that no value uses. Auto-install would answer +// such an import of a bare name from the registry: `import { type Handler } from "aws-lambda"` +// names the types of `@types/aws-lambda`, and the package `aws-lambda` is another thing. +describe.concurrent("auto-install and an import that only tsconfig.json keeps", () => { + const verbatim = JSON.stringify({ compilerOptions: { verbatimModuleSyntax: true } }); + + async function registryOf(name: string, files: Record) { + const tarball = await new Bun.Archive( + Object.fromEntries(Object.entries(files).map(([file, text]) => [`package/${file}`, text])), + { compress: "gzip" }, + ).bytes(); + const requests: string[] = []; + const server = Bun.serve({ + port: 0, + fetch(req) { + const { origin, pathname } = new URL(req.url); + requests.push(pathname); + if (pathname === `/${name}-1.0.0.tgz`) return new Response(tarball); + if (pathname !== `/${name}`) return new Response("not found", { status: 404 }); + return Response.json({ + name, + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name, version: "1.0.0", dist: { tarball: `${origin}/${name}-1.0.0.tgz` } } }, + }); + }, + }); + return { + requests, + bunfig: `[install]\nregistry = "http://127.0.0.1:${server.port}/"\n`, + [Symbol.dispose]() { + server.stop(true); + }, + }; + } + + test.each([ + ["a type-only clause", `import { type Handler } from "pkg";\nexport const handler: Handler = () => {};`], + ["a name that no value uses", `import { Handler } from "pkg";\nexport const handler: Handler = () => {};`], + ["an empty clause", `import {} from "pkg";`], + ["a type-only re-export", `export { type Handler } from "pkg";`], + ])("%s makes no request to the registry", async (_, statement) => { + using registry = await registryOf("pkg", { + "package.json": JSON.stringify({ name: "pkg", version: "1.0.0", main: "index.js" }), + "index.js": `console.log("the package ran");`, + }); + using dir = tempDir("autoinstall-kept-import", { + "tsconfig.json": verbatim, + "bunfig.toml": registry.bunfig, + "entry.ts": `${statement}\nconsole.log("entry");\n`, + }); + expect(await runAutoInstall(String(dir), "entry.ts")).toEqual({ stdout: "entry\n", stderr: "", exitCode: 0 }); + expect(registry.requests).toEqual([]); + }); + + // The project that installs a package did not write its files, and the tsconfig.json in its + // tarball is not the project's either. + test("a package in the cache takes no setting from the tsconfig.json of its tarball", async () => { + using registry = await registryOf("pkg", { + "package.json": JSON.stringify({ name: "pkg", version: "1.0.0", type: "module", module: "index.ts" }), + "tsconfig.json": verbatim, + "index.ts": `import { unused } from "./register.ts";\nexport const value: string = "value";`, + "register.ts": `console.log("register ran");\nexport const unused = 1;`, + }); + using dir = tempDir("autoinstall-cached-tsconfig", { + "bunfig.toml": registry.bunfig, + "entry.ts": `import { value } from "pkg";\nconsole.log(value);\n`, + }); + expect(await runAutoInstall(String(dir), "entry.ts")).toEqual({ stdout: "value\n", stderr: "", exitCode: 0 }); + expect(registry.requests).toEqual(["/pkg", "/pkg-1.0.0.tgz"]); + }); +}); + // A dead proxy, a captive portal, a VPN that has just dropped. The thread is blocked, and the // wait was six times five minutes long. describe.concurrent("auto-install and a registry that accepts the connection and never answers", () => { diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 8db470fe0c02..fee137dc2ac2 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -147,6 +147,53 @@ describe("transpiler cache", () => { writeFileSync(join(temp_dir, "tsconfig.json"), "{}"); expect(await bunRun(join(temp_dir, "a.ts"), env)).toSpawn("a"); }); + test("each tsconfig.json setting that keeps unused imports has its own cache key", async () => { + const filler = "\n//" + Buffer.alloc(5 * 1024, "f").toString(); + writeFileSync(join(temp_dir, "effect.ts"), `console.log("effect"); export type T = number;`); + writeFileSync(join(temp_dir, "a.ts"), `import { type T } from "./effect";\nconsole.log("a");` + filler); + // `preserveValueImports` keeps no statement that binds no value. `verbatimModuleSyntax` does. + writeFileSync(join(temp_dir, "tsconfig.json"), JSON.stringify({ compilerOptions: { preserveValueImports: true } })); + expect(await bunRun(join(temp_dir, "a.ts"), env)).toSpawn("a"); + expect(newCacheCount()).toBe(1); + writeFileSync(join(temp_dir, "tsconfig.json"), JSON.stringify({ compilerOptions: { verbatimModuleSyntax: true } })); + expect(await bunRun(join(temp_dir, "a.ts"), env)).toSpawn("effect\na"); + }); + // `bun test` loads `bun:test` for an import of `vitest`, and `bun run` loads the package. Under + // `verbatimModuleSyntax` a type-only import is such an import, so `bun test` caches no output for it. + test("bun test caches no output for a kept type-only import of vitest", async () => { + const filler = "\n//" + Buffer.alloc(5 * 1024, "f").toString(); + mkdirSync(join(temp_dir, "node_modules", "vitest"), { recursive: true }); + writeFileSync( + join(temp_dir, "node_modules", "vitest", "package.json"), + JSON.stringify({ name: "vitest", main: "index.js" }), + ); + writeFileSync(join(temp_dir, "node_modules", "vitest", "index.js"), `console.log("the vitest package");`); + writeFileSync(join(temp_dir, "tsconfig.json"), JSON.stringify({ compilerOptions: { verbatimModuleSyntax: true } })); + writeFileSync( + join(temp_dir, "helper.ts"), + `import { type Mock } from "vitest";\nexport type M = Mock;\nconsole.log("helper");` + filler, + ); + writeFileSync(join(temp_dir, "helper.test.ts"), `import "./helper";\ntest("runs", () => {});`); + for (const [command, loaded] of [ + ["test", false], + ["run", true], + ] as const) { + await using proc = Bun.spawn({ + cmd: command === "test" ? [bunExe(), "test", "./helper.test.ts"] : [bunExe(), "helper.ts"], + cwd: temp_dir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect({ command, loaded: stdout.includes("the vitest package"), helper: stdout.includes("helper") }).toEqual({ + command, + loaded, + helper: true, + }); + expect(exitCode).toBe(0); + } + }); test("it is indeed content addressable", async () => { writeFileSync(join(temp_dir, "a.js"), dummyFile(50 * 1024, "1", "b")); expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("b"); From 76cc414cb3a09c40c8daef444a16df38f0315081 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:20:52 +0000 Subject: [PATCH 6/9] docs: what a kept import needs, which tsconfig.json applies, how to turn it off - typescript.mdx: the module of a kept import must exist and runs at the place of the statement, a browser bundle includes it, and `bun lint --fix` rewrites `import { type T }` to `import type`. The option comes from the tsconfig.json nearest to the directory that a file is loaded from. The way to turn it off is the tsconfig.json value. `--tsconfig-override` replaces every tsconfig.json, and the dev server does not read it. - The two older settings are unknown options for `bun check`. - quickstart.mdx no longer adds the option to an existing project. - Three lint pages say when `import {} from` and `import { type T }` load the module. --- docs/guides/runtime/typescript.mdx | 4 ++ .../rules/import/no-empty-named-blocks.mdx | 2 +- .../typescript/no-restricted-imports.mdx | 2 +- .../unicorn/require-module-specifiers.mdx | 2 +- docs/quickstart.mdx | 1 - docs/typescript-6.mdx | 2 + docs/typescript.mdx | 46 ++++++++++++++++--- 7 files changed, 48 insertions(+), 11 deletions(-) diff --git a/docs/guides/runtime/typescript.mdx b/docs/guides/runtime/typescript.mdx index 6f7b932396b2..759bf2d5e4b3 100644 --- a/docs/guides/runtime/typescript.mdx +++ b/docs/guides/runtime/typescript.mdx @@ -49,6 +49,10 @@ Here is the full set of recommended `compilerOptions` for a Bun project. With th --- +`verbatimModuleSyntax` also changes which imports Bun runs. See [`verbatimModuleSyntax`](/typescript#verbatimmodulesyntax). + +--- + If you're using TypeScript 6.0 or later, you also need `"types": ["bun"]` in your `compilerOptions`. See [TypeScript 6 and 7](/typescript-6) for details. --- diff --git a/docs/lint/rules/import/no-empty-named-blocks.mdx b/docs/lint/rules/import/no-empty-named-blocks.mdx index f822f01d6e8d..244eb6ec9d46 100644 --- a/docs/lint/rules/import/no-empty-named-blocks.mdx +++ b/docs/lint/rules/import/no-empty-named-blocks.mdx @@ -12,7 +12,7 @@ typeAware: false deprecated: false --- -Empty braces in an import are usually left over after someone deleted the last name. The import still loads and runs the module, but it doesn't say whether that's on purpose. +Empty braces in an import are usually left over after someone deleted the last name. In a JavaScript file, and in a TypeScript file whose `tsconfig.json` sets `verbatimModuleSyntax`, the import still loads and runs the module, but it doesn't say whether that's on purpose. In other TypeScript files Bun removes the import. `--fix` removes the braces if the import also has a default import. An import that has nothing but `{}` isn't fixed, because deleting it would stop the module from being loaded. It comes with two suggestions instead: delete the import, or keep it as `import "./setup"`. diff --git a/docs/lint/rules/typescript/no-restricted-imports.mdx b/docs/lint/rules/typescript/no-restricted-imports.mdx index e5b911276871..4f33c05e29cd 100644 --- a/docs/lint/rules/typescript/no-restricted-imports.mdx +++ b/docs/lint/rules/typescript/no-restricted-imports.mdx @@ -69,7 +69,7 @@ Each `paths` entry is a module name or an object: | `importNamePattern` | Report only the names that match this regular expression. | | `allowImportNamePattern` | Report every name except those that match this regular expression. | -An `import type` is erased when the file is compiled, so it doesn't load anything. With `{ name: "./server/db", allowTypeImports: true }`, these are allowed: +An `import type` is erased when the file is compiled, so it doesn't load anything. With `verbatimModuleSyntax`, `import { type Order }` is not erased: it still loads the module. With `{ name: "./server/db", allowTypeImports: true }`, these are allowed: {/* good {"options": [{"paths": [{"name": "./server/db", "allowTypeImports": true}]}]} */} ```ts diff --git a/docs/lint/rules/unicorn/require-module-specifiers.mdx b/docs/lint/rules/unicorn/require-module-specifiers.mdx index b3ba221c9f7c..c40bbf024eb3 100644 --- a/docs/lint/rules/unicorn/require-module-specifiers.mdx +++ b/docs/lint/rules/unicorn/require-module-specifiers.mdx @@ -14,7 +14,7 @@ deprecated: false `import {} from "./setup.js"` doesn't import any names. It's usually left over after the last name was removed. If you want the module to run for its side effects, `import "./setup.js"` says so. -`--fix` removes the empty braces after a default import. It removes `export {}` if the file has another `import` or `export`. Without one, that line is what makes the file a module: TypeScript rejects `declare global` in a script, and a script's top-level names are global. So only `--fix-dangerously` removes it there, and in a declaration file. No flag touches `import {} from` and `export {} from`, because they still load the module. +`--fix` removes the empty braces after a default import. It removes `export {}` if the file has another `import` or `export`. Without one, that line is what makes the file a module: TypeScript rejects `declare global` in a script, and a script's top-level names are global. So only `--fix-dangerously` removes it there, and in a declaration file. No flag touches `import {} from` and `export {} from`, because they still load the module in a JavaScript file, and in a TypeScript file whose `tsconfig.json` sets `verbatimModuleSyntax`. {/* Generated by src/codegen/lint-rule-docs.ts. Do not edit this section. */} diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index c1b0b1e3af87..925cd99b76b5 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -99,7 +99,6 @@ Build a minimal HTTP server with `Bun.serve`, run it locally, then evolve it by "moduleDetection": "force", "moduleResolution": "bundler", "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, "noEmit": true } } diff --git a/docs/typescript-6.mdx b/docs/typescript-6.mdx index dfd88a2ce226..2726aa6c2b66 100644 --- a/docs/typescript-6.mdx +++ b/docs/typescript-6.mdx @@ -74,6 +74,8 @@ Here's the full recommended `tsconfig.json` for a Bun project using TypeScript 6 } ``` +`verbatimModuleSyntax` also changes which imports Bun runs. See [`verbatimModuleSyntax`](/typescript#verbatimmodulesyntax). + ## Does this apply to TypeScript 7? Yes. TypeScript 7 carries forward the same default. If you're upgrading directly from TypeScript 5 to 7, the same fix applies — add `"types": ["bun"]` to your `compilerOptions`. diff --git a/docs/typescript.mdx b/docs/typescript.mdx index 10c386635f4f..7b9c3dc3faf2 100644 --- a/docs/typescript.mdx +++ b/docs/typescript.mdx @@ -56,7 +56,7 @@ bun init ## `verbatimModuleSyntax` -With `"verbatimModuleSyntax": true`, Bun keeps every import that has no `type` modifier. The imported module runs even when your code uses none of its names. +With `"verbatimModuleSyntax": true`, Bun keeps every import that has no `type` modifier on the statement. The imported module runs even when your code uses none of its names. ```ts index.ts icon="/icons/typescript.svg" import { unused } from "./register-plugin"; // runs ./register-plugin @@ -64,18 +64,46 @@ import { type Options } from "./options"; // runs ./options import type { Config } from "./config"; // removed ``` -Without the option, Bun removes all three imports, because no value uses them. +Without the option, Bun removes all three imports, because no value uses them. Node.js runs a TypeScript file as if the option is set. - `bun run`, `bun test`, and `bun build` keep the statement and remove the names that nothing uses. `import { unused } from "./register-plugin"` becomes `import "./register-plugin"`, so a type that you import without `type` still loads. -- `bun build --no-bundle` prints the unused names too, and prints `import { type Options } from "./options"` as `import {} from "./options"`. -- Bun reads the option from the `tsconfig.json` nearest to each file. Bun does not apply the option to files in `node_modules` or to declaration files (`.d.ts`). +- `bun build --no-bundle` prints the unused names too, and prints `import { type Options } from "./options"` as `import {} from "./options"`. Each name that it prints must be a real export when the output runs. +- Bun does not apply the option to declaration files (`.d.ts`). - `bun run` does not apply the option to a file that uses `module.exports`. That file runs as CommonJS, which cannot hold an `import` statement. -The module of an import that Bun keeps must exist. `import { type Handler } from "aws-lambda"` fails when only `@types/aws-lambda` is installed. Write `import type` for an import that you need only for its types. [`bun check`](/runtime/check) reports a type that you import without `type`. +### What a kept import needs -The two settings that `verbatimModuleSyntax` replaced still work: `"importsNotUsedAsValues": "preserve"` keeps the statement, and `"preserveValueImports": true` keeps the unused names. +A kept import is a request for its module, at its place in the file. -To run a project without this behavior, point Bun at a `tsconfig.json` that turns the option off. +- The module must exist. `import { type Handler } from "aws-lambda"` fails when only `@types/aws-lambda` is installed. `import { type T } from "./types"` fails when only `types.d.ts` exists. +- The module runs where the statement is. It can run before a later `import "./setup"`, and an import cycle through it can fail with `Cannot access 'X' before initialization`. +- A bundle includes the module. In client code, `import { type AppType } from "./server"` puts `./server` in the browser bundle. +- `bun test --coverage` lists the module. + +Write `import type` for an import that you need only for its types. This command rewrites `import { type Options }` to `import type { Options }`: + +```sh terminal icon="terminal" +bun lint --rule '@typescript-eslint/no-import-type-side-effects: error' --fix +``` + +[`bun check`](/runtime/check) reports a type that you import with no `type` at all. + +### Which `tsconfig.json` applies + +Bun reads the option from the `tsconfig.json` or `jsconfig.json` nearest to the directory that each file is loaded from. + +- Bun follows `extends` when it is a relative path that ends in `.json`. It does not follow `references`. +- With no `tsconfig.json`, the option is off. +- A file in `node_modules`, or in a package that [auto-install](/runtime/auto-install) fetched, takes no option. A workspace package that is linked into `node_modules` is loaded from its own directory, so its `tsconfig.json` applies. +- While auto-install is on, Bun does not keep an unused import of a package name. It would be a request to the registry. + +`bun check` selects the `tsconfig.json` as `tsc` does. It can report `TS1484` for a file that the runtime does not apply the option to. + +### Turn it off + +Set `"verbatimModuleSyntax": false` in the `tsconfig.json` that applies to the files. A config that extends it inherits the value. + +For a project with one `tsconfig.json`, `--tsconfig-override` also works. It replaces the `tsconfig.json` of every directory, with its `paths` and JSX settings, and the dev server of `Bun.serve` does not read it. ```json tsconfig.run.json icon="file-json" { @@ -90,6 +118,10 @@ To run a project without this behavior, point Bun at a `tsconfig.json` that turn bun --tsconfig-override=tsconfig.run.json index.ts ``` +### Older settings + +`"importsNotUsedAsValues": "preserve"` keeps the statement, and `"preserveValueImports": true` keeps the unused names. TypeScript 5.5 removed both settings, and `bun check` reports them as unknown options. The transpiler still reads them. + ## Type checking Bun runs TypeScript files without checking their types. Use [`bun check`](/runtime/check) to type check your project. From 61e5fba0ae57524408e31b8d51698703a985a861 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:25:25 +0000 Subject: [PATCH 7/9] Name tsconfig.json in the runtime error for a kept import of types `bun run` reports `Cannot find package 'x'` for an import that only tsconfig.json keeps. When the importer is a TypeScript file under such a tsconfig.json and only `@types/x` or `x.d.ts` exists, the error has a note that says so and names `import type`. --- src/jsc/VirtualMachine.rs | 56 +++++++++++++++++++ .../transpiler/runtime-transpiler.test.ts | 8 +++ 2 files changed, 64 insertions(+) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 773a4f7934e7..16bbe0868ce4 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5673,6 +5673,18 @@ impl VirtualMachine { } notes.push(logged.data.clone()); } + if mode.is_esm() + && jsc_vm.is_import_of_types_that_tsconfig_keeps( + specifier_utf8.slice(), + normalize_source(source_utf8.slice()), + ) + { + notes.push(bun_ast::range_data( + None, + bun_ast::Range::NONE, + "Only the types of this module exist. tsconfig.json keeps an import that no value uses (\"verbatimModuleSyntax\"). Write \"import type\" to drop it.", + )); + } msg.notes = notes.into_boxed_slice(); return Ok(Err(crate::ResolveMessage::create( global, @@ -5687,6 +5699,50 @@ impl VirtualMachine { Ok(Ok(bun_core::String::clone_utf8(result.path))) } + /// A failed import that tsconfig.json keeps in a TypeScript file, of a module that has only `x.d.ts` or `@types/x`. + #[cold] + fn is_import_of_types_that_tsconfig_keeps(&mut self, specifier: &[u8], source: &[u8]) -> bool { + use bun_js_parser::options::TSUnusedImportFlags; + + let source_dir = bun_paths::dirname(source).unwrap_or(b""); + if !bun_paths::is_absolute(source_dir) + || ![&b".ts"[..], b".tsx", b".mts", b".cts"] + .iter() + .any(|extension| source.ends_with(extension)) + { + return false; + } + let resolver = &mut self.transpiler.resolver; + let Ok(Some(dir_info)) = resolver.read_dir_info(source_dir) else { + return false; + }; + if resolver.unused_import_flags_ts(&dir_info) == TSUnusedImportFlags::empty() { + return false; + } + let mut types: Vec = Vec::new(); + if bun_paths::is_package_path(specifier) { + // `@types/scope__name` holds the types of `@scope/name`. + let mut segments = bun_core::strings::split(specifier, b"/"); + let first = segments.next().unwrap_or(b""); + types.extend_from_slice(b"@types/"); + match (first.strip_prefix(b"@"), segments.next()) { + (Some(scope), Some(name)) => { + types.extend_from_slice(scope); + types.extend_from_slice(b"__"); + types.extend_from_slice(name); + } + _ => types.extend_from_slice(first), + } + types.extend_from_slice(b"/package.json"); + } else { + types.extend_from_slice(specifier); + types.extend_from_slice(b".d.ts"); + } + resolver + .resolve(source_dir, &types, bun_ast::ImportKind::Stmt) + .is_ok() + } + /// Worker-thread teardown. pub fn destroy(&mut self) { self.regular_event_loop.deinit(); diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 2ca967d4158c..542b38088ece 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -1092,6 +1092,9 @@ describe("tsconfig unused imports", () => { }); const ran = await run(String(dir), ["main.ts"]); expect(ran.stderr).toContain(`Cannot find ${specifier.startsWith(".") ? "module" : "package"} '${specifier}'`); + expect(ran.stderr).toContain( + `note: Only the types of this module exist. tsconfig.json keeps an import that no value uses ("verbatimModuleSyntax"). Write "import type" to drop it.`, + ); expect(ran.exitCode).toBe(1); const build = await run(String(dir), ["build", "--target=bun", "--outfile=out.js", "main.ts"]); expect(build.stderr).toContain(`error: Could not resolve: "${specifier}"\n`); @@ -1114,8 +1117,13 @@ describe("tsconfig unused imports", () => { test.concurrent("an import that a value uses keeps the hint of bun build", async () => { using dir = tempDir("ts-unused-imports-missing-used", { "tsconfig.json": verbatim, + // A node_modules directory turns auto-install off. + "node_modules/.keep": "", "main.ts": `import { handler } from "not-installed";\nconsole.log(handler);`, }); + const ran = await run(String(dir), ["main.ts"]); + expect(ran.stderr).toContain("Cannot find package 'not-installed'"); + expect(ran.stderr).not.toContain("note:"); const build = await run(String(dir), ["build", "--target=bun", "--outfile=out.js", "main.ts"]); expect(build.stderr).toContain(`Could not resolve: "not-installed". Maybe you need to "bun install"?`); expect(build.stderr).not.toContain("note:"); From 0cf7e64603c57d8799a75f22ade242e4fa7102b2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:03:55 +0000 Subject: [PATCH 8/9] test: keep the auto-install cases beside the other tsconfig cases They ran in test/cli/run/run-autoinstall.test.ts, a file of 300 tests. They now run in test/bundler/transpiler/runtime-transpiler.test.ts with the rest of the matrix. --- .../transpiler/runtime-transpiler.test.ts | 77 ++++++++++++++++++- test/cli/run/run-autoinstall.test.ts | 72 ----------------- 2 files changed, 75 insertions(+), 74 deletions(-) diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 542b38088ece..b35d0e04b3ac 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -697,15 +697,16 @@ describe("tsconfig unused imports", () => { const source = (name: string) => "import { unused } from " + JSON.stringify(path.join(here, "effect-" + name + ".ts")) + '; console.log("' + name + '");'; const dependency = path.join(here, "node_modules", "dep", "index.ts"); + const nowhere = path.join(here, "no-such-directory", "memory.ts"); const build = await Bun.build({ entrypoints: [path.join(here, "entry.ts")], target: "bun", files: { [path.join(here, "entry.ts")]: - 'import "./disk.ts"; import "./memory.ts"; import "/nowhere/memory.ts"; import ' + JSON.stringify(dependency) + ";", + 'import "./disk.ts"; import "./memory.ts"; import ' + JSON.stringify(nowhere) + "; import " + JSON.stringify(dependency) + ";", [path.join(here, "disk.ts")]: source("replaced"), [path.join(here, "memory.ts")]: source("memory"), - "/nowhere/memory.ts": source("nowhere"), + [nowhere]: source("nowhere"), [dependency]: source("dep"), }, }); @@ -1049,6 +1050,78 @@ describe("tsconfig unused imports", () => { expect(await run(String(dir), ["out.js"])).toEqual({ stdout: "main\n", stderr: "", exitCode: 0 }); }); + // Auto-install answers an import of a bare name from the registry. An import that only + // tsconfig.json keeps is not such a request: `import { type Handler } from "aws-lambda"` names + // the types of `@types/aws-lambda`, and the package `aws-lambda` is another thing. + describe("auto-install", () => { + async function registryOf(name: string, files: Record) { + const tarball = await new Bun.Archive( + Object.fromEntries(Object.entries(files).map(([file, text]) => [`package/${file}`, text])), + { compress: "gzip" }, + ).bytes(); + const requests: string[] = []; + const server = Bun.serve({ + port: 0, + fetch(req) { + const { origin, pathname } = new URL(req.url); + requests.push(pathname); + if (pathname === `/${name}-1.0.0.tgz`) return new Response(tarball); + if (pathname !== `/${name}`) return new Response("not found", { status: 404 }); + return Response.json({ + name, + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { name, version: "1.0.0", dist: { tarball: `${origin}/${name}-1.0.0.tgz` } } }, + }); + }, + }); + return { + requests, + bunfig: `[install]\nregistry = "http://127.0.0.1:${server.port}/"\n`, + [Symbol.dispose]() { + server.stop(true); + }, + }; + } + const autoInstall = (dir: string, entry: string) => + run(dir, [entry], { ...bunEnv, BUN_INSTALL_CACHE_DIR: path.join(dir, ".bun-cache") }); + + test.concurrent.each([ + ["a type-only clause", `import { type Handler } from "pkg";\nexport const handler: Handler = () => {};`], + ["a name that no value uses", `import { Handler } from "pkg";\nexport const handler: Handler = () => {};`], + ["an empty clause", `import {} from "pkg";`], + ["a type-only re-export", `export { type Handler } from "pkg";`], + ])("%s makes no request to the registry", async (_, statement) => { + using registry = await registryOf("pkg", { + "package.json": JSON.stringify({ name: "pkg", version: "1.0.0", main: "index.js" }), + "index.js": `console.log("the package ran");`, + }); + using dir = tempDir("ts-unused-imports-auto-install", { + "tsconfig.json": verbatim, + "bunfig.toml": registry.bunfig, + "entry.ts": `${statement}\nconsole.log("entry");\n`, + }); + expect(await autoInstall(String(dir), "entry.ts")).toEqual({ stdout: "entry\n", stderr: "", exitCode: 0 }); + expect(registry.requests).toEqual([]); + }); + + // The project that installs a package did not write its files, and the tsconfig.json in its + // tarball is not the project's either. + test.concurrent("a package in the cache takes no setting from the tsconfig.json of its tarball", async () => { + using registry = await registryOf("pkg", { + "package.json": JSON.stringify({ name: "pkg", version: "1.0.0", type: "module", module: "index.ts" }), + "tsconfig.json": verbatim, + "index.ts": `import { unused } from "./register.ts";\nexport const value: string = "value";`, + "register.ts": `console.log("register ran");\nexport const unused = 1;`, + }); + using dir = tempDir("ts-unused-imports-cached-package", { + "bunfig.toml": registry.bunfig, + "entry.ts": `import { value } from "pkg";\nconsole.log(value);\n`, + }); + expect(await autoInstall(String(dir), "entry.ts")).toEqual({ stdout: "value\n", stderr: "", exitCode: 0 }); + expect(registry.requests).toEqual(["/pkg", "/pkg-1.0.0.tgz"]); + }); + }); + // A kept import is a request for its module, at its place in the file. These programs run // without the setting. With it they do what the output of tsc does in Node. describe("what a kept import asks for", () => { diff --git a/test/cli/run/run-autoinstall.test.ts b/test/cli/run/run-autoinstall.test.ts index 61954db1b8e4..95ee83d0ac08 100644 --- a/test/cli/run/run-autoinstall.test.ts +++ b/test/cli/run/run-autoinstall.test.ts @@ -2182,78 +2182,6 @@ describe.concurrent("auto-install does not run the event loop inside", () => { }); }); -// tsconfig `verbatimModuleSyntax` keeps an import that no value uses. Auto-install would answer -// such an import of a bare name from the registry: `import { type Handler } from "aws-lambda"` -// names the types of `@types/aws-lambda`, and the package `aws-lambda` is another thing. -describe.concurrent("auto-install and an import that only tsconfig.json keeps", () => { - const verbatim = JSON.stringify({ compilerOptions: { verbatimModuleSyntax: true } }); - - async function registryOf(name: string, files: Record) { - const tarball = await new Bun.Archive( - Object.fromEntries(Object.entries(files).map(([file, text]) => [`package/${file}`, text])), - { compress: "gzip" }, - ).bytes(); - const requests: string[] = []; - const server = Bun.serve({ - port: 0, - fetch(req) { - const { origin, pathname } = new URL(req.url); - requests.push(pathname); - if (pathname === `/${name}-1.0.0.tgz`) return new Response(tarball); - if (pathname !== `/${name}`) return new Response("not found", { status: 404 }); - return Response.json({ - name, - "dist-tags": { latest: "1.0.0" }, - versions: { "1.0.0": { name, version: "1.0.0", dist: { tarball: `${origin}/${name}-1.0.0.tgz` } } }, - }); - }, - }); - return { - requests, - bunfig: `[install]\nregistry = "http://127.0.0.1:${server.port}/"\n`, - [Symbol.dispose]() { - server.stop(true); - }, - }; - } - - test.each([ - ["a type-only clause", `import { type Handler } from "pkg";\nexport const handler: Handler = () => {};`], - ["a name that no value uses", `import { Handler } from "pkg";\nexport const handler: Handler = () => {};`], - ["an empty clause", `import {} from "pkg";`], - ["a type-only re-export", `export { type Handler } from "pkg";`], - ])("%s makes no request to the registry", async (_, statement) => { - using registry = await registryOf("pkg", { - "package.json": JSON.stringify({ name: "pkg", version: "1.0.0", main: "index.js" }), - "index.js": `console.log("the package ran");`, - }); - using dir = tempDir("autoinstall-kept-import", { - "tsconfig.json": verbatim, - "bunfig.toml": registry.bunfig, - "entry.ts": `${statement}\nconsole.log("entry");\n`, - }); - expect(await runAutoInstall(String(dir), "entry.ts")).toEqual({ stdout: "entry\n", stderr: "", exitCode: 0 }); - expect(registry.requests).toEqual([]); - }); - - // The project that installs a package did not write its files, and the tsconfig.json in its - // tarball is not the project's either. - test("a package in the cache takes no setting from the tsconfig.json of its tarball", async () => { - using registry = await registryOf("pkg", { - "package.json": JSON.stringify({ name: "pkg", version: "1.0.0", type: "module", module: "index.ts" }), - "tsconfig.json": verbatim, - "index.ts": `import { unused } from "./register.ts";\nexport const value: string = "value";`, - "register.ts": `console.log("register ran");\nexport const unused = 1;`, - }); - using dir = tempDir("autoinstall-cached-tsconfig", { - "bunfig.toml": registry.bunfig, - "entry.ts": `import { value } from "pkg";\nconsole.log(value);\n`, - }); - expect(await runAutoInstall(String(dir), "entry.ts")).toEqual({ stdout: "value\n", stderr: "", exitCode: 0 }); - expect(registry.requests).toEqual(["/pkg", "/pkg-1.0.0.tgz"]); - }); -}); - // A dead proxy, a captive portal, a VPN that has just dropped. The thread is blocked, and the // wait was six times five minutes long. describe.concurrent("auto-install and a registry that accepts the connection and never answers", () => { From 147707b1efb247009a70012020e3a308ee5cdd54 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:29:11 +0000 Subject: [PATCH 9/9] bundler: find node_modules in the key of a `files` entry on Windows The keys of `Bun.build({ files })` have forward slashes on every platform. `Path::is_node_module` looks for the platform separator, so on Windows an entry under node_modules took the tsconfig.json flags of the working directory. The check now looks for `/node_modules/` in the key. --- src/bundler/bundle_v2.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 0ee50a73c293..8caa73328747 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -252,13 +252,18 @@ impl<'a> BundleV2<'a> { .unused_import_flags_ts } - /// A `files` entry is a source that no directory holds, like a plugin's. + /// A `files` entry is a source that no directory holds, like a plugin's. Its key has forward slashes. fn set_file_map_result_flags( &mut self, target: options::Target, result: &mut bun_resolver::Result, ) { - let flags = self.root_unused_import_flags_ts(target, &result.path_pair.primary); + let path = &result.path_pair.primary; + let flags = if bun_core::strings::contains(path.text, b"/node_modules/") { + options::TSUnusedImportFlags::empty() + } else { + self.root_unused_import_flags_ts(target, path) + }; result.flags.set_unused_import_flags_ts(flags); }