Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/oxc_linter/src/generated/rule_runner_impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2815,6 +2815,13 @@ impl RuleRunner for crate::rules::unicorn::require_array_join_separator::Require
Some(&AstTypesBitset::from_types(&[AstType::CallExpression]));
}

impl RuleRunner for crate::rules::unicorn::require_module_specifiers::RequireModuleSpecifiers {
const NODE_TYPES: Option<&AstTypesBitset> = Some(&AstTypesBitset::from_types(&[
AstType::ExportNamedDeclaration,
AstType::ImportDeclaration,
]));
}

impl RuleRunner for crate::rules::unicorn::require_number_to_fixed_digits_argument::RequireNumberToFixedDigitsArgument {
const NODE_TYPES: Option<&AstTypesBitset> = Some(&AstTypesBitset::from_types(&[AstType::CallExpression]));
}
Expand Down
2 changes: 2 additions & 0 deletions crates/oxc_linter/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ pub(crate) mod unicorn {
pub mod prefer_top_level_await;
pub mod prefer_type_error;
pub mod require_array_join_separator;
pub mod require_module_specifiers;
pub mod require_number_to_fixed_digits_argument;
pub mod require_post_message_target_origin;
pub mod switch_case_braces;
Expand Down Expand Up @@ -1221,6 +1222,7 @@ oxc_macros::declare_all_lint_rules! {
unicorn::prefer_string_trim_start_end,
unicorn::prefer_structured_clone,
unicorn::prefer_type_error,
unicorn::require_module_specifiers,
unicorn::require_post_message_target_origin,
unicorn::require_array_join_separator,
unicorn::require_number_to_fixed_digits_argument,
Expand Down
218 changes: 218 additions & 0 deletions crates/oxc_linter/src/rules/unicorn/require_module_specifiers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
use oxc_ast::{
AstKind,
ast::{ExportNamedDeclaration, ImportDeclaration, ImportDeclarationSpecifier},
};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;

use crate::{
AstNode,
context::LintContext,
fixer::{RuleFix, RuleFixer},
rule::Rule,
};

fn require_module_specifiers_diagnostic(span: Span, statement_type: &str) -> OxcDiagnostic {
OxcDiagnostic::warn(format!("Empty {statement_type} specifier is not allowed"))
.with_help("Remove empty braces")
.with_label(span)
}

#[derive(Debug, Default, Clone)]
pub struct RequireModuleSpecifiers;

declare_oxc_lint!(
/// ### What it does
///
/// Enforce non-empty specifier list in `import` and `export` statements.
///
/// ### Why is this bad?
///
/// Empty import/export specifiers add no value and can be confusing.
/// If you want to import a module for side effects, use `import 'module'` instead.
///
/// ### Examples
///
/// Examples of **incorrect** code for this rule:
/// ```js
/// import {} from 'foo';
/// import foo, {} from 'foo';
/// export {} from 'foo';
/// export {};
/// ```
///
/// Examples of **correct** code for this rule:
/// ```js
/// import 'foo';
/// import foo from 'foo';
/// ```
RequireModuleSpecifiers,
unicorn,
suspicious,
fix
);

impl Rule for RequireModuleSpecifiers {
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
match node.kind() {
AstKind::ImportDeclaration(import_decl) => {
let Some(span) = find_empty_braces_in_import(ctx, import_decl) else {
return;
};
ctx.diagnostic_with_fix(
require_module_specifiers_diagnostic(span, "import"),
|fixer| fix_import(fixer, import_decl),
);
}
AstKind::ExportNamedDeclaration(export_decl) => {
if export_decl.declaration.is_none() && export_decl.specifiers.is_empty() {
let span =
find_empty_braces_in_export(ctx, export_decl).unwrap_or(export_decl.span);
ctx.diagnostic_with_fix(
require_module_specifiers_diagnostic(span, "export"),
|fixer| fix_export(fixer, export_decl),
);
}
}
_ => {}
}
}
}

/// Finds empty braces `{}` in the given text and returns their span
fn find_empty_braces_in_text(text: &str, base_span: Span) -> Option<Span> {
let open_brace = text.find('{')?;
let close_brace = text[open_brace + 1..].find('}')?;

// Check if braces contain only whitespace
if !text[open_brace + 1..open_brace + 1 + close_brace].trim().is_empty() {
return None;
}

// Calculate absolute positions
let start = base_span.start + u32::try_from(open_brace).ok()?;
let end = start + u32::try_from(close_brace + 2).ok()?; // +2 to span from '{' to position after '}'
Some(Span::new(start, end))
}

fn find_empty_braces_in_import(
ctx: &LintContext<'_>,
import_decl: &ImportDeclaration<'_>,
) -> Option<Span> {
// Side-effect imports don't have specifiers
let specifiers = import_decl.specifiers.as_ref()?;

// Check for patterns that could have empty braces
let could_have_empty_braces = matches!(
specifiers.as_slice(),
[] | [ImportDeclarationSpecifier::ImportDefaultSpecifier(_)]
);

if !could_have_empty_braces {
return None;
}

let import_text = ctx.source_range(import_decl.span);
find_empty_braces_in_text(import_text, import_decl.span)
}

fn find_empty_braces_in_export(
ctx: &LintContext<'_>,
export_decl: &ExportNamedDeclaration<'_>,
) -> Option<Span> {
let export_text = ctx.source_range(export_decl.span);
find_empty_braces_in_text(export_text, export_decl.span)
}

fn fix_import<'a>(fixer: RuleFixer<'_, 'a>, import_decl: &ImportDeclaration<'a>) -> RuleFix<'a> {
let import_text = fixer.source_range(import_decl.span);

