diff --git a/.changeset/petite-waves-love.md b/.changeset/petite-waves-love.md new file mode 100644 index 000000000000..2d6f12d8e739 --- /dev/null +++ b/.changeset/petite-waves-love.md @@ -0,0 +1,19 @@ +--- +"@biomejs/biome": patch +--- + +The resolver can now correctly resolve `.ts`, `.tsx`, `.d.ts`, `.js` files by `.js` extension if exists, based on [the file extension substitution in TypeScript](https://www.typescriptlang.org/docs/handbook/modules/reference.html#file-extension-substitution), if the `moduleResolution` option is set to `Node16` or `NodeNext`. + +For example, the linter can now detect the floating promise in the following situation, if you have enabled the `noFloatingPromises` rule. + +**`foo.ts`** +```ts +export async function doSomething(): Promise {} +``` + +**`bar.ts`** +```ts +import { doSomething } from "./foo.js"; // doesn't exist actually, but it is resolved to `foo.ts` + +doSomething(); // floating promise! +``` diff --git a/Cargo.lock b/Cargo.lock index c312f4e4e4be..22b06fd6281a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1329,6 +1329,7 @@ dependencies = [ "biome_json_value", "biome_parser", "biome_rowan", + "biome_string_case", "biome_text_size", "camino", "codspeed-divan-compat", diff --git a/crates/biome_deserialize_macros/src/deserializable_derive.rs b/crates/biome_deserialize_macros/src/deserializable_derive.rs index 71a98fd21562..725ba232568d 100644 --- a/crates/biome_deserialize_macros/src/deserializable_derive.rs +++ b/crates/biome_deserialize_macros/src/deserializable_derive.rs @@ -6,10 +6,11 @@ use self::container_attrs::{ContainerAttrs, UnknownFields}; use self::struct_field_attrs::DeprecatedField; use crate::deserializable_derive::enum_variant_attrs::EnumVariantAttrs; use crate::deserializable_derive::struct_field_attrs::StructFieldAttrs; -use biome_string_case::Case; +use biome_string_case::{Case, StrLikeExtension}; use proc_macro_error2::*; use proc_macro2::{Ident, TokenStream}; use quote::quote; +use std::borrow::Cow; use syn::{Data, GenericParam, Generics, Path, Type}; pub(crate) struct DeriveInput { @@ -66,6 +67,7 @@ impl DeriveInput { .collect(); DeserializableData::Enum(DeserializableEnumData { variants, + case_insensitive: attrs.case_insensitive, with_validator: attrs.with_validator, }) } @@ -167,6 +169,7 @@ pub enum DeserializableData { #[derive(Debug)] pub struct DeserializableEnumData { variants: Vec, + case_insensitive: bool, with_validator: bool, } @@ -240,7 +243,14 @@ fn generate_deserializable_enum( let allowed_variants: Vec<_> = data .variants .iter() - .map(|DeserializableVariantData { key, .. }| quote! { #key }) + .map(|DeserializableVariantData { key, .. }| { + let key = if data.case_insensitive { + key.to_ascii_lowercase_cow() + } else { + Cow::Borrowed(key.as_str()) + }; + quote! { #key } + }) .collect(); let deserialize_variants: Vec<_> = data @@ -251,11 +261,22 @@ fn generate_deserializable_enum( ident: variant_ident, key, }| { + let key = if data.case_insensitive { + key.to_ascii_lowercase_cow() + } else { + Cow::Borrowed(key.as_str()) + }; quote! { #key => Self::#variant_ident } }, ) .collect(); + let discriminant = if data.case_insensitive { + quote! { biome_string_case::StrLikeExtension::to_ascii_lowercase_cow(text.text()).as_ref() } + } else { + quote! { text.text() } + }; + let validator = if data.with_validator { quote! { if !biome_deserialize::DeserializableValidator::validate(&mut result, ctx, name, value.range()) { @@ -275,7 +296,8 @@ fn generate_deserializable_enum( value: &impl biome_deserialize::DeserializableValue, name: &str, ) -> Option { - let mut result = match biome_deserialize::Text::deserialize(ctx, value, name)?.text() { + let text = biome_deserialize::Text::deserialize(ctx, value, name)?; + let mut result = match #discriminant { #(#deserialize_variants),*, unknown_variant => { const ALLOWED_VARIANTS: &[&str] = &[#(#allowed_variants),*]; diff --git a/crates/biome_deserialize_macros/src/deserializable_derive/container_attrs.rs b/crates/biome_deserialize_macros/src/deserializable_derive/container_attrs.rs index 31238868b345..782511c12d5e 100644 --- a/crates/biome_deserialize_macros/src/deserializable_derive/container_attrs.rs +++ b/crates/biome_deserialize_macros/src/deserializable_derive/container_attrs.rs @@ -20,6 +20,8 @@ pub(crate) struct ContainerAttrs { pub try_from: Option, /// Ignore unknown fields in a struct upon deserialization. pub unknown_fields: Option, + /// Allow case-insensitive match for enum variants. Only supported in enums. + pub case_insensitive: bool, } /// Attributes for struct that control how unkinown fields are handled. @@ -74,6 +76,9 @@ impl TryFrom<&Vec> for ContainerAttrs { Err(error) => return Err(Error::new(meta.span(), error)), } } + Meta::Path(path) if path.is_ident("case_insensitive") => { + opts.case_insensitive = true + } _ => { let meta_str = meta.to_token_stream().to_string(); return Err(Error::new( diff --git a/crates/biome_module_graph/src/js_module_info/visitor.rs b/crates/biome_module_graph/src/js_module_info/visitor.rs index 3b79a70e9083..8a04f181a59e 100644 --- a/crates/biome_module_graph/src/js_module_info/visitor.rs +++ b/crates/biome_module_graph/src/js_module_info/visitor.rs @@ -18,6 +18,14 @@ use crate::{ use super::{ResolvedPath, collector::JsModuleInfoCollector}; +/// Extensions to try to resolve based on the extension in the import specifier. +/// ref: https://www.typescriptlang.org/docs/handbook/modules/reference.html#the-moduleresolution-compiler-option +const EXTENSION_ALIASES: &[(&str, &[&str])] = &[ + ("js", &["ts", "tsx", "d.ts", "jsx"]), + ("mjs", &["mts", "d.mts"]), + ("cjs", &["cts", "d.cts"]), +]; + pub(crate) struct JsModuleVisitor<'a> { root: AnyJsRoot, directory: &'a Utf8Path, @@ -393,7 +401,7 @@ impl<'a> JsModuleVisitor<'a> { if let Ok(binding) = node.pattern() { self.visit_binding_pattern( binding, - collector, + collector, ); } } @@ -442,6 +450,7 @@ impl<'a> JsModuleVisitor<'a> { condition_names: &["types", "import", "default"], default_files: &["index"], extensions: SUPPORTED_EXTENSIONS, + extension_aliases: EXTENSION_ALIASES, resolve_node_builtins: true, resolve_types: true, ..Default::default() diff --git a/crates/biome_package/Cargo.toml b/crates/biome_package/Cargo.toml index fc8ffece7323..d91c2e3de7fe 100644 --- a/crates/biome_package/Cargo.toml +++ b/crates/biome_package/Cargo.toml @@ -24,6 +24,7 @@ biome_json_syntax = { workspace = true } biome_json_value = { workspace = true } biome_parser = { workspace = true } biome_rowan = { workspace = true } +biome_string_case = { workspace = true } biome_text_size = { workspace = true } camino = { workspace = true } indexmap = { workspace = true } diff --git a/crates/biome_package/src/lib.rs b/crates/biome_package/src/lib.rs index f82cf39fc678..b8f15c9d205d 100644 --- a/crates/biome_package/src/lib.rs +++ b/crates/biome_package/src/lib.rs @@ -10,7 +10,8 @@ use biome_fs::FileSystem; use camino::Utf8Path; pub use license::generated::*; pub use node_js_package::{ - CompilerOptions, Dependencies, NodeJsPackage, PackageJson, PackageType, TsConfigJson, Version, + CompilerOptions, Dependencies, Module, ModuleResolution, NodeJsPackage, PackageJson, + PackageType, TsConfigJson, Version, }; use std::any::TypeId; diff --git a/crates/biome_package/src/node_js_package/mod.rs b/crates/biome_package/src/node_js_package/mod.rs index a802ef3cd2ec..7daee026c4b9 100644 --- a/crates/biome_package/src/node_js_package/mod.rs +++ b/crates/biome_package/src/node_js_package/mod.rs @@ -3,7 +3,7 @@ mod tsconfig_json; use camino::Utf8Path; pub use package_json::{Dependencies, PackageJson, PackageType, Version}; -pub use tsconfig_json::{CompilerOptions, TsConfigJson}; +pub use tsconfig_json::{CompilerOptions, Module, ModuleResolution, TsConfigJson}; use biome_rowan::Language; 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 86d5daf739e7..7212a83dc86b 100644 --- a/crates/biome_package/src/node_js_package/tsconfig_json.rs +++ b/crates/biome_package/src/node_js_package/tsconfig_json.rs @@ -125,10 +125,50 @@ pub struct CompilerOptions { /// See: https://www.typescriptlang.org/tsconfig/#typeRoots #[deserializable(rename = "typeRoots")] pub type_roots: Option>, + + /// Sets the module system for the program. + /// https://www.typescriptlang.org/tsconfig/#module + pub module: Option, + + /// Specify the module resolution strategy. + /// https://www.typescriptlang.org/tsconfig/#moduleResolution + #[deserializable(rename = "moduleResolution")] + pub module_resolution: Option, } pub type CompilerOptionsPathsMap = IndexMap, BuildHasherDefault>; +#[derive(Copy, Clone, Debug, Eq, PartialEq, Deserializable)] +#[deserializable(case_insensitive)] +pub enum Module { + None, + CommonJS, + Amd, + Umd, + System, + ES6, + ES2015, + ES2020, + ES2022, + ESNext, + Node16, + Node18, + Node20, + NodeNext, + Preserve, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Deserializable)] +#[deserializable(case_insensitive)] +pub enum ModuleResolution { + Classic, + Node, + Node10, + Node16, + NodeNext, + Bundler, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum ExtendsField { Single(String), diff --git a/crates/biome_resolver/src/lib.rs b/crates/biome_resolver/src/lib.rs index 2b63a935c444..9eba1de669cd 100644 --- a/crates/biome_resolver/src/lib.rs +++ b/crates/biome_resolver/src/lib.rs @@ -8,7 +8,7 @@ use std::{borrow::Cow, ops::Deref, sync::Arc}; use biome_fs::normalize_path; use biome_json_value::{JsonObject, JsonValue}; -use biome_package::{PackageJson, TsConfigJson}; +use biome_package::{Module, ModuleResolution, PackageJson, TsConfigJson}; use camino::{Utf8Path, Utf8PathBuf}; pub use errors::*; @@ -45,7 +45,10 @@ pub fn resolve( } if is_relative_specifier(specifier) { - return resolve_relative_path(specifier, base_dir, fs, options); + match resolve_relative_path(specifier, base_dir, fs, options) { + Err(ResolveError::NotFound) => { /* continue below */ } + result => return result, + } } if options.assume_relative { @@ -157,15 +160,27 @@ fn resolve_module( ) -> Result { match &options.package_json { DiscoverableManifest::Auto => match fs.find_package_json(base_dir) { - Ok((package_path, manifest)) => { - resolve_module_with_package_json(specifier, &package_path, &manifest, fs, options) - } + Ok((package_path, manifest)) => resolve_module_with_package_json( + specifier, + base_dir, + &package_path, + &manifest, + fs, + options, + ), Err(_) => resolve_dependency(specifier, base_dir, fs, options), }, DiscoverableManifest::Explicit { package_path, manifest, - } => resolve_module_with_package_json(specifier, package_path, manifest, fs, options), + } => resolve_module_with_package_json( + specifier, + base_dir, + package_path, + manifest, + fs, + options, + ), DiscoverableManifest::Off => resolve_dependency(specifier, base_dir, fs, options), } } @@ -174,8 +189,11 @@ fn resolve_module( /// /// The `package.json` allows us to perform alias lookups as well as /// self-lookups (using the package's own `exports` for resolving internally). +/// +/// The `base_dir` is used for relative path resolution with extension aliases. fn resolve_module_with_package_json( specifier: &str, + base_dir: &Utf8Path, package_path: &Utf8Path, package_json: &PackageJson, fs: &dyn ResolverFsProxy, @@ -212,6 +230,16 @@ fn resolve_module_with_package_json( return resolve_import_alias(specifier, package_path, package_json, fs, options); } + if is_relative_specifier(specifier) { + let path = base_dir.join(specifier); + + return tsconfig + .as_ref() + .ok() + .and_then(|tsconfig| resolve_extension_aliases(&path, tsconfig, fs, options).ok()) + .ok_or(ResolveError::NotFound); + } + if let Some(package_name) = &package_json.name && specifier.starts_with(package_name.as_ref()) && specifier @@ -399,6 +427,58 @@ fn resolve_paths_mapping( Err(ResolveError::NotFound) } +/// Resolves the given path with the extension aliases specified in the options, +/// if the `moduleResolution` option in `tsconfig.json` is set to `Node16` or `NodeNext`. +fn resolve_extension_aliases( + path: &Utf8Path, + tsconfig_json: &TsConfigJson, + fs: &dyn ResolverFsProxy, + options: &ResolveOptions, +) -> Result { + // Skip if the `moduleResolution` option is neither `Node16` nor `NodeNext`. + let is_applicable = matches!( + ( + tsconfig_json.compiler_options.module, + tsconfig_json.compiler_options.module_resolution, + ), + ( + Some(Module::Node16 | Module::Node18 | Module::Node20 | Module::NodeNext), + None, + ) | ( + _, + Some(ModuleResolution::Node16 | ModuleResolution::NodeNext), + ) + ); + if !is_applicable { + return Err(ResolveError::NotFound); + } + + // Skip if no extension is in the path. + let Some(extension) = path.extension() else { + return Err(ResolveError::NotFound); + }; + + // Skip if no extension alias is configured. + let Some(&(_, aliases)) = options + .extension_aliases + .iter() + .find(|(ext, _)| *ext == extension) + else { + return Err(ResolveError::NotFound); + }; + + // Try to resolve the path for each extension alias. + for alias in aliases { + match resolve_absolute_path(path.with_extension(alias), fs, options) { + Ok(path) => return Ok(path), + Err(ResolveError::NotFound) => { /* continue */ } + Err(error) => return Err(error), + } + } + + Err(ResolveError::NotFound) +} + /// Resolves the given `target` string from a target mapping. /// /// When a target is found in the `imports` or `exports` mapping, it can point @@ -747,9 +827,19 @@ pub struct ResolveOptions<'a> { /// Extensions are checked in the order given, meaning the first extension /// in the list has the highest priority. /// - /// Extensions should be provided without leading dot. + /// Extensions should be provided without a leading dot. pub extensions: &'a [&'a str], + /// List of extension aliases to search for in absolute or relative paths. + /// Typically used to resolve `.ts` files by `.js` extension. + /// Same behavior as the `extensionAlias` option in [enhanced-resolve](https://github.com/webpack/enhanced-resolve?tab=readme-ov-file#resolver-options). + /// + /// Note that this option is applicable only if the `moduleResolution` option in tsconfig.json + /// is set to `Node16` or `NodeNext`. + /// + /// Extensions should be provided without a leading dot. + pub extension_aliases: &'a [(&'a str, &'a [&'a str])], + /// Defines which `package.json` file should be used. /// /// See [`DiscoverableManifest`] for more details. @@ -819,6 +909,7 @@ impl<'a> ResolveOptions<'a> { condition_names: &[], default_files: &[], extensions: &[], + extension_aliases: &[], package_json: DiscoverableManifest::Auto, resolve_node_builtins: false, resolve_types: false, @@ -857,6 +948,15 @@ impl<'a> ResolveOptions<'a> { self } + /// Sets [`Self::extension_aliases`] and returns this instance. + pub const fn with_extension_aliases( + mut self, + extension_alias: &'a [(&'a str, &'a [&'a str])], + ) -> Self { + self.extension_aliases = extension_alias; + self + } + /// Sets [`Self::package_json`] and returns this instance. pub fn with_package_json( mut self, @@ -896,6 +996,7 @@ impl<'a> ResolveOptions<'a> { condition_names: self.condition_names, default_files: self.default_files, extensions: self.extensions, + extension_aliases: self.extension_aliases, package_json: DiscoverableManifest::Off, resolve_node_builtins: self.resolve_node_builtins, resolve_types: self.resolve_types, @@ -910,6 +1011,7 @@ impl<'a> ResolveOptions<'a> { condition_names: self.condition_names, default_files: self.default_files, extensions: &[], + extension_aliases: &[], package_json: DiscoverableManifest::Off, resolve_node_builtins: self.resolve_node_builtins, resolve_types: self.resolve_types, diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/dir/index.ts b/crates/biome_resolver/tests/fixtures/resolver_cases_8/dir/index.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/dir/index.tsx b/crates/biome_resolver/tests/fixtures/resolver_cases_8/dir/index.tsx new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/crates/biome_resolver/tests/fixtures/resolver_cases_8/dir/index.tsx @@ -0,0 +1 @@ + diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/dir2/index.mts b/crates/biome_resolver/tests/fixtures/resolver_cases_8/dir2/index.mts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/dir2/index.tsx b/crates/biome_resolver/tests/fixtures/resolver_cases_8/dir2/index.tsx new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/dir3/index.js b/crates/biome_resolver/tests/fixtures/resolver_cases_8/dir3/index.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/dir3/index.ts b/crates/biome_resolver/tests/fixtures/resolver_cases_8/dir3/index.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/index.ts b/crates/biome_resolver/tests/fixtures/resolver_cases_8/index.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/package.json b/crates/biome_resolver/tests/fixtures/resolver_cases_8/package.json new file mode 100644 index 000000000000..79a746a7ca8d --- /dev/null +++ b/crates/biome_resolver/tests/fixtures/resolver_cases_8/package.json @@ -0,0 +1,3 @@ +{ + "name": "resolver_cases_8" +} diff --git a/crates/biome_resolver/tests/fixtures/resolver_cases_8/tsconfig.json b/crates/biome_resolver/tests/fixtures/resolver_cases_8/tsconfig.json new file mode 100644 index 000000000000..fd31bd8287ce --- /dev/null +++ b/crates/biome_resolver/tests/fixtures/resolver_cases_8/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "moduleResolution": "Node16" + } +} diff --git a/crates/biome_resolver/tests/spec_tests.rs b/crates/biome_resolver/tests/spec_tests.rs index 4c94cd38ced1..be0aed5bc001 100644 --- a/crates/biome_resolver/tests/spec_tests.rs +++ b/crates/biome_resolver/tests/spec_tests.rs @@ -684,3 +684,65 @@ fn test_resolve_alias_with_multiple_target_values() { Ok(Utf8PathBuf::from(format!("{base_dir}/src/lib/d.js"))) ); } + +#[test] +fn test_resolve_extension_alias() { + let base_dir = get_fixtures_path("resolver_cases_8"); + let fs = OsFileSystem::new(base_dir.clone()); + + let options = ResolveOptions { + default_files: &["index"], + extensions: &["js"], + extension_aliases: &[("js", &["ts", "tsx"]), ("mjs", &["mts"])], + ..Default::default() + }; + + assert_eq!( + resolve("./index.js", &base_dir, &fs, &options), + Ok(Utf8PathBuf::from(format!("{base_dir}/index.ts"))), + "should alias fully specified file", + ); + + assert_eq!( + resolve("./dir/index.js", &base_dir, &fs, &options), + Ok(Utf8PathBuf::from(format!("{base_dir}/dir/index.ts"))), + "should alias fully specified file when there are two alternatives", + ); + + assert_eq!( + resolve("./dir2/index.js", &base_dir, &fs, &options), + Ok(Utf8PathBuf::from(format!("{base_dir}/dir2/index.tsx"))), + "should also allow the second alternative", + ); + + assert_eq!( + resolve("./dir2/index.mjs", &base_dir, &fs, &options), + Ok(Utf8PathBuf::from(format!("{base_dir}/dir2/index.mts"))), + "should support alias another extension", + ); +} + +#[test] +fn test_resolve_extension_alias_not_apply_to_extension_nor_main_files() { + let base_dir = get_fixtures_path("resolver_cases_8"); + let fs = OsFileSystem::new(base_dir.clone()); + + let options = ResolveOptions { + default_files: &["index"], + extensions: &["js"], + extension_aliases: &[("js", &[])], + ..Default::default() + }; + + assert_eq!( + resolve("./dir3", &base_dir, &fs, &options), + Ok(Utf8PathBuf::from(format!("{base_dir}/dir3/index.js"))), + "directory", + ); + + assert_eq!( + resolve("./dir3/index", &base_dir, &fs, &options), + Ok(Utf8PathBuf::from(format!("{base_dir}/dir3/index.js"))), + "file", + ); +}