diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 403a73445d3b9..cffeda576539d 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -10,6 +10,7 @@ mod import { pub mod exports_last; pub mod no_absolute_path; pub mod no_anonymous_default_export; + pub mod no_empty_named_blocks; pub mod no_mutable_exports; // pub mod no_deprecated; // pub mod no_unused_modules; @@ -701,6 +702,7 @@ oxc_macros::declare_all_lint_rules! { import::export, import::exports_last, import::first, + import::no_empty_named_blocks, import::no_anonymous_default_export, import::no_absolute_path, import::no_mutable_exports, diff --git a/crates/oxc_linter/src/rules/import/no_empty_named_blocks.rs b/crates/oxc_linter/src/rules/import/no_empty_named_blocks.rs new file mode 100644 index 0000000000000..fb597a578deea --- /dev/null +++ b/crates/oxc_linter/src/rules/import/no_empty_named_blocks.rs @@ -0,0 +1,130 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +fn no_empty_named_blocks_diagnostic(span: Span) -> OxcDiagnostic { + // See for details + OxcDiagnostic::warn("Unexpected empty named import block.").with_label(span) +} + +#[derive(Debug, Default, Clone)] +pub struct NoEmptyNamedBlocks; + +declare_oxc_lint!( + /// ### What it does + /// + /// Enforces that named import blocks are not empty + /// + /// ### Why is this bad? + /// + /// Empty named imports serve no practical purpose and + /// often result from accidental deletions or tool-generated code. + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```js + /// import {} from 'mod' + /// import Default, {} from 'mod' + /// ``` + /// + /// Examples of **correct** code for this rule: + /// ```js + /// import { mod } from 'mod' + /// import Default, { mod } from 'mod' + /// ``` + NoEmptyNamedBlocks, + import, + suspicious, + fix +); + +impl Rule for NoEmptyNamedBlocks { + #[expect(clippy::cast_possible_truncation)] + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ImportDeclaration(import_decl) = node.kind() else { + return; + }; + // if "import 'foo'" + let Some(specifiers) = import_decl.specifiers.as_ref() else { + return; + }; + if specifiers.is_empty() { + // for "import {} from 'mod'" + ctx.diagnostic_with_fix(no_empty_named_blocks_diagnostic(import_decl.span), |fixer| { + fixer.delete_range(import_decl.span) + }); + } + + let source_token_str = Span::new(import_decl.span.start, import_decl.source.span.start - 1) + .source_text(ctx.source_text()); + // find is there anything between '{' and '}' + if let Some(start) = source_token_str.find('{') { + if let Some(end) = source_token_str[start..].find('}') { + let between_braces = &source_token_str[start + 1..start + end]; + if between_braces.trim().is_empty() { + ctx.diagnostic_with_fix( + no_empty_named_blocks_diagnostic(import_decl.span), + |fixer| { + // "import a, {} from 'mod" => "import a from 'mod'" + // we just remove the space between ',' and '}' + if let Some(comma_idx) = source_token_str[..start].rfind(',') { + let remove_start = comma_idx as u32; + let remove_end = (start + end + 1) as u32; + fixer.delete_range(Span::new(remove_start, remove_end)) + } else { + fixer.noop() + } + }, + ); + } + } + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + "import 'mod'", + "import { mod } from 'mod'", + "import Default, { mod } from 'mod'", + "import { Named } from 'mod'", + "import type { Named } from 'mod'", + "import type Default, { Named } from 'mod'", + "import type * as Namespace from 'mod'", + "import * as Namespace from 'mod'", + ]; + + let fail = vec![ + "import {} from 'mod'", + "import Default, {} from 'mod'", + "import{}from'mod'", + "import type {}from'mod'", + "import type {} from 'mod'", + "import type{}from 'mod'", + ]; + + let fix = vec![ + ("import Default, {} from 'mod'", "import Default from 'mod'", None), + ("import { } from 'mod'", "", None), + ("import a, {} from 'mod'", "import a from 'mod'", None), + ("import a, { } from 'mod'", "import a from 'mod'", None), + ("import a, { } from 'mod'", "import a from 'mod'", None), + ("import a, { } from 'mod'", "import a from 'mod'", None), + ("import a, { } from'mod'", "import a from'mod'", None), + ("import type a, { } from'mod'", "import type a from'mod'", None), + ("import a,{} from 'mod'", "import a from 'mod'", None), + ("import type a,{} from 'foo'", "import type a from 'foo'", None), + ("import type {} from 'foo'", "", None), + ]; + + Tester::new(NoEmptyNamedBlocks::NAME, NoEmptyNamedBlocks::PLUGIN, pass, fail) + .expect_fix(fix) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/import_no_empty_named_blocks.snap b/crates/oxc_linter/src/snapshots/import_no_empty_named_blocks.snap new file mode 100644 index 0000000000000..606c7ba8e849d --- /dev/null +++ b/crates/oxc_linter/src/snapshots/import_no_empty_named_blocks.snap @@ -0,0 +1,74 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ eslint-plugin-import(no-empty-named-blocks): Unexpected empty named import block. + ╭─[no_empty_named_blocks.tsx:1:1] + 1 │ import {} from 'mod' + · ──────────────────── + ╰──── + help: Delete this code. + + ⚠ eslint-plugin-import(no-empty-named-blocks): Unexpected empty named import block. + ╭─[no_empty_named_blocks.tsx:1:1] + 1 │ import {} from 'mod' + · ──────────────────── + ╰──── + + ⚠ eslint-plugin-import(no-empty-named-blocks): Unexpected empty named import block. + ╭─[no_empty_named_blocks.tsx:1:1] + 1 │ import Default, {} from 'mod' + · ───────────────────────────── + ╰──── + help: Delete this code. + + ⚠ eslint-plugin-import(no-empty-named-blocks): Unexpected empty named import block. + ╭─[no_empty_named_blocks.tsx:1:1] + 1 │ import{}from'mod' + · ───────────────── + ╰──── + help: Delete this code. + + ⚠ eslint-plugin-import(no-empty-named-blocks): Unexpected empty named import block. + ╭─[no_empty_named_blocks.tsx:1:1] + 1 │ import{}from'mod' + · ───────────────── + ╰──── + + ⚠ eslint-plugin-import(no-empty-named-blocks): Unexpected empty named import block. + ╭─[no_empty_named_blocks.tsx:1:1] + 1 │ import type {}from'mod' + · ─────────────────────── + ╰──── + help: Delete this code. + + ⚠ eslint-plugin-import(no-empty-named-blocks): Unexpected empty named import block. + ╭─[no_empty_named_blocks.tsx:1:1] + 1 │ import type {}from'mod' + · ─────────────────────── + ╰──── + + ⚠ eslint-plugin-import(no-empty-named-blocks): Unexpected empty named import block. + ╭─[no_empty_named_blocks.tsx:1:1] + 1 │ import type {} from 'mod' + · ───────────────────────── + ╰──── + help: Delete this code. + + ⚠ eslint-plugin-import(no-empty-named-blocks): Unexpected empty named import block. + ╭─[no_empty_named_blocks.tsx:1:1] + 1 │ import type {} from 'mod' + · ───────────────────────── + ╰──── + + ⚠ eslint-plugin-import(no-empty-named-blocks): Unexpected empty named import block. + ╭─[no_empty_named_blocks.tsx:1:1] + 1 │ import type{}from 'mod' + · ─────────────────────── + ╰──── + help: Delete this code. + + ⚠ eslint-plugin-import(no-empty-named-blocks): Unexpected empty named import block. + ╭─[no_empty_named_blocks.tsx:1:1] + 1 │ import type{}from 'mod' + · ─────────────────────── + ╰────