diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 597a841edc1e8..b8843c146362d 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -133,6 +133,7 @@ mod typescript { pub mod no_empty_interface; pub mod no_explicit_any; pub mod no_extra_non_null_assertion; + pub mod no_import_type_side_effects; pub mod no_misused_new; pub mod no_namespace; pub mod no_non_null_asserted_optional_chain; @@ -522,6 +523,7 @@ oxc_macros::declare_all_lint_rules! { typescript::no_empty_interface, typescript::no_explicit_any, typescript::no_extra_non_null_assertion, + typescript::no_import_type_side_effects, typescript::no_misused_new, typescript::no_namespace, typescript::no_non_null_asserted_optional_chain, diff --git a/crates/oxc_linter/src/rules/typescript/no_import_type_side_effects.rs b/crates/oxc_linter/src/rules/typescript/no_import_type_side_effects.rs new file mode 100644 index 0000000000000..0b31577774d8f --- /dev/null +++ b/crates/oxc_linter/src/rules/typescript/no_import_type_side_effects.rs @@ -0,0 +1,167 @@ +use oxc_ast::{ + ast::{ImportDeclarationSpecifier, ImportOrExportKind}, + AstKind, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::{GetSpan, Span}; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +fn no_import_type_side_effects_diagnostic(span0: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("typescript-eslint(no-import-type-side-effects): TypeScript will only remove the inline type specifiers which will leave behind a side effect import at runtime.") + .with_help("Convert this to a top-level type qualifier to properly remove the entire import.") + .with_labels([span0.into()]) +} + +#[derive(Debug, Default, Clone)] +pub struct NoImportTypeSideEffects; + +declare_oxc_lint!( + /// ### What it does + /// + /// Enforce the use of top-level import type qualifier when an import only has specifiers with inline type qualifiers. + /// + /// ### Why is this bad? + /// + /// The `--verbatimModuleSyntax` compiler option causes TypeScript to do simple and predictable transpilation on import declarations. + /// Namely, it completely removes import declarations with a top-level type qualifier, and it removes any import specifiers with an inline type qualifier. + /// + /// The latter behavior does have one potentially surprising effect in that in certain cases TS can leave behind a "side effect" import at runtime: + + /// ```javascript + /// import { type A, type B } from 'mod'; + /// ``` + + /// is transpiled to + /// + /// ```javascript + /// import {} from 'mod'; + /// which is the same as + /// import 'mod'; + /// ``` + + /// For the rare case of needing to import for side effects, this may be desirable - but for most cases you will not want to leave behind an unnecessary side effect import. + /// + /// ### Example + /// ```javascript + /// import { type A } from 'mod'; + /// import { type A as AA } from 'mod'; + /// import { type A, type B } from 'mod'; + /// import { type A as AA, type B as BB } from 'mod'; + /// ``` + NoImportTypeSideEffects, + restriction, +); + +impl Rule for NoImportTypeSideEffects { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ImportDeclaration(import_decl) = node.kind() else { + return; + }; + + if matches!(import_decl.import_kind, ImportOrExportKind::Type) { + return; + } + + let Some(specifiers) = &import_decl.specifiers else { + return; + }; + + let mut type_specifiers = vec![]; + + for specifier in specifiers { + let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier else { + return; + }; + if matches!(specifier.import_kind, ImportOrExportKind::Value) { + return; + } + type_specifiers.push(specifier); + } + // Can report and fix only if all specifiers are inline `type` qualifier: + // `import { type A, type B } from 'foo.js'` + ctx.diagnostic_with_fix( + no_import_type_side_effects_diagnostic(import_decl.span), + |fixer| { + let mut delete_ranges = vec![]; + + for specifier in type_specifiers { + // import { type A } from 'foo.js' + // ^^^^^^^^ + delete_ranges + .push(Span::new(specifier.span.start, specifier.imported.span().start)); + } + + let mut output = String::new(); + let mut last_pos = import_decl.span.start; + for range in delete_ranges { + // import { type A } from 'foo.js' + // ^^^^^^^^^^^^^^^ + // | | + // [last_pos range.start) + output.push_str(ctx.source_range(Span::new(last_pos, range.start))); + // import { type A } from 'foo.js' + // ^ + // | + // last_pos + last_pos = range.end; + } + + // import { type A } from 'foo.js' + // ^^^^^^^^^^^^^^^^^^ + // ^ ^ + // | | + // [last_pos import_decl_span.end) + output.push_str(ctx.source_range(Span::new(last_pos, import_decl.span.end))); + + if let Some(output) = output.strip_prefix("import ") { + let output = format!("import type {output}"); + fixer.replace(import_decl.span, output) + } else { + // Do not do anything, this should never happen + fixer.replace(import_decl.span, ctx.source_range(import_decl.span)) + } + }, + ); + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + "import T from 'mod';", + "import * as T from 'mod';", + "import { T } from 'mod';", + "import type { T } from 'mod';", + "import type { T, U } from 'mod';", + "import { type T, U } from 'mod';", + "import { T, type U } from 'mod';", + "import type T from 'mod';", + "import type T, { U } from 'mod';", + "import T, { type U } from 'mod';", + "import type * as T from 'mod';", + "import 'mod';", + ]; + + let fail = vec![ + "import { type A } from 'mod';", + "import { type A as AA } from 'mod';", + "import { type A, type B } from 'mod';", + "import { type A as AA, type B as BB } from 'mod';", + ]; + + let fix = vec![ + ("import { type A } from 'mod';", "import type { A } from 'mod';", None), + ("import { type A as AA } from 'mod';", "import type { A as AA } from 'mod';", None), + ("import { type A, type B } from 'mod';", "import type { A, B } from 'mod';", None), + ( + "import { type A as AA, type B as BB } from 'mod';", + "import type { A as AA, B as BB } from 'mod';", + None, + ), + ]; + Tester::new(NoImportTypeSideEffects::NAME, pass, fail).expect_fix(fix).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/no_import_type_side_effects.snap b/crates/oxc_linter/src/snapshots/no_import_type_side_effects.snap new file mode 100644 index 0000000000000..f9637035b603f --- /dev/null +++ b/crates/oxc_linter/src/snapshots/no_import_type_side_effects.snap @@ -0,0 +1,31 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: no_import_type_side_effects +--- + ⚠ typescript-eslint(no-import-type-side-effects): TypeScript will only remove the inline type specifiers which will leave behind a side effect import at runtime. + ╭─[no_import_type_side_effects.tsx:1:1] + 1 │ import { type A } from 'mod'; + · ───────────────────────────── + ╰──── + help: Convert this to a top-level type qualifier to properly remove the entire import. + + ⚠ typescript-eslint(no-import-type-side-effects): TypeScript will only remove the inline type specifiers which will leave behind a side effect import at runtime. + ╭─[no_import_type_side_effects.tsx:1:1] + 1 │ import { type A as AA } from 'mod'; + · ─────────────────────────────────── + ╰──── + help: Convert this to a top-level type qualifier to properly remove the entire import. + + ⚠ typescript-eslint(no-import-type-side-effects): TypeScript will only remove the inline type specifiers which will leave behind a side effect import at runtime. + ╭─[no_import_type_side_effects.tsx:1:1] + 1 │ import { type A, type B } from 'mod'; + · ───────────────────────────────────── + ╰──── + help: Convert this to a top-level type qualifier to properly remove the entire import. + + ⚠ typescript-eslint(no-import-type-side-effects): TypeScript will only remove the inline type specifiers which will leave behind a side effect import at runtime. + ╭─[no_import_type_side_effects.tsx:1:1] + 1 │ import { type A as AA, type B as BB } from 'mod'; + · ───────────────────────────────────────────────── + ╰──── + help: Convert this to a top-level type qualifier to properly remove the entire import.