Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Empty file.
1 change: 1 addition & 0 deletions fixtures/tsconfig/cases/extends-symlink/project/src/foo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = "wrong";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "extends": "shared-config/base" }
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "extends": "./configs/base.json" }
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": "..",
"paths": {
"@app/*": ["src/*"]
}
}
}
1 change: 1 addition & 0 deletions fixtures/tsconfig/cases/extends-symlink/src/foo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = "foo";
9 changes: 6 additions & 3 deletions src/cache/cache_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,13 @@ impl<Fs: FileSystem> Cache<Fs> {
ResolveError::from(err)
}
})?;
let tsconfig_real_path = self
.canonicalize(&self.value(&tsconfig_path))
.unwrap_or_else(|_| tsconfig_path.to_path_buf());
let mut tsconfig =
TsConfig::parse(root, &tsconfig_path, tsconfig_string).map_err(|error| {
ResolveError::from_serde_json_error(tsconfig_path.to_path_buf(), &error)
})?;
TsConfig::parse(root, &tsconfig_path, &tsconfig_real_path, tsconfig_string).map_err(
|error| ResolveError::from_serde_json_error(tsconfig_path.to_path_buf(), &error),
)?;

// Run callback (extends/references processing)
callback(&mut tsconfig)?;
Expand Down
131 changes: 128 additions & 3 deletions src/tests/tsconfig_extends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
//! Tests the `extend_tsconfig` method which is responsible for inheriting
//! settings from one tsconfig into another.

use std::path::{Path, PathBuf};
use std::{
fs,
path::{Path, PathBuf},
};

use crate::{
ResolveOptions, Resolver, TsConfig, TsconfigDiscovery, TsconfigOptions, TsconfigReferences,
Expand Down Expand Up @@ -180,8 +183,9 @@ fn test_extend_tsconfig_no_override_existing() {
})
.to_string();

let parent_tsconfig = TsConfig::parse(true, parent_path, parent_config).unwrap().build();
let mut child_tsconfig = TsConfig::parse(true, child_path, child_config).unwrap();
let parent_tsconfig =
TsConfig::parse(true, parent_path, parent_path, parent_config).unwrap().build();
let mut child_tsconfig = TsConfig::parse(true, child_path, child_path, child_config).unwrap();

// Perform the extension
child_tsconfig.extend_tsconfig(&parent_tsconfig);
Expand Down Expand Up @@ -365,3 +369,124 @@ fn test_extend_package() {
assert_eq!(compiler_options.target, Some("ES2020".to_string()));
}
}

/// Create a directory symlink, cleaning up any stale one from previous runs.
/// Returns `false` if symlinks are not supported on this platform.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
fn create_dir_symlink(target: &Path, link: &Path) -> bool {
let _ = fs::remove_file(link);
let _ = fs::remove_dir_all(link);

#[cfg(target_family = "unix")]
{
std::os::unix::fs::symlink(target, link).unwrap();
true
}
#[cfg(target_os = "windows")]
{
std::os::windows::fs::symlink_dir(target, link).unwrap();
true
}
#[cfg(target_family = "wasm")]
{
false
}
}

/// Assert that `@app/foo` resolves to `extends-symlink/src/foo.ts` (via the real
/// base config path), not `extends-symlink/project/src/foo.ts` (via the symlink).
fn assert_symlink_extends_resolves_correctly(config_file: PathBuf, resolve_dir: &Path) {
let resolver = Resolver::new(ResolveOptions {
tsconfig: Some(TsconfigDiscovery::Manual(TsconfigOptions {
config_file,
references: TsconfigReferences::Disabled,
})),
extensions: vec![".ts".into(), ".js".into()],
..ResolveOptions::default()
});

let resolved_path = resolver
.resolve(resolve_dir, "@app/foo")
.expect("should resolve @app/foo via tsconfig paths")
.full_path();
assert!(
resolved_path.ends_with("src/foo.ts"),
"expected path ending with src/foo.ts, got {resolved_path:?}"
);
assert!(
!resolved_path.to_string_lossy().contains("project/src"),
"should resolve to root src/foo.ts, not project/src/foo.ts, got {resolved_path:?}"
);
}

