Repository: oxc-project/oxc-resolver (rust)

Issue:
`${configDir}` replacement not working in `compilerOptions.baseUrl`
Upstream issue https://github.com/rolldown/rolldown/issues/4041

I don't have a minimal repro, but it can be reproduced on rolldown https://stackblitz.com/edit/github-veeyknzt?file=tsconfig.json. From a quick look of the code base, it looks like oxc-resolver replaces `${configDir}` only in `compilerOptions.paths`. 

Pull request description:
fix: resolve `${configDir}` in tsconfig `compilerOptions.baseUrl`
fixes #447

I took the opportunity to refactor and improve tsconfig extend.

Patch:
diff --git a/fixtures/tsconfig/cases/paths_template_variable/foo.js b/fixtures/tsconfig/cases/paths_template_variable/src/foo.js
similarity index 100%
rename from fixtures/tsconfig/cases/paths_template_variable/foo.js
rename to fixtures/tsconfig/cases/paths_template_variable/src/foo.js
diff --git a/fixtures/tsconfig/cases/paths_template_variable/tsconfig2.json b/fixtures/tsconfig/cases/paths_template_variable/tsconfig.json
similarity index 64%
rename from fixtures/tsconfig/cases/paths_template_variable/tsconfig2.json
rename to fixtures/tsconfig/cases/paths_template_variable/tsconfig.json
index e7d8e5e..3e18be7 100644
--- a/fixtures/tsconfig/cases/paths_template_variable/tsconfig2.json
+++ b/fixtures/tsconfig/cases/paths_template_variable/tsconfig.json
@@ -2,7 +2,7 @@
   "compilerOptions": {
     "composite": true,
     "paths": {
-      "foo": ["${configDir}/foo.js"]
+      "foo": ["${configDir}/src/foo.js"]
     }
   }
 }
diff --git a/fixtures/tsconfig/cases/paths_template_variable/tsconfig1.json b/fixtures/tsconfig/cases/paths_template_variable/tsconfig1.json
deleted file mode 100644
index a1a37bb..0000000
--- a/fixtures/tsconfig/cases/paths_template_variable/tsconfig1.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
-  "extends":	"../../tsconfig_template_variable.json"
-}
diff --git a/fixtures/tsconfig/cases/paths_template_variable/tsconfig_base_url1.json b/fixtures/tsconfig/cases/paths_template_variable/tsconfig_base_url1.json
new file mode 100644
index 0000000..ab849a6
--- /dev/null
+++ b/fixtures/tsconfig/cases/paths_template_variable/tsconfig_base_url1.json
@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    "baseUrl": "${configDir}",
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  }
+}
diff --git a/fixtures/tsconfig/cases/paths_template_variable/tsconfig_base_url2.json b/fixtures/tsconfig/cases/paths_template_variable/tsconfig_base_url2.json
new file mode 100644
index 0000000..ab842c6
--- /dev/null
+++ b/fixtures/tsconfig/cases/paths_template_variable/tsconfig_base_url2.json
@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    "baseUrl": "${configDir}/",
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  }
+}
diff --git a/fixtures/tsconfig/cases/paths_template_variable/tsconfig_extends1.json b/fixtures/tsconfig/cases/paths_template_variable/tsconfig_extends1.json
new file mode 100644
index 0000000..b8a5507
--- /dev/null
+++ b/fixtures/tsconfig/cases/paths_template_variable/tsconfig_extends1.json
@@ -0,0 +1,3 @@
+{
+  "extends":	"../../tsconfig_template_variable1.json"
+}
diff --git a/fixtures/tsconfig/cases/paths_template_variable/tsconfig_extends2.json b/fixtures/tsconfig/cases/paths_template_variable/tsconfig_extends2.json
new file mode 100644
index 0000000..5d83a5c
--- /dev/null
+++ b/fixtures/tsconfig/cases/paths_template_variable/tsconfig_extends2.json
@@ -0,0 +1,3 @@
+{
+  "extends":	"../../tsconfig_template_variable2.json"
+}
diff --git a/fixtures/tsconfig/cases/paths_template_variable/tsconfig_extends3.json b/fixtures/tsconfig/cases/paths_template_variable/tsconfig_extends3.json
new file mode 100644
index 0000000..c5e3492
--- /dev/null
+++ b/fixtures/tsconfig/cases/paths_template_variable/tsconfig_extends3.json
@@ -0,0 +1,3 @@
+{
+  "extends":	"../../tsconfig_template_variable3.json"
+}
diff --git a/fixtures/tsconfig/cases/paths_template_variable/tsconfig_extends4.json b/fixtures/tsconfig/cases/paths_template_variable/tsconfig_extends4.json
new file mode 100644
index 0000000..f856bed
--- /dev/null
+++ b/fixtures/tsconfig/cases/paths_template_variable/tsconfig_extends4.json
@@ -0,0 +1,3 @@
+{
+  "extends":	"../../tsconfig_template_variable4.json"
+}
diff --git a/fixtures/tsconfig/cases/project_references/app/tsconfig.json b/fixtures/tsconfig/cases/project_references/app/tsconfig.json
index 7036d2b..b0d1410 100644
--- a/fixtures/tsconfig/cases/project_references/app/tsconfig.json
+++ b/fixtures/tsconfig/cases/project_references/app/tsconfig.json
@@ -17,7 +17,7 @@
       "path": "../project_c/tsconfig.json"
     },
     {
-      "path": "../../paths_template_variable/tsconfig2.json"
+      "path": "../../paths_template_variable/tsconfig.json"
     }
   ]
 }
