diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index a0672199f564d..a347787b1b0d1 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -108,6 +108,7 @@ mod eslint { pub mod no_template_curly_in_string; pub mod no_ternary; pub mod no_this_before_super; + pub mod no_throw_literal; pub mod no_undef; pub mod no_undefined; pub mod no_unexpected_multiline; @@ -577,6 +578,7 @@ oxc_macros::declare_all_lint_rules! { eslint::no_template_curly_in_string, eslint::no_ternary, eslint::no_this_before_super, + eslint::no_throw_literal, eslint::no_undef, eslint::no_undefined, eslint::no_unexpected_multiline, diff --git a/crates/oxc_linter/src/rules/eslint/no_throw_literal.rs b/crates/oxc_linter/src/rules/eslint/no_throw_literal.rs new file mode 100644 index 0000000000000..55447b3a6b3b9 --- /dev/null +++ b/crates/oxc_linter/src/rules/eslint/no_throw_literal.rs @@ -0,0 +1,277 @@ +use oxc_ast::{ + ast::{AssignmentOperator, Expression, LogicalOperator, TSType}, + 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_throw_literal_diagnostic(span: Span, is_undef: bool) -> OxcDiagnostic { + let message = + if is_undef { "Do not throw undefined" } else { "Expected an error object to be thrown" }; + + OxcDiagnostic::warn(message).with_label(span) +} + +#[derive(Debug, Default, Clone)] +pub struct NoThrowLiteral; + +declare_oxc_lint!( + /// ### What it does + /// + /// Disallows throwing literals or non-Error objects as exceptions. + /// + /// ### Why is this bad? + /// + /// It is considered good practice to only throw the Error object itself or an object using + /// the Error object as base objects for user-defined exceptions. The fundamental benefit of + /// Error objects is that they automatically keep track of where they were built and originated. + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```js + /// throw "error"; + /// + /// throw 0; + /// + /// throw undefined; + /// + /// throw null; + /// + /// var err = new Error(); + /// throw "an " + err; + /// // err is recast to a string literal + /// + /// var err = new Error(); + /// throw `${err}` + /// ``` + /// + /// Examples of **correct** code for this rule: + /// ```js + /// throw new Error(); + /// + /// throw new Error("error"); + /// + /// var e = new Error("error"); + /// throw e; + /// + /// try { + /// throw new Error("error"); + /// } catch (e) { + /// throw e; + /// } + /// ``` + NoThrowLiteral, + correctness, + conditional_suggestion, +); + +const SPECIAL_IDENTIFIERS: [&str; 3] = ["undefined", "Infinity", "NaN"]; +impl Rule for NoThrowLiteral { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ThrowStatement(stmt) = node.kind() else { + return; + }; + + let expr = &stmt.argument; + + match expr.get_inner_expression() { + Expression::StringLiteral(_) | Expression::TemplateLiteral(_) => { + let span = expr.span(); + ctx.diagnostic_with_suggestion(no_throw_literal_diagnostic(span, false), |fixer| { + fixer.replace( + span, + format!("new Error({})", span.source_text(ctx.source_text())), + ) + }); + } + Expression::Identifier(id) if SPECIAL_IDENTIFIERS.contains(&id.name.as_str()) => { + ctx.diagnostic(no_throw_literal_diagnostic(expr.span(), true)); + } + expr if !Self::could_be_error(ctx, expr) => { + ctx.diagnostic(no_throw_literal_diagnostic(expr.span(), false)); + } + _ => {} + } + } +} + +impl NoThrowLiteral { + fn could_be_error(ctx: &LintContext, expr: &Expression) -> bool { + match expr.get_inner_expression() { + Expression::NewExpression(_) + | Expression::AwaitExpression(_) + | Expression::CallExpression(_) + | Expression::ChainExpression(_) + | Expression::YieldExpression(_) + | Expression::PrivateFieldExpression(_) + | Expression::StaticMemberExpression(_) + | Expression::ComputedMemberExpression(_) + | Expression::TaggedTemplateExpression(_) => true, + Expression::AssignmentExpression(expr) => { + if matches!( + expr.operator, + AssignmentOperator::Assign | AssignmentOperator::LogicalAnd + ) { + return Self::could_be_error(ctx, &expr.right); + } + + if matches!( + expr.operator, + AssignmentOperator::LogicalOr | AssignmentOperator::LogicalNullish + ) { + return expr + .left + .get_expression() + .map_or(true, |expr| Self::could_be_error(ctx, expr)) + || Self::could_be_error(ctx, &expr.right); + } + + false + } + Expression::SequenceExpression(expr) => { + expr.expressions.last().is_some_and(|expr| Self::could_be_error(ctx, expr)) + } + Expression::LogicalExpression(expr) => { + if matches!(expr.operator, LogicalOperator::And) { + return Self::could_be_error(ctx, &expr.right); + } + + Self::could_be_error(ctx, &expr.left) || Self::could_be_error(ctx, &expr.right) + } + Expression::ConditionalExpression(expr) => { + Self::could_be_error(ctx, &expr.consequent) + || Self::could_be_error(ctx, &expr.alternate) + } + Expression::Identifier(ident) => { + let Some(ref_id) = ident.reference_id() else { + return true; + }; + let reference = ctx.symbols().get_reference(ref_id); + let Some(symbol_id) = reference.symbol_id() else { + return true; + }; + let decl = ctx.nodes().get_node(ctx.symbols().get_declaration(symbol_id)); + match decl.kind() { + AstKind::VariableDeclarator(decl) => { + if let Some(init) = &decl.init { + Self::could_be_error(ctx, init) + } else { + // TODO: warn about throwing undefined + false + } + } + AstKind::Function(_) + | AstKind::Class(_) + | AstKind::TSModuleDeclaration(_) + | AstKind::TSEnumDeclaration(_) => false, + AstKind::FormalParameter(param) => { + !param.pattern.type_annotation.as_ref().is_some_and(|annot| { + is_definitely_non_error_type(&annot.type_annotation) + }) + } + _ => true, + } + } + _ => false, + } + } +} + +fn is_definitely_non_error_type(ty: &TSType) -> bool { + match ty { + TSType::TSNumberKeyword(_) + | TSType::TSStringKeyword(_) + | TSType::TSBooleanKeyword(_) + | TSType::TSNullKeyword(_) + | TSType::TSUndefinedKeyword(_) => true, + TSType::TSUnionType(union) => union.types.iter().all(is_definitely_non_error_type), + TSType::TSIntersectionType(intersect) => { + intersect.types.iter().all(is_definitely_non_error_type) + } + _ => false, + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + "throw new Error();", + "throw new Error('error');", + "throw Error('error');", + "var e = new Error(); throw e;", + "try {throw new Error();} catch (e) {throw e;};", + "throw a;", + "throw foo();", + "throw new foo();", + "throw foo.bar;", + "throw foo[bar];", + "class C { #field; foo() { throw foo.#field; } }", // { "ecmaVersion": 2022 }, + "throw foo = new Error();", + "throw foo.bar ||= 'literal'", // { "ecmaVersion": 2021 }, + "throw foo[bar] ??= 'literal'", // { "ecmaVersion": 2021 }, + "throw 1, 2, new Error();", + "throw 'literal' && new Error();", + "throw new Error() || 'literal';", + "throw foo ? new Error() : 'literal';", + "throw foo ? 'literal' : new Error();", + "throw tag `${foo}`;", // { "ecmaVersion": 6 }, + "function* foo() { var index = 0; throw yield index++; }", // { "ecmaVersion": 6 }, + "async function foo() { throw await bar; }", // { "ecmaVersion": 8 }, + "throw obj?.foo", // { "ecmaVersion": 2020 }, + "throw obj?.foo()", // { "ecmaVersion": 2020 } + "throw obj?.foo() as string", + "throw obj?.foo() satisfies Direction", + // local reference resolution + "const err = new Error(); throw err;", + "function main(x) { throw x; }", // cannot determine type of x + "function main(x: any) { throw x; }", + "function main(x: TypeError) { throw x; }", + ]; + + let fail = vec![ + "throw 'error';", + "throw 0;", + "throw false;", + "throw null;", + "throw {};", + "throw undefined;", + "throw Infinity;", + "throw NaN;", + "throw 'a' + 'b';", + "var b = new Error(); throw 'a' + b;", + "throw foo = 'error';", + "throw foo += new Error();", + "throw foo &= new Error();", + "throw foo &&= 'literal'", // { "ecmaVersion": 2021 }, + "throw new Error(), 1, 2, 3;", + "throw 'literal' && 'not an Error';", + "throw foo && 'literal'", + "throw foo ? 'not an Error' : 'literal';", + "throw `${err}`;", // { "ecmaVersion": 6 } + "throw 0 as number", + "throw 'error' satisfies Error", + // local reference resolution + "let foo = 'foo'; throw foo;", + "let foo = 'foo' as unknown as Error; throw foo;", + "function foo() {}; throw foo;", + "const foo = () => {}; throw foo;", + "class Foo {}\nthrow Foo;", + "function main(x: number) { throw x; }", + "function main(x: string) { throw x; }", + "function main(x: string | number) { throw x; }", + ]; + + let fix = vec![ + ("throw 'error';", "throw new Error('error');"), + ("throw `${err}`;", "throw new Error(`${err}`);"), + ("throw 'error' satisfies Error", "throw new Error('error' satisfies Error)"), + ]; + + Tester::new(NoThrowLiteral::NAME, pass, fail).expect_fix(fix).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/no_throw_literal.snap b/crates/oxc_linter/src/snapshots/no_throw_literal.snap new file mode 100644 index 0000000000000..2067a3bca1e62 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/no_throw_literal.snap @@ -0,0 +1,180 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw 'error'; + · ─────── + ╰──── + help: Replace `'error'` with `new Error('error')`. + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw 0; + · ─ + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw false; + · ───── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw null; + · ──── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw {}; + · ── + ╰──── + + ⚠ eslint(no-throw-literal): Do not throw undefined + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw undefined; + · ───────── + ╰──── + + ⚠ eslint(no-throw-literal): Do not throw undefined + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw Infinity; + · ──────── + ╰──── + + ⚠ eslint(no-throw-literal): Do not throw undefined + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw NaN; + · ─── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw 'a' + 'b'; + · ───────── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:28] + 1 │ var b = new Error(); throw 'a' + b; + · ─────── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw foo = 'error'; + · ───────────── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw foo += new Error(); + · ────────────────── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw foo &= new Error(); + · ────────────────── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw foo &&= 'literal' + · ───────────────── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw new Error(), 1, 2, 3; + · ──────────────────── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw 'literal' && 'not an Error'; + · ─────────────────────────── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw foo && 'literal' + · ──────────────── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw foo ? 'not an Error' : 'literal'; + · ──────────────────────────────── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw `${err}`; + · ──────── + ╰──── + help: Replace ``${err}`` with `new Error(`${err}`)`. + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw 0 as number + · ─ + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:7] + 1 │ throw 'error' satisfies Error + · ─────────────────────── + ╰──── + help: Replace `'error' satisfies Error` with `new Error('error' satisfies Error)`. + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:24] + 1 │ let foo = 'foo'; throw foo; + · ─── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:44] + 1 │ let foo = 'foo' as unknown as Error; throw foo; + · ─── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:26] + 1 │ function foo() {}; throw foo; + · ─── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:29] + 1 │ const foo = () => {}; throw foo; + · ─── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:2:7] + 1 │ class Foo {} + 2 │ throw Foo; + · ─── + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:34] + 1 │ function main(x: number) { throw x; } + · ─ + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:34] + 1 │ function main(x: string) { throw x; } + · ─ + ╰──── + + ⚠ eslint(no-throw-literal): Expected an error object to be thrown + ╭─[no_throw_literal.tsx:1:43] + 1 │ function main(x: string | number) { throw x; } + · ─ + ╰────