Skip to content

Commit b53a0e2

Browse files
leosclaude
andauthored
feat(cli): Add --root-mode argument for .swcrc resolution (#11501)
## Summary Adds `--root-mode` CLI argument to the `swc compile` command, matching Babel's behavior for resolving `.swcrc` configuration files. Closes #1801 ### Modes | Mode | Behavior | |------|----------| | `root` (default) | Only looks for `.swcrc` at the root directory | | `upward` | Walks upward from the file directory, **throws error** if no `.swcrc` is found | | `upward-optional` | Walks upward from the file directory, **falls back silently** if no `.swcrc` is found | ### Changes - **compile.rs**: Add `--root-mode` argument with value parser - **compile.rs**: Use `swcrc: default_swcrc()` in Options construction for consistency with serde default - **config/mod.rs**: Make `default_swcrc()` public so CLI can use it - **lib.rs**: Canonicalize relative paths before calling `find_swcrc` for proper parent traversal - **lib.rs**: Add error when `upward` mode fails to find a `.swcrc` - **issues.rs**: Add 5 CLI tests covering all root-mode behaviors ### Tests | Test | Description | |------|-------------| | `root_mode_upward_finds_parent_config` | Verifies `upward` finds `.swcrc` in parent directory | | `root_mode_root_ignores_parent_config` | Verifies `root` mode does NOT search parent directories | | `root_mode_upward_fails_without_config` | Verifies `upward` fails when no `.swcrc` exists | | `root_mode_upward_optional_succeeds_without_config` | Verifies `upward-optional` succeeds without `.swcrc` | | `root_mode_upward_optional_finds_parent_config` | Verifies `upward-optional` finds parent `.swcrc` | ### Note for maintainers The `Options` struct derives `Default`, which sets `swcrc` to `false` (Rust's bool default). However, the serde configuration uses `#[serde(default = "default_swcrc")]` which returns `true`. To address this discrepancy, the CLI now explicitly sets `swcrc: default_swcrc()` when constructing Options, ensuring consistent behavior. Happy to tweak the PR if you prefer some other approach here. ## Test plan - [x] `cargo test -p swc_cli_impl` - all 9 tests pass - [x] `cargo clippy -p swc_cli_impl --tests -- -D warnings` - no warnings - [x] `cargo fmt --all` - formatted 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fbee775 commit b53a0e2

4 files changed

Lines changed: 221 additions & 3 deletions

File tree

crates/swc/src/config/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -958,7 +958,7 @@ pub enum RootMode {
958958
UpwardOptional,
959959
}
960960

961-
const fn default_swcrc() -> bool {
961+
pub const fn default_swcrc() -> bool {
962962
true
963963
}
964964

crates/swc/src/lib.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,24 @@ impl Compiler {
491491
_ => {
492492
if *swcrc {
493493
if let FileName::Real(ref path) = name {
494-
find_swcrc(path, root, *root_mode)
494+
// Canonicalize relative paths for proper parent traversal
495+
let abs_path = if path.is_relative() {
496+
root.join(path).canonicalize().ok()
497+
} else {
498+
path.canonicalize().ok()
499+
};
500+
let found = abs_path.and_then(|p| find_swcrc(&p, root, *root_mode));
501+
502+
// "upward" mode requires a .swcrc to be found
503+
if found.is_none() && *root_mode == RootMode::Upward {
504+
bail!(
505+
"Could not find .swcrc file while using rootMode \
506+
\"upward\".\nSearched from: {}",
507+
path.display()
508+
);
509+
}
510+
511+
found
495512
} else {
496513
None
497514
}

crates/swc_cli_impl/src/commands/compile.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ use path_absolutize::Absolutize;
1313
use relative_path::RelativePath;
1414
use swc_core::{
1515
base::{
16-
config::{Config, ConfigFile, Options, PluginConfig, SourceMapsConfig},
16+
config::{
17+
default_swcrc, Config, ConfigFile, Options, PluginConfig, RootMode, SourceMapsConfig,
18+
},
1719
try_with_handler, Compiler, HandlerOpts, TransformOutput,
1820
},
1921
common::{
@@ -39,6 +41,11 @@ pub struct CompileOptions {
3941
#[clap(long)]
4042
config_file: Option<PathBuf>,
4143

44+
/// The mode to use for resolving the project root and .swcrc file.
45+
/// Values: root (default), upward, upward-optional
46+
#[clap(long, value_parser = parse_root_mode)]
47+
root_mode: Option<RootMode>,
48+
4249
/// Filename to use when reading from stdin - this will be used in
4350
/// source-maps, errors etc
4451
#[clap(long, short = 'f', group = "input")]
@@ -114,6 +121,17 @@ fn parse_config(s: &str) -> Result<Config, serde_json::Error> {
114121
serde_json::from_str(s)
115122
}
116123

124+
fn parse_root_mode(s: &str) -> Result<RootMode, String> {
125+
match s {
126+
"root" => Ok(RootMode::Root),
127+
"upward" => Ok(RootMode::Upward),
128+
"upward-optional" => Ok(RootMode::UpwardOptional),
129+
_ => Err(format!(
130+
"Invalid root mode '{s}'. Valid values are: root, upward, upward-optional"
131+
)),
132+
}
133+
}
134+
117135
static COMPILER: Lazy<Arc<Compiler>> = Lazy::new(|| {
118136
let cm = Arc::new(SourceMap::new(FilePathMapping::empty()));
119137

@@ -296,6 +314,7 @@ impl CompileOptions {
296314
let mut options = Options {
297315
config: self.config.to_owned().unwrap_or_default(),
298316
config_file,
317+
swcrc: default_swcrc(),
299318
..Options::default()
300319
};
301320

@@ -331,6 +350,10 @@ impl CompileOptions {
331350
options.env_name = env_name.to_string();
332351
}
333352

353+
if let Some(root_mode) = self.root_mode {
354+
options.root_mode = root_mode;
355+
}
356+
334357
if let Some(source_maps) = &self.source_maps {
335358
options.source_maps = Some(match source_maps.as_str() {
336359
"false" => SourceMapsConfig::Bool(false),

crates/swc_cli_impl/tests/issues.rs

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,184 @@ fn issue_9559() -> Result<()> {
158158
Ok(())
159159
}
160160

161+
/// Tests that `--root-mode upward` finds a `.swcrc` in a parent directory.
162+
#[test]
163+
fn root_mode_upward_finds_parent_config() -> Result<()> {
164+
let tmp = TempDir::new()?;
165+
166+
let subdir = tmp.path().join("subdir");
167+
create_dir_all(&subdir)?;
168+
169+
// target: es2022 keeps arrow functions (unlike default es5)
170+
fs::write(
171+
tmp.path().join(".swcrc"),
172+
r#"{
173+
"jsc": {
174+
"parser": { "syntax": "ecmascript" },
175+
"target": "es2022"
176+
}
177+
}"#,
178+
)?;
179+
180+
fs::write(subdir.join("input.js"), "const arrow = () => 'hello';")?;
181+
182+
let mut cmd = cli()?;
183+
cmd.current_dir(&subdir)
184+
.arg("compile")
185+
.arg("--root-mode")
186+
.arg("upward")
187+
.arg("--out-file")
188+
.arg("output.js")
189+
.arg("input.js");
190+
191+
cmd.assert().success();
192+
193+
// Verify the config was found: es2022 keeps arrow functions
194+
let output = fs::read_to_string(subdir.join("output.js"))?;
195+
assert!(
196+
output.contains("=>"),
197+
"Arrow should be kept with es2022 target from parent .swcrc. Got: {output}"
198+
);
199+
200+
Ok(())
201+
}
202+
203+
/// Tests that `--root-mode root` does NOT search parent directories for
204+
/// `.swcrc`.
205+
#[test]
206+
fn root_mode_root_ignores_parent_config() -> Result<()> {
207+
let tmp = TempDir::new()?;
208+
209+
let subdir = tmp.path().join("subdir");
210+
create_dir_all(&subdir)?;
211+
212+
// Parent has .swcrc with es2022 (keeps arrows)
213+
fs::write(
214+
tmp.path().join(".swcrc"),
215+
r#"{
216+
"jsc": {
217+
"parser": { "syntax": "ecmascript" },
218+
"target": "es2022"
219+
}
220+
}"#,
221+
)?;
222+
223+
fs::write(subdir.join("input.js"), "const arrow = () => 'hello';")?;
224+
225+
let mut cmd = cli()?;
226+
cmd.current_dir(&subdir)
227+
.arg("compile")
228+
.arg("--root-mode")
229+
.arg("root")
230+
.arg("--out-file")
231+
.arg("output.js")
232+
.arg("input.js");
233+
234+
cmd.assert().success();
235+
236+
// Parent .swcrc should be ignored, so default es5 transforms arrow to function
237+
let output = fs::read_to_string(subdir.join("output.js"))?;
238+
assert!(
239+
output.contains("function"),
240+
"Parent .swcrc should be ignored with root mode. Got: {output}"
241+
);
242+
243+
Ok(())
244+
}
245+
246+
/// Tests that `--root-mode upward` fails when no `.swcrc` is found.
247+
#[test]
248+
fn root_mode_upward_fails_without_config() -> Result<()> {
249+
let tmp = TempDir::new()?;
250+
251+
// No .swcrc anywhere
252+
fs::write(tmp.path().join("input.js"), "const arrow = () => 'hello';")?;
253+
254+
let mut cmd = cli()?;
255+
cmd.current_dir(&tmp)
256+
.arg("compile")
257+
.arg("--root-mode")
258+
.arg("upward")
259+
.arg("--out-file")
260+
.arg("output.js")
261+
.arg("input.js");
262+
263+
cmd.assert().failure();
264+
265+
Ok(())
266+
}
267+
268+
/// Tests that `--root-mode upward-optional` succeeds even without a `.swcrc`.
269+
#[test]
270+
fn root_mode_upward_optional_succeeds_without_config() -> Result<()> {
271+
let tmp = TempDir::new()?;
272+
273+
// No .swcrc anywhere
274+
fs::write(tmp.path().join("input.js"), "const arrow = () => 'hello';")?;
275+
276+
let mut cmd = cli()?;
277+
cmd.current_dir(&tmp)
278+
.arg("compile")
279+
.arg("--root-mode")
280+
.arg("upward-optional")
281+
.arg("--out-file")
282+
.arg("output.js")
283+
.arg("input.js");
284+
285+
cmd.assert().success();
286+
287+
// Should compile with defaults (es5 transforms arrow to function)
288+
let output = fs::read_to_string(tmp.path().join("output.js"))?;
289+
assert!(
290+
output.contains("function"),
291+
"Should use default es5 target without .swcrc. Got: {output}"
292+
);
293+
294+
Ok(())
295+
}
296+
297+
/// Tests that `--root-mode upward-optional` uses parent `.swcrc` when found.
298+
#[test]
299+
fn root_mode_upward_optional_finds_parent_config() -> Result<()> {
300+
let tmp = TempDir::new()?;
301+
302+
let subdir = tmp.path().join("subdir");
303+
create_dir_all(&subdir)?;
304+
305+
// target: es2022 keeps arrow functions
306+
fs::write(
307+
tmp.path().join(".swcrc"),
308+
r#"{
309+
"jsc": {
310+
"parser": { "syntax": "ecmascript" },
311+
"target": "es2022"
312+
}
313+
}"#,
314+
)?;
315+
316+
fs::write(subdir.join("input.js"), "const arrow = () => 'hello';")?;
317+
318+
let mut cmd = cli()?;
319+
cmd.current_dir(&subdir)
320+
.arg("compile")
321+
.arg("--root-mode")
322+
.arg("upward-optional")
323+
.arg("--out-file")
324+
.arg("output.js")
325+
.arg("input.js");
326+
327+
cmd.assert().success();
328+
329+
// Verify the config was found: es2022 keeps arrow functions
330+
let output = fs::read_to_string(subdir.join("output.js"))?;
331+
assert!(
332+
output.contains("=>"),
333+
"Arrow should be kept with es2022 target from parent .swcrc. Got: {output}"
334+
);
335+
336+
Ok(())
337+
}
338+
161339
/// ln -s $a $b
162340
fn symlink(a: &Path, b: &Path) {
163341
#[cfg(unix)]

0 commit comments

Comments
 (0)