diff --git a/src/ast/loader.rs b/src/ast/loader.rs index 2bd07b85a1d7..a7fc509efb53 100644 --- a/src/ast/loader.rs +++ b/src/ast/loader.rs @@ -100,6 +100,13 @@ bun_core::comptime_string_map! { }; } +/// tsc's `isDeclarationFileName`, minus the rare `.d.*.ts` form. +pub fn is_type_script_declaration_file(path: &[u8]) -> bool { + bun_core::strings::has_suffix_comptime(path, b".d.ts") + || bun_core::strings::has_suffix_comptime(path, b".d.mts") + || bun_core::strings::has_suffix_comptime(path, b".d.cts") +} + impl Loader { #[inline] pub fn is_css(self) -> bool { diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index e0ccd7659e32..281a1ddf12bc 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2395,6 +2395,8 @@ pub mod parse_worker { loader, ); opts.bundle = true; + opts.typescript_declaration_file = loader.is_typescript() + && bun_ast::loader::is_type_script_declaration_file(source.path.text); opts.warn_about_unbundled_modules = false; // `AllowUnresolved` is the same nominal type on // both sides (re-export in options.rs). `'static` erasure: `topts` borrows diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 648bb40bcf38..8fe7e8a66bf9 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2194,12 +2194,29 @@ pub mod bv2_impl { let mut had_busted_dir_cache = false; let resolve_result: _resolver::Result = loop { - // SAFETY: see `transpiler` note above. - match unsafe { &mut *transpiler }.resolver.resolve( - source_dir, - &import_record.specifier, - import_record.kind, - ) { + // Per-resolve flag; see + // `Resolver::importer_is_type_script_declaration_file`. + let resolved = { + // SAFETY: see `transpiler` note above. + unsafe { &mut *transpiler } + .resolver + .importer_is_type_script_declaration_file = + bun_ast::loader::is_type_script_declaration_file( + &import_record.source_file, + ); + // SAFETY: see `transpiler` note above. + let r = unsafe { &mut *transpiler }.resolver.resolve( + source_dir, + &import_record.specifier, + import_record.kind, + ); + // SAFETY: see `transpiler` note above. + unsafe { &mut *transpiler } + .resolver + .importer_is_type_script_declaration_file = false; + r + }; + match resolved { Ok(r) => break r, Err(err) => { // Only perform directory busting when hot-reloading is enabled @@ -6063,11 +6080,20 @@ pub mod bv2_impl { let mut had_busted_dir_cache = false; let resolve_result: _resolver::Result = 'inner: loop { - match transpiler.resolver.resolve_with_framework( - source_dir, - import_record.path.text, - import_record.kind, - ) { + // Per-resolve flag; see + // `Resolver::importer_is_type_script_declaration_file`. + let resolved = { + transpiler.resolver.importer_is_type_script_declaration_file = + bun_ast::loader::is_type_script_declaration_file(source.path.text); + let r = transpiler.resolver.resolve_with_framework( + source_dir, + import_record.path.text, + import_record.kind, + ); + transpiler.resolver.importer_is_type_script_declaration_file = false; + r + }; + match resolved { Ok(r) => break r, Err(err) => { // borrowck — `log_for_resolution_failures` returns diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 0470a8e38238..d34d5ee9f000 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -713,6 +713,10 @@ pub struct ESMConditions { pub(crate) import: ConditionsMap, pub(crate) require: ConditionsMap, pub(crate) style: ConditionsMap, + /// `import` / `require` plus the "types" condition, for declaration-file + /// importers. + pub(crate) import_types: ConditionsMap, + pub(crate) require_types: ConditionsMap, } impl ESMConditions { @@ -763,11 +767,18 @@ impl ESMConditions { require_condition_map.insert(b"default".as_slice(), ()); style_condition_map.insert(b"default".as_slice(), ()); + let mut import_types_condition_map = import_condition_map.clone()?; + import_types_condition_map.insert(b"types".as_slice(), ()); + let mut require_types_condition_map = require_condition_map.clone()?; + require_types_condition_map.insert(b"types".as_slice(), ()); + Ok(ESMConditions { default: default_condition_amp, import: import_condition_map, require: require_condition_map, style: style_condition_map, + import_types: import_types_condition_map, + require_types: require_types_condition_map, }) } @@ -776,12 +787,16 @@ impl ESMConditions { let import = self.import.clone()?; let require = self.require.clone()?; let style = self.style.clone()?; + let import_types = self.import_types.clone()?; + let require_types = self.require_types.clone()?; Ok(ESMConditions { default, import, require, style, + import_types, + require_types, }) } @@ -790,12 +805,16 @@ impl ESMConditions { self.import.reserve(conditions.len()); self.require.reserve(conditions.len()); self.style.reserve(conditions.len()); + self.import_types.reserve(conditions.len()); + self.require_types.reserve(conditions.len()); for condition in conditions { self.default.insert(*condition, ()); self.import.insert(*condition, ()); self.require.insert(*condition, ()); self.style.insert(*condition, ()); + self.import_types.insert(*condition, ()); + self.require_types.insert(*condition, ()); } Ok(()) } @@ -1438,6 +1457,8 @@ impl<'a> BundleOptions<'a> { import: bun_core::handle_oom(self.conditions.import.clone()), require: bun_core::handle_oom(self.conditions.require.clone()), style: bun_core::handle_oom(self.conditions.style.clone()), + import_types: bun_core::handle_oom(self.conditions.import_types.clone()), + require_types: bun_core::handle_oom(self.conditions.require_types.clone()), }, tree_shaking: self.tree_shaking, tree_shaking_override: self.tree_shaking_override, @@ -1692,6 +1713,8 @@ impl<'a> BundleOptions<'a> { import: Default::default(), require: Default::default(), style: Default::default(), + import_types: Default::default(), + require_types: Default::default(), }, // filled below tree_shaking: false, tree_shaking_override: None, diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index cd24d1c280bc..4360fbf3e80c 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -1075,6 +1075,8 @@ fn resolver_bundle_options_subset( import: src.conditions.import.clone().expect("oom"), require: src.conditions.require.clone().expect("oom"), style: src.conditions.style.clone().expect("oom"), + import_types: src.conditions.import_types.clone().expect("oom"), + require_types: src.conditions.require_types.clone().expect("oom"), }, external: src.external.clone(), extra_cjs_extensions: src.extra_cjs_extensions.clone(), @@ -1536,6 +1538,8 @@ impl<'a> Transpiler<'a> { use js_ast::parser::options as p_opts; let mut opts = js_ast::ParserOptions::<'_> { ts: loader.is_typescript(), + typescript_declaration_file: loader.is_typescript() + && bun_ast::loader::is_type_script_declaration_file(source.path.text), jsx: to_parser_jsx_pragma(jsx), keep_names: true, ignore_dce_annotations: self.options.ignore_dce_annotations, diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index ae8c8539a8db..137a567432bb 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -345,6 +345,21 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) is_exported_inside_namespace: RefRefMap, pub(crate) local_type_names: StringBoolMap, + /// Declaration-file mode: module-scope type-only declaration names, in + /// source order, to synthesize `var` bindings for at the end of the parse + /// pass. The map value is whether the declaration was exported. + pub(crate) dts_type_names: StringBoolMap, + pub(crate) dts_type_name_order: Vec<&'a [u8]>, + /// True inside a `declare global { ... }` body, which shares the + /// module-scope parse options but must not synthesize bindings. + pub(crate) dts_suppress_type_name_recording: bool, + /// A type-only `export default` was elided; the end of the parse pass + /// synthesizes one default export unless a real one exists. + pub(crate) dts_needs_default_export_stub: bool, + /// Aliases exported by module-scope export clauses; synthesized bindings + /// for these names stay non-exported so the clause is the sole export. + pub(crate) dts_export_clause_aliases: StringBoolMap, + // This is the reference to the generated function argument for the namespace, // which is different than the reference to the namespace itself: // @@ -4529,6 +4544,177 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ref_ } + /// Declaration-file mode: queue a type-only declaration's name for + /// `synthesize_declaration_file_bindings`. + pub(crate) fn record_declaration_file_type_name( + &mut self, + name: &'a [u8], + is_export: bool, + ) -> Result<(), crate::Error> { + if !Self::IS_TYPESCRIPT_ENABLED + || !self.options.typescript_declaration_file + || self.dts_suppress_type_name_recording + || !js_lexer::is_identifier(name) + { + return Ok(()); + } + match self.dts_type_names.get(name) { + Some(&prev_is_export) => { + if is_export && !prev_is_export { + self.dts_type_names.put(name, true)?; + } + } + None => { + self.dts_type_names.put(name, is_export)?; + self.dts_type_name_order.push(name); + } + } + Ok(()) + } + + /// Declaration-file mode: synthesize `var` bindings (undefined) for the + /// recorded type-only declarations so re-export chains link. Names with a + /// real binding (declaration merging, imports) are skipped. + pub(crate) fn synthesize_declaration_file_bindings( + &mut self, + stmts: &mut BumpVec<'a, Stmt>, + ) -> Result<(), crate::Error> { + // Clause exports defer to real exported declarations: a redundant + // `export type { Foo }` next to a real `export class Foo {}` must not + // duplicate the exported name. + let mut real_export_names = StringBoolMap::default(); + for stmt in stmts.iter() { + match &stmt.data { + js_ast::StmtData::SClass(class) if class.is_export => { + if let Some(name) = class.class.class_name { + real_export_names.put(self.load_name_from_ref(name.ref_), true)?; + } + } + js_ast::StmtData::SFunction(func) + if func.func.flags.contains(js_ast::flags::Function::IsExport) => + { + if let Some(name) = func.func.name { + real_export_names.put(self.load_name_from_ref(name.ref_), true)?; + } + } + js_ast::StmtData::SEnum(data) if data.is_export => { + real_export_names.put(self.load_name_from_ref(data.name.ref_), true)?; + } + js_ast::StmtData::SNamespace(data) if data.is_export => { + real_export_names.put(self.load_name_from_ref(data.name.ref_), true)?; + } + js_ast::StmtData::SLocal(local) if local.is_export => { + for decl in local.decls.slice() { + if let js_ast::b::B::BIdentifier(bind) = &decl.binding.data { + real_export_names.put(self.load_name_from_ref(bind.r#ref), true)?; + } + } + } + _ => {} + } + } + if real_export_names.count() > 0 { + for stmt in stmts.iter_mut() { + match &mut stmt.data { + js_ast::StmtData::SExportClause(data) => { + let items = data.items.slice_mut(); + let len = items.len(); + let mut end = 0usize; + for i in 0..len { + if !real_export_names.contains_key(items[i].alias.slice()) { + items.swap(end, i); + end += 1; + } + } + data.items.truncate(end); + } + js_ast::StmtData::SExportFrom(data) => { + let items = data.items.slice_mut(); + let len = items.len(); + let mut end = 0usize; + for i in 0..len { + if !real_export_names.contains_key(items[i].alias.slice()) { + items.swap(end, i); + end += 1; + } + } + data.items.truncate(end); + } + _ => {} + } + } + } + + // A type-only default export produces a stub only when nothing else + // exports "default": a real default export (class/interface + // declaration merging) or a clause alias (`export { X as default }`, + // `export { default } from`) wins. + if self.dts_needs_default_export_stub + && !self + .dts_export_clause_aliases + .contains_key(b"default".as_slice()) + && !stmts + .iter() + .any(|s| matches!(s.data, js_ast::StmtData::SExportDefault(_))) + { + self.has_es_module_syntax = true; + let default_name = self.create_default_name(bun_ast::Loc::EMPTY); + let value = js_ast::StmtOrExpr::Expr( + self.new_expr(js_ast::E::Undefined {}, bun_ast::Loc::EMPTY), + ); + let stmt = self.s( + S::ExportDefault { + default_name, + value, + }, + bun_ast::Loc::EMPTY, + ); + stmts.push(stmt); + } + if self.dts_type_name_order.is_empty() { + return Ok(()); + } + debug_assert!(self.current_scope == self.module_scope); + let names = core::mem::take(&mut self.dts_type_name_order); + let mut exported_decls: Vec = Vec::new(); + let mut local_decls: Vec = Vec::new(); + for name in names { + if self.current_scope().members.contains_key(name) { + continue; + } + let is_export = self.dts_type_names.get(name).copied().unwrap_or(false) + && !self.dts_export_clause_aliases.contains_key(name); + let ref_ = + self.declare_symbol(js_ast::symbol::Kind::Hoisted, bun_ast::Loc::EMPTY, name)?; + let decl = G::Decl { + binding: self.b(B::Identifier { r#ref: ref_ }, bun_ast::Loc::EMPTY), + value: None, + }; + if is_export { + exported_decls.push(decl); + } else { + local_decls.push(decl); + } + } + for (decls, is_export) in [(exported_decls, true), (local_decls, false)] { + if decls.is_empty() { + continue; + } + let decls = js_ast::g::DeclList::from_slice(&decls); + let stmt = self.s( + S::Local { + kind: js_ast::s::Kind::KVar, + decls, + is_export, + ..Default::default() + }, + bun_ast::Loc::EMPTY, + ); + stmts.push(stmt); + } + Ok(()) + } + pub(crate) fn declare_symbol( &mut self, kind: js_ast::symbol::Kind, @@ -8789,6 +8975,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O emitted_namespace_vars: RefMap::default(), is_exported_inside_namespace: Default::default(), local_type_names: StringBoolMap::default(), + dts_type_names: StringBoolMap::default(), + dts_type_name_order: Vec::new(), + dts_suppress_type_name_recording: false, + dts_needs_default_export_stub: false, + dts_export_clause_aliases: StringBoolMap::default(), enclosing_namespace_arg_ref: None, jsx_imports: crate::JSXImportSymbols::default(), react_refresh: ReactRefresh::default(), diff --git a/src/js_parser/parse/mod.rs b/src/js_parser/parse/mod.rs index 343377682d6e..d80e5a295e6a 100644 --- a/src/js_parser/parse/mod.rs +++ b/src/js_parser/parse/mod.rs @@ -652,6 +652,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ) -> Result { let p = self; let mut name: Option = None; + let mut name_text: &'a [u8] = b""; let class_keyword = p.lexer.range(); if p.lexer.token == T::TClass { //marksyntaxfeature @@ -667,7 +668,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O && (!Self::IS_TYPESCRIPT_ENABLED || p.lexer.identifier != b"implements")) { let name_loc = p.lexer.loc(); - let name_text = p.lexer.identifier; + name_text = p.lexer.identifier; p.lexer.expect(T::TIdentifier)?; // We must return here @@ -727,6 +728,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.has_non_local_export_declare_inside_namespace = true; } + if opts.is_module_scope && !name_text.is_empty() { + p.record_declaration_file_type_name(name_text, opts.is_export)?; + } + return Ok(p.s(S::TypeScript {}, loc)); } } diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index a2c38296078b..9a3fe5277da1 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -66,6 +66,9 @@ pub struct Parser<'a> { pub struct Options<'a> { pub jsx: options::JSX::Pragma, pub ts: bool, + /// `.d.ts`/`.d.mts`/`.d.cts`: type-only exports synthesize undefined + /// `var` bindings so re-export chains through declaration files link. + pub typescript_declaration_file: bool, pub keep_names: bool, pub ignore_dce_annotations: bool, pub preserve_unused_imports_ts: bool, @@ -114,6 +117,7 @@ impl<'a> Default for Options<'a> { Options { jsx: options::JSX::Pragma::default(), ts: false, + typescript_declaration_file: false, keep_names: true, ignore_dce_annotations: false, preserve_unused_imports_ts: false, @@ -162,6 +166,7 @@ impl<'a> Options<'a> { Options { jsx: self.jsx.clone(), ts: self.ts, + typescript_declaration_file: self.typescript_declaration_file, keep_names: self.keep_names, ignore_dce_annotations: self.ignore_dce_annotations, preserve_unused_imports_ts: self.preserve_unused_imports_ts, @@ -242,6 +247,10 @@ impl<'a> Options<'a> { hasher.update(b"NO_TS"); } + if self.typescript_declaration_file { + hasher.update(b"DTS"); + } + if self.ignore_dce_annotations { hasher.update(b"no_dce"); } @@ -260,6 +269,7 @@ impl<'a> Options<'a> { // (see field comment); caller overwrites before use. let mut opts = Options { ts: loader.is_typescript(), + typescript_declaration_file: false, jsx, keep_names: true, ignore_dce_annotations: false, @@ -705,7 +715,12 @@ impl<'a> Parser<'a> { // June 4: "Parsing took: 18028000" // June 4: "Rest of this took: 8003000" let stmts: &'a mut [Stmt] = match p.parse_stmts_up_to(js_lexer::T::TEndOfFile, &mut opts) { - Ok(s) => s.into_bump_slice_mut(), + Ok(mut s) => { + if TS && p.options.typescript_declaration_file { + p.synthesize_declaration_file_bindings(&mut s)?; + } + s.into_bump_slice_mut() + } Err(e) => { parse_tracer.end(); if e == crate::Error::StackOverflow { diff --git a/src/js_parser/parse/parse_fn.rs b/src/js_parser/parse/parse_fn.rs index 6a58ff9ae02c..dde708de8eea 100644 --- a/src/js_parser/parse/parse_fn.rs +++ b/src/js_parser/parse/parse_fn.rs @@ -121,6 +121,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.has_non_local_export_declare_inside_namespace = true; } + if opts.is_module_scope && !name_text.is_empty() { + p.record_declaration_file_type_name(name_text, opts.is_export)?; + } + return Ok(p.s(S::TypeScript {}, loc)); } } diff --git a/src/js_parser/parse/parse_import_export.rs b/src/js_parser/parse/parse_import_export.rs index 3097a7489fb4..5b14f31abc5f 100644 --- a/src/js_parser/parse/parse_import_export.rs +++ b/src/js_parser/parse/parse_import_export.rs @@ -207,18 +207,64 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // "import { type xx as yy } from 'mod'" // "import { type if as yy } from 'mod'" // "import { type 'xx' as yy } from 'mod'" - let _ = p.parse_clause_alias(b"import")?; - p.lexer.next()?; + if p.options.typescript_declaration_file && !p.dts_suppress_type_name_recording + { + // Declaration files keep type-only specifiers as + // runtime imports. + let inner_alias_loc = p.lexer.loc(); + let inner_alias = p.parse_clause_alias(b"import")?; + let mut name = LocRef { + loc: inner_alias_loc, + ref_: p.store_name_in_ref(inner_alias), + }; + let mut original_name = inner_alias; + p.lexer.next()?; - if p.lexer.is_contextual_keyword(b"as") { + if p.lexer.is_contextual_keyword(b"as") { + p.lexer.next()?; + original_name = p.lexer.identifier; + name = LocRef { + loc: inner_alias_loc, + ref_: p.store_name_in_ref(original_name), + }; + p.lexer.expect(T::TIdentifier)?; + } else if !is_identifier_inner { + // An import where the name is a keyword must have an alias + p.lexer.expected_string(b"\"as\"")?; + } + + if is_eval_or_arguments(original_name) { + let r = js_lexer::range_of_identifier(p.source, name.loc); + p.log().add_range_error_fmt( + Some(p.source), + r, + format_args!( + "Cannot use \"{}\" as an identifier here", + bstr::BStr::new(original_name) + ), + ); + } + + items.push(ClauseItem { + alias: inner_alias.into(), + alias_loc: inner_alias_loc, + name, + original_name: original_name.into(), + }); + } else { + let _ = p.parse_clause_alias(b"import")?; p.lexer.next()?; - p.lexer.expect(T::TIdentifier)?; - } else if !is_identifier_inner { - // An import where the name is a keyword must have an alias - p.lexer.expected_string(b"\"as\"")?; + if p.lexer.is_contextual_keyword(b"as") { + p.lexer.next()?; + + p.lexer.expect(T::TIdentifier)?; + } else if !is_identifier_inner { + // An import where the name is a keyword must have an alias + p.lexer.expected_string(b"\"as\"")?; + } + had_type_only_imports = true; } - had_type_only_imports = true; } } else { if p.lexer.is_contextual_keyword(b"as") { @@ -384,16 +430,46 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // "export { type default as if } from 'path'" // "export { type xx as 'yy' }" // "export { type 'xx' } from 'mod'" - let _ = p.parse_clause_alias(b"export").unwrap_or(b""); - p.lexer.next()?; - - if p.lexer.is_contextual_keyword(b"as") { + if p.options.typescript_declaration_file + && !p.dts_suppress_type_name_recording + { + // Declaration files keep type-only specifiers as + // runtime exports. + let inner_loc = p.lexer.loc(); + let inner_name = p.parse_clause_alias(b"export")?; + let name = LocRef { + loc: inner_loc, + ref_: p.store_name_in_ref(inner_name), + }; + let mut item_alias = inner_name; + let mut item_alias_loc = inner_loc; p.lexer.next()?; + + if p.lexer.is_contextual_keyword(b"as") { + p.lexer.next()?; + item_alias_loc = p.lexer.loc(); + item_alias = p.parse_clause_alias(b"export")?; + p.lexer.next()?; + } + + items.push(ClauseItem { + alias: item_alias.into(), + alias_loc: item_alias_loc, + name, + original_name: inner_name.into(), + }); + } else { let _ = p.parse_clause_alias(b"export").unwrap_or(b""); p.lexer.next()?; - } - had_type_only_exports = true; + if p.lexer.is_contextual_keyword(b"as") { + p.lexer.next()?; + let _ = p.parse_clause_alias(b"export").unwrap_or(b""); + p.lexer.next()?; + } + + had_type_only_exports = true; + } } } else { if p.lexer.is_contextual_keyword(b"as") { diff --git a/src/js_parser/parse/parse_skip_typescript.rs b/src/js_parser/parse/parse_skip_typescript.rs index 2104c55b1990..8904e1bcfcaf 100644 --- a/src/js_parser/parse/parse_skip_typescript.rs +++ b/src/js_parser/parse/parse_skip_typescript.rs @@ -1334,6 +1334,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if opts.is_module_scope { self.local_type_names.put(name, true)?; + self.record_declaration_file_type_name(name, opts.is_export)?; } let _ = self.skip_type_script_type_parameters( @@ -1356,6 +1357,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if opts.is_module_scope { self.local_type_names.put(name, true)?; + self.record_declaration_file_type_name(name, opts.is_export)?; } let _ = self.skip_type_script_type_parameters( diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index b04fd5e523c2..9e4411670408 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -820,6 +820,220 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O result } + /// Declaration-file mode: `export default ` (for + /// example `export default interface Foo {}` or the tsc emit + /// `export default function f(): void;`) still gets a `default` export, + /// bound to undefined. Synthesized at the end of the parse pass so + /// overloads and declaration merging with a real default export emit + /// exactly one. + fn dts_default_export_stub(p: &mut Self, loc: bun_ast::Loc) -> Result { + p.dts_needs_default_export_stub = true; + Ok(p.s(S::TypeScript {}, loc)) + } + + /// `export * [as ns] from "path"`; split out of `t_export` so + /// declaration-file mode can route `export type * from` here too. + #[inline(never)] + fn t_export_star( + p: &mut Self, + opts: &mut ParseStatementOptions<'a>, + loc: bun_ast::Loc, + ) -> Result { + if !opts.is_module_scope && (!opts.is_namespace_scope || !opts.is_typescript_declare) { + p.lexer.unexpected()?; + return Err(crate::Error::SyntaxError); + } + + p.lexer.next()?; + // Both arms below assign exactly once before any read. + let namespace_ref: Ref; + let mut alias: Option = None; + let path: ParsedPath; + + if p.lexer.is_contextual_keyword(b"as") { + // "export * as ns from 'path'" + p.lexer.next()?; + let name = p.parse_clause_alias(b"export")?; + namespace_ref = p.store_name_in_ref(name); + if Self::IS_TYPESCRIPT_ENABLED + && p.options.typescript_declaration_file + && opts.is_module_scope + && !p.dts_suppress_type_name_recording + { + // The star alias wins over a synthesized export for the same + // name (see `dts_export_clause_aliases`). + p.dts_export_clause_aliases.put(name, true)?; + } + alias = Some(G::ExportStarAlias { + loc: p.lexer.loc(), + original_name: bun_ast::StoreStr::new(name), + }); + p.lexer.next()?; + p.lexer.expect_contextual_keyword(b"from")?; + path = p.parse_path()?; + } else { + // "export * from 'path'" + p.lexer.expect_contextual_keyword(b"from")?; + path = p.parse_path()?; + // Sanitize the basename into an identifier and copy into the arena. + let name: &'a [u8] = { + use std::io::Write as _; + let base = fs::PathName::init(path.text).non_unique_name_string_base(); + let mut buf: Vec = Vec::new(); + write!(&mut buf, "{}", bun_core::fmt::fmt_identifier(base)).expect("unreachable"); + p.arena.alloc_slice_copy(&buf) + }; + namespace_ref = p.store_name_in_ref(name); + } + + let import_record_index = p.add_import_record(ImportKind::Stmt, path.loc, path.text); + + if path.is_macro { + p.log().add_error( + Some(p.source), + path.loc, + b"cannot use macro in export statement", + ); + } else if path.import_tag != ImportRecordTag::None { + p.log().add_error( + Some(p.source), + loc, + b"cannot use export statement with \"type\" attribute", + ); + } + + if Self::TRACK_SYMBOL_USAGE_DURING_PARSE_PASS { + // In the scan pass, we need _some_ way of knowing *not* to mark as unused + p.import_records.items_mut()[import_record_index as usize] + .flags + .insert(ImportRecordFlags::CALLS_RUNTIME_RE_EXPORT_FN); + } + + p.lexer.expect_or_insert_semicolon()?; + p.has_es_module_syntax = true; + Ok(p.s( + S::ExportStar { + namespace_ref, + alias, + import_record_index, + }, + loc, + )) + } + + /// `export { ... } [from "path"]`; split out of `t_export` so + /// declaration-file mode can route `export type { ... }` here too. + #[inline(never)] + fn t_export_clause_stmt( + p: &mut Self, + opts: &mut ParseStatementOptions<'a>, + loc: bun_ast::Loc, + ) -> Result { + if !opts.is_module_scope && (!opts.is_namespace_scope || !opts.is_typescript_declare) { + p.lexer.unexpected()?; + return Err(crate::Error::SyntaxError); + } + + let export_clause = p.parse_export_clause()?; + if Self::IS_TYPESCRIPT_ENABLED + && p.options.typescript_declaration_file + && opts.is_module_scope + && !p.dts_suppress_type_name_recording + { + // A clause export wins over a synthesized one for the same name + // (see `dts_export_clause_aliases`). + for item in export_clause.clauses.iter() { + p.dts_export_clause_aliases.put(item.alias.slice(), true)?; + } + } + if p.lexer.is_contextual_keyword(b"from") { + p.lexer.expect_contextual_keyword(b"from")?; + let parsed_path = p.parse_path()?; + + 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)); + } + } + + if parsed_path.is_macro { + p.log().add_error( + Some(p.source), + loc, + b"export from cannot be used with \"type\": \"macro\"", + ); + } else if parsed_path.import_tag != ImportRecordTag::None { + p.log().add_error( + Some(p.source), + loc, + b"export from cannot be used with \"type\" attribute", + ); + } + + let import_record_index = + p.add_import_record(ImportKind::Stmt, parsed_path.loc, parsed_path.text); + let path_name = fs::PathName::init(parsed_path.text); + let namespace_ref = { + use std::io::Write as _; + let mut buf: Vec = Vec::new(); + write!( + &mut buf, + "import_{}", + bun_core::fmt::fmt_identifier(path_name.non_unique_name_string_base()) + ) + .expect("unreachable"); + p.store_name_in_ref(p.arena.alloc_slice_copy(&buf)) + }; + + if Self::TRACK_SYMBOL_USAGE_DURING_PARSE_PASS { + // In the scan pass, we need _some_ way of knowing *not* to mark as unused + p.import_records.items_mut()[import_record_index as usize] + .flags + .insert(ImportRecordFlags::CALLS_RUNTIME_RE_EXPORT_FN); + } + p.current_scope_mut().is_after_const_local_prefix = true; + p.has_es_module_syntax = true; + return Ok(p.s( + S::ExportFrom { + // SAFETY: sole owner — fresh arena slice from parse_export_clause, + // moved into the AST node here; no other &mut alias exists. + items: export_clause.clauses.into(), + is_single_line: export_clause.is_single_line, + namespace_ref, + import_record_index, + }, + loc, + )); + } + p.lexer.expect_or_insert_semicolon()?; + + if Self::IS_TYPESCRIPT_ENABLED { + // export {type Foo}; + // -> + // 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)); + } + } + p.has_es_module_syntax = true; + Ok(p.s( + S::ExportClause { + // SAFETY: sole owner — fresh arena slice from parse_export_clause, + // moved into the AST node here; no other &mut alias exists. + items: export_clause.clauses.into(), + is_single_line: export_clause.is_single_line, + }, + loc, + )) + } + // ─── heavy bodies still blocked ────────────────────────────────────────── #[inline(never)] fn t_export( @@ -930,6 +1144,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ); return Err(crate::Error::SyntaxError); } + // Declaration files keep `export type { }` / + // `export type * from` as runtime exports. + if p.options.typescript_declaration_file + && opts.is_module_scope + && !p.dts_suppress_type_name_recording + { + match p.lexer.token { + T::TOpenBrace => { + return Self::t_export_clause_stmt(p, opts, loc); + } + T::TAsterisk => { + return Self::t_export_star(p, opts, loc); + } + _ => {} + } + } let mut skipper = ParseStatementOptions { is_module_scope: opts.is_module_scope, is_export: true, @@ -997,6 +1227,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let stmt = p.parse_fn_stmt(loc, &mut stmt_opts, Some(async_range))?; if matches!(stmt.data, js_ast::StmtData::STypeScript(_)) { // This was just a type annotation + if p.options.typescript_declaration_file + && opts.is_module_scope + && !p.dts_suppress_type_name_recording + { + return Self::dts_default_export_stub(p, loc); + } return Ok(stmt); } @@ -1054,6 +1290,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O match &stmt.data { // This was just a type annotation js_ast::StmtData::STypeScript(_) => { + if p.options.typescript_declaration_file + && opts.is_module_scope + && !p.dts_suppress_type_name_recording + { + return Self::dts_default_export_stub(p, loc); + } return Ok(stmt); } @@ -1192,184 +1434,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O loc, )) } - T::TAsterisk => { - if !opts.is_module_scope - && (!opts.is_namespace_scope || !opts.is_typescript_declare) - { - p.lexer.unexpected()?; - return Err(crate::Error::SyntaxError); - } - - p.lexer.next()?; - // Both arms below assign exactly once before any read. - let namespace_ref: Ref; - let mut alias: Option = None; - let path: ParsedPath; - - if p.lexer.is_contextual_keyword(b"as") { - // "export * as ns from 'path'" - p.lexer.next()?; - let name = p.parse_clause_alias(b"export")?; - namespace_ref = p.store_name_in_ref(name); - alias = Some(G::ExportStarAlias { - loc: p.lexer.loc(), - original_name: bun_ast::StoreStr::new(name), - }); - p.lexer.next()?; - p.lexer.expect_contextual_keyword(b"from")?; - path = p.parse_path()?; - } else { - // "export * from 'path'" - p.lexer.expect_contextual_keyword(b"from")?; - path = p.parse_path()?; - // Sanitize the basename into an identifier and copy into the arena. - let name: &'a [u8] = { - use std::io::Write as _; - let base = fs::PathName::init(path.text).non_unique_name_string_base(); - let mut buf: Vec = Vec::new(); - write!(&mut buf, "{}", bun_core::fmt::fmt_identifier(base)) - .expect("unreachable"); - p.arena.alloc_slice_copy(&buf) - }; - namespace_ref = p.store_name_in_ref(name); - } - - let import_record_index = p.add_import_record( - ImportKind::Stmt, - path.loc, - path.text, - // TODO: import assertions - // path.assertions - ); - - if path.is_macro { - p.log().add_error( - Some(p.source), - path.loc, - b"cannot use macro in export statement", - ); - } else if path.import_tag != ImportRecordTag::None { - p.log().add_error( - Some(p.source), - loc, - b"cannot use export statement with \"type\" attribute", - ); - } - - if Self::TRACK_SYMBOL_USAGE_DURING_PARSE_PASS { - // In the scan pass, we need _some_ way of knowing *not* to mark as unused - p.import_records.items_mut()[import_record_index as usize] - .flags - .insert(ImportRecordFlags::CALLS_RUNTIME_RE_EXPORT_FN); - } - - p.lexer.expect_or_insert_semicolon()?; - p.has_es_module_syntax = true; - Ok(p.s( - S::ExportStar { - namespace_ref, - alias, - import_record_index, - }, - loc, - )) - } - T::TOpenBrace => { - if !opts.is_module_scope - && (!opts.is_namespace_scope || !opts.is_typescript_declare) - { - p.lexer.unexpected()?; - return Err(crate::Error::SyntaxError); - } - - let export_clause = p.parse_export_clause()?; - if p.lexer.is_contextual_keyword(b"from") { - p.lexer.expect_contextual_keyword(b"from")?; - let parsed_path = p.parse_path()?; - - 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)); - } - } - - if parsed_path.is_macro { - p.log().add_error( - Some(p.source), - loc, - b"export from cannot be used with \"type\": \"macro\"", - ); - } else if parsed_path.import_tag != ImportRecordTag::None { - p.log().add_error( - Some(p.source), - loc, - b"export from cannot be used with \"type\" attribute", - ); - } - - let import_record_index = - p.add_import_record(ImportKind::Stmt, parsed_path.loc, parsed_path.text); - let path_name = fs::PathName::init(parsed_path.text); - let namespace_ref = { - use std::io::Write as _; - let mut buf: Vec = Vec::new(); - write!( - &mut buf, - "import_{}", - bun_core::fmt::fmt_identifier(path_name.non_unique_name_string_base()) - ) - .expect("unreachable"); - p.store_name_in_ref(p.arena.alloc_slice_copy(&buf)) - }; - - if Self::TRACK_SYMBOL_USAGE_DURING_PARSE_PASS { - // In the scan pass, we need _some_ way of knowing *not* to mark as unused - p.import_records.items_mut()[import_record_index as usize] - .flags - .insert(ImportRecordFlags::CALLS_RUNTIME_RE_EXPORT_FN); - } - p.current_scope_mut().is_after_const_local_prefix = true; - p.has_es_module_syntax = true; - return Ok(p.s( - S::ExportFrom { - // SAFETY: sole owner — fresh arena slice from parse_export_clause, - // moved into the AST node here; no other &mut alias exists. - items: export_clause.clauses.into(), - is_single_line: export_clause.is_single_line, - namespace_ref, - import_record_index, - }, - loc, - )); - } - p.lexer.expect_or_insert_semicolon()?; - - if Self::IS_TYPESCRIPT_ENABLED { - // export {type Foo}; - // -> - // 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)); - } - } - p.has_es_module_syntax = true; - Ok(p.s( - S::ExportClause { - // SAFETY: sole owner — fresh arena slice from parse_export_clause, - // moved into the AST node here; no other &mut alias exists. - items: export_clause.clauses.into(), - is_single_line: export_clause.is_single_line, - }, - loc, - )) - } + T::TAsterisk => Self::t_export_star(p, opts, loc), + T::TOpenBrace => Self::t_export_clause_stmt(p, opts, loc), T::TEquals => { // "export = value;" @@ -1573,6 +1639,20 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ); } else { // "import type foo from 'bar';" + if p.options.typescript_declaration_file + && opts.is_module_scope + && !p.dts_suppress_type_name_recording + { + // Declaration files keep type-only + // imports as runtime imports. + stmt.default_name.as_mut().unwrap().ref_ = + p.store_name_in_ref(default_name); + p.lexer.expect_contextual_keyword(b"from")?; + let path = p.parse_path()?; + p.lexer.expect_or_insert_semicolon()?; + return p + .process_import_statement(stmt, path, loc, false); + } p.lexer.expect_contextual_keyword(b"from")?; let _ = p.parse_path()?; p.lexer.expect_or_insert_semicolon()?; @@ -1584,6 +1664,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // "import type * as foo from 'bar';" p.lexer.next()?; p.lexer.expect_contextual_keyword(b"as")?; + if p.options.typescript_declaration_file + && opts.is_module_scope + && !p.dts_suppress_type_name_recording + { + stmt = S::Import { + namespace_ref: p.store_name_in_ref(p.lexer.identifier), + star_name_loc: p.lexer.loc(), + import_record_index: u32::MAX, + ..Default::default() + }; + p.lexer.expect(T::TIdentifier)?; + p.lexer.expect_contextual_keyword(b"from")?; + let path = p.parse_path()?; + p.lexer.expect_or_insert_semicolon()?; + return p.process_import_statement(stmt, path, loc, false); + } p.lexer.expect(T::TIdentifier)?; p.lexer.expect_contextual_keyword(b"from")?; let _ = p.parse_path()?; @@ -1593,7 +1689,26 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O T::TOpenBrace => { // "import type {foo} from 'bar';" - let _ = p.parse_import_clause()?; + let import_clause = p.parse_import_clause()?; + if p.options.typescript_declaration_file + && opts.is_module_scope + && !p.dts_suppress_type_name_recording + { + stmt = S::Import { + namespace_ref: Ref::NONE, + import_record_index: u32::MAX, + // SAFETY: sole owner — fresh arena slice from + // parse_import_clause, moved into the AST node + // here; no other &mut alias exists. + items: import_clause.items.into(), + is_single_line: import_clause.is_single_line, + ..Default::default() + }; + p.lexer.expect_contextual_keyword(b"from")?; + let path = p.parse_path()?; + p.lexer.expect_or_insert_semicolon()?; + return p.process_import_statement(stmt, path, loc, false); + } p.lexer.expect_contextual_keyword(b"from")?; let _ = p.parse_path()?; p.lexer.expect_or_insert_semicolon()?; @@ -1779,6 +1894,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // "type Foo = any" let mut stmt_opts = ParseStatementOptions { is_module_scope: opts.is_module_scope, + is_export: opts.is_export, ..Default::default() }; p.skip_type_script_type_stmt(&mut stmt_opts)?; @@ -1806,6 +1922,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if !p.lexer.has_newline_before || opts.is_name_optional { let mut stmt_opts = ParseStatementOptions { is_module_scope: opts.is_module_scope, + is_export: opts.is_export, ..Default::default() }; @@ -1873,7 +1990,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.lexer.next()?; p.lexer.expect(T::TOpenBrace)?; let scope_index = p.scopes_in_order.len(); - let _ = p.parse_stmts_up_to(T::TCloseBrace, opts)?; + let old_suppress = p.dts_suppress_type_name_recording; + p.dts_suppress_type_name_recording = true; + let body_result = p.parse_stmts_up_to(T::TCloseBrace, opts); + p.dts_suppress_type_name_recording = old_suppress; + let _ = body_result?; p.lexer.next()?; // The statements inside are dropped, so discard any scopes they // recorded or the visit pass will hit a scope order mismatch. @@ -1963,6 +2084,31 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + // Declaration files record `declare var/let/const` names so + // `synthesize_declaration_file_bindings` emits them alongside + // every other type-only declaration form (one dedup site). + if Self::IS_TYPESCRIPT_ENABLED + && p.options.typescript_declaration_file + && opts.is_module_scope + && !p.dts_suppress_type_name_recording + { + if let js_ast::StmtData::SLocal(local) = &stmt.data { + let mut _decls = bun_alloc::ArenaVec::::with_capacity_in( + local.decls.len_u32() as usize, + p.arena, + ); + for decl in local.decls.slice() { + Self::extract_decls_for_binding(decl.binding, &mut _decls)?; + } + for decl in _decls.iter() { + if let js_ast::b::B::BIdentifier(bind) = &decl.binding.data { + let name = p.load_name_from_ref(bind.r#ref); + p.record_declaration_file_type_name(name, opts.is_export)?; + } + } + } + } + return Ok(Some(p.s(S::TypeScript {}, loc))); } } diff --git a/src/js_parser/parse/parse_typescript.rs b/src/js_parser/parse/parse_typescript.rs index 0be9bef96c78..663639ad7aa1 100644 --- a/src/js_parser/parse/parse_typescript.rs +++ b/src/js_parser/parse/parse_typescript.rs @@ -210,6 +210,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // "namespace foo {}"; let name_loc = p.lexer.loc(); let name_text = p.lexer.identifier; + // `declare module "foo"`: `lexer.identifier` is stale for the + // string-literal form. + let name_is_identifier = p.lexer.token == T::TIdentifier; p.lexer.next()?; // Generate the namespace object @@ -267,7 +270,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O is_typescript_declare: opts.is_typescript_declare, ..ParseStatementOptions::default() }; - stmts = p.parse_stmts_up_to(T::TCloseBrace, &mut _opts)?; + // Ambient bodies are dropped wholesale; declaration-file mode must + // not retain type-only specifiers (and their import records) here. + let old_suppress = p.dts_suppress_type_name_recording; + if opts.is_typescript_declare { + p.dts_suppress_type_name_recording = true; + } + let body = p.parse_stmts_up_to(T::TCloseBrace, &mut _opts); + p.dts_suppress_type_name_recording = old_suppress; + stmts = body?; p.lexer.next()?; } let has_non_local_export_declare_inside_namespace = @@ -398,8 +409,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O || opts.is_typescript_declare { p.pop_and_discard_scope(scope_index); - if opts.is_module_scope { + if opts.is_module_scope && name_is_identifier { p.local_type_names.put(name_text, true)?; + p.record_declaration_file_type_name(name_text, opts.is_export)?; } return Ok(p.s(S::TypeScript {}, loc)); } @@ -548,6 +560,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if opts.is_typescript_declare { // "import type foo = require('bar');" // "import type foo = bar.baz;" + // + // Declaration files synthesize an undefined binding for the name + // (not the require result: the `bar.baz` form would evaluate a + // synthesized undefined) so `export { foo }` clauses still link. + if opts.is_module_scope { + p.record_declaration_file_type_name(default_name, opts.is_export)?; + } return Ok(p.s(S::TypeScript {}, loc)); } @@ -737,6 +756,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.has_non_local_export_declare_inside_namespace = true; } + if opts.is_module_scope { + p.record_declaration_file_type_name(name_text, opts.is_export)?; + } + return Ok(p.s(S::TypeScript {}, loc)); } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 6f69f5b977f4..bc1ce57bfdb4 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4082,6 +4082,11 @@ impl VirtualMachine { top_level_dir }; + // See `Resolver::importer_is_type_script_declaration_file`. + let importer_is_declaration_file = !is_special_source + && is_a_file_path + && bun_ast::loader::is_type_script_declaration_file(source); + // A `loop` // returning the resolver result; `retry_on_not_found` is consumed on // the first miss. @@ -4093,12 +4098,23 @@ impl VirtualMachine { bun_ast::ImportKind::Require }; let global_cache = self.transpiler.resolver.opts.global_cache; - match self.transpiler.resolver.resolve_and_auto_install( - source_to_use, - normalized_specifier, - import_kind, - global_cache, - ) { + // The flag is per-resolve state. + let resolved = { + self.transpiler + .resolver + .importer_is_type_script_declaration_file = importer_is_declaration_file; + let r = self.transpiler.resolver.resolve_and_auto_install( + source_to_use, + normalized_specifier, + import_kind, + global_cache, + ); + self.transpiler + .resolver + .importer_is_type_script_declaration_file = false; + r + }; + match resolved { ResultUnion::Success(r) => break r, ResultUnion::Failure(e) => return Err(e.into()), ResultUnion::Pending(_) | ResultUnion::NotFound => { diff --git a/src/resolver/options.rs b/src/resolver/options.rs index a8815289577b..b0efe2085b4e 100644 --- a/src/resolver/options.rs +++ b/src/resolver/options.rs @@ -52,6 +52,39 @@ pub struct Conditions { pub import: crate::package_json::ConditionsMap, pub require: crate::package_json::ConditionsMap, pub style: crate::package_json::ConditionsMap, + /// `import` / `require` plus the "types" condition, for declaration-file + /// importers (see `Resolver::importer_is_type_script_declaration_file`). + pub import_types: crate::package_json::ConditionsMap, + pub require_types: crate::package_json::ConditionsMap, +} + +impl Conditions { + /// Condition set for an exports/imports-map walk. On the field, not + /// `Resolver`, so call sites can keep `debug_logs` mutably borrowed. + /// CSS `@import` kinds stay the caller's concern: the sites that had a + /// `style` arm keep it inline, the rest never routed them to `style`. + pub fn for_kind( + &self, + importer_is_type_script_declaration_file: bool, + kind: bun_ast::ImportKind, + ) -> &crate::package_json::ConditionsMap { + match kind { + bun_ast::ImportKind::Require | bun_ast::ImportKind::RequireResolve => { + if importer_is_type_script_declaration_file { + &self.require_types + } else { + &self.require + } + } + _ => { + if importer_is_type_script_declaration_file { + &self.import_types + } else { + &self.import + } + } + } + } } /// `Copy` tag selecting one of the extension-order lists owned by diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index b3c577215aee..3b9c069c27f8 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -574,6 +574,12 @@ pub struct Resolver<'a> { /// /// When this is null, it is as if it is set to `&.{ path.dirname(referrer) }`. pub custom_dir_paths: Option<&'a [bun_core::String]>, + + /// Per-resolve state set by callers (same contract as `custom_dir_paths`). + /// Declaration-file importers resolve like tsc: bare specifiers match the + /// "types" exports condition and relative runtime specifiers prefer their + /// declaration-file siblings. + pub importer_is_type_script_declaration_file: bool, } /// RAII guard returned by [`Resolver::scoped_log`]. Restores the previous @@ -649,6 +655,7 @@ impl<'a> Resolver<'a> { // Transient per-resolve scratch (only set for `require(..., {paths})`); // never carried across worker init. custom_dir_paths: None, + importer_is_type_script_declaration_file: false, } } @@ -935,6 +942,7 @@ impl<'a> Resolver<'a> { standalone_module_graph: None, prefer_module_field: true, custom_dir_paths: None, + importer_is_type_script_declaration_file: false, } } @@ -2678,15 +2686,14 @@ impl<'a> Resolver<'a> { { let esm_resolution = ESModule { conditions: match kind { - ast::ImportKind::Require - | ast::ImportKind::RequireResolve => { - &self.opts.conditions.require - } ast::ImportKind::At | ast::ImportKind::AtConditional => { &self.opts.conditions.style } - _ => &self.opts.conditions.import, + _ => self.opts.conditions.for_kind( + self.importer_is_type_script_declaration_file, + kind, + ), }, debug_logs: self.debug_logs.as_mut(), module_type: &mut module_type, @@ -2735,15 +2742,14 @@ impl<'a> Resolver<'a> { if extname == b".js" && esm.subpath.len() > 3 { let esm_resolution = ESModule { conditions: match kind { - ast::ImportKind::Require - | ast::ImportKind::RequireResolve => { - &self.opts.conditions.require - } ast::ImportKind::At | ast::ImportKind::AtConditional => { &self.opts.conditions.style } - _ => &self.opts.conditions.import, + _ => self.opts.conditions.for_kind( + self.importer_is_type_script_declaration_file, + kind, + ), }, debug_logs: self.debug_logs.as_mut(), module_type: &mut module_type, @@ -3176,13 +3182,10 @@ impl<'a> Resolver<'a> { // directory path accidentally being interpreted as URL escapes. { let esm_resolution = ESModule { - conditions: match kind { - ast::ImportKind::Require - | ast::ImportKind::RequireResolve => { - &self.opts.conditions.require - } - _ => &self.opts.conditions.import, - }, + conditions: self.opts.conditions.for_kind( + self.importer_is_type_script_declaration_file, + kind, + ), debug_logs: self.debug_logs.as_mut(), module_type: &mut module_type, } @@ -3215,13 +3218,10 @@ impl<'a> Resolver<'a> { let extname = bun_paths::extension(esm.subpath); if extname == b".js" && esm.subpath.len() > 3 { let esm_resolution = ESModule { - conditions: match kind { - ast::ImportKind::Require - | ast::ImportKind::RequireResolve => { - &self.opts.conditions.require - } - _ => &self.opts.conditions.import, - }, + conditions: self.opts.conditions.for_kind( + self.importer_is_type_script_declaration_file, + kind, + ), debug_logs: self.debug_logs.as_mut(), module_type: &mut module_type, } @@ -4853,12 +4853,10 @@ impl<'a> Resolver<'a> { // the `ESModule` is constructed as a temporary whose // borrow of `self.debug_logs` ends as soon as `resolve_imports` returns. let esm_resolution = ESModule { - conditions: match kind { - ast::ImportKind::Require | ast::ImportKind::RequireResolve => { - &self.opts.conditions.require - } - _ => &self.opts.conditions.import, - }, + conditions: self + .opts + .conditions + .for_kind(self.importer_is_type_script_declaration_file, kind), debug_logs: self.debug_logs.as_mut(), module_type: &mut module_type, } @@ -5602,11 +5600,53 @@ impl<'a> Resolver<'a> { dec_ret!(MatchStatus::NotFound); } + /// tsc resolution inside declaration files: "./foo.mjs" from a ".d.mts" + /// file means "./foo.d.mts". + fn load_declaration_file_sibling( + &mut self, + path: &[u8], + extension_order: options::ExtOrder, + ) -> Option { + let base = bun_paths::basename(path); + let last_dot = strings::last_index_of_char(base, b'.')?; + let ext = &base[last_dot..base.len()]; + let declaration_exts: &[&[u8]] = if ext == b".js" || ext == b".jsx" { + &[b".d.ts", b".d.mts"] + } else if ext == b".mjs" { + &[b".d.mts"] + } else if ext == b".cjs" { + &[b".d.cts"] + } else { + return None; + }; + let stem_len = path.len() - ext.len(); + let mut buf = bun_paths::path_buffer_pool::get(); + for declaration_ext in declaration_exts { + let total_len = stem_len + declaration_ext.len(); + if total_len > buf.len() { + return None; + } + buf[..stem_len].copy_from_slice(&path[..stem_len]); + buf[stem_len..total_len].copy_from_slice(declaration_ext); + // No further recursion: ".ts"/".mts"/".cts" never match above. + if let Some(result) = self.load_as_file(&buf[..total_len], extension_order) { + return Some(result); + } + } + None + } + pub(crate) fn load_as_file( &mut self, path: &[u8], extension_order: options::ExtOrder, ) -> Option { + if self.importer_is_type_script_declaration_file { + if let Some(result) = self.load_declaration_file_sibling(path, extension_order) { + return Some(result); + } + } + // SAFETY: RealFS is the global singleton. Derive provenance from the raw // `*mut FileSystem` field so intervening `unsafe { &mut *self.fs() }` calls in // `load_extension` / `dirname_store.append_slice` don't invalidate `rfs` @@ -5757,31 +5797,31 @@ impl<'a> Resolver<'a> { // replacing it with a TypeScript one; e.g. "./foo.js" can be matched // by "./foo.ts" or "./foo.d.ts" // - // We don't care about ".d.ts" files because we can't do anything with - // those, so we ignore that part of the behavior. - // // See the discussion here for more historical context: // https://github.com/microsoft/TypeScript/issues/4595 if let Some(last_dot) = strings::last_index_of_char(base, b'.') { let ext = &base[last_dot..base.len()]; - // NOTE: the node_modules gate only applies to the `.mjs` arm. - if ext == b".js" - || ext == b".jsx" - || (ext == b".mjs" - && (!FeatureFlags::DISABLE_AUTO_JS_TO_TS_IN_NODE_MODULES - || !strings::path_contains_node_modules_folder(path))) + // NOTE: the node_modules gate only applies to the `.mjs` source arm. + let source_exts: &[&[u8]] = if ext == b".js" || ext == b".jsx" { + &[b".ts", b".tsx", b".mts"] + } else if ext == b".mjs" + && (!FeatureFlags::DISABLE_AUTO_JS_TO_TS_IN_NODE_MODULES + || !strings::path_contains_node_modules_folder(path)) { + &[b".mts"] + } else { + &[] + }; + + // Declaration files get the ".d.ts" part of the tsc behavior + // quoted above via `load_declaration_file_sibling`; other + // importers keep the pre-existing source-only rewrites. + if !source_exts.is_empty() { let segment = &base[0..last_dot]; let tail = &mut bufs!(load_as_file)[path.len() - base.len()..]; tail[..segment.len()].copy_from_slice(segment); - let exts: &[&[u8]] = if ext == b".mjs" { - &[b".mts"] - } else { - &[b".ts", b".tsx", b".mts"] - }; - - for ext_to_replace in exts { + for ext_to_replace in source_exts.iter() { let buffer = &mut tail[0..segment.len() + ext_to_replace.len()]; buffer[segment.len()..].copy_from_slice(ext_to_replace); diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 221b041af7a9..14b75879a65d 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -4979,6 +4979,30 @@ unsafe fn resolve<'a>( ImportKind::Require }; + // See `Resolver::importer_is_type_script_declaration_file`. + let importer_is_declaration_file = !is_special_source + && is_a_file_path + && bun_ast::loader::is_type_script_declaration_file(source); + if importer_is_declaration_file { + // SAFETY: plain bool field; single-entry on the JS thread. + unsafe { + (*vm) + .transpiler + .resolver + .importer_is_type_script_declaration_file = true; + } + } + // `guard` takes the Copy pointer by value; the closure borrows nothing. + let _declaration_file_flag_guard = scopeguard::guard(vm, |vm| { + // SAFETY: see above. + unsafe { + (*vm) + .transpiler + .resolver + .importer_is_type_script_declaration_file = false; + } + }); + // This cache-bust is disabled when the filesystem is not being used to // resolve. let mut retry_on_not_found = bun_paths::is_absolute(source_to_use); diff --git a/test/js/bun/typescript/declaration-files.test.ts b/test/js/bun/typescript/declaration-files.test.ts new file mode 100644 index 000000000000..db2ffc1ce940 --- /dev/null +++ b/test/js/bun/typescript/declaration-files.test.ts @@ -0,0 +1,410 @@ +// TypeScript declaration files (.d.ts / .d.mts / .d.cts) imported at runtime. +// https://github.com/oven-sh/bun/issues/36751 +// +// Declaration files are parsed so type-only declarations synthesize real +// (undefined) bindings, relative runtime specifiers inside them resolve to +// declaration siblings, and bare specifiers match the "types" exports +// condition. A declaration graph therefore loads and links instead of +// throwing "export 'X' not found" / "Cannot find module './x.mjs'". + +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +async function run(dir: unknown, entry: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), entry], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +test.concurrent("type-only re-exports across .d.mts files link (issue repro)", async () => { + using dir = tempDir("dts-36751", { + "EventTypes.d.mts": `export type ValueOf = T[keyof T]; +export const BUEvents = { A: "a" } as const; +`, + "utils.d.mts": `export { ValueOf, BUEvents } from "./EventTypes.d.mts"; +`, + "index.ts": `import { ValueOf, BUEvents } from "./utils.d.mts"; +console.log("OK:", JSON.stringify(BUEvents)); +`, + }); + const { stdout, stderr, exitCode } = await run(dir, "index.ts"); + expect(stderr).toBe(""); + expect(stdout).toBe(`OK: {"A":"a"}\n`); + expect(exitCode).toBe(0); +}); + +test.concurrent("type aliases, interfaces and declare statements become undefined exports", async () => { + using dir = tempDir("dts-synth", { + "api.d.ts": `export type Alias = string; +export declare type DeclaredAlias = number; +export interface Shape { + x: number; +} +export declare const version: string; +export declare function helper(): void; +export declare class Client {} +export declare enum Flags {} +export declare namespace NS { + const x: number; +} +type LocalOnly = number; +declare const hidden: LocalOnly; +export { LocalOnly, hidden }; +export { Alias }; +export type { Shape }; +export { version }; +`, + "index.ts": `import * as api from "./api.d.ts"; +console.log(JSON.stringify(Object.keys(api).sort())); +console.log(JSON.stringify(Object.values(api).map(v => typeof v))); +`, + }); + const { stdout, stderr, exitCode } = await run(dir, "index.ts"); + expect(stderr).toBe(""); + expect(JSON.parse(stdout.split("\n")[0])).toEqual([ + "Alias", + "Client", + "DeclaredAlias", + "Flags", + "LocalOnly", + "NS", + "Shape", + "helper", + "hidden", + "version", + ]); + for (const t of JSON.parse(stdout.split("\n")[1])) { + expect(t).toBe("undefined"); + } + expect(exitCode).toBe(0); +}); + +test.concurrent("export type { } and export type * from are kept in declaration files", async () => { + using dir = tempDir("dts-export-type", { + "types.d.mts": `export type A = number; +export interface B {} +`, + "index.d.mts": `export type { A } from "./types.d.mts"; +export type * from "./types.d.mts"; +export interface Star {} +export * as Star from "./types.d.mts"; +export class Real { + constructor() {} +} +export type { Real }; +`, + "main.ts": `import * as m from "./index.d.mts"; +console.log(JSON.stringify(Object.keys(m).sort()), typeof m.Star, typeof m.Real); +`, + }); + const { stdout, stderr, exitCode } = await run(dir, "main.ts"); + expect(stderr).toBe(""); + const [keys, starType, realType] = stdout.trim().split(" "); + expect(JSON.parse(keys)).toEqual(["A", "B", "Real", "Star"]); + expect(starType).toBe("object"); + expect(realType).toBe("function"); + expect(exitCode).toBe(0); +}); + +test.concurrent("relative .mjs specifiers in declaration files resolve to .d.mts siblings", async () => { + // tsc-style declaration emit: the .d.mts graph references "./helper.mjs" + // which only ships as "helper.d.mts" (type-only module, no runtime file). + using dir = tempDir("dts-sibling", { + "helper.d.mts": `export type Prettify = { [K in keyof T]: T[K] }; +export interface Options { + debug?: boolean; +} +`, + "index.d.mts": `import { Prettify, Options } from "./helper.mjs"; +export { type Prettify, Options }; +`, + "main.ts": `import * as m from "./index.d.mts"; +console.log(JSON.stringify(Object.keys(m).sort())); +`, + }); + const { stdout, stderr, exitCode } = await run(dir, "main.ts"); + expect(stderr).toBe(""); + expect(JSON.parse(stdout.trim())).toEqual(["Options", "Prettify"]); + expect(exitCode).toBe(0); +}); + +test.concurrent("declaration sibling resolution covers .js, .mjs and .cjs specifiers", async () => { + using dir = tempDir("dts-sibling-exts", { + "a.d.ts": `export type AType = 1; +`, + "b.d.mts": `export interface BType {} +`, + "c.d.cts": `export type CType = 2; +`, + "d.d.mts": `export type DType = 4; +`, + "e.d.ts": `export type EType = 5; +`, + // ".js" and ".jsx" map to a ".d.ts" sibling, or fall back to ".d.mts" + // when no ".d.ts" exists; ".mjs" maps to ".d.mts"; ".cjs" maps to ".d.cts". + "wrapper.d.ts": `export { AType } from "./a.js"; +export { BType } from "./b.js"; +export { CType } from "./c.cjs"; +export { DType } from "./d.mjs"; +export { EType } from "./e.jsx"; +`, + "main.ts": `import * as m from "./wrapper.d.ts"; +console.log(JSON.stringify(Object.keys(m).sort())); +`, + }); + const { stdout, stderr, exitCode } = await run(dir, "main.ts"); + expect(stderr).toBe(""); + expect(JSON.parse(stdout.trim())).toEqual(["AType", "BType", "CType", "DType", "EType"]); + expect(exitCode).toBe(0); +}); + +test.concurrent("bare specifiers in declaration files match the types exports condition", async () => { + // The package's runtime entry doesn't export the type name; its "types" + // entry does. Declaration-file importers must pick the "types" entry. + using dir = tempDir("dts-types-condition", { + "node_modules/some-lib/package.json": `{ + "name": "some-lib", + "type": "module", + "exports": { + ".": { + "types": "./index.d.mts", + "default": "./index.mjs" + } + } +}`, + "node_modules/some-lib/index.mjs": `export const runtimeOnly = 1; +`, + "node_modules/some-lib/index.d.mts": `export type LibOptions = { a: number }; +export declare const runtimeOnly: number; +`, + "wrapper.d.mts": `export { LibOptions } from "some-lib"; +`, + "main.ts": `import * as m from "./wrapper.d.mts"; +console.log(JSON.stringify(Object.keys(m))); +`, + }); + const { stdout, stderr, exitCode } = await run(dir, "main.ts"); + expect(stderr).toBe(""); + expect(JSON.parse(stdout.trim())).toEqual(["LibOptions"]); + expect(exitCode).toBe(0); +}); + +test.concurrent("runtime values declared in the imported declaration file itself are preserved", async () => { + using dir = tempDir("dts-values", { + "mixed.d.mts": `export type T = number; +export const value = 42; +`, + "index.ts": `import { value } from "./mixed.d.mts"; +console.log(value); +`, + }); + const { stdout, stderr, exitCode } = await run(dir, "index.ts"); + expect(stderr).toBe(""); + expect(stdout).toBe("42\n"); + expect(exitCode).toBe(0); +}); + +test.concurrent("normal .ts files still elide type-only exports", async () => { + // The declaration-file behavior must not leak into regular TypeScript: + // a type-only export in a .ts file does not produce a runtime binding. + using dir = tempDir("dts-scope", { + "types.ts": `export type OnlyAType = number; +export const real = 1; +`, + "index.ts": `import * as m from "./types.ts"; +console.log(JSON.stringify(Object.keys(m))); +`, + }); + const { stdout, stderr, exitCode } = await run(dir, "index.ts"); + expect(stderr).toBe(""); + expect(stdout).toBe(`["real"]\n`); + expect(exitCode).toBe(0); +}); + +test.concurrent("type-only default exports synthesize a default binding", async () => { + using dir = tempDir("dts-default", { + // tsc's emit for a default-exported function is a body-less declaration, + // one per overload signature; only one default export may be synthesized. + "fn.d.ts": `export default function pLimit(concurrency: number): void; +export default function pLimit(): void; +`, + "iface.d.ts": `export default interface Props { + x: number; +} +`, + "const.d.ts": `declare const _default: { a: number }; +export default _default; +`, + "alias.d.ts": `type Alias = string; +export { Alias as default }; +`, + "klass.d.ts": `export default class Client { + constructor(url: string); +} +`, + // Class/interface declaration merging on the default export keeps the + // real class as the sole default, in either order. + "merge1.d.ts": `export default class Merged { + constructor(); +} +export default interface Merged { + x: number; +} +`, + "merge2.d.ts": `export default interface Merged { + x: number; +} +export default class Merged { + constructor(); +} +`, + // A clause alias for "default" also wins over the stub. + "clause-default.d.ts": `declare const impl: number; +export { impl as default }; +export default interface Props { + x: number; +} +`, + "index.ts": `import fn from "./fn.d.ts"; +import iface from "./iface.d.ts"; +import c from "./const.d.ts"; +import alias from "./alias.d.ts"; +import Klass from "./klass.d.ts"; +import M1 from "./merge1.d.ts"; +import M2 from "./merge2.d.ts"; +import CD from "./clause-default.d.ts"; +console.log(JSON.stringify([typeof fn, typeof iface, typeof c, typeof alias, typeof Klass, typeof M1, typeof M2, typeof CD])); +`, + }); + const { stdout, stderr, exitCode } = await run(dir, "index.ts"); + expect(stderr).toBe(""); + expect(JSON.parse(stdout.trim())).toEqual([ + "undefined", + "undefined", + "undefined", + "undefined", + "function", + "function", + "function", + "undefined", + ]); + expect(exitCode).toBe(0); +}); + +test.concurrent("require() loads .d.cts declaration files", async () => { + using dir = tempDir("dts-cts", { + // DefinitelyTyped-style CommonJS declaration: `export =`. + "lib.d.cts": `interface SomeInterface { + a: number; +} +export = SomeInterface; +`, + // ESM-syntax declarations loaded via require() interop. + "esm-ish.d.cts": `export type Foo = number; +export interface Bar {} +`, + // `import x = require(...)` resolving a .cjs specifier to its .d.cts sibling. + "helper.d.cts": `export type HelperType = string; +`, + "wrapper.d.cts": `import helper = require("./helper.cjs"); +export { HelperType } from "./helper.cjs"; +`, + // `import type x = require(...)` synthesizes an undefined binding so the + // export clause links. + "typeonly.d.cts": `import type h = require("./helper.cjs"); +export { h }; +`, + "index.ts": `const lib = require("./lib.d.cts"); +const esm = require("./esm-ish.d.cts"); +const wrapper = require("./wrapper.d.cts"); +const typeonly = require("./typeonly.d.cts"); +console.log(JSON.stringify([typeof lib, Object.keys(esm).sort(), Object.keys(wrapper), Object.keys(typeonly)])); +`, + }); + const { stdout, stderr, exitCode } = await run(dir, "index.ts"); + expect(stderr).toBe(""); + const [libType, esmKeys, wrapperKeys, typeonlyKeys] = JSON.parse(stdout.trim()); + expect(libType).toBe("undefined"); + expect(esmKeys).toEqual(["Bar", "Foo"]); + expect(wrapperKeys).toEqual(["HelperType"]); + expect(typeonlyKeys).toEqual(["h"]); + expect(exitCode).toBe(0); +}); + +test.concurrent("require() from a .d.cts picks a package's types export over require", async () => { + // Pins the deliberate semantics: from declaration files, bare specifiers + // resolve to the "types" entry, so type names link and runtime values + // shadow to undefined. + using dir = tempDir("dts-require-types", { + "node_modules/clib/package.json": `{ + "name": "clib", + "exports": { + ".": { + "types": "./index.d.cts", + "require": "./index.cjs" + } + } +}`, + "node_modules/clib/index.cjs": `module.exports = { runtimeOnly: 1 }; +`, + "node_modules/clib/index.d.cts": `export type COpts = { a: number }; +export declare const runtimeOnly: number; +`, + "wrapper.d.cts": `export { COpts, runtimeOnly } from "clib"; +`, + "main.ts": `import * as m from "./wrapper.d.cts"; +console.log(JSON.stringify([Object.keys(m).sort(), m.runtimeOnly === undefined])); +`, + }); + const { stdout, stderr, exitCode } = await run(dir, "main.ts"); + expect(stderr).toBe(""); + expect(JSON.parse(stdout.trim())).toEqual([["COpts", "runtimeOnly"], true]); + expect(exitCode).toBe(0); +}); + +test.concurrent("export type inside a namespace body in a declaration file still parses", async () => { + using dir = tempDir("dts-namespace-scope", { + "ns.d.ts": `export namespace Foo { + export type Inner = number; +} +export namespace Bar { + type X = number; + export type { X }; +} +declare module "some-ambient" { + import type { Options } from "./never-resolved"; + export type { Options }; +} +export const marker = 1; +`, + "index.ts": `import { marker } from "./ns.d.ts"; +console.log(marker); +`, + }); + const { stdout, stderr, exitCode } = await run(dir, "index.ts"); + expect(stderr).toBe(""); + expect(stdout).toBe("1\n"); + expect(exitCode).toBe(0); +}); + +test.concurrent("non-declaration importers do not fall back to declaration siblings", async () => { + // Blast-radius pin: the declaration-sibling fallback applies only when the + // importer is itself a declaration file. A regular .ts importer of a + // missing runtime file keeps the resolution error. + using dir = tempDir("dts-no-global-fallback", { + "only-types.d.mts": `export type T = number; +`, + "index.ts": `import "./only-types.mjs"; +console.log("loaded"); +`, + }); + const { stdout, stderr, exitCode } = await run(dir, "index.ts"); + expect(stderr).toContain("Cannot find module"); + expect(exitCode).not.toBe(0); +});