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
112 changes: 110 additions & 2 deletions crates/oxc_linter/src/rules/unicorn/prefer_event_target.rs
Original file line number Diff line number Diff line change
@@ -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`")
Expand Down Expand Up @@ -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;
Expand All @@ -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;",
Expand Down
42 changes: 33 additions & 9 deletions crates/oxc_linter/src/utils/unicorn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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
Expand Down
Loading