/// When a tsconfig extends another via a symlinked package name (e.g. pnpm workspace),
/// `baseUrl` and `paths` should be resolved relative to the real (canonical) path
/// of the extended tsconfig, matching TypeScript's behavior.
#[test]
#[cfg_attr(target_family = "wasm", ignore)]
fn test_extend_tsconfig_via_symlink_package() {
let f = super::fixture_root().join("tsconfig/cases/extends-symlink");
let symlink_path = f.join("project/node_modules/shared-config");
let real_target = f.join("real-configs").canonicalize().unwrap();

if !create_dir_symlink(&real_target, &symlink_path) {
return;
}

// extends: "shared-config/base"
assert_symlink_extends_resolves_correctly(f.join("project/tsconfig.json"), &f.join("project"));

let _ = fs::remove_file(&symlink_path);
let _ = fs::remove_dir_all(&symlink_path);
}

/// Same as above but with a relative `extends` path going through a symlinked directory.
#[test]
#[cfg_attr(target_family = "wasm", ignore)]
fn test_extend_tsconfig_via_symlink_relative() {
let f = super::fixture_root().join("tsconfig/cases/extends-symlink");
let symlink_path = f.join("project/configs");
let real_target = f.join("real-configs").canonicalize().unwrap();

if !create_dir_symlink(&real_target, &symlink_path) {
return;
}

// extends: "./configs/base.json"
assert_symlink_extends_resolves_correctly(
f.join("project/tsconfig.relative.json"),
&f.join("project"),
);

let _ = fs::remove_file(&symlink_path);
let _ = fs::remove_dir_all(&symlink_path);
}