diff --git a/fixtures/tsconfig/foo.js b/fixtures/tsconfig/src/foo.js
similarity index 100%
rename from fixtures/tsconfig/foo.js
rename to fixtures/tsconfig/src/foo.js
diff --git a/fixtures/tsconfig/tsconfig.json b/fixtures/tsconfig/tsconfig.json
index eb16d12..be5f4d5 100644
--- a/fixtures/tsconfig/tsconfig.json
+++ b/fixtures/tsconfig/tsconfig.json
@@ -1,7 +1,7 @@
 {
   "compilerOptions": {
     "paths": {
-      "ts-path": ["foo.js"]
+      "ts-path": ["src/foo.js"]
     }
   }
 }
diff --git a/fixtures/tsconfig/tsconfig_template_variable.json b/fixtures/tsconfig/tsconfig_template_variable1.json
similarity index 55%
rename from fixtures/tsconfig/tsconfig_template_variable.json
rename to fixtures/tsconfig/tsconfig_template_variable1.json
index 6f27234..f456bb4 100644
--- a/fixtures/tsconfig/tsconfig_template_variable.json
+++ b/fixtures/tsconfig/tsconfig_template_variable1.json
@@ -1,7 +1,7 @@
 {
   "compilerOptions": {
     "paths": {
-      "foo": ["${configDir}/foo.js"]
+      "foo": ["${configDir}/src/foo.js"]
     }
   }
 }
diff --git a/fixtures/tsconfig/tsconfig_template_variable2.json b/fixtures/tsconfig/tsconfig_template_variable2.json
new file mode 100644
index 0000000..04acb65
--- /dev/null
+++ b/fixtures/tsconfig/tsconfig_template_variable2.json
@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    "baseUrl": "${configDir}",
+    "paths": {
+      "foo": ["./src/foo.js"]
+    }
+  }
+}
diff --git a/fixtures/tsconfig/tsconfig_template_variable3.json b/fixtures/tsconfig/tsconfig_template_variable3.json
new file mode 100644
index 0000000..6643b0b
--- /dev/null
+++ b/fixtures/tsconfig/tsconfig_template_variable3.json
@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "foo": ["${configDir}/src/foo.js"]
+    }
+  }
+}
diff --git a/fixtures/tsconfig/tsconfig_template_variable4.json b/fixtures/tsconfig/tsconfig_template_variable4.json
new file mode 100644
index 0000000..0b72fd4
--- /dev/null
+++ b/fixtures/tsconfig/tsconfig_template_variable4.json
@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    "baseUrl": "${configDir}",
+    "paths": {
+      "foo": ["${configDir}/src/foo.js"]
+    }
+  }
+}
diff --git a/src/fs_cache.rs b/src/fs_cache.rs
index 7b2321f..0a81272 100644
--- a/src/fs_cache.rs
+++ b/src/fs_cache.rs
@@ -179,8 +179,7 @@ impl<Fs: FileSystem> Cache for FsCache<Fs> {
                 ResolveError::from_serde_json_error(tsconfig_path.to_path_buf(), &error)
             })?;
         callback(&mut tsconfig)?;
