diff --git a/Cargo.lock b/Cargo.lock index 036baf436302..da37a1299ed9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -899,6 +899,7 @@ dependencies = [ "bun_crash_handler", "bun_io", "bun_options_types", + "bun_paths", "bun_ptr", "bun_sourcemap", "bun_wyhash", diff --git a/src/ast/import_record.rs b/src/ast/import_record.rs index b3be6300b937..92091bc93f2f 100644 --- a/src/ast/import_record.rs +++ b/src/ast/import_record.rs @@ -103,6 +103,10 @@ 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; + + /// An external matched by resolved path: `path.text` is the file's + /// absolute path and prints relative to the output file's directory. + const PRINT_PATH_RELATIVE_TO_OUTPUT = 1 << 28; } } diff --git a/src/bundler/Chunk.rs b/src/bundler/Chunk.rs index 40ea7ba5d266..a08e21a5d00e 100644 --- a/src/bundler/Chunk.rs +++ b/src/bundler/Chunk.rs @@ -61,6 +61,9 @@ pub struct Chunk<'a> { pub(crate) final_rel_path: Box<[u8]>, /// The path template used to generate `final_rel_path` pub(crate) template: PathTemplate, + /// Absolute directory this chunk is written to (outdir + `template.rel_dir()`); + /// `PRINT_PATH_RELATIVE_TO_OUTPUT` import paths print relative to it. + pub(crate) output_dir_abs: Box<[u8]>, /// For code splitting pub(crate) cross_chunk_imports: Vec, @@ -197,6 +200,7 @@ impl Default for Chunk<'_> { entry_bits: AutoBitSet::init_empty(0).expect("static AutoBitSet"), final_rel_path: Box::default(), template: PathTemplate::default(), + output_dir_abs: Box::default(), cross_chunk_imports: Vec::new(), content: Content::default(), entry_point: EntryPoint::default(), diff --git a/src/bundler/Graph.rs b/src/bundler/Graph.rs index 4a944ff156f2..8acf8b7f07c0 100644 --- a/src/bundler/Graph.rs +++ b/src/bundler/Graph.rs @@ -91,6 +91,9 @@ pub struct Graph<'a> { /// /// Helps skip a loop. pub(crate) has_any_secondary_paths: bool, + + /// Some import record has `PRINT_PATH_RELATIVE_TO_OUTPUT`, so chunks need `output_dir_abs`. + pub(crate) has_output_relative_import_paths: bool, } #[derive(Default)] @@ -179,6 +182,7 @@ impl<'a> Graph<'a> { kit_referenced_server_data: false, kit_referenced_client_data: false, has_any_secondary_paths: false, + has_output_relative_import_paths: false, } } } diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 7ed96c3aaa28..14aedd73fb73 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -2396,6 +2396,7 @@ impl<'a> LinkerContext<'a> { runtime_require_ref: Option, source_index: Index, source: &Source, + output_dir_abs: &[u8], module_info: Option<&mut crate::analyze_transpiled_module::ModuleInfo>, ) -> js_printer::PrintResult { let parts_to_print = &[Part { @@ -2491,6 +2492,7 @@ impl<'a> LinkerContext<'a> { }, mangled_props: Some(mangled_props), module_info, + output_dir: output_dir_abs, ..Default::default() }; diff --git a/src/bundler/PathToSourceIndexMap.rs b/src/bundler/PathToSourceIndexMap.rs index f0e42df43810..5e6b55c6f84c 100644 --- a/src/bundler/PathToSourceIndexMap.rs +++ b/src/bundler/PathToSourceIndexMap.rs @@ -1,23 +1,8 @@ +use bun_ast::{ImportRecord, ImportRecordFlags}; use bun_collections::StringHashMap; use crate::IndexStringMap::IndexInt; -/// Abstracts over the two structurally-identical `Path` ports (`bun_paths::fs::Path` -/// and `bun_resolver::fs::Path`) so the bundler can key the map with either while -/// the crates converge. Both expose `.text: &[u8]`, which is all we need. -pub trait PathLike { - fn path_text(&self) -> &[u8]; -} - -// `bun_resolver::fs::Path` is now a re-export of `bun_paths::fs::Path` (D090), -// so a single impl covers both. -impl PathLike for bun_paths::fs::Path<'_> { - #[inline] - fn path_text(&self) -> &[u8] { - self.text - } -} - /// The lifetime of the keys are not owned by this map. /// /// We assume it's arena allocated. @@ -33,8 +18,21 @@ pub type Map = StringHashMap; pub(crate) type GetOrPutResult<'a> = bun_collections::string_hash_map::GetOrPutResult<'a, IndexInt>; impl PathToSourceIndexMap { - pub(crate) fn get_path(&self, path: &impl PathLike) -> Option { - self.get(path.path_text()) + /// Source index by `record`'s path. An external file record never has one, + /// even when its file is also bundled (e.g. as an entry point). + pub(crate) fn get_record(&self, record: &ImportRecord) -> Option { + self.get_record_as(record, record.path.text) + } + + /// `get_record`, looked up under `text` (e.g. a dedup sibling's resolved path). + pub(crate) fn get_record_as(&self, record: &ImportRecord, text: &[u8]) -> Option { + if record + .flags + .contains(ImportRecordFlags::PRINT_PATH_RELATIVE_TO_OUTPUT) + { + return None; + } + self.get(text) } pub(crate) fn get(&self, text: impl AsRef<[u8]>) -> Option { diff --git a/src/bundler/barrel_imports.rs b/src/bundler/barrel_imports.rs index ae8bb4b5be1f..5508cac26086 100644 --- a/src/bundler/barrel_imports.rs +++ b/src/bundler/barrel_imports.rs @@ -323,7 +323,7 @@ fn record_target( if rec.source_index.is_valid() { return Some(rec.source_index.get()); } - map?.get_path(&rec.path) + map?.get_record(rec) } /// The export names the result of `import()` / `require()` record `idx` can @@ -536,7 +536,7 @@ pub(crate) fn schedule_barrel_deferred_imports( let target = if ir.source_index.is_valid() { ir.source_index.get() } else if let Some(map) = path_to_source_index_map { - match map.get().get(resolved_path_text) { + match map.get().get_record_as(ir, resolved_path_text) { Some(t) => t, None => continue, } @@ -629,7 +629,7 @@ pub(crate) fn schedule_barrel_deferred_imports( let ir_target = if ir.source_index.is_valid() { ir.source_index.get() } else if let Some(map) = path_to_source_index_map { - match map.get().get(resolved_path_text) { + match map.get().get_record_as(ir, resolved_path_text) { Some(t) => t, None => continue, } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 460af1e5f06a..f4acbb54e678 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2193,19 +2193,30 @@ pub mod bv2_impl { while let Some(redirect_id) = get_redirect_id(self.redirects[other_source.get() as usize]) { - let (other_src_idx, other_path) = { + let (other_src_idx, other_path, other_flags) = { let other_import_records = self.all_import_records [other_source.get() as usize] .as_slice(); let other_import_record = &other_import_records[redirect_id as usize]; - (other_import_record.source_index, other_import_record.path) + ( + other_import_record.source_index, + other_import_record.path, + other_import_record.flags, + ) }; let import_record = &mut self.all_import_records [import_record_list_id.get() as usize] .as_mut_slice()[ir_idx]; import_record.source_index = other_src_idx; import_record.path = other_path; + // How to print `path` travels with it. + import_record.flags.set( + bun_ast::ImportRecordFlags::PRINT_PATH_RELATIVE_TO_OUTPUT, + other_flags.contains( + bun_ast::ImportRecordFlags::PRINT_PATH_RELATIVE_TO_OUTPUT, + ), + ); other_source = other_src_idx; if redirect_count == Self::MAX_REDIRECTS { import_record.path.is_disabled = true; @@ -2749,6 +2760,16 @@ pub mod bv2_impl { let mut had_busted_dir_cache = false; let resolve_result = loop { + // SAFETY: see `transpiler` note above. + let options = unsafe { &(*transpiler).options }; + if let Some(external) = self.external_file_result( + options, + source_dir, + &import_record.specifier, + import_record.kind, + ) { + break external; + } // SAFETY: see `transpiler` note above. match unsafe { &mut *transpiler }.resolver.resolve( source_dir, @@ -2917,6 +2938,20 @@ pub mod bv2_impl { }; if resolve_result.flags.is_external() { + if let Some(file_path) = self.external_file_path( + &import_record.specifier, + import_record.kind, + &path, + target, + ) { + let record: &mut ImportRecord = &mut self.graph.ast.items_import_records_mut() + [import_record.importer_source_index as usize] + .as_mut_slice()[import_record.import_record_index as usize]; + record.path = file_path; + record + .flags + .insert(bun_ast::ImportRecordFlags::PRINT_PATH_RELATIVE_TO_OUTPUT); + } return; } @@ -4534,6 +4569,8 @@ pub mod bv2_impl { event_loop: EventLoop, entry_points: &[&[u8]], ) -> Result>, Error> { + // A scan follows what a file loads. `ExternalFiles` is about a bundle's output. + transpiler.options.external_files = Default::default(); let mut this = BundleV2::init(transpiler, None, alloc, event_loop, false, None, alloc)?; this.unique_key = generate_unique_key(); @@ -6400,6 +6437,93 @@ pub mod bv2_impl { ) } + /// The resolver's external result for a specifier that `options::ExternalFiles` matches. + fn external_file_result<'r>( + &self, + options: &options::BundleOptions<'_>, + source_dir: &[u8], + specifier: &'r [u8], + kind: ImportKind, + ) -> Option<_resolver::Result<'r>> + where + 'a: 'r, + { + let external_files = &options.external_files; + if external_files.is_empty() { + return None; + } + let text: &'r [u8] = if external_files.matches_specifier(specifier) { + specifier + } else if !external_files.has_path_patterns() + // The resolver keeps a specifier that a wildcard matches as written. + || options::ExternalFiles::any_matches(&options.external.patterns, specifier) + { + return None; + } else if bun_paths::is_absolute(specifier) { + if !external_files.matches_path(specifier) { + return None; + } + specifier + } else if !is_package_path(specifier) || kind.is_from_css() { + // The path as the resolver joins it, before it looks for the file. + let mut spill = Vec::new(); + let joined = + bun_paths::resolve_path::join_abs_string_spill::( + self.transpiler.fs().top_level_dir(), + &mut spill, + &[source_dir, specifier], + ); + if !external_files.matches_path(joined) { + return None; + } + self.arena().alloc_slice_copy(joined) + } else { + return None; + }; + Some(_resolver::Result { + import_kind: kind, + path_pair: _resolver::PathPair { + primary: Fs::Path::init(text), + secondary: None, + }, + flags: _resolver::ResultFlags::IS_EXTERNAL, + ..Default::default() + }) + } + + /// For a relative JS specifier that matched an `external` file by path: the absolute + /// path to store on the record (`PRINT_PATH_RELATIVE_TO_OUTPUT`). `None` otherwise. + fn external_file_path( + &mut self, + specifier: &[u8], + kind: ImportKind, + resolved: &Fs::Path<'_>, + target: options::Target, + ) -> Option> { + if !resolved.is_file() + || !bun_paths::is_absolute(resolved.text) + || bun_paths::is_absolute(specifier) + || kind.is_from_css() + { + return None; + } + self.graph.has_output_relative_import_paths = true; + let mut path = self + .path_with_pretty_initialized(resolved, target) + .expect("oom"); + // "." and "./lib/" name a directory: the printer keeps the separator the join stripped. + if _resolver::Resolver::import_path_names_directory(specifier) + && path.text.last() != Some(&bun_paths::SEP) + { + let text = self + .arena() + .alloc_slice_fill_copy(path.text.len() + 1, bun_paths::SEP); + text[..path.text.len()].copy_from_slice(path.text); + path.text = text; + } + Some(path) + } + fn reserve_source_indexes_for_bake(&mut self) -> Result<(), Error> { let Some(fw) = &self.framework else { return Ok(()); @@ -6882,6 +7006,14 @@ pub mod bv2_impl { let mut had_busted_dir_cache = false; let resolve_result: _resolver::Result<'a> = 'inner: loop { + if let Some(external) = self.external_file_result( + &transpiler.options, + source_dir, + import_record.path.text, + import_record.kind, + ) { + break external; + } match transpiler.resolver.resolve_with_framework( source_dir, import_record.path.text, @@ -7104,6 +7236,16 @@ pub mod bv2_impl { ) { import_record.path = resolve_result.path_pair.primary; + } else if let Some(file_path) = self.external_file_path( + import_record.path.text, + import_record.kind, + path, + target, + ) { + import_record.path = file_path; + import_record + .flags + .insert(bun_ast::ImportRecordFlags::PRINT_PATH_RELATIVE_TO_OUTPUT); } import_record.flags.set( bun_ast::ImportRecordFlags::IS_EXTERNAL_WITHOUT_SIDE_EFFECTS, @@ -7491,7 +7633,7 @@ pub mod bv2_impl { if !only_selected_record(ctx.only_records, i) { continue; } - if let Some(source_index) = path_to_source_index_map.get_path(&record.path) { + if let Some(source_index) = path_to_source_index_map.get_record(record) { if save_import_record_source_index || input_file_loaders[source_index as usize].is_css() { @@ -7834,7 +7976,7 @@ pub mod bv2_impl { let resolved_index = if star_ir.source_index.is_valid() { star_ir.source_index.get() } else if let Some(idx) = - this.graph.build_graphs[result_ast_target].get_path(&star_ir.path) + this.graph.build_graphs[result_ast_target].get_record(star_ir) { idx } else { diff --git a/src/bundler/linker_context/MetafileBuilder.rs b/src/bundler/linker_context/MetafileBuilder.rs index 10c528d91108..a6f5b3b185af 100644 --- a/src/bundler/linker_context/MetafileBuilder.rs +++ b/src/bundler/linker_context/MetafileBuilder.rs @@ -322,6 +322,13 @@ pub(crate) fn generate(c: &mut LinkerContext, chunks: &mut [Chunk]) -> crate::Re } } } + // External file: `pretty` is cwd-relative like the "inputs" keys. + if record + .flags + .contains(ImportRecordFlags::PRINT_PATH_RELATIVE_TO_OUTPUT) + { + break 'path record.path.pretty; + } record.path.text }; { diff --git a/src/bundler/linker_context/computeChunks.rs b/src/bundler/linker_context/computeChunks.rs index a68ae8b2fc6c..c7c034e1d8b7 100644 --- a/src/bundler/linker_context/computeChunks.rs +++ b/src/bundler/linker_context/computeChunks.rs @@ -766,6 +766,35 @@ pub(crate) fn compute_chunks<'a>( } } + if bv2.graph.has_output_relative_import_paths { + let opts = &bv2.transpiler().options; + let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir(); + if this.options.compile_mode.is_executable() { + // An executable resolves what it does not embed against the working directory. + for chunk in chunks.iter_mut() { + chunk.output_dir_abs = Box::from(top_level_dir); + } + } else { + let output_base: &[u8] = if !opts.output_dir.is_empty() { + &opts.output_dir + } else { + // Empty for stdout and in-memory `Bun.build`: joins to the working directory. + &opts.cli_output_dir + }; + // A naming template can expand to more than a path buffer holds. + let mut spill: Vec = Vec::new(); + for chunk in chunks.iter_mut() { + let chunk_dir = chunk.template.rel_dir(); + chunk.output_dir_abs = + Box::from(resolve_path::join_abs_string_spill::< + bun_paths::platform::Auto, + >( + top_level_dir, &mut spill, &[output_base, &chunk_dir] + )); + } + } + } + // Transfer ownership of the single backing buffer; every `chunk.unique_key` // above borrows into it. (The builder `Drop`s on error and `unique_key_buf` // is only assigned here on success, so no rollback guard is needed.) diff --git a/src/bundler/linker_context/generateCodeForFileInChunkJS.rs b/src/bundler/linker_context/generateCodeForFileInChunkJS.rs index 431592d8f91a..9ca0f0d59b04 100644 --- a/src/bundler/linker_context/generateCodeForFileInChunkJS.rs +++ b/src/bundler/linker_context/generateCodeForFileInChunkJS.rs @@ -222,6 +222,7 @@ pub fn generate_code_for_file_in_chunk_js<'r, 'src>( None, part_range.source_index, source, + &chunk.output_dir_abs, module_info, ); } @@ -980,6 +981,7 @@ pub fn generate_code_for_file_in_chunk_js<'r, 'src>( runtime_require_ref, part_range.source_index, source_ref, + &chunk.output_dir_abs, module_info, ) } diff --git a/src/bundler/options.rs b/src/bundler/options.rs index f73f0c365893..31cc4833dd47 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -181,6 +181,69 @@ pub(crate) fn init_external_modules( result } +/// File-path `external` entries as only a bundle matches them: as written, or a wildcard by path. +#[derive(Default)] +pub struct ExternalFiles { + /// Entries as written. A specifier that matches stays in the output as written. + exact: StringSet, + /// `./`, `../` and absolute wildcards, made absolute. + abs_patterns: Vec, +} + +impl ExternalFiles { + pub(crate) fn init( + fs: &mut Fs::Implementation, + cwd: &[u8], + externals: &[Box<[u8]>], + log: &mut bun_ast::Log, + ) -> ExternalFiles { + let mut result = ExternalFiles::default(); + for external in externals { + if bun_paths::is_package_path(external) { + continue; + } + if strings::index_of_char(external, b'*').is_none() { + result.exact.insert(external).expect("unreachable"); + continue; + } + let normalized = validate_path(log, fs, cwd, external, b"external path"); + if let Some(star) = strings::index_of_char(&normalized, b'*') { + let star = star as usize; + result.abs_patterns.push(WildcardPattern { + prefix: Box::from(&normalized[0..star]), + suffix: Box::from(&normalized[star + 1..]), + }); + } + } + result + } + + #[inline] + pub(crate) fn is_empty(&self) -> bool { + self.exact.count() == 0 && self.abs_patterns.is_empty() + } + + pub(crate) fn matches_specifier(&self, specifier: &[u8]) -> bool { + self.exact.count() > 0 && self.exact.contains(specifier) + } + + pub(crate) fn has_path_patterns(&self) -> bool { + !self.abs_patterns.is_empty() + } + + pub(crate) fn matches_path(&self, abs_path: &[u8]) -> bool { + Self::any_matches(&self.abs_patterns, abs_path) + } + + pub(crate) fn any_matches(patterns: &[WildcardPattern], text: &[u8]) -> bool { + patterns.iter().any(|pattern| { + text.len() >= pattern.prefix.len() + pattern.suffix.len() + && text.starts_with(&pattern.prefix) + && text.ends_with(&pattern.suffix) + }) + } +} + pub use bun_resolve_builtins::node_builtins::{ BUN_NODE_BUILTIN_PATTERNS_COMPAT, NODE_BUILTIN_PATTERNS, NODE_BUILTIN_PATTERNS_RAW, }; @@ -1226,6 +1289,8 @@ pub struct BundleOptions<'a> { pub(crate) output_dir_handle: Option, pub output_dir: Box<[u8]>, + /// `bun build` without `--outdir`: the directory the CLI writes to (empty: stdout). + pub cli_output_dir: Box<[u8]>, pub root_dir: Box<[u8]>, pub(crate) write: bool, @@ -1246,6 +1311,7 @@ pub struct BundleOptions<'a> { /// through those sibling raw pointers UB under stacked borrows. pub log: *mut bun_ast::Log, pub(crate) external: ExternalModules, + pub(crate) external_files: ExternalFiles, pub allow_unresolved: AllowUnresolved, pub entry_points: Box<[Box<[u8]>]>, pub entry_naming: Box<[u8]>, @@ -1482,6 +1548,7 @@ impl<'a> BundleOptions<'a> { // close the parent's fd when the worker options drop. output_dir_handle: None, output_dir: self.output_dir.clone(), + cli_output_dir: self.cli_output_dir.clone(), root_dir: self.root_dir.clone(), write: self.write, preserve_symlinks: self.preserve_symlinks, @@ -1493,6 +1560,8 @@ impl<'a> BundleOptions<'a> { main_fields: self.main_fields.clone(), log: self.log, external: self.external.clone(), + // Import records resolve on the bundle thread, never on a worker. + external_files: ExternalFiles::default(), allow_unresolved: self.allow_unresolved.clone(), entry_points: self.entry_points.clone(), entry_naming: self.entry_naming.clone(), @@ -1716,9 +1785,11 @@ impl<'a> BundleOptions<'a> { define: Box::new(defines::Define::default()), loaders, output_dir: Box::from(transform.output_dir.as_deref().unwrap_or(b"out")), + cli_output_dir: Box::default(), target, write: transform.write.unwrap_or(false), external: ExternalModules::default(), // filled below + external_files: ExternalFiles::default(), entry_points: transform.entry_points.clone().into_boxed_slice(), out_extensions: StringHashMap::default(), // filled below env: Env::default(), @@ -1934,6 +2005,14 @@ impl<'a> BundleOptions<'a> { opts.log_mut(), opts.target, ); + if !transform.external.is_empty() { + opts.external_files = ExternalFiles::init( + &mut fs.fs, + top_level_dir, + &transform.external, + opts.log_mut(), + ); + } opts.out_extensions = opts.target.out_extensions(); opts.source_map = SourceMapOption::from_api(transform.source_map); @@ -2485,6 +2564,29 @@ impl PathTemplate { ) } + /// Directory part of the template as written to disk, `/`-separated. A `[hash]` not + /// yet known takes a stand-in; hashes have no separators, so the depth is right. + pub(crate) fn rel_dir(&self) -> Box<[u8]> { + let mut rel = Vec::::new(); + path_template_print( + &mut rel, + &self.data, + &self.placeholder.dir, + &self.placeholder.name, + &self.placeholder.ext, + Some( + self.placeholder + .hash + .unwrap_or_else(|| bun_core::fmt::ContentHash::new(0, self.hash_len())), + ), + &self.placeholder.target, + true, + ) + .expect("write to Vec"); + bun_paths::resolve_path::platform_to_posix_in_place::(&mut rel); + Box::from(bun_paths::resolve_path::dirname::(&rel)) + } + /// Max output path length. A `PathBuffer` also needs room for a `.map` or `.jsc` suffix and the NUL. pub(crate) const MAX_OUTPUT_PATH_LEN: usize = bun_paths::MAX_PATH_BYTES - 1 - ".map".len(); diff --git a/src/js_printer/Cargo.toml b/src/js_printer/Cargo.toml index 74d7e86e8587..085879303550 100644 --- a/src/js_printer/Cargo.toml +++ b/src/js_printer/Cargo.toml @@ -24,5 +24,6 @@ bun_wyhash.workspace = true bun_crash_handler.workspace = true bun_io.workspace = true bun_ast.workspace = true +bun_paths.workspace = true bun_sourcemap.workspace = true bun_options_types.workspace = true diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index d4534dbbc5e6..a03441ae8e72 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1380,6 +1380,10 @@ pub struct Options<'a> { pub line_offset_tables: Option<&'a SourceMap::line_offset_table::List>, pub mangled_props: Option<&'a crate::MangledProps>, + + /// Absolute directory of the output file; `PRINT_PATH_RELATIVE_TO_OUTPUT` + /// records print relative to it (as-is when empty). + pub output_dir: &'a [u8], } impl<'a> Options<'a> { @@ -1436,6 +1440,7 @@ impl<'a> Default for Options<'a> { has_dynamic_import_items: false, line_offset_tables: None, mangled_props: None, + output_dir: b"", } } } @@ -3614,10 +3619,10 @@ pub(crate) mod __gated_printer { } self.print(b"("); - self.print_string_literal_utf8( - self.import_record(e.import_record_index as usize).path.text, - true, + let path = self.import_record_path_text( + self.import_record(e.import_record_index as usize), ); + self.print_string_literal_utf8(path, true); self.print(b")"); if wrap { @@ -5632,8 +5637,9 @@ pub(crate) mod __gated_printer { self.print_semicolon_after_statement(); if Self::MAY_HAVE_MODULE_INFO { + let irp = self.import_record_path_text(import_record); if let Some(mi) = self.module_info() { - let irp_id = mi.str(import_record.path.text); + let irp_id = mi.str(irp); mi.request_module( irp_id, analyze_transpiled_module::FetchParameters::None, @@ -5815,9 +5821,10 @@ pub(crate) mod __gated_printer { if Self::MAY_HAVE_MODULE_INFO && self.module_info.is_some() { // reshaped for borrowck — re-borrow module_info per item so // `name_for_symbol` (which needs `&mut self`) can run between uses. + let irp = self.import_record_path_text(import_record); let irp_id = { let mi = self.module_info().expect("infallible: module_info enabled"); - let id = mi.str(import_record.path.text); + let id = mi.str(irp); mi.request_module(id, analyze_transpiled_module::FetchParameters::None); id }; @@ -6344,9 +6351,10 @@ pub(crate) mod __gated_printer { // so we re-borrow it between `name_for_symbol` calls instead of holding // a single long-lived `mi` across the whole block. `irp_id` is Copy. use analyze_transpiled_module::FetchParameters as FP; + let irp = self.import_record_path_text(record); let (irp_id, fetch_parameters) = { let mi = self.module_info().expect("infallible: module_info enabled"); - let irp_id = mi.str(record.path.text); + let irp_id = mi.str(irp); let fetch_parameters: FP = if IS_BUN_PLATFORM { if let Some(loader) = record.loader { use bun_ast::Loader; @@ -6522,14 +6530,59 @@ pub(crate) mod __gated_printer { Ok(()) } - pub(crate) fn print_import_record_path(&mut self, import_record: &ImportRecord) { + /// `path.text`, or for a `PRINT_PATH_RELATIVE_TO_OUTPUT` record that + /// absolute path made relative to `options.output_dir`. + pub(crate) fn import_record_path_text(&self, import_record: &ImportRecord<'a>) -> &'a [u8] { + let text: &'a [u8] = import_record.path.text; + if !import_record + .flags + .contains(ImportRecordFlags::PRINT_PATH_RELATIVE_TO_OUTPUT) + || self.options.output_dir.is_empty() + { + return text; + } + // `relative_platform` has no length limit, unlike the `_buf` variant. + let rel = bun_paths::resolve_path::relative_platform::< + bun_paths::resolve_path::platform::Auto, + false, + >(self.options.output_dir, text); + // No relative path leads to another Windows volume: `rel` is then absolute. + let is_absolute = bun_paths::is_absolute(rel); + let rel = rel.strip_suffix(&[bun_paths::SEP]).unwrap_or(rel); + if rel.is_empty() { + return b"./"; + } + let is_parent = rel + .strip_prefix(b"..") + .is_some_and(|rest| rest.first().is_none_or(|&c| c == bun_paths::SEP)); + let prefix: &[u8] = if is_parent || is_absolute { b"" } else { b"./" }; + // The resolver keeps the trailing separator of a specifier that names a directory. + let suffix: &[u8] = if text.last() == Some(&bun_paths::SEP) { + b"/" + } else { + b"" + }; + let out = self + .bump + .alloc_slice_fill_copy(prefix.len() + rel.len() + suffix.len(), 0u8); + out[..prefix.len()].copy_from_slice(prefix); + out[prefix.len()..][..rel.len()].copy_from_slice(rel); + bun_paths::resolve_path::platform_to_posix_in_place::( + &mut out[prefix.len()..][..rel.len()], + ); + out[prefix.len() + rel.len()..].copy_from_slice(suffix); + out + } + + pub(crate) fn print_import_record_path(&mut self, import_record: &ImportRecord<'a>) { if IS_JSON { unreachable!(); } - let quote = best_quote_char_for_string(import_record.path.text, false); + let text = self.import_record_path_text(import_record); + let quote = best_quote_char_for_string(text, false); self.print(quote); - self.print_string_characters_utf8(import_record.path.text, quote); + self.print_string_characters_utf8(text, quote); self.print(quote); } diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index a5aaebbcce80..f067ec1e7adb 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -3011,6 +3011,14 @@ mod tests { } } + #[test] + fn relative_to_another_windows_volume_is_the_absolute_path() { + let relative = relative_platform::; + assert_eq!(relative(b"C:\\out", b"D:\\src\\lib.js"), b"D:\\src\\lib.js"); + // A drive letter is the same volume in either case. + assert_eq!(relative(b"c:\\out", b"C:\\src\\lib.js"), b"..\\src\\lib.js"); + } + #[test] fn join_string_buf_skips_empty_parts_and_normalizes() { let mut out = [0u8; 64]; diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 067192e01069..14e8cf03097d 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -2058,7 +2058,7 @@ impl<'a> Resolver<'a> { /// Whether an import specifier explicitly names a directory: a trailing /// separator, `.`, `..`, or a path ending in `/.` or `/..`. - fn import_path_names_directory(import_path: &[u8]) -> bool { + pub fn import_path_names_directory(import_path: &[u8]) -> bool { let Some(&last) = import_path.last() else { return false; }; diff --git a/src/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index 02697be05537..0ff6542f9eb8 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -616,6 +616,18 @@ impl BuildCommand { // resolver subset; bundler-side `entry_naming` is sufficient. } + if this_transpiler.options.output_dir.is_empty() && !ctx.bundler_options.compile { + // Like the `'dump` block: a linked source map's two files go next to the entry. + let dir = if this_transpiler.options.source_map == options::SourceMapOption::Linked + && ctx.args.entry_points.len() == 1 + { + bun_core::dirname(&ctx.args.entry_points[0]) + } else { + bun_core::dirname(outfile) + }; + this_transpiler.options.cli_output_dir = dir.unwrap_or(b"").into(); + } + // Stack-owned Mini event loop so its tasks/concurrent_tasks queues // drop at scope exit; the arena bulk-free skips Drop. Outlives the // BACKREF passed to `generate_from_cli`. diff --git a/test/bundler/bun-build-api.test.ts b/test/bundler/bun-build-api.test.ts index c7ca18450820..9276ff4ab911 100644 --- a/test/bundler/bun-build-api.test.ts +++ b/test/bundler/bun-build-api.test.ts @@ -2604,8 +2604,8 @@ test.skipIf(isWindows)( describe.concurrent("Bun.build with a path longer than the path buffer", () => { const components = Math.ceil((MAX_PATH_BYTES * 1.25) / 201); - async function run(script: string) { - using dir = tempDir("bun-build-long-path", {}); + async function run(script: string, files: Record = {}) { + using dir = tempDir("bun-build-long-path", files); await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -2659,6 +2659,28 @@ describe.concurrent("Bun.build with a path longer than the path buffer", () => { ).toEqual({ stdout: "true [ true, true ]\n", stderr: "", exitCode: 0 }); }); + test("prints an external file relative to an output directory that is too long to open", async () => { + // `../lib.js` matches the external by its resolved path, so it is printed relative to the chunk's directory. + expect( + await run( + ` + const build = await Bun.build({ + entrypoints: ["./src/entry.js"], + external: ["./lib.js"], + naming: long + "/[name].[ext]", + throw: false, + }); + const text = build.success ? await build.outputs[0].text() : ""; + console.log(build.success, text.includes('from "' + Buffer.alloc(${components * 3}, "../").toString() + 'lib.js"')); + `, + { + "src/entry.js": `import lib from "../lib.js"; console.log(lib);`, + "lib.js": `export default 1;`, + }, + ), + ).toEqual({ stdout: "true true\n", stderr: "", exitCode: 0 }); + }); + test("fails the build when a naming template expands to a path that is too long to write", async () => { expect( await run(` diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index 7fad99c9d459..42a5505bf73f 100644 --- a/test/bundler/bun-build-compile.test.ts +++ b/test/bundler/bun-build-compile.test.ts @@ -2190,6 +2190,39 @@ describe("compiled binary validity", () => { } }); + // An executable resolves what it does not embed against the working directory, not + // against the directory it was written to. + test("an external file resolves from the working directory of a compiled binary", async () => { + using dir = tempDir("build-compile-external-file", { + "src/entry.js": `import lib from "../lib.js"; console.log(lib);`, + "lib.js": `export default "external-lib";`, + }); + + const result = await Bun.build({ + entrypoints: [join(dir + "", "src", "entry.js")], + external: [join(dir + "", "lib.js")], + outdir: join(dir + "", "dist"), + compile: { + outfile: "app-external", + }, + }); + + expect(result.success).toBe(true); + + await using proc = Bun.spawn({ + cmd: [result.outputs[0].path], + env: bunEnv, + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, stderr }).toEqual({ stdout: "external-lib\n", stderr: "" }); + expect(exitCode).toBe(0); + }); + test("compiled binary runs and produces expected output", async () => { using dir = tempDir("build-compile-runs", { "app.js": `console.log("compile-test-output");`, diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index d6cfb2141007..95a716a47bff 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -1869,6 +1869,277 @@ describe("bundler", () => { api.expectFile("/out.js").toContain("h("); }, }); + // `--external ./x` matches like esbuild: a specifier written exactly like the + // external is kept as written... + itBundled("edgecase/ExternalFileSpecifierAsWrittenIsKept", { + files: { + "/entry.js": /* js */ ` + import config from "./config.js"; + import { viaSub } from "./sub/reexport.js"; + console.log(config, viaSub); + `, + // The same text names a different file here; it is matched by text all the same. + "/sub/reexport.js": `export { default as viaSub } from "./config.js";`, + "/config.js": `export default "root";`, + "/sub/config.js": `export default "sub";`, + }, + external: ["./config.js"], + outdir: "/out", + onAfterBundle(api) { + const entry = api.readFile("/out/entry.js"); + expect(entry).not.toContain(`"root"`); + expect(entry).not.toContain(`"sub"`); + const imports = new Bun.Transpiler().scanImports(entry).map(i => i.path); + expect([...new Set(imports)]).toEqual(["./config.js"]); + }, + }); + // ...while any other `./` or `../` specifier that resolves to the file is + // matched by path. That specifier is relative to the importer, so once hoisted + // into an output file it has to be relative to that file instead, wherever the + // file lands (two nested entry points here). + itBundled("edgecase/ExternalFileRelativeToOutputFile", { + files: { + "/src/a/entry.js": /* js */ ` + import lib from "../lib.js"; + import viaDir from "../dir/mod.js"; + console.log("a", lib.id, viaDir); + `, + "/src/b/deep/entry.js": /* js */ ` + import lib from "../../lib.js"; + export const id = "b " + lib.id; + console.log(id); + `, + "/src/dir/mod.js": /* js */ ` + import lib from "../lib.js"; + export default "mod(" + lib.id + ")"; + `, + "/src/lib.js": `export default { id: "lib" };`, + }, + entryPoints: ["/src/a/entry.js", "/src/b/deep/entry.js"], + outputPaths: ["/out/a/entry.js", "/out/b/deep/entry.js"], + external: ["./src/lib.js"], + outdir: "/out", + onAfterBundle(api) { + const imports = (file: string) => new Bun.Transpiler().scanImports(api.readFile(file)).map(i => i.path); + expect(imports("/out/a/entry.js")).toEqual(["../../src/lib.js", "../../src/lib.js"]); + expect(imports("/out/b/deep/entry.js")).toEqual(["../../../src/lib.js"]); + }, + run: [ + { file: "/out/a/entry.js", stdout: "a lib mod(lib)" }, + { file: "/out/b/deep/entry.js", stdout: "b lib" }, + ], + }); + itBundled("edgecase/ExternalFileRelativeToOutputFileSplitting", { + files: { + "/src/a/entry.js": /* js */ ` + import { shared } from "../shared.js"; + console.log("a", shared); + `, + "/src/b/entry.js": /* js */ ` + import { shared } from "../shared.js"; + console.log("b", shared); + `, + "/src/shared.js": /* js */ ` + import lib from "./vendor/lib.js"; + export const shared = "shared(" + lib.id + ")"; + `, + "/src/vendor/lib.js": `export default { id: "lib" };`, + }, + backend: "api", + entryPoints: ["/src/a/entry.js", "/src/b/entry.js"], + outputPaths: ["/out/a/entry.js", "/out/b/entry.js"], + external: ["{{root}}/src/vendor/lib.js"], + splitting: true, + outdir: "/out", + chunkNaming: "chunks/[name]-[hash].[ext]", + onAfterBundle(api) { + const chunk = readdirSync(join(api.root, "out", "chunks"))[0]; + const imports = new Bun.Transpiler().scanImports(api.readFile("/out/chunks/" + chunk)).map(i => i.path); + expect(imports).toEqual(["../../src/vendor/lib.js"]); + }, + run: [ + { file: "/out/a/entry.js", stdout: "a shared(lib)" }, + { file: "/out/b/entry.js", stdout: "b shared(lib)" }, + ], + }); + itBundled("edgecase/ExternalFileRelativeToOutfile", { + files: { + "/src/entry.js": /* js */ ` + import lib from "./lib.js"; + console.log(lib.id); + `, + "/src/lib.js": `export default { id: "lib" };`, + }, + backend: "cli", + external: ["./src/lib.js"], + outfile: "/dist/js/app.js", + onAfterBundle(api) { + const imports = new Bun.Transpiler().scanImports(api.readFile("/dist/js/app.js")).map(i => i.path); + expect(imports).toEqual(["../../src/lib.js"]); + }, + run: { file: "/dist/js/app.js", stdout: "lib" }, + }); + // `require()`, `require.resolve()` and `import()` print the path the same way. + itBundled("edgecase/ExternalFileRelativeToOutputFileCJS", { + files: { + "/src/entry.js": /* js */ ` + const lib = require("../vendor/lib.cjs"); + const resolved = require.resolve("../vendor/lib.cjs"); + import("../vendor/lib.cjs").then(ns => console.log(lib.id, ns.default.id, resolved === require.resolve(resolved))); + `, + "/vendor/lib.cjs": `module.exports = { id: "lib" };`, + }, + format: "cjs", + target: "node", + external: ["./vendor/lib.cjs"], + outdir: "/out/nested", + onAfterBundle(api) { + const entry = api.readFile("/out/nested/entry.js"); + expect(entry).toContain(`require("../../vendor/lib.cjs")`); + expect(entry).toContain(`require.resolve("../../vendor/lib.cjs")`); + expect(entry).toContain(`import("../../vendor/lib.cjs")`); + }, + run: { file: "/out/nested/entry.js", stdout: "lib lib true" }, + }); + // The directory of an output file is known before its hash is. + itBundled("edgecase/ExternalFileRelativeToOutputFileHashedDirectory", { + files: { + "/src/entry.js": /* js */ ` + import lib from "./lib.js"; + console.log(lib.id); + `, + "/src/lib.js": `export default { id: "lib" };`, + }, + external: ["./src/lib.js"], + outdir: "/out", + entryNaming: "[hash]/[name].[ext]", + onAfterBundle(api) { + const [hashed] = readdirSync(join(api.root, "out")); + const imports = new Bun.Transpiler().scanImports(api.readFile(`/out/${hashed}/entry.js`)).map(i => i.path); + expect(imports).toEqual(["../../src/lib.js"]); + }, + }); + // Same, when an onResolve plugin declines and the native resolver runs second. + itBundled("edgecase/ExternalFileRelativeToOutputFileAfterPlugin", { + files: { + "/src/entry.js": /* js */ ` + import lib from "../vendor/lib.js"; + console.log(lib.id); + `, + "/vendor/lib.js": `export default { id: "lib" };`, + }, + plugins(builder) { + builder.onResolve({ filter: /lib/ }, () => undefined); + }, + external: ["./vendor/lib.js"], + outdir: "/out/nested", + onAfterBundle(api) { + const imports = new Bun.Transpiler().scanImports(api.readFile("/out/nested/entry.js")).map(i => i.path); + expect(imports).toEqual(["../../vendor/lib.js"]); + }, + run: { file: "/out/nested/entry.js", stdout: "lib" }, + }); + // A `module.exports = require()` re-export is redirected to its target; the + // redirected record points at the same external file and prints the same way. + itBundled("edgecase/ExternalFileRelativeToOutputFileThroughCommonJSReexport", { + files: { + "/src/entry.js": /* js */ ` + import lib from "./shim.cjs"; + console.log(lib.id); + `, + "/src/shim.cjs": `module.exports = require("../vendor/lib.js");`, + "/vendor/lib.js": `export default { id: "lib" };`, + }, + external: ["./vendor/lib.js"], + outdir: "/out/nested", + onAfterBundle(api) { + const imports = new Bun.Transpiler().scanImports(api.readFile("/out/nested/entry.js")).map(i => i.path); + expect(imports).toEqual(["../../vendor/lib.js"]); + }, + run: { file: "/out/nested/entry.js", stdout: "lib" }, + }); + // An external file that is also an entry point is still external where it is + // imported: matching by path must not resolve the import to the bundled copy. + itBundled("edgecase/ExternalFileThatIsAlsoAnEntryPoint", { + files: { + "/src/entry.js": /* js */ ` + import lib from "../lib.js"; + console.log("entry", lib.id); + `, + "/lib.js": `export default { id: "lib" };`, + }, + entryPoints: ["/src/entry.js", "/lib.js"], + outputPaths: ["/out/src/entry.js", "/out/lib.js"], + external: ["./lib.js"], + outdir: "/out", + onAfterBundle(api) { + const entry = api.readFile("/out/src/entry.js"); + expect(entry).not.toContain(`id: "lib"`); + expect(new Bun.Transpiler().scanImports(entry).map(i => i.path)).toEqual(["../../lib.js"]); + }, + run: { file: "/out/src/entry.js", stdout: "entry lib" }, + }); + // A specifier that names a directory still names it after the rewrite: `../lib` + // from the output would load the sibling `lib.js` instead of `lib/index.js`. + itBundled("edgecase/ExternalDirectoryKeepsTrailingSeparator", { + files: { + "/lib/entry.js": /* js */ ` + import index from "."; + console.log(index); + `, + "/lib/index.js": `export default "index";`, + "/lib.js": `export default "sibling";`, + }, + external: ["./lib"], + outdir: "/out", + onAfterBundle(api) { + const imports = new Bun.Transpiler().scanImports(api.readFile("/out/entry.js")).map(i => i.path); + expect(imports).toEqual(["../lib/"]); + }, + run: { file: "/out/entry.js", stdout: "index" }, + }); + // A `./`-prefixed wildcard names files, so besides matching specifiers as + // written (kept as written, like esbuild) it also matches by resolved path: + // `../vendor/b.js` from a subdirectory is as external as `./vendor/a.js` from + // the root, instead of being bundled while the other is left external. + itBundled("edgecase/ExternalRelativeWildcardMatchesResolvedPath", { + files: { + "/entry.js": /* js */ ` + import a from "./vendor/a.js"; + import viaSub from "./sub/mod.js"; + console.log(a, viaSub); + `, + "/sub/mod.js": /* js */ ` + import b from "../vendor/b.js"; + export default "sub(" + b + ")"; + `, + "/vendor/a.js": `export default "a";`, + "/vendor/b.js": `export default "b";`, + }, + external: ["./vendor/*"], + outdir: "/out", + onAfterBundle(api) { + const entry = api.readFile("/out/entry.js"); + expect(entry).not.toContain(`"b"`); + expect(new Bun.Transpiler().scanImports(entry).map(i => i.path)).toEqual(["./vendor/a.js", "../vendor/b.js"]); + }, + }); + // A macro file runs at build time. An `external` that equals its specifier as + // written names another file, and does not change which file the macro is. + itBundled("edgecase/MacroFileSpecifierEqualsExternalAsWritten", { + files: { + "/src/entry.ts": /* js */ ` + import { which } from "./macro.ts" with { type: "macro" }; + console.log(which()); + `, + "/src/macro.ts": `export const which = () => "src/macro.ts";`, + "/macro.ts": `export const which = () => "macro.ts";`, + }, + entryPoints: ["/src/entry.ts"], + external: ["./macro.ts"], + target: "bun", + run: { stdout: "src/macro.ts" }, + }); itBundled("edgecase/IntegerUnderflow#12547", { files: { "/entry.js": ` diff --git a/test/bundler/cli.test.ts b/test/bundler/cli.test.ts index 60600531627a..88fac107536f 100644 --- a/test/bundler/cli.test.ts +++ b/test/bundler/cli.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isWindows, normalizeBunSnapshot, tempDir, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isWindows, normalizeBunSnapshot, substDrive, tempDir, tmpdirSync } from "harness"; import fs, { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import path, { join } from "node:path"; @@ -267,6 +267,42 @@ console.log(utils());`, expect(await stderr.text()).toBe(""); expect(await exited).toBe(0); }); + + test("an external file resolves from where the output is written without --outdir", async () => { + using dir = tempDir("bun-build-external-no-outdir", { + "src/entry.ts": `import lib from "../lib.js"; import { viaSub } from "./sub/mod.ts"; console.log(lib, viaSub);`, + "src/sub/mod.ts": `import lib from "../../lib.js"; export const viaSub = "sub:" + lib;`, + "lib.js": `export default "lib";`, + }); + + // A linked source map makes it two files, so they are written to disk instead of stdout. + await using build = Bun.spawn({ + cmd: [bunExe(), "build", "src/entry.ts", "--external", "./lib.js", "--sourcemap=linked"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [, buildStderr, buildExitCode] = await Promise.all([ + build.stdout.text(), + build.stderr.text(), + build.exited, + ]); + expect(buildStderr).toBe(""); + expect(buildExitCode).toBe(0); + + const [written] = [...new Bun.Glob("**/entry.js").scanSync(String(dir))]; + await using run = Bun.spawn({ + cmd: [bunExe(), written], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([run.stdout.text(), run.stderr.text(), run.exited]); + expect({ stdout, stderr }).toEqual({ stdout: "lib sub:lib\n", stderr: "" }); + expect(exitCode).toBe(0); + }); }, 10_000, ); @@ -309,6 +345,36 @@ test.skipIf(!isWindows)("should be able to handle pretty path on windows #13897" expect(buildOut?.success).toBe(true); }); +test.skipIf(!isWindows)("an external file is an absolute path in output written to another drive", async () => { + using dir = tempDir("bun-build-external-other-drive", { + "src/entry.mjs": `import lib from "./lib.js"; console.log(lib);`, + "src/lib.js": `export default "lib";`, + "mount": {}, + }); + using drive = substDrive(join(String(dir), "mount")); + const outdir = drive.root + "out"; + + await using build = Bun.spawn({ + cmd: [bunExe(), "build", "./src/entry.mjs", "--external", "./src/lib.js", "--outdir", outdir], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [, buildStderr, buildExitCode] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); + expect(buildStderr).toBe(""); + expect(buildExitCode).toBe(0); + + // No relative path leads from one drive to another. + const written = join(outdir, "entry.js"); + expect((await Bun.file(written).text()).match(/from "([^"]+)"/)?.[1]).toMatch(/^[A-Za-z]:\/.*\/src\/lib\.js$/); + + await using run = Bun.spawn({ cmd: [bunExe(), written], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([run.stdout.text(), run.stderr.text(), run.exited]); + expect({ stdout, stderr }).toEqual({ stdout: "lib\n", stderr: "" }); + expect(exitCode).toBe(0); +}); + test("you can use --outfile=... and --sourcemap", async () => { const tmpdir = tmpdirSync(); const inputFile = path.join(tmpdir, "input.js"); diff --git a/test/bundler/esbuild/default.test.ts b/test/bundler/esbuild/default.test.ts index aae64e643e2e..8fb5aec96a51 100644 --- a/test/bundler/esbuild/default.test.ts +++ b/test/bundler/esbuild/default.test.ts @@ -2242,7 +2242,6 @@ describe.concurrent("bundler", () => { external: ["@scope/foo"], }); itBundled("default/ExternalModuleExclusionRelativePath", { - todo: true, files: { "/Users/user/project/src/index.js": `import './nested/folder/test'`, "/Users/user/project/src/nested/folder/test.js": /* js */ ` diff --git a/test/bundler/metafile.test.ts b/test/bundler/metafile.test.ts index 8953f2ac6bb9..b0fcbee2d995 100644 --- a/test/bundler/metafile.test.ts +++ b/test/bundler/metafile.test.ts @@ -328,6 +328,41 @@ describe("bundler metafile", () => { expect(foundExternal).toBe(true); }); + test("an external file matched by path is listed like an input, and printed relative to the output", async () => { + using dir = tempDir("metafile-external-file", { + "src/entry.js": `import lib from "../lib.js"; console.log(lib);`, + "lib.js": `export default 1;`, + }); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const build = await Bun.build({ entrypoints: ["./src/entry.js"], external: ["./lib.js"], metafile: true }); + console.log(JSON.stringify({ + imports: build.metafile.inputs["src/entry.js"].imports, + // No outdir: the output is relative to the working directory. + printed: new Bun.Transpiler().scanImports(await build.outputs[0].text()).map(i => i.path), + })); + `, + ], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + imports: [{ path: "lib.js", kind: "import-statement", original: "../lib.js", external: true }], + printed: ["./lib.js"], + }); + expect(exitCode).toBe(0); + }); + test("metafile with code splitting", async () => { using dir = tempDir("metafile-splitting-test", { "a.js": `import { shared } from "./shared.js"; console.log("a", shared);`, diff --git a/test/cli/test/test-changed.test.ts b/test/cli/test/test-changed.test.ts index 09a80ce39ef1..12886e034d33 100644 --- a/test/cli/test/test-changed.test.ts +++ b/test/cli/test/test-changed.test.ts @@ -394,6 +394,21 @@ describe.concurrent("bun test --changed", () => { expect(exitCode).toBe(0); }); + // `bun build` matches a file `external` as written. The scan of what a test loads does not. + test("a bunfig.toml `external` that equals an import only as written does not hide the dependency", async () => { + using dir = tempDir("test-changed-bunfig-external-as-written", { + ...fixture, + // Names /helper. src/util.ts imports "./helper", which is src/helper.ts. + "bunfig.toml": `external = ["./helper"]\n`, + }); + initRepo(String(dir)); + appendFileSync(join(String(dir), "src", "helper.ts"), "// touched\n"); + + const { stderr, exitCode } = await runTestChanged(String(dir)); + expect(ranFiles(stderr, names)).toEqual(["a.test.ts"]); + expect(exitCode).toBe(0); + }); + test("a node:vm timeout can interrupt a hot loop", async () => { using dir = tempDir("test-changed-vm-timeout", { "package.json": JSON.stringify({ name: "vm-timeout", type: "module" }), diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index bee8739d8625..c0b7cbb2ec07 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -2186,7 +2186,12 @@ describe.concurrent("dot specifiers resolve to the directory index, not a siblin }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(stdout).toContain('from "."'); + // Matched by its resolved path, so the specifier is printed relative to where + // the output goes (stdout: the cwd). It still names the directory, not the + // sibling lib.ts, and neither file is bundled. + expect(stdout).toContain('from "./lib/"'); + expect(stdout).not.toContain("sibling"); + expect(stdout).not.toContain('"index"'); expect(exitCode).toBe(0); }); @@ -2255,3 +2260,25 @@ test.concurrent.each(["import", "require"])( expect({ stdout, stderr, exitCode }).toEqual({ stdout: "later.mjs\nlater.mjs\n", stderr: "", exitCode: 0 }); }, ); + +// `bun build` matches a file `external` as written, and a wildcard by path. A runtime does neither. +test("a file `external` in bunfig.toml does not change which file a runtime import loads", async () => { + using dir = tempDir("bunfig-external-file-runtime", { + "bunfig.toml": `external = ["./lib.js", "./vendor/*"]`, + "src/entry.cjs": `console.log(JSON.stringify([require("./lib.js"), require("../vendor/b")]));`, + "src/lib.js": `module.exports = "src/lib.js";`, + "vendor/b.js": `module.exports = "vendor/b.js";`, + // What "./lib.js" names when it is read against the working directory. + "lib.js": `module.exports = "lib.js";`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "src/entry.cjs"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ stdout: `["src/lib.js","vendor/b.js"]\n`, stderr: "" }); + expect(exitCode).toBe(0); +});