diff --git a/apps/oxlint/src/snapshots/fixtures__overrides_with_plugin_-c .oxlintrc.json@oxlint.snap b/apps/oxlint/src/snapshots/fixtures__overrides_with_plugin_-c .oxlintrc.json@oxlint.snap index b2bd915f423fa..cce2536c23f46 100644 --- a/apps/oxlint/src/snapshots/fixtures__overrides_with_plugin_-c .oxlintrc.json@oxlint.snap +++ b/apps/oxlint/src/snapshots/fixtures__overrides_with_plugin_-c .oxlintrc.json@oxlint.snap @@ -23,23 +23,23 @@ working directory: fixtures/overrides_with_plugin `---- help: Consider removing this declaration. - x ]8;;https://oxc.rs/docs/guide/usage/linter/rules/jest/valid-title.html\eslint-plugin-jest(valid-title)]8;;\: "Should not have an empty title" - ,-[index.test.ts:4:6] + ! ]8;;https://oxc.rs/docs/guide/usage/linter/rules/jest/expect-expect.html\eslint-plugin-jest(expect-expect)]8;;\: Test has no assertions + ,-[index.test.ts:4:3] 3 | 4 | it("", () => {}); - : ^^ + : ^^ 5 | // ^ jest/no-valid-title error as explicitly set in the `.test.ts` override `---- - help: "Write a meaningful title for your test" + help: Add assertion(s) in this Test - ! ]8;;https://oxc.rs/docs/guide/usage/linter/rules/jest/expect-expect.html\eslint-plugin-jest(expect-expect)]8;;\: Test has no assertions - ,-[index.test.ts:4:3] + x ]8;;https://oxc.rs/docs/guide/usage/linter/rules/jest/valid-title.html\eslint-plugin-jest(valid-title)]8;;\: "Should not have an empty title" + ,-[index.test.ts:4:6] 3 | 4 | it("", () => {}); - : ^^ + : ^^ 5 | // ^ jest/no-valid-title error as explicitly set in the `.test.ts` override `---- - help: Add assertion(s) in this Test + help: "Write a meaningful title for your test" Found 2 warnings and 2 errors. Finished in ms on 2 files with 87 rules using 1 threads. diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 5ba016b73f5ce..8a33679be59b9 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -34,6 +34,7 @@ mod import { pub mod no_self_import; pub mod no_unassigned_import; pub mod no_webpack_loader_syntax; + pub mod prefer_default_export; pub mod unambiguous; } @@ -743,6 +744,7 @@ oxc_macros::declare_all_lint_rules! { import::no_named_as_default_member, import::no_self_import, import::no_webpack_loader_syntax, + import::prefer_default_export, import::unambiguous, jest::consistent_test_it, jest::expect_expect, diff --git a/crates/oxc_linter/src/rules/import/prefer_default_export.rs b/crates/oxc_linter/src/rules/import/prefer_default_export.rs new file mode 100644 index 0000000000000..c95d9dab38537 --- /dev/null +++ b/crates/oxc_linter/src/rules/import/prefer_default_export.rs @@ -0,0 +1,320 @@ +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; +use serde_json::Value; + +use crate::{context::LintContext, module_record::ExportEntry, rule::Rule}; + +fn prefer_default_export_diagnostic(span: Span, target: Target) -> OxcDiagnostic { + let msg = if target == Target::Single { + "Prefer default export on a file with single export." + } else { + "Prefer default export to be present on every file that has export." + }; + OxcDiagnostic::warn(msg).with_help("Prefer a default export").with_label(span) +} + +#[derive(Debug, Default, PartialEq, Clone, Copy)] +enum Target { + #[default] + Single, + Any, +} + +impl From<&str> for Target { + fn from(raw: &str) -> Self { + if raw == "any" { Self::Any } else { Self::Single } + } +} + +#[derive(Debug, Default, Clone)] +pub struct PreferDefaultExport { + target: Target, +} + +declare_oxc_lint!( + /// ### What it does + /// + /// In exporting files, this rule checks if there is default export or not. + /// + /// ### Why is this bad? + /// + /// This rule exists to standardize module exports by preferring default exports + /// when a module only has one export, enhancing readability, maintainability. + /// + /// ### Examples + /// + /// Examples of **incorrect** code for the `{ target: "single" }` option: + /// ```js + /// export const foo = 'foo'; + /// ``` + /// + /// Examples of **correct** code for the `{ target: "single" }` option: + /// ```js + /// export const foo = 'foo'; + /// const bar = 'bar'; + /// export default bar; + /// ``` + /// + /// Examples of **incorrect** code for the `{ target: "any" }` option: + /// ```js + /// export const foo = 'foo'; + /// export const baz = 'baz'; + /// ``` + /// + /// Examples of **correct** code for the `{ target: "any" }` option: + /// ```js + /// export default function bar() {}; + /// ``` + PreferDefaultExport, + import, + style, +); + +impl Rule for PreferDefaultExport { + fn from_configuration(value: Value) -> Self { + let obj = value.get(0); + Self { + target: obj + .and_then(|v| v.get("target")) + .and_then(Value::as_str) + .map(Target::from) + .unwrap_or_default(), + } + } + + fn run_once(&self, ctx: &LintContext<'_>) { + let module_record = ctx.module_record(); + if module_record.export_default.is_some() { + return; + } + let star_export_entries = &module_record.star_export_entries; + + if !star_export_entries.is_empty() { + return; + } + let indirect_entries = &module_record.indirect_export_entries; + let local_entries = &module_record.local_export_entries; + + if exist_type(indirect_entries) || exist_type(local_entries) { + return; + } + + if self.target == Target::Single { + if indirect_entries.len() + local_entries.len() == 1 { + for entry in indirect_entries { + ctx.diagnostic(prefer_default_export_diagnostic(entry.span, self.target)); + } + for entry in local_entries { + ctx.diagnostic(prefer_default_export_diagnostic( + entry.statement_span, + self.target, + )); + } + } + } else { + // find the last export statement + if let Some(last_export_span) = indirect_entries + .iter() + .chain(local_entries.iter()) + .max_by_key(|entry| entry.statement_span.start) + { + ctx.diagnostic(prefer_default_export_diagnostic( + last_export_span.statement_span, + self.target, + )); + } + } + } +} + +fn exist_type(export_entries: &[ExportEntry]) -> bool { + export_entries.iter().any(|entry| entry.is_type) +} + +#[test] +fn test() { + use crate::tester::Tester; + use serde_json::json; + + let pass = vec![ + ("export default a", None), + ("export const { foo, bar } = item;", None), + ("export const { foo, bar: baz } = item;", None), + ("export const { foo: { bar, baz } } = item;", None), + ("export const [a, b] = item;", None), + ( + " + let item; + export const foo = item; + export { item }; + ", + None, + ), + ( + " + let foo; + export { foo as default } + ", + None, + ), + ("export const [CounterProvider,, withCounter] = func()", None), + ("export * from './foo'", None), + ("export default function bar() {};", None), + ( + " + export const foo = 'foo'; + export const bar = 'bar'; + ", + None, + ), + ( + " + export const foo = 'foo'; + export function bar() {}; + ", + None, + ), + ( + " + export const foo = 'foo'; + export default bar; + ", + None, + ), + ( + " + let foo, bar; + export { foo, bar } + ", + None, + ), + ( + " + export default a; + export * from './foo' + export const a = 3; + export { MemoryValue } from './Memory' + ", + None, + ), + ("export type foo = string", None), + ("import * as foo from './foo'", None), + ("export type UserId = number;", None), + ("export { a, b } from 'foo.js'", None), + ("let foo; export { foo as 'default' };", None), + ("export default function bar() {};", Some(json!([{"target": "any"}]))), + ( + " + export const foo = 'foo'; + export const bar = 'bar'; + export default 42; + ", + Some(json!([{"target": "any"}])), + ), + ("export default a = 2;", Some(json!([{"target": "any"}]))), + ( + " + export const a = 2; + export default function foo() {}; + ", + Some(json!([{"target": "any"}])), + ), + ( + " + export const a = 5; + export function bar(){}; + let foo; + export { foo as default } + ", + Some(json!([{"target": "any"}])), + ), + ("export * from './foo';", Some(json!([{"target": "any"}]))), + ("import * as foo from './foo';", Some(json!([{"target": "any"}]))), + ("const a = 5;", Some(json!([{"target": "any"}]))), + ( + "export const a = 4; let foo; export { foo as 'default' };", + Some(json!([{"target": "any"}])), + ), + ( + " + export type foo = string; + export type bar = number; + ", + None, + ), + ( + " + export const a = 2; + export { a } from './c'; + export type bar = number; + ", + Some(json!([{"target": "any"}])), + ), + ]; + + let fail = vec![ + ("export const a = 3", None), + ("export { MemoryValue } from './Memory'", None), + ("export const a = 3", Some(json!([{"target": "any"}]))), + ("export function bar() {}", None), + ("export const foo = 'foo';", None), + ("const foo = 'foo'; export { foo };", None), + ("export const { foo } = { foo: 'bar' };", None), + ("export const { foo: { bar } } = { foo: { bar: 'baz' } };", None), + ("export const [a] = ['foo']", None), + ( + " + export const foo = 'foo' + export const bar = 'bar'; + ", + Some(json!([{"target": "any"}])), + ), + ( + " + export const foo = 'foo'; + export function bar() {}; + ", + Some(json!([{"target": "any"}])), + ), + ( + " + let foo, bar; + export { foo, bar } + ", + Some(json!([{"target": "any"}])), + ), + ( + " + let item; + export const foo = item; + export { item }; + ", + Some(json!([{"target": "any"}])), + ), + ("export { a, b } from 'foo.js'", Some(json!([{"target": "any"}]))), + ( + " + const foo = 'foo'; + export { foo }; + ", + Some(json!([{"target": "any"}])), + ), + ("export const { foo } = { foo: 'bar' };", Some(json!([{"target": "any"}]))), + ( + "export const { foo: { bar } } = { foo: { bar: 'baz' } };", + Some(json!([{"target": "any"}])), + ), + ( + " + export const b = 3; + export const r = 3; + export { last } from './Memory' + ", + Some(json!([{"target": "any"}])), + ), + ]; + + Tester::new(PreferDefaultExport::NAME, PreferDefaultExport::PLUGIN, pass, fail) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/import_prefer_default_export.snap b/crates/oxc_linter/src/snapshots/import_prefer_default_export.snap new file mode 100644 index 0000000000000..8f256d23d2ae7 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/import_prefer_default_export.snap @@ -0,0 +1,140 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export on a file with single export. + ╭─[prefer_default_export.tsx:1:1] + 1 │ export const a = 3 + · ────────────────── + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export on a file with single export. + ╭─[prefer_default_export.tsx:1:10] + 1 │ export { MemoryValue } from './Memory' + · ─────────── + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export to be present on every file that has export. + ╭─[prefer_default_export.tsx:1:1] + 1 │ export const a = 3 + · ────────────────── + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export on a file with single export. + ╭─[prefer_default_export.tsx:1:1] + 1 │ export function bar() {} + · ──────────────────────── + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export on a file with single export. + ╭─[prefer_default_export.tsx:1:1] + 1 │ export const foo = 'foo'; + · ───────────────────────── + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export on a file with single export. + ╭─[prefer_default_export.tsx:1:20] + 1 │ const foo = 'foo'; export { foo }; + · ─────────────── + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export on a file with single export. + ╭─[prefer_default_export.tsx:1:1] + 1 │ export const { foo } = { foo: 'bar' }; + · ────────────────────────────────────── + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export on a file with single export. + ╭─[prefer_default_export.tsx:1:1] + 1 │ export const { foo: { bar } } = { foo: { bar: 'baz' } }; + · ──────────────────────────────────────────────────────── + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export on a file with single export. + ╭─[prefer_default_export.tsx:1:1] + 1 │ export const [a] = ['foo'] + · ────────────────────────── + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export to be present on every file that has export. + ╭─[prefer_default_export.tsx:3:17] + 2 │ export const foo = 'foo' + 3 │ export const bar = 'bar'; + · ───────────────────────── + 4 │ + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export to be present on every file that has export. + ╭─[prefer_default_export.tsx:3:17] + 2 │ export const foo = 'foo'; + 3 │ export function bar() {}; + · ──────────────────────── + 4 │ + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export to be present on every file that has export. + ╭─[prefer_default_export.tsx:3:17] + 2 │ let foo, bar; + 3 │ export { foo, bar } + · ─────────────────── + 4 │ + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export to be present on every file that has export. + ╭─[prefer_default_export.tsx:4:17] + 3 │ export const foo = item; + 4 │ export { item }; + · ──────────────── + 5 │ + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export to be present on every file that has export. + ╭─[prefer_default_export.tsx:1:1] + 1 │ export { a, b } from 'foo.js' + · ───────────────────────────── + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export to be present on every file that has export. + ╭─[prefer_default_export.tsx:3:17] + 2 │ const foo = 'foo'; + 3 │ export { foo }; + · ─────────────── + 4 │ + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export to be present on every file that has export. + ╭─[prefer_default_export.tsx:1:1] + 1 │ export const { foo } = { foo: 'bar' }; + · ────────────────────────────────────── + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export to be present on every file that has export. + ╭─[prefer_default_export.tsx:1:1] + 1 │ export const { foo: { bar } } = { foo: { bar: 'baz' } }; + · ──────────────────────────────────────────────────────── + ╰──── + help: Prefer a default export + + ⚠ eslint-plugin-import(prefer-default-export): Prefer default export to be present on every file that has export. + ╭─[prefer_default_export.tsx:4:17] + 3 │ export const r = 3; + 4 │ export { last } from './Memory' + · ─────────────────────────────── + 5 │ + ╰──── + help: Prefer a default export