diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 11657d1bc2fd5..ca3e6e74a9ac3 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -108,6 +108,7 @@ mod eslint { pub mod no_this_before_super; pub mod no_undef; pub mod no_undefined; + pub mod no_unexpected_multiline; pub mod no_unreachable; pub mod no_unsafe_finally; pub mod no_unsafe_negation; @@ -569,6 +570,7 @@ oxc_macros::declare_all_lint_rules! { eslint::no_this_before_super, eslint::no_undef, eslint::no_undefined, + eslint::no_unexpected_multiline, eslint::no_unreachable, eslint::no_unsafe_finally, eslint::no_unsafe_negation, diff --git a/crates/oxc_linter/src/rules/eslint/no_unexpected_multiline.rs b/crates/oxc_linter/src/rules/eslint/no_unexpected_multiline.rs new file mode 100644 index 0000000000000..dd47a83f0905d --- /dev/null +++ b/crates/oxc_linter/src/rules/eslint/no_unexpected_multiline.rs @@ -0,0 +1,383 @@ +use memchr::{memchr, memrchr}; +use oxc_ast::{ast::BinaryOperator, AstKind}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::{GetSpan, Span}; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +#[derive(Debug, Default, Clone)] +pub struct NoUnexpectedMultiline; + +enum DiagnosticKind { + FunctionCall { open_paren_span: Span }, + PropertyAccess { open_bracket_span: Span }, + TaggedTemplate { backtick_span: Span }, + Division { slash_span: Span }, +} + +fn no_unexpected_multiline_diagnostic(kind: &DiagnosticKind) -> OxcDiagnostic { + match kind { + DiagnosticKind::FunctionCall { open_paren_span } => OxcDiagnostic::warn( + "Unexpected newline between function name and open parenthesis of function call", + ) + .with_label( + open_paren_span.label("this is parsed as a function call, which may be unintentional"), + ) + .with_help( + "If you did not intend to make a function call, insert ';' before the parenthesis", + ), + DiagnosticKind::PropertyAccess { open_bracket_span } => OxcDiagnostic::warn( + "Unexpected newline between object and open bracket of property access", + ) + .with_label( + open_bracket_span + .label("this is parsed as a property access, which may be unintentional"), + ) + .with_help("If you did not intend to access a property, insert ';' before the bracket"), + DiagnosticKind::TaggedTemplate { backtick_span } => { + OxcDiagnostic::warn( + "Unexpected newline between template tag and template literal", + ) + .with_label(backtick_span.label( + "this is parsed as a tagged template, which may be unintentional", + )) + .with_help("If you did not intend for this to be a tagged template, insert ';' before the backtick") + } + DiagnosticKind::Division { slash_span } => { + OxcDiagnostic::warn( + "Unexpected newline between numerator and division operator", + ) + .with_label( + slash_span.label("this is parsed as division, which may be unintentional"), + ) + .with_help("If you did not intend to divide, insert ';' before the slash") + } + } +} + +declare_oxc_lint!( + /// ### What it does + /// + /// In most cases, semicolons are not required in JavaScript in order for code to be parsed + /// and executed as expected. Typically this occurs because semicolons are automatically + /// inserted based on a fixed set of rules. This rule exists to detect those cases where a semicolon + /// is NOT inserted automatically, and may be parsed differently than expected. + /// + /// ### Why is this bad? + /// + /// Code that has unexpected newlines may be parsed and executed differently than what the + /// developer intended. This can lead to bugs that are difficult to track down. + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```js + /// var a = b + /// (x || y).doSomething() + /// + /// var a = b + /// [a, b, c].forEach(doSomething) + /// + /// let x = function() {} + /// `hello` + /// + /// foo + /// /bar/g.test(baz) + /// ``` + /// + /// Examples of **correct** code for this rule: + /// ```js + /// var a = b; + /// (x || y).doSomething() + /// + /// var a = b; + /// [a, b, c].forEach(doSomething) + /// + /// let x = function() {}; + /// `hello` + /// + /// foo; + /// /bar/g.test(baz) + /// ``` + NoUnexpectedMultiline, + suspicious, + fix_dangerous +); + +impl Rule for NoUnexpectedMultiline { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + match node.kind() { + AstKind::CallExpression(call_expr) => { + if call_expr.optional { + return; + } + if let Some(AstKind::ChainExpression(_)) = ctx.nodes().parent_kind(node.id()) { + return; + } + let span = Span::new(call_expr.callee.span().end, call_expr.span.end); + let src = ctx.source_range(span).as_bytes(); + let Some(open_paren) = memchr(b'(', src) else { + return; + }; + let Some(newline) = memchr(b'\n', src) else { + return; + }; + if newline < open_paren { + let paren_span = + Span::sized(span.start + u32::try_from(open_paren).unwrap(), 1); + ctx.diagnostic_with_dangerous_fix( + no_unexpected_multiline_diagnostic(&DiagnosticKind::FunctionCall { + open_paren_span: paren_span, + }), + |fixer| fixer.insert_text_before_range(paren_span, ";"), + ); + } + } + AstKind::MemberExpression(member_expr) => { + if !member_expr.is_computed() || member_expr.optional() { + return; + } + let span = Span::new(member_expr.object().span().end, member_expr.span().end); + let src = ctx.source_range(span).as_bytes(); + let Some(open_bracket) = memchr(b'[', src) else { + return; + }; + let Some(newline) = memchr(b'\n', src) else { + return; + }; + if newline < open_bracket { + let bracket_span = + Span::sized(span.start + u32::try_from(open_bracket).unwrap(), 1); + ctx.diagnostic_with_dangerous_fix( + no_unexpected_multiline_diagnostic(&DiagnosticKind::PropertyAccess { + open_bracket_span: bracket_span, + }), + |fixer| fixer.insert_text_before_range(bracket_span, ";"), + ); + } + } + AstKind::TaggedTemplateExpression(tagged_template_expr) => { + let start = if let Some(generics) = &tagged_template_expr.type_parameters { + generics.span.end + } else { + tagged_template_expr.tag.span().end + }; + let span = Span::new(start, tagged_template_expr.span.end); + let src = ctx.source_range(span).as_bytes(); + let Some(backtick) = memchr(b'`', src) else { + return; + }; + let Some(newline) = memchr(b'\n', src) else { + return; + }; + if newline < backtick { + let backtick_span = + Span::sized(span.start + u32::try_from(backtick).unwrap(), 1); + ctx.diagnostic_with_dangerous_fix( + no_unexpected_multiline_diagnostic(&DiagnosticKind::TaggedTemplate { + backtick_span, + }), + |fixer| fixer.insert_text_before_range(backtick_span, ";"), + ); + } + } + AstKind::BinaryExpression(binary_expr) => { + if binary_expr.operator != BinaryOperator::Division { + return; + } + let Some(AstKind::BinaryExpression(parent_binary_expr)) = + ctx.nodes().parent_kind(node.id()) + else { + return; + }; + if parent_binary_expr.operator != BinaryOperator::Division { + return; + } + let span = Span::new(binary_expr.left.span().end, parent_binary_expr.span().end); + let src = ctx.source_range(span); + + let Some(newline) = memchr(b'\n', src.as_bytes()) else { + return; + }; + let Some(first_slash) = memchr(b'/', src.as_bytes()) else { + return; + }; + let Some(second_slash) = memrchr(b'/', src.as_bytes()) else { + return; + }; + if first_slash == second_slash { + return; + } + + // the "identifier" will be the characters immediately following the second slash + // until we reach a non-identifier character + let ident_name = src + .chars() + .skip(second_slash + 1) + // This is a rough approximation of "looks like an identifier" + .take_while(|c| { + !(c.is_whitespace() || c.is_ascii_punctuation()) || c == &'_' || c == &'$' + }) + .collect::(); + + if newline < parent_binary_expr.left.span().start as usize + // The identifier name should look like it was an attempt to use a regex + && is_regex_flag(ident_name.as_str()) + // if it was a regex attempt, the second slash should be before the identifier + && second_slash + (span.start as usize) + 1 == parent_binary_expr.right.span().start as usize + { + let slash_span = + Span::sized(span.start + u32::try_from(first_slash).unwrap(), 1); + + ctx.diagnostic_with_dangerous_fix( + no_unexpected_multiline_diagnostic(&DiagnosticKind::Division { + slash_span, + }), + |fixer| fixer.insert_text_before_range(slash_span, ";"), + ); + } + } + _ => {} + } + } +} + +// based on ESLint source: REGEX_FLAG_MATCHER = /^[gimsuy]+$/u; +fn is_regex_flag(str: &str) -> bool { + // check if all characters in the string are in the set [gimsuy] + for c in str.chars() { + if !matches!(c, 'g' | 'i' | 'm' | 's' | 'u' | 'y') { + return false; + } + } + true +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + "(x || y).aFunction()", + "[a, b, c].forEach(doSomething)", + "var a = b;\n(x || y).doSomething()", + "var a = b\n;(x || y).doSomething()", + "var a = b\nvoid (x || y).doSomething()", + "var a = b;\n[1, 2, 3].forEach(console.log)", + "var a = b\nvoid [1, 2, 3].forEach(console.log)", + "\"abc\\\n(123)\"", + "var a = (\n(123)\n)", + "f(\n(x)\n)", + "(\nfunction () {}\n)[1]", + "let x = function() {};\n `hello`", // { "ecmaVersion": 6 }, + "let x = function() {}\nx `hello`", // { "ecmaVersion": 6 }, + "String.raw `Hi\n${2+3}!`;", // { "ecmaVersion": 6 }, + "x\n.y\nz `Valid Test Case`", // { "ecmaVersion": 6 }, + "f(x\n)`Valid Test Case`", // { "ecmaVersion": 6 }, + "x.\ny `Valid Test Case`", // { "ecmaVersion": 6 }, + "(x\n)`Valid Test Case`", // { "ecmaVersion": 6 }, + " + foo + / bar /2 + ", + " + foo + / bar / mgy + ", + " + foo + / bar / + gym + ", + " + foo + / bar + / ygm + ", + " + foo + / bar /GYM + ", + " + foo + / bar / baz + ", + "foo /bar/g", + " + foo + /denominator/ + 2 + ", + " + foo + / /abc/ + ", + " + 5 / (5 + / 5) + ", + " + tag` + multiline + `; + ", // { "parser": require("../../fixtures/parsers/typescript-parsers/tagged-template-with-generic/tagged-template-with-generic-1") }, + " + tag< + generic + >` + multiline + `; + ", // { "parser": require("../../fixtures/parsers/typescript-parsers/tagged-template-with-generic/tagged-template-with-generic-2") }, + " + tag< + generic + >`multiline`; + ", // { "parser": require("../../fixtures/parsers/typescript-parsers/tagged-template-with-generic/tagged-template-with-generic-3") }, + "var a = b\n ?.(x || y).doSomething()", // { "ecmaVersion": 2020 }, + "var a = b\n ?.[a, b, c].forEach(doSomething)", // { "ecmaVersion": 2020 }, + "var a = b?.\n (x || y).doSomething()", // { "ecmaVersion": 2020 }, + "var a = b?.\n [a, b, c].forEach(doSomething)", // { "ecmaVersion": 2020 }, + "class C { field1\n[field2]; }", // { "ecmaVersion": 2022 }, + "class C { field1\n*gen() {} }", // { "ecmaVersion": 2022 }, + "class C { field1 = () => {}\n[field2]; }", // { "ecmaVersion": 2022 }, + "class C { field1 = () => {}\n*gen() {} }", // { "ecmaVersion": 2022 } + ]; + + let fail = vec![ + "var a = b\n(x || y).doSomething()", + "var a = (a || b)\n(x || y).doSomething()", + "var a = (a || b)\n(x).doSomething()", + "var a = b\n[a, b, c].forEach(doSomething)", + "var a = b\n (x || y).doSomething()", + "var a = b\n [a, b, c].forEach(doSomething)", + "let x = function() {}\n `hello`", // { "ecmaVersion": 6 }, + "let x = function() {}\nx\n`hello`", // { "ecmaVersion": 6 }, + "x\n.y\nz\n`Invalid Test Case`", // { "ecmaVersion": 6 }, + " + foo + / bar /gym + ", + " + foo + / bar /g + ", + " + foo + / bar /g.test(baz) + ", + " + foo + /bar/gimuygimuygimuy.test(baz) + ", + " + foo + /bar/s.test(baz) + ", + "const x = aaaa<\n test\n>/*\ntest\n*/`foo`", // { "parser": require("../../fixtures/parsers/typescript-parsers/tagged-template-with-generic/tagged-template-with-generic-and-comment") }, + "class C { field1 = obj\n[field2]; }", // { "ecmaVersion": 2022 }, + "class C { field1 = function() {}\n[field2]; }", // { "ecmaVersion": 2022 } + ]; + + Tester::new(NoUnexpectedMultiline::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/no_unexpected_multiline.snap b/crates/oxc_linter/src/snapshots/no_unexpected_multiline.snap new file mode 100644 index 0000000000000..bb6914fdcb6ba --- /dev/null +++ b/crates/oxc_linter/src/snapshots/no_unexpected_multiline.snap @@ -0,0 +1,160 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ eslint(no-unexpected-multiline): Unexpected newline between function name and open parenthesis of function call + ╭─[no_unexpected_multiline.tsx:2:1] + 1 │ var a = b + 2 │ (x || y).doSomething() + · ┬ + · ╰── this is parsed as a function call, which may be unintentional + ╰──── + help: If you did not intend to make a function call, insert ';' before the parenthesis + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between function name and open parenthesis of function call + ╭─[no_unexpected_multiline.tsx:2:1] + 1 │ var a = (a || b) + 2 │ (x || y).doSomething() + · ┬ + · ╰── this is parsed as a function call, which may be unintentional + ╰──── + help: If you did not intend to make a function call, insert ';' before the parenthesis + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between function name and open parenthesis of function call + ╭─[no_unexpected_multiline.tsx:2:1] + 1 │ var a = (a || b) + 2 │ (x).doSomething() + · ┬ + · ╰── this is parsed as a function call, which may be unintentional + ╰──── + help: If you did not intend to make a function call, insert ';' before the parenthesis + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between object and open bracket of property access + ╭─[no_unexpected_multiline.tsx:2:1] + 1 │ var a = b + 2 │ [a, b, c].forEach(doSomething) + · ┬ + · ╰── this is parsed as a property access, which may be unintentional + ╰──── + help: If you did not intend to access a property, insert ';' before the bracket + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between function name and open parenthesis of function call + ╭─[no_unexpected_multiline.tsx:2:5] + 1 │ var a = b + 2 │ (x || y).doSomething() + · ┬ + · ╰── this is parsed as a function call, which may be unintentional + ╰──── + help: If you did not intend to make a function call, insert ';' before the parenthesis + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between object and open bracket of property access + ╭─[no_unexpected_multiline.tsx:2:3] + 1 │ var a = b + 2 │ [a, b, c].forEach(doSomething) + · ┬ + · ╰── this is parsed as a property access, which may be unintentional + ╰──── + help: If you did not intend to access a property, insert ';' before the bracket + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between template tag and template literal + ╭─[no_unexpected_multiline.tsx:2:2] + 1 │ let x = function() {} + 2 │ `hello` + · ┬ + · ╰── this is parsed as a tagged template, which may be unintentional + ╰──── + help: If you did not intend for this to be a tagged template, insert ';' before the backtick + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between template tag and template literal + ╭─[no_unexpected_multiline.tsx:3:1] + 2 │ x + 3 │ `hello` + · ┬ + · ╰── this is parsed as a tagged template, which may be unintentional + ╰──── + help: If you did not intend for this to be a tagged template, insert ';' before the backtick + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between template tag and template literal + ╭─[no_unexpected_multiline.tsx:4:1] + 3 │ z + 4 │ `Invalid Test Case` + · ┬ + · ╰── this is parsed as a tagged template, which may be unintentional + ╰──── + help: If you did not intend for this to be a tagged template, insert ';' before the backtick + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between numerator and division operator + ╭─[no_unexpected_multiline.tsx:3:4] + 2 │ foo + 3 │ / bar /gym + · ┬ + · ╰── this is parsed as division, which may be unintentional + 4 │ + ╰──── + help: If you did not intend to divide, insert ';' before the slash + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between numerator and division operator + ╭─[no_unexpected_multiline.tsx:3:4] + 2 │ foo + 3 │ / bar /g + · ┬ + · ╰── this is parsed as division, which may be unintentional + 4 │ + ╰──── + help: If you did not intend to divide, insert ';' before the slash + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between numerator and division operator + ╭─[no_unexpected_multiline.tsx:3:4] + 2 │ foo + 3 │ / bar /g.test(baz) + · ┬ + · ╰── this is parsed as division, which may be unintentional + 4 │ + ╰──── + help: If you did not intend to divide, insert ';' before the slash + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between numerator and division operator + ╭─[no_unexpected_multiline.tsx:3:4] + 2 │ foo + 3 │ /bar/gimuygimuygimuy.test(baz) + · ┬ + · ╰── this is parsed as division, which may be unintentional + 4 │ + ╰──── + help: If you did not intend to divide, insert ';' before the slash + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between numerator and division operator + ╭─[no_unexpected_multiline.tsx:3:4] + 2 │ foo + 3 │ /bar/s.test(baz) + · ┬ + · ╰── this is parsed as division, which may be unintentional + 4 │ + ╰──── + help: If you did not intend to divide, insert ';' before the slash + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between template tag and template literal + ╭─[no_unexpected_multiline.tsx:5:3] + 4 │ test + 5 │ */`foo` + · ┬ + · ╰── this is parsed as a tagged template, which may be unintentional + ╰──── + help: If you did not intend for this to be a tagged template, insert ';' before the backtick + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between object and open bracket of property access + ╭─[no_unexpected_multiline.tsx:2:1] + 1 │ class C { field1 = obj + 2 │ [field2]; } + · ┬ + · ╰── this is parsed as a property access, which may be unintentional + ╰──── + help: If you did not intend to access a property, insert ';' before the bracket + + ⚠ eslint(no-unexpected-multiline): Unexpected newline between object and open bracket of property access + ╭─[no_unexpected_multiline.tsx:2:1] + 1 │ class C { field1 = function() {} + 2 │ [field2]; } + · ┬ + · ╰── this is parsed as a property access, which may be unintentional + ╰──── + help: If you did not intend to access a property, insert ';' before the bracket