feat(resolver): tsconfig project references (#862)

closes #751
This commit is contained in:
Boshen 2023-09-07 14:01:48 +08:00 committed by GitHub
parent 58f4e234da
commit 5bbad73db5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 167 additions and 29 deletions

View file

@ -2,6 +2,9 @@
* [enhanced-resolve](https://github.com/webpack/enhanced-resolve) configurations
* built-in [tsconfig-paths-webpack-plugin](https://github.com/dividab/tsconfig-paths-webpack-plugin)
* support extending tsconfig defined in `tsconfig.extends`
* support paths alias defined in `tsconfig.compilerOptions.paths`
* support project references defined `tsconfig.references`
#### Resolver Options

View file

@ -1,6 +1,6 @@
use once_cell::sync::OnceCell as OnceLock;
use std::{
borrow::Borrow,
borrow::{Borrow, Cow},
convert::AsRef,
hash::{BuildHasherDefault, Hash, Hasher},
ops::Deref,
@ -45,18 +45,23 @@ impl<Fs: FileSystem> Cache<Fs> {
pub fn tsconfig(
&self,
tsconfig_path: &Path,
tsconfig_path: &CachedPath,
callback: impl FnOnce(&mut TsConfig) -> Result<(), ResolveError>, // callback for modifying tsconfig with `extends`
) -> Result<Arc<TsConfig>, ResolveError> {
self.tsconfigs
.entry(tsconfig_path.to_path_buf())
.entry(tsconfig_path.path().to_path_buf())
.or_try_insert_with(|| {
let tsconfig_path = if tsconfig_path.is_dir(&self.fs) {
Cow::Owned(tsconfig_path.path().join("tsconfig.json"))
} else {
Cow::Borrowed(tsconfig_path.path())
};
let mut tsconfig_string = self
.fs
.read_to_string(tsconfig_path)
.read_to_string(&tsconfig_path)
.map_err(|_| ResolveError::NotFound(tsconfig_path.to_path_buf()))?;
let mut tsconfig =
TsConfig::parse(tsconfig_path, &mut tsconfig_string).map_err(|error| {
TsConfig::parse(&tsconfig_path, &mut tsconfig_string).map_err(|error| {
ResolveError::from_serde_json_error(tsconfig_path.to_path_buf(), &error)
})?;
callback(&mut tsconfig)?;

View file

@ -224,7 +224,9 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
}
// tsconfig-paths
if let Some(path) = self.load_tsconfig_paths(specifier, &mut ResolveContext::default())? {
if let Some(path) =
self.load_tsconfig_paths(cached_path, specifier, &mut ResolveContext::default())?
{
return Ok(path);
}
@ -890,11 +892,16 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
Err(ResolveError::ExtensionAlias)
}
fn load_tsconfig_paths(&self, specifier: &str, ctx: &mut ResolveContext) -> ResolveState {
fn load_tsconfig_paths(
&self,
cached_path: &CachedPath,
specifier: &str,
ctx: &mut ResolveContext,
) -> ResolveState {
let Some(tsconfig_path) = &self.options.tsconfig else { return Ok(None) };
let tsconfig_path = self.cache.value(tsconfig_path);
let tsconfig = self.load_tsconfig(&tsconfig_path)?;
let paths = tsconfig.resolve_path_alias(specifier);
let paths = tsconfig.resolve(cached_path.path(), specifier);
for path in paths {
let cached_path = self.cache.value(&path);
if let Ok(path) = self.require_relative(&cached_path, ".", ctx) {
@ -905,28 +912,35 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
}
fn load_tsconfig(&self, cached_path: &CachedPath) -> Result<Arc<TsConfig>, ResolveError> {
debug_assert!(cached_path.path().extension().is_some_and(|ext| ext == "json"));
self.cache.tsconfig(cached_path.path(), |tsconfig| {
if tsconfig.extends().is_empty() {
return Ok(());
self.cache.tsconfig(cached_path, |tsconfig| {
// Extend tsconfig
if !tsconfig.extends().is_empty() {
let resolver = self.clone_with_options(ResolveOptions {
extensions: vec![".json".into()],
main_files: vec!["tsconfig".into()],
..ResolveOptions::default()
});
let mut extended_tsconfigs = vec![];
for tsconfig_extend_specifier in tsconfig.extends() {
let extended_tsconfig_path = resolver.require(
cached_path.parent().unwrap(),
tsconfig_extend_specifier,
&mut ResolveContext::default(),
)?;
let extended_tsconfig = self.load_tsconfig(&extended_tsconfig_path)?;
extended_tsconfigs.push(extended_tsconfig);
}
for extended_tsconfig in extended_tsconfigs {
tsconfig.extend_tsconfig(&extended_tsconfig);
}
}
let resolver = self.clone_with_options(ResolveOptions {
extensions: vec![".json".into()],
main_files: vec!["tsconfig".into()],
..ResolveOptions::default()
});
let mut extended_tsconfigs = vec![];
for tsconfig_extend_specifier in tsconfig.extends() {
let extended_tsconfig_path = resolver.require(
cached_path.parent().unwrap(),
tsconfig_extend_specifier,
&mut ResolveContext::default(),
)?;
let extended_tsconfig = self.load_tsconfig(&extended_tsconfig_path)?;
extended_tsconfigs.push(extended_tsconfig);
}
for extended_tsconfig in extended_tsconfigs {
tsconfig.extend_tsconfig(&extended_tsconfig);
// Load project references
let directory = tsconfig.directory().to_path_buf();
for reference in tsconfig.references_mut() {
let reference_tsconfig_path =
self.cache.value(&directory.normalize_with(&reference.path));
let tsconfig = self.cache.tsconfig(&reference_tsconfig_path, |_| Ok(()))?;
reference.tsconfig.replace(tsconfig);
}
Ok(())
})

View file

@ -17,6 +17,7 @@ mod scoped_packages;
mod simple;
mod symlink;
mod tsconfig_paths;
mod tsconfig_project_references;
use crate::Resolver;
use std::{env, path::PathBuf, sync::Arc, thread};

View file

@ -0,0 +1,32 @@
//! Tests for tsconfig project references
use crate::{ResolveOptions, Resolver};
use std::env;
#[test]
fn test() {
let f = env::current_dir().unwrap().join("tests/tsconfig_project_references");
let resolver = Resolver::new(ResolveOptions {
tsconfig: Some(f.join("app")),
..ResolveOptions::default()
});
#[rustfmt::skip]
let pass = [
// Test normal paths alias
(f.join("app"), "@/index.ts", f.join("app/aliased/index.ts")),
(f.join("app"), "@/../index.ts", f.join("app/index.ts")),
// Test project reference
(f.join("project_a"), "@/index.ts", f.join("project_a/aliased/index.ts")),
(f.join("project_b/src"), "@/index.ts", f.join("project_b/src/aliased/index.ts")),
// Does not have paths alias
(f.join("project_a"), "./index.ts", f.join("project_a/index.ts")),
(f.join("project_c"), "./index.ts", f.join("project_c/index.ts")),
];
for (path, request, expected) in pass {
let resolved_path = resolver.resolve(&path, request).map(|f| f.full_path());
assert_eq!(resolved_path, Ok(expected), "{request} {path:?}");
}
}

View file

@ -1,6 +1,7 @@
use std::{
hash::BuildHasherDefault,
path::{Path, PathBuf},
sync::Arc,
};
use crate::{json_comments::strip_comments_in_place, PathUtil};
@ -20,10 +21,26 @@ pub struct TsConfig {
#[serde(default, deserialize_with = "deserialize_extends")]
extends: Vec<String>,
#[serde(default)]
references: Vec<ProjectReference>,
#[serde(default)]
compiler_options: CompilerOptions,
}
/// Project Reference
/// <https://www.typescriptlang.org/docs/handbook/project-references.html>
#[derive(Debug, Deserialize)]
pub struct ProjectReference {
/// The path property of each reference can point to a directory containing a tsconfig.json file,
/// or to the config file itself (which may have any name).
pub path: PathBuf,
/// Reference to the resolved tsconfig
#[serde(skip)]
pub tsconfig: Option<Arc<TsConfig>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompilerOptions {
@ -79,6 +96,17 @@ impl TsConfig {
&self.extends
}
pub fn references_mut(&mut self) -> &mut Vec<ProjectReference> {
self.references.as_mut()
}
fn base_path(&self) -> &Path {
self.compiler_options
.base_url
.as_ref()
.map_or_else(|| self.directory(), |path| path.as_ref())
}
pub fn extend_tsconfig(&mut self, tsconfig: &Self) {
let compiler_options = &mut self.compiler_options;
if compiler_options.base_url.is_none() {
@ -90,6 +118,20 @@ impl TsConfig {
}
}
pub fn resolve(&self, path: &Path, specifier: &str) -> Vec<PathBuf> {
if path.starts_with(self.base_path()) {
return self.resolve_path_alias(specifier);
}
for reference in &self.references {
if let Some(tsconfig) = &reference.tsconfig {
if path.starts_with(tsconfig.base_path()) {
return tsconfig.resolve_path_alias(specifier);
}
}
}
vec![]
}
// Copied from parcel
// <https://github.com/parcel-bundler/parcel/blob/b6224fd519f95e68d8b93ba90376fd94c8b76e69/packages/utils/node-resolver-rs/src/tsconfig.rs#L93>
pub fn resolve_path_alias(&self, specifier: &str) -> Vec<PathBuf> {

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["./aliased/*"]
}
},
"references": [
{
"path": "../project_a/conf.json"
},
{
"path": "../project_b"
},
{
"path": "../project_c/tsconfig.json"
}
]
}

View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"paths": {
"@/*": ["./aliased/*"]
}
}
}

View file

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"baseUrl": "./src",
"paths": {
"@/*": ["./aliased/*"]
}
}
}

View file

@ -0,0 +1,5 @@
{
"compilerOptions": {
"composite": true
}
}