let Some(comma_pos) = import_text.find(',') else {
return fixer.noop();
};
let Some(from_pos) = import_text[comma_pos..].find("from") else {
return fixer.noop();
};

// Remove empty braces: "import foo, {} from 'bar'" -> "import foo from 'bar'"
let default_part = &import_text[..comma_pos];
let from_part = &import_text[comma_pos + from_pos..];
fixer.replace(import_decl.span, format!("{default_part} {from_part}"))
}

fn fix_export<'a>(
fixer: RuleFixer<'_, 'a>,
export_decl: &ExportNamedDeclaration<'a>,
) -> RuleFix<'a> {
if export_decl.source.is_some() {
return fixer.noop();
}

// Remove the entire `export {}` statement
fixer.delete(&export_decl.span)
}

#[test]
fn test() {
use crate::tester::Tester;

let pass = vec![
r#"import "foo""#,
r#"import foo from "foo""#,
r#"import * as foo from "foo""#,
r#"import {foo} from "foo""#,
r#"import foo,{bar} from "foo""#,
r#"import type foo from "foo""#,
r#"import type foo,{bar} from "foo""#,
r#"import foo,{type bar} from "foo""#,
"const foo = 1;
export {foo};",
r#"export {foo} from "foo""#,
r#"export * as foo from "foo""#,
r"export type {Foo}",
r"export type foo = Foo",
r#"export type {foo} from "foo""#,
r#"export type * as foo from "foo""#,
"export const foo = 1",
"export function foo() {}",
"export class foo {}",
"export const {} = foo",
"export const [] = foo",
];

let fail = vec![
r#"import {} from "foo";"#,
r#"import{}from"foo";"#,
r#"import {
} from "foo";"#,
r#"import foo, {} from "foo";"#,
r#"import foo,{}from "foo";"#,
r#"import foo, {
} from "foo";"#,
r#"import foo,{}/* comment */from "foo";"#,
r#"import type {} from "foo""#,
r#"import type{}from"foo""#,
r#"import type foo, {} from "foo""#,
r#"import type foo,{}from "foo""#,
"export {}",
r#"export {} from "foo";"#,
r#"export{}from"foo";"#,
r#"export {
} from "foo";"#,
r#"export {} from "foo" with {type: "json"};"#,
r"export type{}",
r#"export type {} from "foo""#,
];

let fix = vec![
(r#"import foo, {} from "foo";"#, r#"import foo from "foo";"#),
(r#"import foo,{} from "foo";"#, r#"import foo from "foo";"#),
("export {}", ""),
("export {};", ""),
];

Tester::new(RequireModuleSpecifiers::NAME, RequireModuleSpecifiers::PLUGIN, pass, fail)
.expect_fix(fix)
.test_and_snapshot();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
---
source: crates/oxc_linter/src/tester.rs
---
⚠ eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
╭─[require_module_specifiers.tsx:1:8]
1 │ import {} from "foo";
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
╭─[require_module_specifiers.tsx:1:7]
1 │ import{}from"foo";
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
╭─[require_module_specifiers.tsx:1:8]
1 │ ╭─▶ import {
2 │ ╰─▶ } from "foo";
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
╭─[require_module_specifiers.tsx:1:13]
1 │ import foo, {} from "foo";
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
╭─[require_module_specifiers.tsx:1:12]
1 │ import foo,{}from "foo";
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
╭─[require_module_specifiers.tsx:1:13]
1 │ ╭─▶ import foo, {
2 │ ╰─▶ } from "foo";
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
╭─[require_module_specifiers.tsx:1:12]
1 │ import foo,{}/* comment */from "foo";
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
╭─[require_module_specifiers.tsx:1:13]
1 │ import type {} from "foo"
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
╭─[require_module_specifiers.tsx:1:12]
1 │ import type{}from"foo"
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
╭─[require_module_specifiers.tsx:1:18]
1 │ import type foo, {} from "foo"
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
╭─[require_module_specifiers.tsx:1:17]
1 │ import type foo,{}from "foo"
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
╭─[require_module_specifiers.tsx:1:8]
1 │ export {}
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
╭─[require_module_specifiers.tsx:1:8]
1 │ export {} from "foo";
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
╭─[require_module_specifiers.tsx:1:7]
1 │ export{}from"foo";
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
╭─[require_module_specifiers.tsx:1:8]
1 │ ╭─▶ export {
2 │ ╰─▶ } from "foo";
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
╭─[require_module_specifiers.tsx:1:8]
1 │ export {} from "foo" with {type: "json"};
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
╭─[require_module_specifiers.tsx:1:12]
1 │ export type{}
· ──
╰────
help: Remove empty braces

⚠ eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
╭─[require_module_specifiers.tsx:1:13]
1 │ export type {} from "foo"
· ──
╰────
help: Remove empty braces
Loading