diff --git a/.changeset/based-bears-brawl.md b/.changeset/based-bears-brawl.md new file mode 100644 index 000000000000..1b77fc3ef71f --- /dev/null +++ b/.changeset/based-bears-brawl.md @@ -0,0 +1,28 @@ +--- +"@biomejs/biome": minor +--- + +Biome's resolver now supports `baseUrl` if specified in `tsconfig.json`. + +#### Example + +Given the following file structure: + +**`tsconfig.json`** +```json +{ + "compilerOptions": { + "baseUrl": "./src", + } +} +``` + +**`src/foo.ts`** +```ts +export function foo() {} +``` + +In this scenario, `import { foo } from "foo";` should work regardless of the +location of the file containing the `import` statement. + +Fixes [#6432](https://github.com/biomejs/biome/issues/6432). diff --git a/crates/biome_package/src/lib.rs b/crates/biome_package/src/lib.rs index e0a938092a35..f82cf39fc678 100644 --- a/crates/biome_package/src/lib.rs +++ b/crates/biome_package/src/lib.rs @@ -10,7 +10,7 @@ use biome_fs::FileSystem; use camino::Utf8Path; pub use license::generated::*; pub use node_js_package::{ - Dependencies, NodeJsPackage, PackageJson, PackageType, TsConfigJson, Version, + CompilerOptions, Dependencies, NodeJsPackage, PackageJson, PackageType, TsConfigJson, Version, }; use std::any::TypeId; @@ -30,7 +30,10 @@ pub trait Manifest: Debug + Sized { type Language: Language; /// Loads the manifest of the package from the root node. - fn deserialize_manifest(root: &LanguageRoot) -> Deserialized; + fn deserialize_manifest( + root: &LanguageRoot, + path: &Utf8Path, + ) -> Deserialized; /// Reads the manifest from the given `path`. fn read_manifest(fs: &dyn FileSystem, path: &Utf8Path) -> Deserialized; @@ -41,7 +44,7 @@ pub trait Package { type Manifest: Manifest; /// Inserts a manifest into the package, taking care of deserialization. - fn insert_serialized_manifest(&mut self, root: &PackageRoot); + fn insert_serialized_manifest(&mut self, root: &PackageRoot, path: &Utf8Path); fn manifest(&self) -> Option<&Self::Manifest> { None diff --git a/crates/biome_package/src/node_js_package/mod.rs b/crates/biome_package/src/node_js_package/mod.rs index 22596c46ea0f..a802ef3cd2ec 100644 --- a/crates/biome_package/src/node_js_package/mod.rs +++ b/crates/biome_package/src/node_js_package/mod.rs @@ -1,8 +1,9 @@ mod package_json; mod tsconfig_json; +use camino::Utf8Path; pub use package_json::{Dependencies, PackageJson, PackageType, Version}; -pub use tsconfig_json::TsConfigJson; +pub use tsconfig_json::{CompilerOptions, TsConfigJson}; use biome_rowan::Language; @@ -22,8 +23,12 @@ pub struct NodeJsPackage { } impl NodeJsPackage { - pub fn insert_serialized_tsconfig(&mut self, content: &ProjectLanguageRoot) { - let tsconfig = TsConfigJson::deserialize_manifest(content); + pub fn insert_serialized_tsconfig( + &mut self, + content: &ProjectLanguageRoot, + path: &Utf8Path, + ) { + let tsconfig = TsConfigJson::deserialize_manifest(content, path); let (tsconfig, deserialize_diagnostics) = tsconfig.consume(); self.tsconfig = Some(tsconfig.unwrap_or_default()); self.diagnostics = deserialize_diagnostics @@ -46,8 +51,12 @@ pub(crate) type ProjectLanguageRoot = <::Language as Language> impl Package for NodeJsPackage { type Manifest = PackageJson; - fn insert_serialized_manifest(&mut self, content: &ProjectLanguageRoot) { - let deserialized = Self::Manifest::deserialize_manifest(content); + fn insert_serialized_manifest( + &mut self, + content: &ProjectLanguageRoot, + path: &Utf8Path, + ) { + let deserialized = Self::Manifest::deserialize_manifest(content, path); let (manifest, diagnostics) = deserialized.consume(); self.manifest = manifest; self.diagnostics = diagnostics diff --git a/crates/biome_package/src/node_js_package/package_json.rs b/crates/biome_package/src/node_js_package/package_json.rs index d2e2418f599e..4368dbf8fe71 100644 --- a/crates/biome_package/src/node_js_package/package_json.rs +++ b/crates/biome_package/src/node_js_package/package_json.rs @@ -106,7 +106,10 @@ impl PackageJson { impl Manifest for PackageJson { type Language = JsonLanguage; - fn deserialize_manifest(root: &LanguageRoot) -> Deserialized { + fn deserialize_manifest( + root: &LanguageRoot, + _path: &Utf8Path, + ) -> Deserialized { deserialize_from_json_ast::(root, "") } diff --git a/crates/biome_package/src/node_js_package/tsconfig_json.rs b/crates/biome_package/src/node_js_package/tsconfig_json.rs index a513b6a4d90a..86d5daf739e7 100644 --- a/crates/biome_package/src/node_js_package/tsconfig_json.rs +++ b/crates/biome_package/src/node_js_package/tsconfig_json.rs @@ -39,14 +39,23 @@ pub struct TsConfigJson { impl Manifest for TsConfigJson { type Language = JsonLanguage; - fn deserialize_manifest(root: &LanguageRoot) -> Deserialized { - deserialize_from_json_ast::(root, "") + fn deserialize_manifest( + root: &LanguageRoot, + path: &Utf8Path, + ) -> Deserialized { + let deserialized = deserialize_from_json_ast::(root, ""); + let (mut tsconfig, errors) = deserialized.consume(); + if let Some(manifest) = tsconfig.as_mut() { + manifest.initialise_paths(path); + } + + Deserialized::new(tsconfig, errors) } fn read_manifest(fs: &dyn biome_fs::FileSystem, path: &Utf8Path) -> Deserialized { match fs.read_file_from_path(path) { Ok(content) => { - let (manifest, errors) = Self::parse(true, path, &content); + let (manifest, errors) = Self::parse(path, &content); Deserialized::new(Some(manifest), errors) } Err(error) => Deserialized::new(None, vec![Error::from(error)]), @@ -55,7 +64,7 @@ impl Manifest for TsConfigJson { } impl TsConfigJson { - fn parse(root: bool, path: &Utf8Path, json: &str) -> (Self, Vec) { + fn parse(path: &Utf8Path, json: &str) -> (Self, Vec) { let (tsconfig, diagnostics) = deserialize_from_json_str( json, JsonParserOptions::default() @@ -66,34 +75,52 @@ impl TsConfigJson { .consume(); let mut tsconfig: Self = tsconfig.unwrap_or_default(); - tsconfig.root = root; - tsconfig.path = path.to_path_buf(); + tsconfig.initialise_paths(path); + + (tsconfig, diagnostics) + } + + /// Initialises the paths stored in the manifest. + /// + /// `path` must be an absolute path to the `tsconfig.json` file itself. + fn initialise_paths(&mut self, path: &Utf8Path) { + // Some tests that use UNIX paths are not recognised as absolute on + // Windows... + #[cfg(not(target_os = "windows"))] + debug_assert!(path.is_absolute()); + + self.root = true; // For now we only support root configs. + + self.path = path.to_path_buf(); let directory = path.parent(); - if let Some(base_url) = tsconfig.compiler_options.base_url { - tsconfig.compiler_options.base_url = + if let Some(base_url) = self.compiler_options.base_url.as_ref() { + self.compiler_options.base_url = directory.map(|dir| normalize_path(&dir.join(base_url))); } - if tsconfig.compiler_options.paths.is_some() { - tsconfig.compiler_options.paths_base = - tsconfig.compiler_options.base_url.as_ref().map_or_else( - || directory.map_or_else(Default::default, Utf8Path::to_path_buf), - Clone::clone, - ); + if self.compiler_options.paths.is_some() { + self.compiler_options.paths_base = self.compiler_options.base_url.as_ref().map_or_else( + || directory.map_or_else(Default::default, Utf8Path::to_path_buf), + Clone::clone, + ); } - (tsconfig, diagnostics) } } #[derive(Clone, Debug, Default, Deserializable)] pub struct CompilerOptions { + /// https://www.typescriptlang.org/tsconfig/#baseUrl + /// + /// The base URL is normalised to an absolute path after parsing. pub base_url: Option, /// Path aliases. pub paths: Option, /// The actual base from where path aliases are resolved. + /// + /// The base URL is normalised to an absolute path. #[deserializable(skip)] - paths_base: Utf8PathBuf, + pub paths_base: Utf8PathBuf, /// See: https://www.typescriptlang.org/tsconfig/#typeRoots #[deserializable(rename = "typeRoots")] diff --git a/crates/biome_package/tests/manifest_spec_tests.rs b/crates/biome_package/tests/manifest_spec_tests.rs index d733620a38bd..262c7cfd4a03 100644 --- a/crates/biome_package/tests/manifest_spec_tests.rs +++ b/crates/biome_package/tests/manifest_spec_tests.rs @@ -1,9 +1,8 @@ use biome_diagnostics::{DiagnosticExt, print_diagnostic_to_string}; use biome_json_parser::{JsonParserOptions, parse_json}; use biome_package::{NodeJsPackage, Package}; -use std::ffi::OsStr; +use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; use std::fs::read_to_string; -use std::path::Path; mod manifest { tests_macros::gen_tests! {"tests/manifest/invalid/*.{json}", crate::run_invalid_manifests, "module"} @@ -15,16 +14,16 @@ mod tsconfig { } fn run_invalid_manifests(input: &'static str, _: &str, _: &str, _: &str) { - let input_file = Path::new(input); - let file_name = input_file.file_name().and_then(OsStr::to_str).unwrap(); + let input_file = Utf8Path::new(input); + let file_name = input_file.file_name().unwrap(); let input_code = read_to_string(input_file) .unwrap_or_else(|err| panic!("failed to read {input_file:?}: {err:?}")); let mut package = NodeJsPackage::default(); - match input_file.extension().map(OsStr::as_encoded_bytes) { + match input_file.extension().map(str::as_bytes) { Some(b"json") => { let parsed = parse_json(input_code.as_str(), JsonParserOptions::default()); - package.insert_serialized_manifest(&parsed.tree()); + package.insert_serialized_manifest(&parsed.tree(), input_file); } _ => { panic!("Extension not supported"); @@ -69,19 +68,19 @@ fn run_invalid_manifests(input: &'static str, _: &str, _: &str, _: &str) { } fn run_invalid_tsconfig(input: &'static str, _: &str, _: &str, _: &str) { - let input_file = Path::new(input); - let file_name = input_file.file_name().and_then(OsStr::to_str).unwrap(); + let input_file = Utf8Path::new(input); + let file_name = input_file.file_name().unwrap(); let input_code = read_to_string(input_file) .unwrap_or_else(|err| panic!("failed to read {input_file:?}: {err:?}")); let mut project = NodeJsPackage::default(); - match input_file.extension().map(OsStr::as_encoded_bytes) { + match input_file.extension().map(str::as_bytes) { Some(b"json") => { let parsed = parse_json( input_code.as_str(), JsonParserOptions::default().with_allow_comments(), ); - project.insert_serialized_tsconfig(&parsed.tree()); + project.insert_serialized_tsconfig(&parsed.tree(), input_file); } _ => { panic!("Extension not supported"); @@ -126,19 +125,19 @@ fn run_invalid_tsconfig(input: &'static str, _: &str, _: &str, _: &str) { } fn run_valid_tsconfig(input: &'static str, _: &str, _: &str, _: &str) { - let input_file = Path::new(input); - let file_name = input_file.file_name().and_then(OsStr::to_str).unwrap(); + let input_file = Utf8Path::new(input); + let file_name = input_file.file_name().unwrap(); let input_code = read_to_string(input_file) .unwrap_or_else(|err| panic!("failed to read {input_file:?}: {err:?}")); let mut project = NodeJsPackage::default(); - match input_file.extension().map(OsStr::as_encoded_bytes) { + match input_file.extension().map(str::as_bytes) { Some(b"json") => { let parsed = parse_json( input_code.as_str(), JsonParserOptions::default().with_allow_comments(), ); - project.insert_serialized_tsconfig(&parsed.tree()); + project.insert_serialized_tsconfig(&parsed.tree(), input_file); } _ => { panic!("Extension not supported"); @@ -154,11 +153,38 @@ fn run_valid_tsconfig(input: &'static str, _: &str, _: &str, _: &str) { let mut snapshot_result = String::new(); + let strip_prefix = |path: &mut Utf8PathBuf| { + if path.to_string().is_empty() { + return; + } + + assert!(path.is_absolute()); + let mut stripped_path = Utf8PathBuf::from(""); + let mut past_prefix = false; + for component in path.components() { + if past_prefix { + stripped_path.push(component); + } else if component == Utf8Component::Normal("tests") { + past_prefix = true; + } + } + *path = stripped_path; + }; + + let mut tsconfig = project.tsconfig.unwrap(); + strip_prefix(&mut tsconfig.path); + strip_prefix(&mut tsconfig.compiler_options.paths_base); + tsconfig + .compiler_options + .base_url + .as_mut() + .map(strip_prefix); + snapshot_result.push_str("## Input\n\n"); snapshot_result.push_str(&input_code); snapshot_result.push_str("\n\n"); snapshot_result.push_str("## Data structure\n\n"); - snapshot_result.push_str(&format!("{:#?}", project.tsconfig.unwrap())); + snapshot_result.push_str(&format!("{tsconfig:#?}").replace("\\\\", "/")); insta::with_settings!({ prepend_module_to_snapshot => false, diff --git a/crates/biome_package/tests/tsconfig/valid/tsconfig.valid.baseUrl.json.snap b/crates/biome_package/tests/tsconfig/valid/tsconfig.valid.baseUrl.json.snap index d303d62584a8..64734f71acdd 100644 --- a/crates/biome_package/tests/tsconfig/valid/tsconfig.valid.baseUrl.json.snap +++ b/crates/biome_package/tests/tsconfig/valid/tsconfig.valid.baseUrl.json.snap @@ -14,12 +14,12 @@ expression: tsconfig.valid.baseUrl.json ## Data structure TsConfigJson { - root: false, - path: "", + root: true, + path: "/tsconfig/valid/tsconfig.valid.baseUrl.json", extends: None, compiler_options: CompilerOptions { base_url: Some( - "src", + "/tsconfig/valid/src", ), paths: None, paths_base: "", diff --git a/crates/biome_package/tests/tsconfig/valid/tsconfig.valid.paths.json.snap b/crates/biome_package/tests/tsconfig/valid/tsconfig.valid.paths.json.snap index da49f7803332..b33171c7d0ea 100644 --- a/crates/biome_package/tests/tsconfig/valid/tsconfig.valid.paths.json.snap +++ b/crates/biome_package/tests/tsconfig/valid/tsconfig.valid.paths.json.snap @@ -20,12 +20,12 @@ expression: tsconfig.valid.paths.json ## Data structure TsConfigJson { - root: false, - path: "", + root: true, + path: "/tsconfig/valid/tsconfig.valid.paths.json", extends: None, compiler_options: CompilerOptions { base_url: Some( - "src", + "/tsconfig/valid/src", ), paths: Some( { @@ -35,7 +35,7 @@ TsConfigJson { ], }, ), - paths_base: "", + paths_base: "/tsconfig/valid/src", type_roots: None, }, references: [], diff --git a/crates/biome_project_layout/src/project_layout.rs b/crates/biome_project_layout/src/project_layout.rs index 6d12bfd07b81..adb65b65608c 100644 --- a/crates/biome_project_layout/src/project_layout.rs +++ b/crates/biome_project_layout/src/project_layout.rs @@ -126,7 +126,7 @@ impl ProjectLayout { /// See also [Self::insert_node_manifest()]. pub fn insert_serialized_node_manifest(&self, path: Utf8PathBuf, manifest: &SendNode) { self.0.pin().update_or_insert_with( - path, + path.clone(), |data| { let mut node_js_package = NodeJsPackage { manifest: Default::default(), @@ -137,7 +137,10 @@ impl ProjectLayout { .map(|package| package.tsconfig.clone()) .unwrap_or_default(), }; - node_js_package.insert_serialized_manifest(&manifest.to_language_root()); + node_js_package.insert_serialized_manifest( + &manifest.to_language_root(), + &path.join("package.json"), + ); PackageData { node_package: Some(node_js_package), @@ -145,7 +148,10 @@ impl ProjectLayout { }, || { let mut node_js_package = NodeJsPackage::default(); - node_js_package.insert_serialized_manifest(&manifest.to_language_root()); + node_js_package.insert_serialized_manifest( + &manifest.to_language_root(), + &path.join("package.json"), + ); PackageData { node_package: Some(node_js_package), @@ -158,7 +164,7 @@ impl ProjectLayout { /// parsing the manifest on demand. pub fn insert_serialized_tsconfig(&self, path: Utf8PathBuf, manifest: &SendNode) { self.0.pin().update_or_insert_with( - path, + path.clone(), |data| { let mut node_js_package = NodeJsPackage { manifest: data @@ -169,7 +175,10 @@ impl ProjectLayout { diagnostics: Default::default(), tsconfig: Default::default(), }; - node_js_package.insert_serialized_tsconfig(&manifest.to_language_root()); + node_js_package.insert_serialized_tsconfig( + &manifest.to_language_root(), + &path.join("tsconfig.json"), + ); PackageData { node_package: Some(node_js_package), @@ -177,7 +186,10 @@ impl ProjectLayout { }, || { let mut node_js_package = NodeJsPackage::default(); - node_js_package.insert_serialized_tsconfig(&manifest.to_language_root()); + node_js_package.insert_serialized_tsconfig( + &manifest.to_language_root(), + &path.join("tsconfig.json"), + ); PackageData { node_package: Some(node_js_package), diff --git a/crates/biome_resolver/src/lib.rs b/crates/biome_resolver/src/lib.rs index 2829fc51f37a..a67154c6c0ae 100644 --- a/crates/biome_resolver/src/lib.rs +++ b/crates/biome_resolver/src/lib.rs @@ -183,9 +183,15 @@ fn resolve_module_with_package_json( ) -> Result { // `tsconfig.json` may only be found in directories containing a // `package.json`, so this is the only place we need to attempt to use it. - let tsconfig = fs.read_tsconfig_json(&package_path.join("tsconfig.json")); - if let Some(path) = tsconfig.as_ref().ok().and_then(|ts_config| { - resolve_paths_mapping(specifier, ts_config, package_path, fs, options).ok() + let tsconfig = match &options.tsconfig { + DiscoverableManifest::Auto => fs + .read_tsconfig_json(&package_path.join("tsconfig.json")) + .map(Cow::Owned), + DiscoverableManifest::Explicit { manifest, .. } => Ok(Cow::Borrowed(*manifest)), + DiscoverableManifest::Off => Err(ResolveError::NotFound), + }; + if let Some(path) = tsconfig.as_ref().ok().and_then(|tsconfig| { + resolve_paths_mapping(specifier, tsconfig, package_path, fs, options).ok() }) { return Ok(path); } @@ -222,6 +228,17 @@ fn resolve_module_with_package_json( ); } + if let Some(base_url) = tsconfig + .as_ref() + .ok() + .and_then(|tsconfig| tsconfig.compiler_options.base_url.as_ref()) + { + match resolve_relative_path(specifier, base_url, fs, options) { + Err(ResolveError::NotFound) => { /* continue below */ } + result => return result, + } + } + resolve_dependency(specifier, package_path, fs, options) } @@ -337,12 +354,12 @@ fn resolve_paths_mapping( let resolve_specifier = |specifier: &str| { if is_relative_specifier(specifier) { - let base_dir = match &tsconfig_json.compiler_options.base_url { - Some(base_url) => base_url.as_path(), - None => package_path, - }; - - resolve_relative_path(specifier, base_dir, fs, options) + resolve_relative_path( + specifier, + &tsconfig_json.compiler_options.paths_base, + fs, + options, + ) } else { resolve_dependency(specifier, package_path, fs, options) } @@ -686,6 +703,7 @@ fn strip_query_and_fragment(specifier: &str) -> &str { } /// Options to pass to the resolver. +#[derive(Clone)] pub struct ResolveOptions<'a> { /// If `true`, specifiers are assumed to be relative paths. Resolving them /// as a package will still be attempted if resolving as a relative path @@ -898,7 +916,7 @@ impl<'a> ResolveOptions<'a> { /// `tsconfig.json` will be automatically discovered, but this enum allows to /// turn them off completely, or to provide an explicit manifest to be used /// instead. -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] pub enum DiscoverableManifest { #[default] Auto, diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_7/node_modules/bar/index.js b/crates/biome_resolver/tests/fixtures/resolver_cases_7/node_modules/bar/index.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_7/node_modules/bar/package.json b/crates/biome_resolver/tests/fixtures/resolver_cases_7/node_modules/bar/package.json new file mode 100644 index 000000000000..68da66cd41e1 --- /dev/null +++ b/crates/biome_resolver/tests/fixtures/resolver_cases_7/node_modules/bar/package.json @@ -0,0 +1,8 @@ +{ + "name": "bar", + "exports": { + ".": { + "default": "./index.js" + } + } +} diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_7/package.json b/crates/biome_resolver/tests/fixtures/resolver_cases_7/package.json new file mode 100644 index 000000000000..32ada4129ca0 --- /dev/null +++ b/crates/biome_resolver/tests/fixtures/resolver_cases_7/package.json @@ -0,0 +1,3 @@ +{ + "name": "resolver_cases_7" +} diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_7/src/bar.ts b/crates/biome_resolver/tests/fixtures/resolver_cases_7/src/bar.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_7/src/foo/foo.ts b/crates/biome_resolver/tests/fixtures/resolver_cases_7/src/foo/foo.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_7/tsconfig.json b/crates/biome_resolver/tests/fixtures/resolver_cases_7/tsconfig.json new file mode 100644 index 000000000000..985c756e2969 --- /dev/null +++ b/crates/biome_resolver/tests/fixtures/resolver_cases_7/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "baseUrl": "./src" + } +} diff --git a/crates/biome_resolver/tests/spec_tests.rs b/crates/biome_resolver/tests/spec_tests.rs index e9b6b61ba5f5..3d3a274f981c 100644 --- a/crates/biome_resolver/tests/spec_tests.rs +++ b/crates/biome_resolver/tests/spec_tests.rs @@ -1,4 +1,5 @@ use biome_fs::OsFileSystem; +use biome_package::{CompilerOptions, TsConfigJson}; use biome_resolver::*; use camino::{Utf8Path, Utf8PathBuf}; @@ -533,3 +534,83 @@ fn test_resolve_type_definitions_without_type_specification() { ))) ); } + +#[test] +fn test_resolve_from_base_url() { + let base_dir = get_fixtures_path("resolver_cases_7"); + let fs = OsFileSystem::new(base_dir.clone()); + + let options = ResolveOptions { + condition_names: &["default"], + extensions: &["js", "ts"], + ..Default::default() + }; + + // Make sure resolution works with explicitly specified `tsconfig.json`. + assert_eq!( + resolve( + "bar", + &base_dir.join("src"), + &fs, + &options + .clone() + .with_tsconfig(DiscoverableManifest::Explicit { + package_path: Utf8PathBuf::from(format!("{base_dir}/tsconfig.json")), + manifest: &TsConfigJson { + compiler_options: CompilerOptions { + base_url: Some(base_dir.join("src")), + ..Default::default() + }, + ..Default::default() + } + }) + ), + Ok(Utf8PathBuf::from(format!("{base_dir}/src/bar.ts"))) + ); + + // Make sure resolution works with auto-discovered `tsconfig.json`. + assert_eq!( + resolve("bar", &base_dir.join("src"), &fs, &options), + Ok(Utf8PathBuf::from(format!("{base_dir}/src/bar.ts"))) + ); + + // It shouldn't matter if we're resolving from a different base directory. + assert_eq!( + resolve("bar", &base_dir, &fs, &options), + Ok(Utf8PathBuf::from(format!("{base_dir}/src/bar.ts"))) + ); + assert_eq!( + resolve("bar", &base_dir.join("src/foo"), &fs, &options), + Ok(Utf8PathBuf::from(format!("{base_dir}/src/bar.ts"))) + ); + + // Resolution should work in subfolders too. + assert_eq!( + resolve("foo/foo", &base_dir.join("src"), &fs, &options), + Ok(Utf8PathBuf::from(format!("{base_dir}/src/foo/foo.ts"))) + ); + + // Make sure resolution falls back to `node_modules/` without `baseUrl`. + assert_eq!( + resolve( + "bar", + &base_dir.join("src"), + &fs, + &options + .clone() + .with_tsconfig(DiscoverableManifest::Explicit { + package_path: Utf8PathBuf::from(format!("{base_dir}/tsconfig.json")), + manifest: &TsConfigJson { + compiler_options: CompilerOptions { + base_url: None, + ..Default::default() + }, + ..Default::default() + } + }) + ), + Ok(Utf8PathBuf::from(format!( + "{base_dir}/node_modules/bar/index.js" + ))) + ); +}