Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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.

59 changes: 51 additions & 8 deletions crates/swc/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ impl Options {
keep_class_names,
base_url,
paths,
preserve_symlinks,
minify: mut js_minify,
experimental,
#[cfg(feature = "lint")]
Expand All @@ -310,6 +311,7 @@ impl Options {
} = cfg.jsc;
let loose = loose.into_bool();
let preserve_all_comments = preserve_all_comments.into_bool();
let preserve_symlinks = preserve_symlinks.into_bool();
let keep_class_names = keep_class_names.into_bool();
let external_helpers = external_helpers.into_bool();

Expand Down Expand Up @@ -590,7 +592,13 @@ impl Options {
};

let paths = paths.into_iter().collect();
let resolver = ModuleConfig::get_resolver(&base_url, paths, base, cfg.module.as_ref());
let resolver = ModuleConfig::get_resolver(
&base_url,
paths,
base,
cfg.module.as_ref(),
preserve_symlinks,
);

let target = es_version;
let inject_helpers = !self.skip_helper_injection;
Expand Down Expand Up @@ -1371,6 +1379,15 @@ pub struct JscConfig {
#[serde(default)]
pub paths: Paths,

/// When true, do not resolve symlinks via `canonicalize()` during module
/// resolution. This is needed when a bundler sets `resolve.symlinks: false`
/// so that imports from symlinked source files resolve relative to the
/// symlink location rather than the real file location.
///
/// See https://github.com/swc-project/swc/issues/11584
#[serde(default)]
pub preserve_symlinks: BoolConfig<false>,

#[serde(default)]
pub minify: Option<JsMinifyOptions>,

Expand Down Expand Up @@ -1605,54 +1622,70 @@ impl ModuleConfig {
paths: CompiledPaths,
base: &FileName,
config: Option<&ModuleConfig>,
preserve_symlinks: bool,
) -> Option<(FileName, Arc<dyn ImportResolver>)> {
let skip_resolver = base_url.as_os_str().is_empty() && paths.is_empty();

if skip_resolver {
return None;
}

let base = match base {
FileName::Real(v) if !skip_resolver => {
FileName::Real(v.canonicalize().unwrap_or_else(|_| v.to_path_buf()))
let base = if preserve_symlinks {
base.clone()
} else {
match base {
FileName::Real(v) if !skip_resolver => {
FileName::Real(v.canonicalize().unwrap_or_else(|_| v.to_path_buf()))
}
_ => base.clone(),
}
_ => base.clone(),
};

let base_url = base_url.to_path_buf();
let resolver = match config {
None => build_resolver(base_url, paths, false, &util::Config::default_js_ext()),
None => build_resolver(
base_url,
paths,
false,
&util::Config::default_js_ext(),
preserve_symlinks,
),
Some(ModuleConfig::Es6(config)) | Some(ModuleConfig::NodeNext(config)) => {
build_resolver(
base_url,
paths,
config.config.resolve_fully,
&config.config.out_file_extension,
preserve_symlinks,
)
}
Some(ModuleConfig::CommonJs(config)) => build_resolver(
base_url,
paths,
config.resolve_fully,
&config.out_file_extension,
preserve_symlinks,
),
Some(ModuleConfig::Umd(config)) => build_resolver(
base_url,
paths,
config.config.resolve_fully,
&config.config.out_file_extension,
preserve_symlinks,
),
Some(ModuleConfig::Amd(config)) => build_resolver(
base_url,
paths,
config.config.resolve_fully,
&config.config.out_file_extension,
preserve_symlinks,
),
Some(ModuleConfig::SystemJs(config)) => build_resolver(
base_url,
paths,
config.config.resolve_fully,
&config.config.out_file_extension,
preserve_symlinks,
),
};

Expand Down Expand Up @@ -1681,6 +1714,7 @@ impl ModuleConfig {
_paths: CompiledPaths,
_base: &FileName,
_config: Option<&ModuleConfig>,
_preserve_symlinks: bool,
) -> Option<(FileName, Arc<dyn swc_ecma_loader::resolve::Resolve>)> {
None
}
Expand Down Expand Up @@ -1998,9 +2032,10 @@ fn build_resolver(
paths: CompiledPaths,
resolve_fully: bool,
file_extension: &str,
preserve_symlinks: bool,
) -> SwcImportResolver {
static CACHE: Lazy<
DashMap<(PathBuf, CompiledPaths, bool, String), SwcImportResolver, FxBuildHasher>,
DashMap<(PathBuf, CompiledPaths, bool, String, bool), SwcImportResolver, FxBuildHasher>,
> = Lazy::new(Default::default);

// On Windows, we need to normalize path as UNC path.
Expand All @@ -2022,6 +2057,7 @@ fn build_resolver(
paths.clone(),
resolve_fully,
file_extension.to_owned(),
preserve_symlinks,
)) {
return cached.clone();
}
Expand All @@ -2044,13 +2080,20 @@ fn build_resolver(
base_dir: Some(base_url.clone()),
resolve_fully,
file_extension: file_extension.to_owned(),
preserve_symlinks,
},
);
Arc::new(r)
};

CACHE.insert(
(base_url, paths, resolve_fully, file_extension.to_owned()),
(
base_url,
paths,
resolve_fully,
file_extension.to_owned(),
preserve_symlinks,
),
r.clone(),
);

Expand Down
1 change: 1 addition & 0 deletions crates/swc_ecma_transforms_module/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ swc_ecma_visit = { version = "20.0.0", path = "../swc_ecma_visit" }
[dev-dependencies]
indexmap = { workspace = true, features = ["serde"] }
serde_json = { workspace = true }
tempfile = { workspace = true }

swc_ecma_loader = { version = "18.0.0", path = "../swc_ecma_loader", features = [
"node",
Expand Down
23 changes: 20 additions & 3 deletions crates/swc_ecma_transforms_module/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ pub struct Config {
pub base_dir: Option<PathBuf>,
pub resolve_fully: bool,
pub file_extension: String,
/// When true, do not resolve symlinks via `canonicalize()`.
///
/// This is needed when a bundler sets `resolve.symlinks: false` so that
/// imports from symlinked source files resolve relative to the symlink
/// location rather than the real file location.
///
/// See https://github.com/swc-project/swc/issues/11584
pub preserve_symlinks: bool,
}

impl Default for Config {
Expand All @@ -109,6 +117,7 @@ impl Default for Config {
file_extension: crate::util::Config::default_js_ext(),
resolve_fully: bool::default(),
base_dir: Option::default(),
preserve_symlinks: bool::default(),
}
}
}
Expand Down Expand Up @@ -257,9 +266,17 @@ where
// Bazel uses symlink
//
// https://github.com/swc-project/swc/issues/8265
if let FileName::Real(resolved) = &target.filename {
if let Ok(orig) = canonicalize(resolved) {
target.filename = FileName::Real(orig);
//
// When `preserve_symlinks` is true, skip canonicalization so that
// symlinked paths are preserved. This is needed when a bundler sets
// `resolve.symlinks: false`.
//
// https://github.com/swc-project/swc/issues/11584
if !self.config.preserve_symlinks {
if let FileName::Real(resolved) = &target.filename {
if let Ok(orig) = canonicalize(resolved) {
target.filename = FileName::Real(orig);
}
}
}

Expand Down
103 changes: 103 additions & 0 deletions crates/swc_ecma_transforms_module/tests/path_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use swc_ecma_transforms_module::{
rewriter::import_rewriter,
};
use swc_ecma_transforms_testing::{test_fixture, FixtureTestConfig};
use tempfile::TempDir;
use testing::run_test2;

type TestProvider = NodeImportResolver<NodeModulesResolver>;
Expand Down Expand Up @@ -107,6 +108,7 @@ fn paths_resolver(base_dir: &Path, rules: Vec<(String, Vec<String>)>) -> JscPath
base_dir: Some(base_dir),
resolve_fully: true,
file_extension: swc_ecma_transforms_module::util::Config::default_js_ext(),
..Default::default()
},
)
}
Expand Down Expand Up @@ -157,3 +159,104 @@ fn fixture(input_dir: PathBuf) {
},
);
}

/// Test for https://github.com/swc-project/swc/issues/11584
///
/// When `preserve_symlinks` is true, `NodeImportResolver` should not
/// canonicalize resolved target paths. This ensures that symlinked source
/// files resolve imports relative to the symlink location, not the real
/// file location.
///
/// Directory structure:
/// tmpdir/
/// real/
/// lib/
/// dep.js <- real file
/// project/
/// lib/ <- symlink -> ../../real/lib
/// src/
/// index.js <- real file, imports ../lib/dep
///
/// Without preserve_symlinks, the resolved path `project/lib/dep.js` gets
/// canonicalized to `real/lib/dep.js`, and the relative path from
/// `project/src/` to `real/lib/dep.js` becomes `../../real/lib/dep`
/// instead of the correct `../lib/dep`.
#[cfg(unix)]
#[test]
fn issue_11584_preserve_symlinks() {
use std::{fs, os::unix::fs as unix_fs};

let tmpdir = TempDir::new().unwrap();
let base_dir = tmpdir.path().canonicalize().unwrap();

let real_lib = base_dir.join("real").join("lib");
let project_dir = base_dir.join("project");
let project_src = project_dir.join("src");

fs::create_dir_all(&real_lib).unwrap();
fs::create_dir_all(&project_src).unwrap();

// Create the real dep file
fs::write(
real_lib.join("dep.js"),
"module.exports.VALUE = \"hello\";\n",
)
.unwrap();

// Create source file in project/src/
fs::write(
project_src.join("index.js"),
"import { VALUE } from \"../lib/dep\";\n",
)
.unwrap();

// Create symlink: project/lib -> ../real/lib
unix_fs::symlink(&real_lib, project_dir.join("lib")).unwrap();

// The base filename is in project/src/ (a real file, not a symlink).
// The import ../lib/dep resolves to project/lib/dep.js which is a symlink.
let base = FileName::Real(project_src.join("index.js"));

// Without preserve_symlinks (default), the resolved path
// project/lib/dep.js gets canonicalized to real/lib/dep.js and the
// relative path becomes ../../real/lib/dep instead of ../lib/dep.
let resolver_no_preserve = NodeImportResolver::with_config(
NodeModulesResolver::new(swc_ecma_loader::TargetEnv::Node, Default::default(), true),
swc_ecma_transforms_module::path::Config {
base_dir: Some(base_dir.clone()),
preserve_symlinks: false,
..Default::default()
},
);

let result_no_preserve = resolver_no_preserve
.resolve_import(&base, "../lib/dep")
.unwrap();
// This demonstrates the bug: canonicalization resolves the symlink,
// producing a path through the real directory instead of the symlink.
assert_eq!(
&*result_no_preserve, "../../real/lib/dep.js",
"Without preserve_symlinks, canonicalization resolves the symlink to the real path"
);

// With preserve_symlinks, the symlink path should be preserved.
let resolver_preserve = NodeImportResolver::with_config(
NodeModulesResolver::new(swc_ecma_loader::TargetEnv::Node, Default::default(), true),
swc_ecma_transforms_module::path::Config {
base_dir: Some(base_dir.clone()),
preserve_symlinks: true,
..Default::default()
},
);

let result_preserve = resolver_preserve
.resolve_import(&base, "../lib/dep")
.unwrap();
// The resolver appends .js since it resolves to dep.js on disk, but
// the path should stay relative to the symlink location (project/lib/)
// instead of resolving through to the real location (real/lib/).
assert_eq!(
&*result_preserve, "../lib/dep.js",
"With preserve_symlinks, the path should stay relative to the symlink location"
);
}
Loading