Skip to content

Commit 42be377

Browse files
committed
feat(linter): eslint-plugin-unicorn require-module-specifiers
1 parent a0ccada commit 42be377

File tree

3 files changed

+332
-0
lines changed

3 files changed

+332
-0
lines changed

crates/oxc_linter/src/rules.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ mod unicorn {
474474
pub mod prefer_structured_clone;
475475
pub mod prefer_type_error;
476476
pub mod require_array_join_separator;
477+
pub mod require_module_specifiers;
477478
pub mod require_number_to_fixed_digits_argument;
478479
pub mod require_post_message_target_origin;
479480
pub mod switch_case_braces;
@@ -1174,6 +1175,7 @@ oxc_macros::declare_all_lint_rules! {
11741175
unicorn::prefer_string_trim_start_end,
11751176
unicorn::prefer_structured_clone,
11761177
unicorn::prefer_type_error,
1178+
unicorn::require_module_specifiers,
11771179
unicorn::require_post_message_target_origin,
11781180
unicorn::require_array_join_separator,
11791181
unicorn::require_number_to_fixed_digits_argument,
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
use oxc_ast::{
2+
AstKind,
3+
ast::{ExportNamedDeclaration, ImportDeclaration, ImportDeclarationSpecifier},
4+
};
5+
use oxc_diagnostics::OxcDiagnostic;
6+
use oxc_macros::declare_oxc_lint;
7+
use oxc_span::Span;
8+
9+
use crate::{
10+
AstNode,
11+
context::LintContext,
12+
fixer::{RuleFix, RuleFixer},
13+
rule::Rule,
14+
};
15+
16+
fn require_module_specifiers_diagnostic(span: Span, statement_type: &str) -> OxcDiagnostic {
17+
OxcDiagnostic::warn(format!("Empty {} specifier is not allowed", statement_type))
18+
.with_help("Remove empty braces")
19+
.with_label(span)
20+
}
21+
22+
#[derive(Debug, Default, Clone)]
23+
pub struct RequireModuleSpecifiers;
24+
25+
declare_oxc_lint!(
26+
/// ### What it does
27+
///
28+
/// Enforce non-empty specifier list in `import` and `export` statements.
29+
///
30+
/// ### Why is this bad?
31+
///
32+
/// Empty import/export specifiers add no value and can be confusing.
33+
/// If you want to import a module for side effects, use `import 'module'` instead.
34+
///
35+
/// ### Examples
36+
///
37+
/// Examples of **incorrect** code for this rule:
38+
/// ```js
39+
/// import {} from 'foo';
40+
/// import foo, {} from 'foo';
41+
/// export {} from 'foo';
42+
/// export {};
43+
/// ```
44+
///
45+
/// Examples of **correct** code for this rule:
46+
/// ```js
47+
/// import 'foo';
48+
/// import foo from 'foo';
49+
/// ```
50+
RequireModuleSpecifiers,
51+
unicorn,
52+
correctness,
53+
fix
54+
);
55+
56+
impl Rule for RequireModuleSpecifiers {
57+
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
58+
match node.kind() {
59+
AstKind::ImportDeclaration(import_decl) => {
60+
let Some(span) = find_empty_braces_in_import(ctx, import_decl) else {
61+
return;
62+
};
63+
ctx.diagnostic_with_fix(
64+
require_module_specifiers_diagnostic(span, "import"),
65+
|fixer| fix_import(fixer, import_decl),
66+
);
67+
}
68+
AstKind::ExportNamedDeclaration(export_decl) => {
69+
if export_decl.declaration.is_none() && export_decl.specifiers.is_empty() {
70+
let span =
71+
find_empty_braces_in_export(ctx, export_decl).unwrap_or(export_decl.span);
72+
ctx.diagnostic(require_module_specifiers_diagnostic(span, "export"));
73+
}
74+
}
75+
_ => {}
76+
}
77+
}
78+
}
79+
80+
/// Finds empty braces `{}` in the given text and returns their span
81+
fn find_empty_braces_in_text(text: &str, base_span: Span) -> Option<Span> {
82+
let open_brace = text.find('{')?;
83+
let close_brace = text[open_brace + 1..].find('}')?;
84+
85+
// Check if braces contain only whitespace
86+
if !text[open_brace + 1..open_brace + 1 + close_brace].trim().is_empty() {
87+
return None;
88+
}
89+
90+
// Calculate absolute positions
91+
let start = base_span.start + u32::try_from(open_brace).ok()?;
92+
let end = start + u32::try_from(close_brace + 1).ok()? + 1; // +2 for '{' and '}'
93+
Some(Span::new(start, end))
94+
}
95+
96+
fn find_empty_braces_in_import(
97+
ctx: &LintContext<'_>,
98+
import_decl: &ImportDeclaration<'_>,
99+
) -> Option<Span> {
100+
// Side-effect imports don't have specifiers
101+
let specifiers = import_decl.specifiers.as_ref()?;
102+
103+
// Check for patterns that could have empty braces
104+
let could_have_empty_braces = matches!(
105+
specifiers.as_slice(),
106+
[] | [ImportDeclarationSpecifier::ImportDefaultSpecifier(_)]
107+
);
108+
109+
if !could_have_empty_braces {
110+
return None;
111+
}
112+
113+
let import_text = ctx.source_range(import_decl.span);
114+
find_empty_braces_in_text(import_text, import_decl.span)
115+
}
116+
117+
fn find_empty_braces_in_export(
118+
ctx: &LintContext<'_>,
119+
export_decl: &ExportNamedDeclaration<'_>,
120+
) -> Option<Span> {
121+
let export_text = ctx.source_range(export_decl.span);
122+
find_empty_braces_in_text(export_text, export_decl.span)
123+
}
124+
125+
fn fix_import<'a>(fixer: RuleFixer<'_, 'a>, import_decl: &ImportDeclaration<'a>) -> RuleFix<'a> {
126+
let import_text = fixer.source_range(import_decl.span);
127+
128+
// Only fix `import foo, {} from 'bar'` - safe removal of empty braces
129+
let Some(comma_pos) = import_text.find(',') else {
130+
return fixer.noop(); // Don't fix `import {} from 'foo'` - changes behavior
131+
};
132+
let Some(from_pos) = import_text[comma_pos..].find("from") else {
133+
return fixer.noop();
134+
};
135+
136+
// Remove empty braces: "import foo, {} from 'bar'" -> "import foo from 'bar'"
137+
let default_part = &import_text[..comma_pos];
138+
let from_part = &import_text[comma_pos + from_pos..];
139+
fixer.replace(import_decl.span, format!("{default_part} {from_part}"))
140+
}
141+
142+
#[test]
143+
fn test() {
144+
use crate::tester::Tester;
145+
146+
let pass = vec![
147+
r#"import "foo""#,
148+
r#"import foo from "foo""#,
149+
r#"import * as foo from "foo""#,
150+
r#"import {foo} from "foo""#,
151+
r#"import foo,{bar} from "foo""#,
152+
r#"import type foo from "foo""#,
153+
r#"import type foo,{bar} from "foo""#,
154+
r#"import foo,{type bar} from "foo""#,
155+
"const foo = 1;
156+
export {foo};",
157+
r#"export {foo} from "foo""#,
158+
r#"export * as foo from "foo""#,
159+
r"export type {Foo}",
160+
r"export type foo = Foo",
161+
r#"export type {foo} from "foo""#,
162+
r#"export type * as foo from "foo""#,
163+
"export const foo = 1",
164+
"export function foo() {}",
165+
"export class foo {}",
166+
"export const {} = foo",
167+
"export const [] = foo",
168+
];
169+
170+
let fail = vec![
171+
r#"import {} from "foo";"#,
172+
r#"import{}from"foo";"#,
173+
r#"import {
174+
} from "foo";"#,
175+
r#"import foo, {} from "foo";"#,
176+
r#"import foo,{}from "foo";"#,
177+
r#"import foo, {
178+
} from "foo";"#,
179+
r#"import foo,{}/* comment */from "foo";"#,
180+
r#"import type {} from "foo""#,
181+
r#"import type{}from"foo""#,
182+
r#"import type foo, {} from "foo""#,
183+
r#"import type foo,{}from "foo""#,
184+
"export {}",
185+
r#"export {} from "foo";"#,
186+
r#"export{}from"foo";"#,
187+
r#"export {
188+
} from "foo";"#,
189+
r#"export {} from "foo" with {type: "json"};"#,
190+
r"export type{}",
191+
r#"export type {} from "foo""#,
192+
];
193+
194+
let fix = vec![
195+
(r#"import foo, {} from "foo";"#, r#"import foo from "foo";"#),
196+
(r#"import foo,{} from "foo";"#, r#"import foo from "foo";"#),
197+
];
198+
199+
Tester::new(RequireModuleSpecifiers::NAME, RequireModuleSpecifiers::PLUGIN, pass, fail)
200+
.expect_fix(fix)
201+
.test_and_snapshot();
202+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
---
2+
source: crates/oxc_linter/src/tester.rs
3+
---
4+
eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
5+
╭─[require_module_specifiers.tsx:1:8]
6+
1import {} from "foo";
7+
· ──
8+
╰────
9+
help: Remove empty braces
10+
11+
eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
12+
╭─[require_module_specifiers.tsx:1:7]
13+
1import{}from"foo";
14+
· ──
15+
╰────
16+
help: Remove empty braces
17+
18+
eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
19+
╭─[require_module_specifiers.tsx:1:8]
20+
1 │ ╭─▶ import {
21+
2 │ ╰─▶ } from "foo";
22+
╰────
23+
help: Remove empty braces
24+
25+
eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
26+
╭─[require_module_specifiers.tsx:1:13]
27+
1import foo, {} from "foo";
28+
· ──
29+
╰────
30+
help: Remove empty braces
31+
32+
eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
33+
╭─[require_module_specifiers.tsx:1:12]
34+
1import foo,{}from "foo";
35+
· ──
36+
╰────
37+
help: Remove empty braces
38+
39+
eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
40+
╭─[require_module_specifiers.tsx:1:13]
41+
1 │ ╭─▶ import foo, {
42+
2 │ ╰─▶ } from "foo";
43+
╰────
44+
help: Remove empty braces
45+
46+
eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
47+
╭─[require_module_specifiers.tsx:1:12]
48+
1import foo,{}/* comment */from "foo";
49+
· ──
50+
╰────
51+
help: Remove empty braces
52+
53+
eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
54+
╭─[require_module_specifiers.tsx:1:13]
55+
1import type {} from "foo"
56+
· ──
57+
╰────
58+
help: Remove empty braces
59+
60+
eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
61+
╭─[require_module_specifiers.tsx:1:12]
62+
1import type{}from"foo"
63+
· ──
64+
╰────
65+
help: Remove empty braces
66+
67+
eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
68+
╭─[require_module_specifiers.tsx:1:18]
69+
1import type foo, {} from "foo"
70+
· ──
71+
╰────
72+
help: Remove empty braces
73+
74+
eslint-plugin-unicorn(require-module-specifiers): Empty import specifier is not allowed
75+
╭─[require_module_specifiers.tsx:1:17]
76+
1import type foo,{}from "foo"
77+
· ──
78+
╰────
79+
help: Remove empty braces
80+
81+
eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
82+
╭─[require_module_specifiers.tsx:1:8]
83+
1export {}
84+
· ──
85+
╰────
86+
help: Remove empty braces
87+
88+
eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
89+
╭─[require_module_specifiers.tsx:1:8]
90+
1export {} from "foo";
91+
· ──
92+
╰────
93+
help: Remove empty braces
94+
95+
eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
96+
╭─[require_module_specifiers.tsx:1:7]
97+
1export{}from"foo";
98+
· ──
99+
╰────
100+
help: Remove empty braces
101+
102+
eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
103+
╭─[require_module_specifiers.tsx:1:8]
104+
1 │ ╭─▶ export {
105+
2 │ ╰─▶ } from "foo";
106+
╰────
107+
help: Remove empty braces
108+
109+
eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
110+
╭─[require_module_specifiers.tsx:1:8]
111+
1export {} from "foo" with {type: "json"};
112+
· ──
113+
╰────
114+
help: Remove empty braces
115+
116+
eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
117+
╭─[require_module_specifiers.tsx:1:12]
118+
1export type{}
119+
· ──
120+
╰────
121+
help: Remove empty braces
122+
123+
eslint-plugin-unicorn(require-module-specifiers): Empty export specifier is not allowed
124+
╭─[require_module_specifiers.tsx:1:13]
125+
1export type {} from "foo"
126+
· ──
127+
╰────
128+
help: Remove empty braces

0 commit comments

Comments
 (0)