-        tsconfig.expand_template_variables();
-        let tsconfig = Arc::new(tsconfig);
+        let tsconfig = Arc::new(tsconfig.build());
         tsconfigs.insert(path.to_path_buf(), Arc::clone(&tsconfig));
         Ok(tsconfig)
     }
diff --git a/src/tsconfig.rs b/src/tsconfig.rs
index af5dfb1..3fd04b8 100644
--- a/src/tsconfig.rs
+++ b/src/tsconfig.rs
@@ -1,4 +1,5 @@
 use std::{
+    fmt::Debug,
     hash::BuildHasherDefault,
     path::{Path, PathBuf},
     sync::Arc,
@@ -9,6 +10,8 @@ use rustc_hash::FxHasher;
 
 use crate::{TsconfigReferences, path::PathUtil};
 
+const TEMPLATE_VARIABLE: &str = "${configDir}";
+
 pub type CompilerOptionsPathsMap = IndexMap<String, Vec<String>, BuildHasherDefault<FxHasher>>;
 
 /// Abstract representation for the contents of a `tsconfig.json` file, as well
@@ -17,8 +20,8 @@ pub type CompilerOptionsPathsMap = IndexMap<String, Vec<String>, BuildHasherDefa
 /// This representation makes no assumptions regarding how the file was
 /// deserialized.
 #[allow(clippy::missing_errors_doc)] // trait impls should be free to return any typesafe error
-pub trait TsConfig: Sized {
-    type Co: CompilerOptions;
+pub trait TsConfig: Sized + Debug {
+    type Co: CompilerOptions + Debug;
 
     /// Whether this is the caller tsconfig.
     /// Used for final template variable substitution when all configs are extended and merged.
@@ -74,37 +77,24 @@ pub trait TsConfig: Sized {
         self.compiler_options().base_url().unwrap_or_else(|| self.directory())
     }
 
-    /// Expands all template variables in this tsconfig.
-    fn expand_template_variables(&mut self) {
-        if self.root() {
-            let dir = self.directory().to_path_buf();
-            // Substitute template variable in `tsconfig.compilerOptions.paths`
-            if let Some(paths) = &mut self.compiler_options_mut().paths_mut() {
-                for paths in paths.values_mut() {
-                    for path in paths {
-                        Self::substitute_template_variable(&dir, path);
-                    }
-                }
-            }
-        }
-    }
-
     /// Inherits settings from the given tsconfig into `self`.
     fn extend_tsconfig(&mut self, tsconfig: &Self) {
         let compiler_options = self.compiler_options_mut();
-        if compiler_options.paths().is_none() {
-            compiler_options.set_paths_base(compiler_options.base_url().map_or_else(
-                || tsconfig.compiler_options().paths_base().to_path_buf(),
-                Path::to_path_buf,
-            ));
-            compiler_options.set_paths(tsconfig.compiler_options().paths().cloned());
-        }
+
         if compiler_options.base_url().is_none() {
             if let Some(base_url) = tsconfig.compiler_options().base_url() {
                 compiler_options.set_base_url(base_url.to_path_buf());
             }
         }
 
+        if compiler_options.paths().is_none() {
+            let paths_base = compiler_options
+                .base_url()
+                .map_or_else(|| tsconfig.directory().to_path_buf(), Path::to_path_buf);
+            compiler_options.set_paths_base(paths_base);
+            compiler_options.set_paths(tsconfig.compiler_options().paths().cloned());
+        }
+
         if compiler_options.experimental_decorators().is_none() {
             if let Some(experimental_decorators) =
                 tsconfig.compiler_options().experimental_decorators()
@@ -138,6 +128,65 @@ pub trait TsConfig: Sized {
         }
     }
 
+    /// "Build" the root tsconfig, resolve:
+    ///
+    /// * `{configDir}` template variable
+    /// * `paths_base` for resolving paths alias
+    /// * `baseUrl` to absolute path
+    #[must_use]
+    fn build(mut self) -> Self {
+        // Only the root tsconfig requires paths resolution.
+        if !self.root() {
+            return self;
+        }
+
+        let config_dir = self.directory().to_path_buf();
+
+        if let Some(base_url) = self.compiler_options().base_url() {
+            // Substitute template variable in `tsconfig.compilerOptions.baseUrl`.
+            let base_url = base_url.to_string_lossy().strip_prefix(TEMPLATE_VARIABLE).map_or_else(
+                || config_dir.normalize_with(base_url),
+                |stripped_path| config_dir.join(stripped_path.trim_start_matches('/')),
+            );
+            self.compiler_options_mut().set_base_url(base_url);
+        }
+
+        if self.compiler_options().paths().is_some() {
+            // `bases_base` should use config dir if it is not resolved with base url nor extended
+            // with another tsconfig.
+            if let Some(base_url) = self.compiler_options().base_url().map(Path::to_path_buf) {
+                self.compiler_options_mut().set_paths_base(base_url);
+            }
+
+            if self.compiler_options().paths_base().as_os_str().is_empty() {
+                self.compiler_options_mut().set_paths_base(config_dir.clone());
+            }
+
+            // Substitute template variable in `tsconfig.compilerOptions.paths`.
+            for paths in self.compiler_options_mut().paths_mut().unwrap().values_mut() {
+                for path in paths {
+                    Self::substitute_template_variable(&config_dir, path);
+                }
+            }
+        }
+
+        self
+    }
+
+    /// Template variable `${configDir}` for substitution of config files
+    /// directory path.
+    ///
+    /// NOTE: All tests cases are just a head replacement of `${configDir}`, so
+    ///       we are constrained as such.
+    ///
+    /// See <https://github.com/microsoft/TypeScript/pull/58042>.
+    fn substitute_template_variable(directory: &Path, path: &mut String) {
+        if let Some(stripped_path) = path.strip_prefix(TEMPLATE_VARIABLE) {
+            *path =
+                directory.join(stripped_path.trim_start_matches('/')).to_string_lossy().to_string();
+        }
+    }
+
     /// Resolves the given `specifier` within the project configured by this
     /// tsconfig, relative to the given `path`.
     ///
@@ -220,20 +269,6 @@ pub trait TsConfig: Sized {
             .chain(base_url_iter)
             .collect()
     }
-
-    /// Template variable `${configDir}` for substitution of config files
-    /// directory path.
-    ///
-    /// NOTE: All tests cases are just a head replacement of `${configDir}`, so
-    ///       we are constrained as such.
-    ///
-    /// See <https://github.com/microsoft/TypeScript/pull/58042>.
-    fn substitute_template_variable(directory: &Path, path: &mut String) {
-        const TEMPLATE_VARIABLE: &str = "${configDir}/";
-        if let Some(stripped_path) = path.strip_prefix(TEMPLATE_VARIABLE) {
-            *path = directory.join(stripped_path).to_string_lossy().to_string();
-        }
-    }
 }
 
 /// Compiler Options.
diff --git a/src/tsconfig_serde.rs b/src/tsconfig_serde.rs
index 18eea43..8ffa1da 100644
--- a/src/tsconfig_serde.rs
+++ b/src/tsconfig_serde.rs
@@ -6,8 +6,7 @@ use std::{
 use serde::Deserialize;
 
 use crate::{
-    CompilerOptions, CompilerOptionsPathsMap, PathUtil, ProjectReference, TsConfig,
-    TsconfigReferences,
+    CompilerOptions, CompilerOptionsPathsMap, ProjectReference, TsConfig, TsconfigReferences,
 };
 
 #[derive(Debug, Deserialize)]
@@ -265,14 +264,6 @@ impl TsConfigSerde {
         let mut tsconfig: Self = serde_json::from_str(json)?;
         tsconfig.root = root;
         tsconfig.path = path.to_path_buf();
-        let directory = tsconfig.directory().to_path_buf();
-        if let Some(base_url) = tsconfig.compiler_options.base_url {
-            tsconfig.compiler_options.base_url = Some(directory.normalize_with(base_url));
-        }
-        if tsconfig.compiler_options.paths.is_some() {
-            tsconfig.compiler_options.paths_base =
-                tsconfig.compiler_options.base_url.as_ref().map_or(directory, Clone::clone);
-        }
         Ok(tsconfig)
     }
 }


Test patch:
diff --git a/src/tests/tsconfig_paths.rs b/src/tests/tsconfig_paths.rs
index 2d9fdf8..a79d580 100644
--- a/src/tests/tsconfig_paths.rs
+++ b/src/tests/tsconfig_paths.rs
@@ -11,12 +11,12 @@ use crate::{
 
 // <https://github.com/parcel-bundler/parcel/blob/b6224fd519f95e68d8b93ba90376fd94c8b76e69/packages/utils/node-resolver-rs/src/lib.rs#L2303>
 #[test]
-fn tsconfig() {
+fn tsconfig_resolve() {
     let f = super::fixture_root().join("tsconfig");
 
     #[rustfmt::skip]
     let pass = [
-        (f.clone(), None, "ts-path", f.join("foo.js")),
+        (f.clone(), None, "ts-path", f.join("src/foo.js")),
         (f.join("nested"), None, "ts-path", f.join("nested/test.js")),
         (f.join("cases/index"), None, "foo", f.join("node_modules/tsconfig-index/foo.js")),
         // This requires reading package.json.tsconfig field
@@ -43,7 +43,7 @@ fn tsconfig() {
 
     #[rustfmt::skip]
     let data = [
-        (f.join("node_modules/tsconfig-not-used"), "ts-path", Ok(f.join("foo.js"))),
+        (f.join("node_modules/tsconfig-not-used"), "ts-path", Ok(f.join("src/foo.js"))),
     ];
 
     let resolver = Resolver::new(ResolveOptions {
@@ -130,7 +130,7 @@ fn test_paths() {
         }
     })
     .to_string();
-    let tsconfig = TsConfigSerde::parse(true, path, &mut tsconfig_json).unwrap();
+    let tsconfig = TsConfigSerde::parse(true, path, &mut tsconfig_json).unwrap().build();
 
     let data = [
         ("jquery", vec!["/foo/node_modules/jquery/dist/jquery"]),
@@ -160,7 +160,7 @@ fn test_base_url() {
         }
     })
     .to_string();
-    let tsconfig = TsConfigSerde::parse(true, path, &mut tsconfig_json).unwrap();
+    let tsconfig = TsConfigSerde::parse(true, path, &mut tsconfig_json).unwrap().build();
 
     let data = [
         ("foo", vec!["/foo/src/foo"]),
@@ -191,7 +191,7 @@ fn test_paths_and_base_url() {
         }
     })
     .to_string();
-    let tsconfig = TsConfigSerde::parse(true, path, &mut tsconfig_json).unwrap();
+    let tsconfig = TsConfigSerde::parse(true, path, &mut tsconfig_json).unwrap().build();
 
     let data = [
         ("test", vec!["/foo/src/generated/test", "/foo/src/test"]),
@@ -244,9 +244,17 @@ fn test_template_variable() {
 
     #[rustfmt::skip]
     let pass = [
-        (f2.clone(), "tsconfig1.json", "foo", f2.join("foo.js")),
-        (f2.clone(), "tsconfig2.json", "foo", f2.join("foo.js")),
-        (f.clone(), "tsconfig_template_variable.json", "foo", f.join("foo.js")),
+        (f2.clone(), "tsconfig.json", "foo", f2.join("src/foo.js")),
+        (f2.clone(), "tsconfig_base_url1.json", "@/foo", f2.join("src/foo.js")),
+        (f2.clone(), "tsconfig_base_url2.json", "@/foo", f2.join("src/foo.js")),
+        (f2.clone(), "tsconfig_extends1.json", "foo", f2.join("src/foo.js")),
+        (f2.clone(), "tsconfig_extends2.json", "foo", f2.join("src/foo.js")),
+        (f2.clone(), "tsconfig_extends3.json", "foo", f2.join("src/foo.js")),
+        (f2.clone(), "tsconfig_extends4.json", "foo", f2.join("src/foo.js")),
+        (f.clone(), "tsconfig_template_variable1.json", "foo", f.join("src/foo.js")),
+        (f.clone(), "tsconfig_template_variable2.json", "foo", f.join("src/foo.js")),
+        (f.clone(), "tsconfig_template_variable3.json", "foo", f.join("src/foo.js")),
+        (f.clone(), "tsconfig_template_variable4.json", "foo", f.join("src/foo.js")),
     ];
 
     for (dir, tsconfig, request, expected) in pass {
diff --git a/src/tests/tsconfig_project_references.rs b/src/tests/tsconfig_project_references.rs
index 0d019d2..5556535 100644
--- a/src/tests/tsconfig_project_references.rs
+++ b/src/tests/tsconfig_project_references.rs
@@ -28,7 +28,7 @@ fn auto() {
         // Template variable
         {
             let dir = f.parent().unwrap().join("paths_template_variable");
-            (dir.clone(), "foo", dir.join("foo.js"))
+            (dir.clone(), "foo", dir.join("src/foo.js"))
         }
     ];