diff --git a/crates/oxc_linter/src/rules/unicorn/prefer_event_target.rs b/crates/oxc_linter/src/rules/unicorn/prefer_event_target.rs index 20a99b15d2d15..d0a8d15da0b66 100644 --- a/crates/oxc_linter/src/rules/unicorn/prefer_event_target.rs +++ b/crates/oxc_linter/src/rules/unicorn/prefer_event_target.rs @@ -1,10 +1,18 @@ use oxc_allocator::{GetAddress, UnstableAddress}; -use oxc_ast::{AstKind, ast::Expression}; +use oxc_ast::{ + AstKind, + ast::{Argument, BindingPattern, Expression, IdentifierReference}, +}; use oxc_diagnostics::OxcDiagnostic; use oxc_macros::declare_oxc_lint; use oxc_span::Span; -use crate::{AstNode, context::LintContext, rule::Rule}; +use crate::{ + AstNode, ast_util::get_declaration_of_variable, context::LintContext, rule::Rule, + utils::is_import_from_module, +}; + +const IGNORED_PACKAGES: [&str; 2] = ["@angular/core", "eventemitter3"]; fn prefer_event_target_diagnostic(span: Span) -> OxcDiagnostic { OxcDiagnostic::warn("Prefer `EventTarget` over `EventEmitter`") @@ -68,10 +76,99 @@ impl Rule for PreferEventTarget { _ => return, } + if is_event_emitter_from_ignored_package(ident, ctx) { + return; + } + ctx.diagnostic(prefer_event_target_diagnostic(ident.span)); } } +fn is_ignored_package(source: &str) -> bool { + IGNORED_PACKAGES.contains(&source) +} + +fn is_await_import_or_require_from_ignored_packages(expr: &Expression) -> bool { + match expr.get_inner_expression() { + Expression::CallExpression(call_expr) => { + !call_expr.optional + && call_expr.callee.is_specific_id("require") + && call_expr.arguments.len() == 1 + && match &call_expr.arguments[0] { + Argument::StringLiteral(source) => is_ignored_package(source.value.as_str()), + Argument::TemplateLiteral(source) => source + .single_quasi() + .is_some_and(|source| is_ignored_package(source.as_str())), + _ => false, + } + } + Expression::AwaitExpression(await_expr) => match await_expr.argument.get_inner_expression() + { + Expression::ImportExpression(import_expr) => { + match import_expr.source.get_inner_expression() { + Expression::StringLiteral(source) => is_ignored_package(source.value.as_str()), + Expression::TemplateLiteral(source) => source + .single_quasi() + .is_some_and(|source| is_ignored_package(source.as_str())), + _ => false, + } + } + _ => false, + }, + _ => false, + } +} + +fn is_event_emitter_member_access_from_ignored_packages(expr: &Expression) -> bool { + let Some(member_expr) = expr.get_inner_expression().as_member_expression() else { + return false; + }; + + !member_expr.optional() + && !member_expr.is_computed() + && member_expr.static_property_name() == Some("EventEmitter") + && is_await_import_or_require_from_ignored_packages(member_expr.object()) +} + +fn is_event_emitter_from_ignored_package<'a>( + ident: &IdentifierReference<'a>, + ctx: &LintContext<'a>, +) -> bool { + if IGNORED_PACKAGES.iter().any(|package_name| is_import_from_module(ident, package_name, ctx)) { + return true; + } + + let Some(declaration_node) = get_declaration_of_variable(ident, ctx) else { + return false; + }; + + let AstKind::VariableDeclarator(var_decl) = declaration_node.kind() else { + return false; + }; + + if let BindingPattern::ObjectPattern(object_pattern) = &var_decl.id + && object_pattern.properties.iter().any(|property| { + property.key.is_specific_static_name("EventEmitter") + && property + .value + .get_identifier_name() + .is_some_and(|name| name.as_str() == "EventEmitter") + }) + && var_decl.init.as_ref().is_some_and(is_await_import_or_require_from_ignored_packages) + { + return true; + } + + if var_decl.id.get_identifier_name().is_some_and(|name| name.as_str() == "EventEmitter") + && let Some(init) = &var_decl.init + && is_event_emitter_member_access_from_ignored_packages(init) + { + return true; + } + + false +} + #[test] fn test() { use crate::tester::Tester; @@ -89,6 +186,17 @@ fn test() { "const Foo = class EventEmitter extends Foo {}", "new Foo(EventEmitter)", "new foo.EventEmitter()", + r#"import { EventEmitter } from "@angular/core"; class Foo extends EventEmitter {}"#, + r#"const { EventEmitter } = require("@angular/core"); class Foo extends EventEmitter {}"#, + r#"let { EventEmitter } = require("@angular/core"); class Foo extends EventEmitter {}"#, + r#"const EventEmitter = require("@angular/core").EventEmitter; class Foo extends EventEmitter {}"#, + r#"var EventEmitter = require("eventemitter3").EventEmitter; class Foo extends EventEmitter {}"#, + r#"import EventEmitter from "eventemitter3"; class Foo extends EventEmitter {}"#, + r#"import { EventEmitter } from "eventemitter3"; class Foo extends EventEmitter {}"#, + r#"async function f() { const { EventEmitter } = await import("eventemitter3"); class Foo extends EventEmitter {} }"#, + r"async function f() { const { EventEmitter } = await import(`eventemitter3`); class Foo extends EventEmitter {} }", + r#"async function f() { const EventEmitter = (await import("eventemitter3")).EventEmitter; class Foo extends EventEmitter {} }"#, + r"async function f() { const EventEmitter = (await import(`@angular/core`)).EventEmitter; class Foo extends EventEmitter {} }", "EventTarget()", "new EventTarget", "const target = new EventTarget;", diff --git a/crates/oxc_linter/src/utils/unicorn.rs b/crates/oxc_linter/src/utils/unicorn.rs index aa1fd612ed0d0..67956684facf5 100644 --- a/crates/oxc_linter/src/utils/unicorn.rs +++ b/crates/oxc_linter/src/utils/unicorn.rs @@ -32,20 +32,16 @@ pub const BUILT_IN_ERRORS: [&str; 9] = [ "AggregateError", ]; -/// Returns `true` when `ident` resolves to a named import with the given source module and -/// imported symbol name. +/// Returns `true` when `ident` resolves to any import binding from `module_name`. /// /// This checks semantic resolution first (`reference_id` -> `symbol_id`) and then validates: /// - the symbol is an import binding, -/// - the enclosing declaration source matches `module_name`, -/// - one of the declaration's `ImportSpecifier`s maps to this symbol and `imported_name`. +/// - the enclosing declaration source matches `module_name`. /// -/// Aliased named imports are supported (`import { Foo as Bar }` matches `Bar` for `"Foo"`). -/// Namespace/default imports are intentionally ignored. -pub fn is_import_symbol( +/// Named, default, and namespace imports are all supported. +pub fn is_import_from_module( ident: &IdentifierReference, module_name: &str, - imported_name: &str, ctx: &LintContext, ) -> bool { let reference = ctx.scoping().get_reference(ident.reference_id()); @@ -62,10 +58,38 @@ pub fn is_import_symbol( return false; }; - if import_decl.source.value.as_str() != module_name { + import_decl.source.value.as_str() == module_name +} + +/// Returns `true` when `ident` resolves to a named import with the given source module and +/// imported symbol name. +/// +/// This checks semantic resolution first (`reference_id` -> `symbol_id`) and then validates: +/// - the symbol is an import binding, +/// - the enclosing declaration source matches `module_name`, +/// - one of the declaration's `ImportSpecifier`s maps to this symbol and `imported_name`. +/// +/// Aliased named imports are supported (`import { Foo as Bar }` matches `Bar` for `"Foo"`). +/// Namespace/default imports are intentionally ignored. +pub fn is_import_symbol( + ident: &IdentifierReference, + module_name: &str, + imported_name: &str, + ctx: &LintContext, +) -> bool { + if !is_import_from_module(ident, module_name, ctx) { return false; } + let reference = ctx.scoping().get_reference(ident.reference_id()); + let Some(symbol_id) = reference.symbol_id() else { + return false; + }; + let declaration_id = ctx.scoping().symbol_declaration(symbol_id); + let AstKind::ImportDeclaration(import_decl) = ctx.nodes().parent_kind(declaration_id) else { + return false; + }; + import_decl.specifiers.iter().flatten().any(|specifier| match specifier { ImportDeclarationSpecifier::ImportSpecifier(import_specifier) => { import_specifier.local.symbol_id() == symbol_id