diff --git a/src/rules.rs b/src/rules.rs index ff86954a..152a6319 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -9,6 +9,8 @@ use std::cmp::Ordering; use std::collections::HashSet; pub mod adjacent_overload_signatures; +pub mod bad_char_at_comparison; +pub mod bad_object_literal_comparison; pub mod ban_ts_comment; pub mod ban_types; pub mod ban_unknown_rule_code; @@ -116,6 +118,7 @@ pub mod no_window; pub mod no_window_prefix; pub mod no_with; pub mod node_builtin_specifier; +pub mod number_arg_out_of_range; pub mod prefer_as_const; pub mod prefer_ascii; pub mod prefer_const; @@ -253,6 +256,8 @@ pub(crate) fn sort_rules_by_priority(rules: &mut [Box]) { fn get_all_rules_raw() -> Vec> { vec![ Box::new(adjacent_overload_signatures::AdjacentOverloadSignatures), + Box::new(bad_char_at_comparison::BadCharAtComparison), + Box::new(bad_object_literal_comparison::BadObjectLiteralComparison), Box::new(ban_ts_comment::BanTsComment), Box::new(ban_types::BanTypes), Box::new(ban_unknown_rule_code::BanUnknownRuleCode), @@ -364,6 +369,7 @@ fn get_all_rules_raw() -> Vec> { Box::new(no_window_prefix::NoWindowPrefix), Box::new(no_with::NoWith), Box::new(node_builtin_specifier::NodeBuiltinsSpecifier), + Box::new(number_arg_out_of_range::NumberArgOutOfRange), Box::new(prefer_as_const::PreferAsConst), Box::new(prefer_ascii::PreferAscii), Box::new(prefer_const::PreferConst), diff --git a/src/rules/bad_char_at_comparison.rs b/src/rules/bad_char_at_comparison.rs new file mode 100644 index 00000000..355a8d8b --- /dev/null +++ b/src/rules/bad_char_at_comparison.rs @@ -0,0 +1,156 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use super::{Context, LintRule}; +use crate::handler::{Handler, Traverse}; +use crate::tags::{self, Tags}; +use crate::Program; +use deno_ast::view::BinaryOp; +use deno_ast::view::{BinExpr, Callee, Expr, Lit, MemberProp}; +use deno_ast::SourceRanged; +use derive_more::Display; + +#[derive(Debug)] +pub struct BadCharAtComparison; + +const CODE: &str = "bad-char-at-comparison"; + +#[derive(Display)] +enum BadCharAtComparisonMessage { + #[display( + fmt = "`charAt` returns a single character, so comparing its result against a string of a different length is always false" + )] + Unexpected, +} + +#[derive(Display)] +enum BadCharAtComparisonHint { + #[display( + fmt = "Compare against a single character, or use `startsWith`/`slice` to compare longer substrings" + )] + Fix, +} + +impl LintRule for BadCharAtComparison { + fn tags(&self) -> Tags { + &[tags::RECOMMENDED] + } + + fn code(&self) -> &'static str { + CODE + } + + fn lint_program_with_ast_view<'view>( + &self, + context: &mut Context, + program: Program<'_>, + ) { + BadCharAtComparisonHandler.traverse(program, context); + } +} + +struct BadCharAtComparisonHandler; + +impl Handler for BadCharAtComparisonHandler { + fn bin_expr(&mut self, bin_expr: &BinExpr, ctx: &mut Context) { + if !matches!( + bin_expr.op(), + BinaryOp::EqEq | BinaryOp::NotEq | BinaryOp::EqEqEq | BinaryOp::NotEqEq + ) { + return; + } + + let bad = (is_char_at_call(&bin_expr.left) + && is_bad_comparison_string(&bin_expr.right)) + || (is_char_at_call(&bin_expr.right) + && is_bad_comparison_string(&bin_expr.left)); + + if bad { + ctx.add_diagnostic_with_hint( + bin_expr.range(), + CODE, + BadCharAtComparisonMessage::Unexpected, + BadCharAtComparisonHint::Fix, + ); + } + } +} + +fn is_char_at_call(expr: &Expr) -> bool { + let Expr::Call(call) = expr else { + return false; + }; + let Callee::Expr(callee) = call.callee else { + return false; + }; + let Expr::Member(member) = callee else { + return false; + }; + let MemberProp::Ident(prop) = member.prop else { + return false; + }; + prop.sym() == "charAt" && call.args.len() == 1 +} + +fn is_bad_comparison_string(expr: &Expr) -> bool { + let Expr::Lit(Lit::Str(s)) = expr else { + return false; + }; + let value = s.value().to_string_lossy(); + // `charAt` yields exactly one UTF-16 code unit. An empty string or a single + // character may legitimately be compared; anything longer can never match. + value.len() >= 2 && value.chars().count() != 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + // Some tests are derived from + // https://github.com/oxc-project/oxc/blob/main/crates/oxc_linter/src/rules/oxc/bad_char_at_comparison.rs + // MIT Licensed. + + #[test] + fn bad_char_at_comparison_valid() { + assert_lint_ok! { + BadCharAtComparison, + "a.charAt(4) === 'a';", + "a.charAt(4) === '\\n';", + "a.charAt(4) !== 'b';", + // Not a `charAt` call. + "chatAt(4) === 'a2';", + "a.foo(4) === 'a2';", + // Right-hand side is not a string literal. + "a.charAt(4) === 'a' + 'b';", + "a.charAt(4) === x;", + }; + } + + #[test] + fn bad_char_at_comparison_invalid() { + assert_lint_err! { + BadCharAtComparison, + "a.charAt(4) === 'aa';": [ + { + col: 0, + message: BadCharAtComparisonMessage::Unexpected, + hint: BadCharAtComparisonHint::Fix, + } + ], + "a.charAt(822) !== 'foo';": [ + { + col: 0, + message: BadCharAtComparisonMessage::Unexpected, + hint: BadCharAtComparisonHint::Fix, + } + ], + // Reversed operand order. + "'aa' === a.charAt(4);": [ + { + col: 0, + message: BadCharAtComparisonMessage::Unexpected, + hint: BadCharAtComparisonHint::Fix, + } + ] + }; + } +} diff --git a/src/rules/bad_object_literal_comparison.rs b/src/rules/bad_object_literal_comparison.rs new file mode 100644 index 00000000..f6a367c3 --- /dev/null +++ b/src/rules/bad_object_literal_comparison.rs @@ -0,0 +1,154 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use super::{Context, LintRule}; +use crate::handler::{Handler, Traverse}; +use crate::tags::{self, Tags}; +use crate::Program; +use deno_ast::view::BinaryOp; +use deno_ast::view::{BinExpr, Expr}; +use deno_ast::SourceRanged; +use derive_more::Display; + +#[derive(Debug)] +pub struct BadObjectLiteralComparison; + +const CODE: &str = "bad-object-literal-comparison"; + +#[derive(Display)] +enum BadObjectLiteralComparisonMessage { + #[display( + fmt = "Comparing against an empty object literal is always false (or always true for `!=`/`!==`); object references are never equal" + )] + Object, + #[display( + fmt = "Comparing against an empty array literal is always false (or always true for `!=`/`!==`); array references are never equal" + )] + Array, +} + +#[derive(Display)] +enum BadObjectLiteralComparisonHint { + #[display( + fmt = "To check for an empty object, use `Object.keys(x).length === 0`" + )] + Object, + #[display( + fmt = "To check for an empty array, use `Array.isArray(x) && x.length === 0`" + )] + Array, +} + +impl LintRule for BadObjectLiteralComparison { + fn tags(&self) -> Tags { + &[tags::RECOMMENDED] + } + + fn code(&self) -> &'static str { + CODE + } + + fn lint_program_with_ast_view<'view>( + &self, + context: &mut Context, + program: Program<'_>, + ) { + BadObjectLiteralComparisonHandler.traverse(program, context); + } +} + +struct BadObjectLiteralComparisonHandler; + +impl Handler for BadObjectLiteralComparisonHandler { + fn bin_expr(&mut self, bin_expr: &BinExpr, ctx: &mut Context) { + if !matches!( + bin_expr.op(), + BinaryOp::EqEq | BinaryOp::NotEq | BinaryOp::EqEqEq | BinaryOp::NotEqEq + ) { + return; + } + + if is_empty_object(&bin_expr.left) || is_empty_object(&bin_expr.right) { + ctx.add_diagnostic_with_hint( + bin_expr.range(), + CODE, + BadObjectLiteralComparisonMessage::Object, + BadObjectLiteralComparisonHint::Object, + ); + } else if is_empty_array(&bin_expr.left) || is_empty_array(&bin_expr.right) + { + ctx.add_diagnostic_with_hint( + bin_expr.range(), + CODE, + BadObjectLiteralComparisonMessage::Array, + BadObjectLiteralComparisonHint::Array, + ); + } + } +} + +fn is_empty_object(expr: &Expr) -> bool { + matches!(expr, Expr::Object(obj) if obj.props.is_empty()) +} + +fn is_empty_array(expr: &Expr) -> bool { + matches!(expr, Expr::Array(arr) if arr.elems.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Some tests are derived from + // https://github.com/oxc-project/oxc/blob/main/crates/oxc_linter/src/rules/oxc/bad_object_literal_comparison.rs + // MIT Licensed. + + #[test] + fn bad_object_literal_comparison_valid() { + assert_lint_ok! { + BadObjectLiteralComparison, + "if (x === null) {}", + "if (x === undefined) {}", + "if (typeof x === 'object') {}", + "if (x === y) {}", + // Non-empty literals are not flagged. + "if (x === { a: 1 }) {}", + "if (x !== [1]) {}", + }; + } + + #[test] + fn bad_object_literal_comparison_invalid() { + assert_lint_err! { + BadObjectLiteralComparison, + "if (x === {}) {}": [ + { + col: 4, + message: BadObjectLiteralComparisonMessage::Object, + hint: BadObjectLiteralComparisonHint::Object, + } + ], + "if (x != {}) {}": [ + { + col: 4, + message: BadObjectLiteralComparisonMessage::Object, + hint: BadObjectLiteralComparisonHint::Object, + } + ], + "if (arr !== []) {}": [ + { + col: 4, + message: BadObjectLiteralComparisonMessage::Array, + hint: BadObjectLiteralComparisonHint::Array, + } + ], + // Reversed operand order. + "[] === x;": [ + { + col: 0, + message: BadObjectLiteralComparisonMessage::Array, + hint: BadObjectLiteralComparisonHint::Array, + } + ] + }; + } +} diff --git a/src/rules/number_arg_out_of_range.rs b/src/rules/number_arg_out_of_range.rs new file mode 100644 index 00000000..d6777645 --- /dev/null +++ b/src/rules/number_arg_out_of_range.rs @@ -0,0 +1,166 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use super::{Context, LintRule}; +use crate::handler::{Handler, Traverse}; +use crate::tags::{self, Tags}; +use crate::Program; +use deno_ast::view::{CallExpr, Callee, Expr, ExprOrSpread, Lit, MemberProp}; +use deno_ast::SourceRanged; + +#[derive(Debug)] +pub struct NumberArgOutOfRange; + +const CODE: &str = "number-arg-out-of-range"; + +impl LintRule for NumberArgOutOfRange { + fn tags(&self) -> Tags { + &[tags::RECOMMENDED] + } + + fn code(&self) -> &'static str { + CODE + } + + fn lint_program_with_ast_view<'view>( + &self, + context: &mut Context, + program: Program<'_>, + ) { + NumberArgOutOfRangeHandler.traverse(program, context); + } +} + +struct NumberArgOutOfRangeHandler; + +impl Handler for NumberArgOutOfRangeHandler { + fn call_expr(&mut self, call_expr: &CallExpr, ctx: &mut Context) { + let Callee::Expr(callee) = call_expr.callee else { + return; + }; + let Expr::Member(member) = callee else { + return; + }; + let Some(name) = static_member_name(&member.prop) else { + return; + }; + + let (min, max) = match name.as_str() { + "toString" => (2, 36), + "toFixed" | "toExponential" => (0, 100), + "toPrecision" => (1, 100), + _ => return, + }; + + let Some(ExprOrSpread { expr, .. }) = call_expr.args.first() else { + return; + }; + let Expr::Lit(Lit::Num(num)) = expr else { + return; + }; + + let value = num.inner.value; + if value < f64::from(min) || value > f64::from(max) { + ctx.add_diagnostic_with_hint( + call_expr.range(), + CODE, + format!("The argument to `{name}` must be between {min} and {max}"), + format!("Pass a value between {min} and {max}"), + ); + } + } +} + +/// Returns the statically known property name of a member expression, handling +/// both `obj.name` and `obj["name"]` forms. +fn static_member_name(prop: &MemberProp) -> Option { + match prop { + MemberProp::Ident(ident) => Some(ident.sym().to_string()), + MemberProp::Computed(computed) => match computed.expr { + Expr::Lit(Lit::Str(s)) => Some(s.value().to_string_lossy().into_owned()), + _ => None, + }, + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Some tests are derived from + // https://github.com/oxc-project/oxc/blob/main/crates/oxc_linter/src/rules/oxc/number_arg_out_of_range.rs + // MIT Licensed. + + #[test] + fn number_arg_out_of_range_valid() { + assert_lint_ok! { + NumberArgOutOfRange, + "x.toString(16);", + "x.toFixed(2);", + "x.toExponential(0);", + "x.toPrecision(10);", + // Valid in V8/Deno (ES2018+ raised the upper bound to 100). + "x.toFixed(22);", + "x['toExponential'](22);", + "x.toPrecision(100);", + "x.toFixed(100);", + "x.toExponential(100);", + // No argument. + "x.toString();", + // Non-literal argument. + "x.toString(y);", + // Unrelated method. + "x.slice(64);", + }; + } + + #[test] + fn number_arg_out_of_range_invalid() { + assert_lint_err! { + NumberArgOutOfRange, + "x.toString(1);": [ + { + col: 0, + message: "The argument to `toString` must be between 2 and 36", + hint: "Pass a value between 2 and 36", + } + ], + "x.toString(43);": [ + { + col: 0, + message: "The argument to `toString` must be between 2 and 36", + hint: "Pass a value between 2 and 36", + } + ], + "x.toFixed(101);": [ + { + col: 0, + message: "The argument to `toFixed` must be between 0 and 100", + hint: "Pass a value between 0 and 100", + } + ], + "x.toPrecision(0);": [ + { + col: 0, + message: "The argument to `toPrecision` must be between 1 and 100", + hint: "Pass a value between 1 and 100", + } + ], + "x.toPrecision(101);": [ + { + col: 0, + message: "The argument to `toPrecision` must be between 1 and 100", + hint: "Pass a value between 1 and 100", + } + ], + // Computed member access. + "x['toExponential'](101);": [ + { + col: 0, + message: "The argument to `toExponential` must be between 0 and 100", + hint: "Pass a value between 0 and 100", + } + ] + }; + } +}