Skip to content

Commit fe38342

Browse files
authored
feat(es): Add jsc.preserveSymlinks to swc::Options (#11813)
## Description Wires the `NodeImportResolver::with_config_preserving_symlinks` constructor (added in #11801) through to `swc::Options` as a new `jsc.preserveSymlinks` boolean config option. When enabled: - `ModuleConfig::get_resolver` skips the `base.canonicalize()` call on the input file, so the input path is left at its symlink location. - `build_resolver` constructs the `NodeImportResolver` via the new preserve-symlinks constructor, so rewritten module specifiers don't re-canonicalize target paths. - The `build_resolver` memoization key is extended with `preserve_symlinks` so the two modes don't share cached resolver instances. Default behaviour is unchanged — canonicalization still happens unless the flag is explicitly set. ## Motivation Monorepos that symlink shared source files (e.g. `shared/foo.ts` symlinked into `packages/app-a/src/foo.ts` and `packages/app-b/src/foo.ts`) break under the current behaviour: once any link is followed to the real path, relative imports inside `shared/` resolve against the real parent directory rather than the importing package, producing module-not-found errors. `resolve.symlinks: false` at the bundler level (rspack, webpack) is not enough if the downstream transformer re-canonicalizes. This PR is the upstream companion to web-infra-dev/rspack#13762, which exposes a `preserveSymlinks` option on `builtin:swc-loader` once this lands. ## Test `crates/swc/tests/preserve_symlinks.rs` (new) exercises the full `swc::Options` pipeline against a tempdir with the shape: ``` root/ src/index.ts // import "../server/source"; shared/source.ts // export const value = 1; server/source.ts // symlink -> ../shared/source.ts ``` It asserts that: - With `jsc.preserveSymlinks = false` (default): the rewritten specifier is `"../shared/source"` (canonicalized). - With `jsc.preserveSymlinks = true`: the rewritten specifier remains `"../server/source"` (symlink preserved). The existing low-level coverage in `crates/swc_ecma_transforms_module/tests/path_node.rs::symlink_paths_are_preserved_only_when_opted_in` is unchanged. ## Related issue: Closes #11584
1 parent 4fb500c commit fe38342

5 files changed

Lines changed: 185 additions & 13 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
swc: major
3+
swc_core: minor
4+
---
5+
6+
feat(es): add `jsc.preserveSymlinks` option to opt out of symlink canonicalization in the module transform resolver

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/swc/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ ansi_term = { workspace = true }
147147
codspeed-criterion-compat = { workspace = true }
148148
criterion = { workspace = true }
149149
par-core = { workspace = true, features = ["chili"] }
150+
tempfile = { workspace = true }
150151
walkdir = { workspace = true }
151152

152153
swc_ecma_ast = { version = "23.0.0", path = "../swc_ecma_ast", features = [

crates/swc/src/config/mod.rs

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -306,10 +306,12 @@ impl Options {
306306
lints,
307307
preserve_all_comments,
308308
rewrite_relative_import_extensions,
309+
preserve_symlinks,
309310
..
310311
} = cfg.jsc;
311312
let loose = loose.into_bool();
312313
let preserve_all_comments = preserve_all_comments.into_bool();
314+
let preserve_symlinks = preserve_symlinks.into_bool();
313315
let keep_class_names = keep_class_names.into_bool();
314316
let external_helpers = external_helpers.into_bool();
315317

@@ -590,7 +592,13 @@ impl Options {
590592
};
591593

592594
let paths = paths.into_iter().collect();
593-
let resolver = ModuleConfig::get_resolver(&base_url, paths, base, cfg.module.as_ref());
595+
let resolver = ModuleConfig::get_resolver(
596+
&base_url,
597+
paths,
598+
base,
599+
cfg.module.as_ref(),
600+
preserve_symlinks,
601+
);
594602

595603
let target = es_version;
596604
let inject_helpers = !self.skip_helper_injection;
@@ -1391,6 +1399,17 @@ pub struct JscConfig {
13911399
/// https://www.typescriptlang.org/tsconfig/#rewriteRelativeImportExtensions
13921400
#[serde(default)]
13931401
pub rewrite_relative_import_extensions: BoolConfig<false>,
1402+
1403+
/// When `true`, symlinked paths are preserved in generated module
1404+
/// specifiers instead of being canonicalized to their real paths.
1405+
///
1406+
/// This is the config-level analogue of Node's `--preserve-symlinks`.
1407+
/// Enable it when your project relies on symlinked source files (for
1408+
/// example, a monorepo that symlinks shared sources into each package)
1409+
/// and you want relative imports inside those files to continue to
1410+
/// resolve against their symlinked location rather than the real path.
1411+
#[serde(default)]
1412+
pub preserve_symlinks: BoolConfig<false>,
13941413
}
13951414

13961415
#[derive(Debug, Default, Clone, Serialize, Deserialize, Merge)]
@@ -1604,6 +1623,7 @@ impl ModuleConfig {
16041623
paths: CompiledPaths,
16051624
base: &FileName,
16061625
config: Option<&ModuleConfig>,
1626+
preserve_symlinks: bool,
16071627
) -> Option<(FileName, Arc<dyn ImportResolver>)> {
16081628
let skip_resolver = base_url.as_os_str().is_empty() && paths.is_empty();
16091629

@@ -1612,46 +1632,57 @@ impl ModuleConfig {
16121632
}
16131633

16141634
let base = match base {
1615-
FileName::Real(v) if !skip_resolver => {
1635+
FileName::Real(v) if !skip_resolver && !preserve_symlinks => {
16161636
FileName::Real(v.canonicalize().unwrap_or_else(|_| v.to_path_buf()))
16171637
}
16181638
_ => base.clone(),
16191639
};
16201640

16211641
let base_url = base_url.to_path_buf();
16221642
let resolver = match config {
1623-
None => build_resolver(base_url, paths, false, &util::Config::default_js_ext()),
1643+
None => build_resolver(
1644+
base_url,
1645+
paths,
1646+
false,
1647+
&util::Config::default_js_ext(),
1648+
preserve_symlinks,
1649+
),
16241650
Some(ModuleConfig::Es6(config)) | Some(ModuleConfig::NodeNext(config)) => {
16251651
build_resolver(
16261652
base_url,
16271653
paths,
16281654
config.config.resolve_fully,
16291655
&config.config.out_file_extension,
1656+
preserve_symlinks,
16301657
)
16311658
}
16321659
Some(ModuleConfig::CommonJs(config)) => build_resolver(
16331660
base_url,
16341661
paths,
16351662
config.resolve_fully,
16361663
&config.out_file_extension,
1664+
preserve_symlinks,
16371665
),
16381666
Some(ModuleConfig::Umd(config)) => build_resolver(
16391667
base_url,
16401668
paths,
16411669
config.config.resolve_fully,
16421670
&config.config.out_file_extension,
1671+
preserve_symlinks,
16431672
),
16441673
Some(ModuleConfig::Amd(config)) => build_resolver(
16451674
base_url,
16461675
paths,
16471676
config.config.resolve_fully,
16481677
&config.config.out_file_extension,
1678+
preserve_symlinks,
16491679
),
16501680
Some(ModuleConfig::SystemJs(config)) => build_resolver(
16511681
base_url,
16521682
paths,
16531683
config.config.resolve_fully,
16541684
&config.config.out_file_extension,
1685+
preserve_symlinks,
16551686
),
16561687
};
16571688

@@ -1680,6 +1711,7 @@ impl ModuleConfig {
16801711
_paths: CompiledPaths,
16811712
_base: &FileName,
16821713
_config: Option<&ModuleConfig>,
1714+
_preserve_symlinks: bool,
16831715
) -> Option<(FileName, Arc<dyn swc_ecma_loader::resolve::Resolve>)> {
16841716
None
16851717
}
@@ -1997,9 +2029,10 @@ fn build_resolver(
19972029
paths: CompiledPaths,
19982030
resolve_fully: bool,
19992031
file_extension: &str,
2032+
preserve_symlinks: bool,
20002033
) -> SwcImportResolver {
20012034
static CACHE: Lazy<
2002-
DashMap<(PathBuf, CompiledPaths, bool, String), SwcImportResolver, FxBuildHasher>,
2035+
DashMap<(PathBuf, CompiledPaths, bool, String, bool), SwcImportResolver, FxBuildHasher>,
20032036
> = Lazy::new(Default::default);
20042037

20052038
// On Windows, we need to normalize path as UNC path.
@@ -2021,6 +2054,7 @@ fn build_resolver(
20212054
paths.clone(),
20222055
resolve_fully,
20232056
file_extension.to_owned(),
2057+
preserve_symlinks,
20242058
)) {
20252059
return cached.clone();
20262060
}
@@ -2037,19 +2071,27 @@ fn build_resolver(
20372071
let r = TsConfigResolver::new(r, base_url.clone(), paths.clone());
20382072
let r = CachingResolver::new(256, r);
20392073

2040-
let r = NodeImportResolver::with_config(
2041-
r,
2042-
modules::path::Config {
2043-
base_dir: Some(base_url.clone()),
2044-
resolve_fully,
2045-
file_extension: file_extension.to_owned(),
2046-
},
2047-
);
2074+
let cfg = modules::path::Config {
2075+
base_dir: Some(base_url.clone()),
2076+
resolve_fully,
2077+
file_extension: file_extension.to_owned(),
2078+
};
2079+
let r = if preserve_symlinks {
2080+
NodeImportResolver::with_config_preserving_symlinks(r, cfg)
2081+
} else {
2082+
NodeImportResolver::with_config(r, cfg)
2083+
};
20482084
Arc::new(r)
20492085
};
20502086

20512087
CACHE.insert(
2052-
(base_url, paths, resolve_fully, file_extension.to_owned()),
2088+
(
2089+
base_url,
2090+
paths,
2091+
resolve_fully,
2092+
file_extension.to_owned(),
2093+
preserve_symlinks,
2094+
),
20532095
r.clone(),
20542096
);
20552097

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! Integration test for `jsc.preserveSymlinks`.
2+
//!
3+
//! Exercises the full `swc::Options` pipeline against a symlinked source file,
4+
//! mirroring the low-level check in
5+
//! `swc_ecma_transforms_module::tests::path_node::symlink_paths_are_preserved_only_when_opted_in`.
6+
use std::{
7+
fs::{create_dir_all, write},
8+
path::Path,
9+
};
10+
11+
use swc::{
12+
config::{Config, IsModule, JscConfig, ModuleConfig, Options},
13+
Compiler,
14+
};
15+
use swc_common::FileName;
16+
use swc_ecma_parser::{Syntax, TsSyntax};
17+
use tempfile::tempdir;
18+
use testing::Tester;
19+
20+
fn create_symlink(target: &Path, link: &Path) {
21+
#[cfg(unix)]
22+
{
23+
std::os::unix::fs::symlink(target, link).unwrap();
24+
}
25+
26+
#[cfg(windows)]
27+
{
28+
std::os::windows::fs::symlink_file(target, link).unwrap();
29+
}
30+
}
31+
32+
fn setup_symlink_fixture(root: &Path) {
33+
create_dir_all(root.join("src")).unwrap();
34+
create_dir_all(root.join("shared")).unwrap();
35+
create_dir_all(root.join("server")).unwrap();
36+
37+
write(root.join("src/index.ts"), "import \"../server/source\";\n").unwrap();
38+
write(root.join("shared/source.ts"), "export const value = 1;\n").unwrap();
39+
40+
create_symlink(
41+
&root.join("shared/source.ts"),
42+
&root.join("server/source.ts"),
43+
);
44+
}
45+
46+
fn options_for(root: &Path, preserve_symlinks: bool) -> Options {
47+
Options {
48+
swcrc: false,
49+
filename: root.join("src/index.ts").display().to_string(),
50+
config: Config {
51+
jsc: JscConfig {
52+
syntax: Some(Syntax::Typescript(TsSyntax::default())),
53+
base_url: root.to_path_buf(),
54+
preserve_symlinks: preserve_symlinks.into(),
55+
..Default::default()
56+
},
57+
module: Some(ModuleConfig::Es6(Default::default())),
58+
is_module: Some(IsModule::Bool(true)),
59+
..Default::default()
60+
},
61+
..Default::default()
62+
}
63+
}
64+
65+
fn compile(index_path: &Path, options: Options) -> String {
66+
let source = std::fs::read_to_string(index_path).unwrap();
67+
let index_path = index_path.to_path_buf();
68+
69+
Tester::new()
70+
.print_errors(|cm, handler| {
71+
let c = Compiler::new(cm.clone());
72+
73+
let fm = cm.new_source_file(FileName::Real(index_path.clone()).into(), source.clone());
74+
let result = c.process_js_file(fm, &handler, &options);
75+
76+
match result {
77+
Ok(v) if !handler.has_errors() => Ok(v.code),
78+
_ => Err(()),
79+
}
80+
})
81+
.unwrap()
82+
}
83+
84+
#[test]
85+
fn preserve_symlinks_default_canonicalizes_import() {
86+
let sandbox = tempdir().unwrap();
87+
let root = sandbox.path().canonicalize().unwrap();
88+
89+
setup_symlink_fixture(&root);
90+
91+
let options = options_for(&root, false);
92+
let code = compile(&root.join("src/index.ts"), options);
93+
94+
assert!(
95+
code.contains("../shared/source"),
96+
"default should canonicalize to the real path; got:\n{code}"
97+
);
98+
assert!(
99+
!code.contains("../server/source"),
100+
"default must not leave the symlink path in the output; got:\n{code}"
101+
);
102+
}
103+
104+
#[test]
105+
fn preserve_symlinks_opt_in_keeps_symlink_import() {
106+
let sandbox = tempdir().unwrap();
107+
let root = sandbox.path().canonicalize().unwrap();
108+
109+
setup_symlink_fixture(&root);
110+
111+
let options = options_for(&root, true);
112+
let code = compile(&root.join("src/index.ts"), options);
113+
114+
assert!(
115+
code.contains("../server/source"),
116+
"preserveSymlinks=true should keep the symlink path; got:\n{code}"
117+
);
118+
assert!(
119+
!code.contains("../shared/source"),
120+
"preserveSymlinks=true must not canonicalize the target; got:\n{code}"
121+
);
122+
}

0 commit comments

Comments
 (0)