Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(linter): implement useImportExtensions #2725

Merged
merged 12 commits into from
May 20, 2024
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ our [guidelines for writing a good changelog entry](https://github.com/biomejs/b

#### New features

- Add [nursery/useImportExtensions](https://biomejs.dev/linter/rules/use-import-extensions/).
Contributed by @minht11

- Add [nursery/useThrowNewError](https://biomejs.dev/linter/rules/use-throw-new-error/).
Contributed by @minht11

Expand Down Expand Up @@ -100,6 +103,7 @@ z.object({})

- Add [nursery/noUselessStringConcat](https://biomejs.dev/linter/rules/no-useless-string-concat/).
- Add [nursery/useExplicitLengthCheck](https://biomejs.dev/linter/rules/use-explicit-length-check/).
Contributed by @minht11
ematipico marked this conversation as resolved.
Show resolved Hide resolved

- `useExhaustiveDependencies` now recognizes (some) dependencies that change on
every render ([#2374](https://github.com/biomejs/biome/issues/2374)).
Expand Down
31 changes: 25 additions & 6 deletions crates/biome_configuration/src/linter/rules.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/biome_diagnostics_categories/src/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ define_categories! {
"lint/nursery/useExplicitLengthCheck": "https://biomejs.dev/linter/rules/use-explicit-length-check",
"lint/nursery/useFocusableInteractive": "https://biomejs.dev/linter/rules/use-focusable-interactive",
"lint/nursery/useGenericFontNames": "https://biomejs.dev/linter/rules/use-generic-font-names",
"lint/nursery/useImportExtensions": "https://biomejs.dev/linter/rules/use-import-extensions",
"lint/nursery/useImportRestrictions": "https://biomejs.dev/linter/rules/use-import-restrictions",
"lint/nursery/useSortedClasses": "https://biomejs.dev/linter/rules/use-sorted-classes",
"lint/nursery/useThrowNewError": "https://biomejs.dev/linter/rules/use-throw-new-error",
Expand Down
2 changes: 2 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

221 changes: 221 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery/use_import_extensions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
use std::path::{Component, Path};

use biome_analyze::{
context::RuleContext, declare_rule, ActionCategory, Ast, FixKind, Rule, RuleDiagnostic,
};
use biome_console::markup;
use biome_diagnostics::Applicability;
use biome_js_factory::make;
use biome_js_syntax::{inner_string_text, AnyJsImportSpecifierLike, JsLanguage};
use biome_rowan::{BatchMutationExt, SyntaxToken};

use crate::JsRuleAction;

declare_rule! {
/// Require import extensions for relative imports.
minht11 marked this conversation as resolved.
Show resolved Hide resolved
///
/// Browsers and Node.js do not natively support importing files without extensions. This rule
/// enforces the use of import extensions for relative imports to make the code more consistent.
minht11 marked this conversation as resolved.
Show resolved Hide resolved
///
/// Tooling also benefits from explicit import extensions, because they do not need to guess which
minht11 marked this conversation as resolved.
Show resolved Hide resolved
/// file to resolve.
///
/// ## Examples
///
/// ### Invalid
///
/// ```js,expect_diagnostic
/// import "./foo";
/// ```
/// ```js,expect_diagnostic
/// import "./bar/";
/// ```
/// ```js,expect_diagnostic
/// import "../";
/// ```
/// ```js,expect_diagnostic
/// import "../.";
/// ```
///
/// ### Valid
///
/// ```js
/// import "biome";
/// ```
/// ```js
/// import "./foo.js";
/// ```
/// ```js
/// import "./bar/index.js";
/// ```
///
/// ## Caveats
///
/// If you are using TypeScript, TypeScript version 5.0 and later is required, also make sure to enable
/// [allowImportingTsExtensions=true](https://typescriptlang.org/tsconfig#allowImportingTsExtensions) in your `tsconfig.json`.
///
/// Rule does not yet check filesystem for file type. It tries to guess which extension
/// it should add based on the file extension of the current file and the import path.
/// When applying the suggested fix, make sure to verify that the file type is correct.
///
pub UseImportExtensions {
version: "next",
name: "useImportExtensions",
language: "js",
recommended: false,
fix_kind: FixKind::Unsafe,
}
}

impl Rule for UseImportExtensions {
type Query = Ast<AnyJsImportSpecifierLike>;
type State = UseImportExtensionsState;
type Signals = Option<Self::State>;
type Options = ();

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();

let file_ext = ctx.file_path().extension()?.to_str()?;

get_extensionless_import(file_ext, node)
}

fn diagnostic(_: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
Some(
RuleDiagnostic::new(
rule_category!(),
state.module_name_token.text_range(),
markup! {
"Add a file extension for relative imports."
},
)
.note(markup! {
"Explicit import improves compatibility with browsers and makes file resolution in tooling faster."
}),
)
}

fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<JsRuleAction> {
let mut mutation = ctx.root().begin();

let (suggested_path, extension) = state.suggestion.clone()?;
let new_module_name = if ctx.as_preferred_quote().is_double() {
make::js_string_literal(&suggested_path)
} else {
make::js_string_literal_single_quotes(&suggested_path)
};

mutation.replace_element(
state.module_name_token.clone().into(),
new_module_name.into(),
);

Some(JsRuleAction {
category: ActionCategory::QuickFix,
applicability: Applicability::MaybeIncorrect,
message: markup! {
"Add potential import extension "<Emphasis>"."{extension}</Emphasis>"."
}
.to_owned(),
mutation,
})
}
}

pub struct UseImportExtensionsState {
suggestion: Option<(String, String)>,
module_name_token: SyntaxToken<JsLanguage>,
}

fn get_extensionless_import(
Conaclos marked this conversation as resolved.
Show resolved Hide resolved
file_ext: &str,
node: &AnyJsImportSpecifierLike,
) -> Option<UseImportExtensionsState> {
let module_name_token = node.module_name_token()?;
let module_path = inner_string_text(&module_name_token);
let path = Path::new(module_path.text());
let mut path_components = path.components();
let first_component = path_components.next()?;

if !matches!(first_component, Component::CurDir | Component::ParentDir)
|| path.extension().is_some()
{
return None;
}

let last_component = path_components.last().unwrap_or(first_component);
let has_query_or_hash = last_component
.as_os_str()
.to_str()
.map_or(false, |last| last.contains('?') || last.contains('#'));

if has_query_or_hash {
return Some(UseImportExtensionsState {
module_name_token,
suggestion: None,
});
}

let import_ext = resolve_import_extension(file_ext, path);
let mut path_buf = path.to_path_buf();

let is_index_file = match last_component {
Component::ParentDir => true,
// `import ".././"` is the same as `import "../"`
// Rust Path does not expose `./` path segment at very end, likely because it does not do anything.
// To provide proper fix, we need to remove it as well.
Component::Normal(os_str) if module_path.ends_with("./") || module_path.ends_with('.') => {
if let Some(base_name) = os_str.to_str() {
path_buf.set_file_name(base_name);

true
} else {
false
}
}
_ if module_path.ends_with('/') => true,
_ => false,
};

if is_index_file {
let part = format!("index.{}", import_ext);
path_buf.push(part);
} else {
path_buf.set_extension(import_ext);
}

Some(UseImportExtensionsState {
module_name_token: module_name_token.clone(),
suggestion: Some((
path_buf.to_string_lossy().to_string(),
import_ext.to_string(),
)),
})
}

fn resolve_import_extension<'a>(file_ext: &str, path: &Path) -> &'a str {
// TODO. This is not very accurate. We should use file system access to determine the file type.
let (potential_ext, potential_component_ext) = match file_ext {
"ts" | "tsx" | "astro" => ("ts", "tsx"),
"mts" => ("mts", "tsx"),
"mjs" => ("mjs", "jsx"),
minht11 marked this conversation as resolved.
Show resolved Hide resolved
"cjs" => ("cjs", "jsx"),
"cts" => ("cts", "tsx"),
// Unlikely that these frameworks would import tsx file.
"svelte" | "vue" => ("ts", "ts"),
_ => ("js", "jsx"),
};

let maybe_is_component = path
.file_stem()
.and_then(|stem| stem.to_str())
.and_then(|stem| stem.chars().next())
.is_some_and(|c| c.is_uppercase());

if maybe_is_component {
return potential_component_ext;
}

potential_ext
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ declare_rule! {
}

impl Rule for UseImportRestrictions {
// TODO. This does not handle dynamic imports and require calls.
type Query = Ast<JsModuleSource>;
type State = ImportRestrictionsState;
type Signals = Option<Self::State>;
Expand Down Expand Up @@ -132,6 +133,8 @@ fn get_restricted_import(module_path: &TokenText) -> Option<ImportRestrictionsSt
let mut path_parts: Vec<_> = module_path.text().split('/').collect();
let mut index_filename = None;

// TODO. The implementation could be optimized further by using
// `Path::new(module_path.text())` for further inspiration see `use_import_extensions` rule.
if let Some(extension) = get_extension(&path_parts) {
if !SOURCE_EXTENSIONS.contains(&extension) {
return None; // Resource files are exempt.
Expand Down
2 changes: 2 additions & 0 deletions crates/biome_js_analyze/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ pub type UseHookAtTopLevel =
pub type UseHtmlLang = <lint::a11y::use_html_lang::UseHtmlLang as biome_analyze::Rule>::Options;
pub type UseIframeTitle =
<lint::a11y::use_iframe_title::UseIframeTitle as biome_analyze::Rule>::Options;
pub type UseImportExtensions =
<lint::nursery::use_import_extensions::UseImportExtensions as biome_analyze::Rule>::Options;
pub type UseImportRestrictions =
<lint::nursery::use_import_restrictions::UseImportRestrictions as biome_analyze::Rule>::Options;
pub type UseImportType =
Expand Down