/// Same as above but with an absolute `extends` path going through a symlinked directory.
#[test]
#[cfg_attr(target_family = "wasm", ignore)]
fn test_extend_tsconfig_via_symlink_absolute() {
let f = super::fixture_root().join("tsconfig/cases/extends-symlink");
// Use a unique symlink name to avoid racing with the relative test
let symlink_path = f.join("project/configs-abs");
let real_target = f.join("real-configs").canonicalize().unwrap();

if !create_dir_symlink(&real_target, &symlink_path) {
return;
}

// Write a tsconfig with an absolute extends path at runtime (not portable for fixtures)
let absolute_tsconfig = f.join("project/tsconfig.absolute.json");
let absolute_extends = symlink_path.join("base.json");
fs::write(
&absolute_tsconfig,
format!(r#"{{ "extends": "{}" }}"#, absolute_extends.to_string_lossy().replace('\\', "/")),
)
.unwrap();

assert_symlink_extends_resolves_correctly(absolute_tsconfig.clone(), &f.join("project"));

let _ = fs::remove_file(&absolute_tsconfig);
let _ = fs::remove_file(&symlink_path);
let _ = fs::remove_dir_all(&symlink_path);
}
32 changes: 29 additions & 3 deletions src/tsconfig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ pub struct TsConfig {
#[serde(skip)]
pub path: PathBuf,

/// Canonical (real) path to `tsconfig.json`, with symlinks resolved.
///
/// TypeScript computes `baseUrl` and `paths` relative to the real path of the
/// tsconfig, not the symlink location. TypeScript's `preserveSymlinks` option
/// only affects module resolution, not tsconfig `extends` resolution.
#[serde(skip)]
pub real_path: PathBuf,

#[serde(default)]
pub files: Option<Vec<PathBuf>>,

Expand Down Expand Up @@ -84,7 +92,12 @@ impl TsConfig {
/// # Errors
///
/// * Any error that can be returned by `serde_json::from_str()`.
pub fn parse(root: bool, path: &Path, json: String) -> Result<Self, serde_json::Error> {
pub fn parse(
root: bool,
path: &Path,
real_path: &Path,
json: String,
) -> Result<Self, serde_json::Error> {
let mut json = json.into_bytes();
replace_bom_with_whitespace(&mut json);
_ = json_strip_comments::strip_slice(&mut json);
Expand All @@ -95,14 +108,16 @@ impl TsConfig {
};
tsconfig.root = root;
tsconfig.path = path.to_path_buf();
tsconfig.real_path = real_path.to_path_buf();
let real_directory = tsconfig.real_directory();
tsconfig.compiler_options.paths_base =
tsconfig.compiler_options.base_url.as_ref().map_or_else(
|| tsconfig.directory().to_path_buf(),
|| real_directory.to_path_buf(),
|base_url| {
if base_url.to_string_lossy().starts_with(TEMPLATE_VARIABLE) {
base_url.clone()
} else {
tsconfig.directory().normalize_with(base_url)
real_directory.normalize_with(base_url)
}
},
);
Expand Down Expand Up @@ -146,6 +161,17 @@ impl TsConfig {
self.path.parent().unwrap()
}

/// Directory to `tsconfig.json`, with symlinks resolved.
///
/// # Panics
///
/// * When the `tsconfig.json` real path is misconfigured.
#[must_use]
pub fn real_directory(&self) -> &Path {
debug_assert!(self.real_path.file_name().is_some());
self.real_path.parent().unwrap()
}

/// Returns any paths to tsconfigs that should be extended by this tsconfig.
pub(crate) fn extends(&self) -> impl Iterator<Item = &str> {
let specifiers = match &self.extends {
Expand Down
47 changes: 31 additions & 16 deletions src/tsconfig_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,30 @@ use crate::{

#[derive(Default)]
pub struct TsconfigResolveContext {
/// Original paths, used for error messages.
extended_configs: Vec<PathBuf>,
/// Real (canonical) paths, used for circular detection so that symlinked
/// paths are correctly identified as the same file.
extended_configs_real: Vec<PathBuf>,
}

impl TsconfigResolveContext {
pub fn with_extended_file<R, T: FnOnce(&mut Self) -> R>(&mut self, path: PathBuf, cb: T) -> R {
pub fn with_extended_file<R, T: FnOnce(&mut Self) -> R>(
&mut self,
path: PathBuf,
real_path: PathBuf,
cb: T,
) -> R {
self.extended_configs.push(path);
self.extended_configs_real.push(real_path);
let result = cb(self);
self.extended_configs.pop();
self.extended_configs_real.pop();
result
}

pub fn is_already_extended(&self, path: &Path) -> bool {
self.extended_configs.iter().any(|config| config == path)
pub fn is_already_extended(&self, real_path: &Path) -> bool {
self.extended_configs_real.iter().any(|config| config == real_path)
}

pub fn get_extended_configs_with(&self, path: PathBuf) -> Vec<PathBuf> {
Expand Down Expand Up @@ -184,7 +195,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
let directory = self.cache.value(tsconfig.directory());
tracing::trace!(tsconfig = ?tsconfig, "load_tsconfig");

if ctx.is_already_extended(tsconfig.path()) {
if ctx.is_already_extended(&tsconfig.real_path) {
return Err(ResolveError::TsconfigCircularExtend(
ctx.get_extended_configs_with(tsconfig.path().to_path_buf()).into(),
));
Expand All @@ -196,18 +207,22 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
.map(|specifier| self.get_extended_tsconfig_path(&directory, tsconfig, specifier))
.collect::<Result<Vec<_>, _>>()?;
if !extended_tsconfig_paths.is_empty() {
ctx.with_extended_file(tsconfig.path().to_owned(), |ctx| {
for extended_tsconfig_path in extended_tsconfig_paths {
let extended_tsconfig = self.load_tsconfig(
/* root */ false,
&extended_tsconfig_path,
TsconfigReferences::Disabled,
ctx,
)?;
tsconfig.extend_tsconfig(&extended_tsconfig);
}
Result::Ok::<(), ResolveError>(())
})?;
ctx.with_extended_file(
tsconfig.path().to_owned(),
tsconfig.real_path.clone(),
|ctx| {
for extended_tsconfig_path in extended_tsconfig_paths {
let extended_tsconfig = self.load_tsconfig(
/* root */ false,
&extended_tsconfig_path,
TsconfigReferences::Disabled,
ctx,
)?;
tsconfig.extend_tsconfig(&extended_tsconfig);
}
Result::Ok::<(), ResolveError>(())
},
)?;
}

if tsconfig.load_references(references) {
Expand Down
Loading