Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7c0f6a6
bundler: print relative file externals relative to the output file
robobun Sep 8, 2026
60c0b5e
bundler: match absolute externals as written too
robobun Sep 8, 2026
a97fa2a
bundler: keep a file external's specifier when written like the external
robobun Sep 8, 2026
93078f8
test: a "." external matched by path prints relative to the output, l…
robobun Sep 8, 2026
c3ee97c
trim comments
robobun Sep 8, 2026
00ac41d
Merge remote-tracking branch 'origin/main' into robobun/7755fe04/exte…
robobun Sep 8, 2026
ebcc088
review: unwrap_or_else for clippy::or_fun_call; dev-server barrel loo…
robobun Sep 8, 2026
8584c74
Merge remote-tracking branch 'origin/main' into robobun/7755fe04/exte…
robobun Oct 26, 2026
760db76
bundler: compute a chunk's output directory only when an external fil…
robobun Oct 26, 2026
ea23948
bundler: print external paths relative to where bun build writes with…
robobun Oct 26, 2026
5b7a14c
one-line comment
robobun Oct 26, 2026
2757203
bundler: a compiled executable's external paths are relative to the w…
robobun Oct 26, 2026
d9135ea
Merge remote-tracking branch 'origin/main' into robobun/7755fe04/exte…
robobun Oct 27, 2026
a62c238
bundler: print an external file on another Windows volume as an absol…
robobun Oct 27, 2026
2fd19b3
one-line comments
robobun Oct 27, 2026
d2394ea
Merge remote-tracking branch 'origin/main' into robobun/7755fe04/exte…
robobun Oct 27, 2026
f8be86c
bundler: match a file external as written, and a wildcard by path, in…
robobun Oct 27, 2026
8b86452
attach the safety comment to its block; one-line doc comments
robobun Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/ast/import_record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
Resolved
const PRINT_PATH_RELATIVE_TO_OUTPUT = 1 << 28;
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/bundler/Chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) output_dir_abs: Box<[u8]>,

/// For code splitting
pub(crate) cross_chunk_imports: Vec<ChunkImport>,
Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions src/bundler/Graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/bundler/LinkerContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2396,6 +2396,7 @@ impl<'a> LinkerContext<'a> {
runtime_require_ref: Option<Ref>,
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 {
Expand Down Expand Up @@ -2491,6 +2492,7 @@ impl<'a> LinkerContext<'a> {
},
mangled_props: Some(mangled_props),
module_info,
output_dir: output_dir_abs,
..Default::default()
};

Expand Down
34 changes: 16 additions & 18 deletions src/bundler/PathToSourceIndexMap.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -33,8 +18,21 @@ pub type Map = StringHashMap<IndexInt>;
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<IndexInt> {
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).
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) fn get_record(&self, record: &ImportRecord) -> Option<IndexInt> {
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<IndexInt> {
if record
.flags
.contains(ImportRecordFlags::PRINT_PATH_RELATIVE_TO_OUTPUT)
{
return None;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
self.get(text)
}

pub(crate) fn get(&self, text: impl AsRef<[u8]>) -> Option<IndexInt> {
Expand Down
6 changes: 3 additions & 3 deletions src/bundler/barrel_imports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
}
Expand Down
150 changes: 146 additions & 4 deletions src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -4534,6 +4569,8 @@ pub mod bv2_impl {
event_loop: EventLoop,
entry_points: &[&[u8]],
) -> Result<Box<BundleV2<'a>>, 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();

Expand Down Expand Up @@ -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::<bun_paths::platform::Loose>(
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.
Comment thread
robobun marked this conversation as resolved.
Resolved
fn external_file_path(
&mut self,
specifier: &[u8],
kind: ImportKind,
resolved: &Fs::Path<'_>,
target: options::Target,
) -> Option<Fs::Path<'a>> {
if !resolved.is_file()
|| !bun_paths::is_absolute(resolved.text)
|| bun_paths::is_absolute(specifier)
Comment thread
robobun marked this conversation as resolved.
Resolved
|| kind.is_from_css()
Comment thread
robobun marked this conversation as resolved.
Resolved
{
return None;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
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(());
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions src/bundler/linker_context/MetafileBuilder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
{
Expand Down
Loading
Loading