From 7edc7f06901571635ec5c618b1dc2e861e2fa441 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 23 Oct 2023 02:42:09 +0100 Subject: [PATCH] feat(linter) eslint-plugin-react(jsx-no-comment-text-nodes) (#1027) --- crates/oxc_ast/src/ast_kind.rs | 4 + crates/oxc_ast/src/visit.rs | 8 +- crates/oxc_linter/src/rules.rs | 2 + .../rules/react/jsx_no_comment_text_nodes.rs | 313 ++++++++++++++++++ .../snapshots/jsx_no_comment_text_nodes.snap | 66 ++++ 5 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 crates/oxc_linter/src/rules/react/jsx_no_comment_text_nodes.rs create mode 100644 crates/oxc_linter/src/snapshots/jsx_no_comment_text_nodes.snap diff --git a/crates/oxc_ast/src/ast_kind.rs b/crates/oxc_ast/src/ast_kind.rs index f668e083b..98bd3b26b 100644 --- a/crates/oxc_ast/src/ast_kind.rs +++ b/crates/oxc_ast/src/ast_kind.rs @@ -114,6 +114,7 @@ pub enum AstKind<'a> { JSXOpeningElement(&'a JSXOpeningElement<'a>), JSXElementName(&'a JSXElementName<'a>), JSXAttributeItem(&'a JSXAttributeItem<'a>), + JSXText(&'a JSXText), // TypeScript TSModuleBlock(&'a TSModuleBlock<'a>), @@ -240,6 +241,7 @@ impl<'a> AstKind<'a> { | Self::JSXElementName(_) | Self::JSXFragment(_) | Self::JSXAttributeItem(_) + | Self::JSXText(_) ) } @@ -361,6 +363,7 @@ impl<'a> GetSpan for AstKind<'a> { Self::JSXElement(x) => x.span, Self::JSXFragment(x) => x.span, Self::JSXAttributeItem(x) => x.span(), + Self::JSXText(x) => x.span, Self::TSModuleBlock(x) => x.span, @@ -526,6 +529,7 @@ impl<'a> AstKind<'a> { Self::JSXElement(_) => "JSXElement".into(), Self::JSXFragment(_) => "JSXFragment".into(), Self::JSXAttributeItem(_) => "JSXAttributeItem".into(), + Self::JSXText(_) => "JSXText".into(), Self::TSModuleBlock(_) => "TSModuleBlock".into(), diff --git a/crates/oxc_ast/src/visit.rs b/crates/oxc_ast/src/visit.rs index b3f75e463..af82e9e68 100644 --- a/crates/oxc_ast/src/visit.rs +++ b/crates/oxc_ast/src/visit.rs @@ -1094,7 +1094,7 @@ pub trait Visit<'a>: Sized { JSXChild::Fragment(elem) => self.visit_jsx_fragment(elem), JSXChild::ExpressionContainer(expr) => self.visit_jsx_expression_container(expr), JSXChild::Spread(expr) => self.visit_jsx_spread_child(expr), - JSXChild::Text(_) => {} + JSXChild::Text(expr) => self.visit_jsx_text(expr), } } @@ -1102,6 +1102,12 @@ pub trait Visit<'a>: Sized { self.visit_expression(&child.expression); } + fn visit_jsx_text(&mut self, child: &JSXText) { + let kind = AstKind::JSXText(self.alloc(child)); + self.enter_node(kind); + self.leave_node(kind); + } + /* ---------- Pattern ---------- */ fn visit_binding_pattern(&mut self, pat: &BindingPattern<'a>) { diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index d7123f0f3..b795e250a 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -126,6 +126,7 @@ mod jest { mod react { pub mod jsx_key; + pub mod jsx_no_comment_text_nodes; pub mod jsx_no_duplicate_props; pub mod jsx_no_useless_fragment; pub mod no_children_prop; @@ -254,6 +255,7 @@ oxc_macros::declare_all_lint_rules! { unicorn::throw_new_error, unicorn::prefer_array_flat_map, react::jsx_key, + react::jsx_no_comment_text_nodes, react::jsx_no_duplicate_props, react::jsx_no_useless_fragment, react::no_children_prop, diff --git a/crates/oxc_linter/src/rules/react/jsx_no_comment_text_nodes.rs b/crates/oxc_linter/src/rules/react/jsx_no_comment_text_nodes.rs new file mode 100644 index 000000000..711827201 --- /dev/null +++ b/crates/oxc_linter/src/rules/react/jsx_no_comment_text_nodes.rs @@ -0,0 +1,313 @@ +use lazy_static::lazy_static; +use oxc_ast::AstKind; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::{Atom, Span}; +use regex::Regex; + +use crate::{context::LintContext, rule::Rule, AstNode}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint-plugin-react(jsx-no-comment-TextNodes): Comments inside children section of tag should be placed inside braces")] +#[diagnostic(severity(warning))] +struct JsxNoCommentTextNodesDiagnostic(#[label] pub Span); + +#[derive(Debug, Default, Clone)] +pub struct JsxNoCommentTextNodes; + +declare_oxc_lint!( + /// ### What it does + /// + /// This rule prevents comment strings (e.g. beginning with `//` or `/*`) from being accidentally injected as a text node in JSX statements. + /// + /// ### Why is this bad? + /// + /// In JSX, any text node that is not wrapped in curly braces is considered a literal string to be rendered. This can lead to unexpected behavior when the text contains a comment. + /// + /// ### Example + /// ```javascript + /// // Incorrect: + /// + /// const Hello = () => { + /// return
// empty div
; + /// } + /// + /// const Hello = () => { + /// return
/* empty div */
; + /// } + /// + /// // Correct: + /// + /// const Hello = () => { + /// return
// empty div
; + /// } + /// + /// const Hello = () => { + /// return
{/* empty div */}
; + /// } + /// ``` + JsxNoCommentTextNodes, + suspicious +); + +impl Rule for JsxNoCommentTextNodes { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXText(jsx_text) = node.kind() else { return }; + + if control_patterns(&jsx_text.value) { + ctx.diagnostic(JsxNoCommentTextNodesDiagnostic(jsx_text.span)); + } + } +} + +fn control_patterns(pattern: &Atom) -> bool { + lazy_static! { + static ref CTL_PAT: Regex = Regex::new(r"(?m)^\s*/(/|\*)",).unwrap(); + } + CTL_PAT.is_match(pattern.as_str()) +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + ( + " + class Comp1 extends Component { + render() { + return ( +
+ {/* valid */} +
+ ); + } + } + ", + None, + ), + ( + " + class Comp1 extends Component { + render() { + return ( + <> + {/* valid */} + + ); + } + } + ", + None, + ), + ( + " + class Comp1 extends Component { + render() { + return (
{/* valid */}
); + } + } + ", + None, + ), + ( + " + class Comp1 extends Component { + render() { + const bar = (
{/* valid */}
); + return bar; + } + } + ", + None, + ), + ( + " + var Hello = createReactClass({ + foo: (
{/* valid */}
), + render() { + return this.foo; + }, + }); + ", + None, + ), + ( + " + class Comp1 extends Component { + render() { + return ( +
+ {/* valid */} + {/* valid 2 */} + {/* valid 3 */} +
+ ); + } + } + ", + None, + ), + ( + " + class Comp1 extends Component { + render() { + return ( +
+
+ ); + } + } + ", + None, + ), + ( + " + var foo = require('foo'); + ", + None, + ), + ( + " + + {/* valid */} + + ", + None, + ), + ( + " + +  https://www.example.com/attachment/download/1 + + ", + None, + ), + ( + " + + ", + None, + ), + ( + " + + ", + None, + ), + ( + " + <> + ", + None, + ), + ( + " + + ", + None, + ), + ("
// TODO: Write perfect code
", None), + ("
/* TODO: Write perfect code */
", None), + ( + " +
+ // ...
+
+ ", + None, + ), + ]; + + let fail = vec![ + ( + " + class Comp1 extends Component { + render() { + return (
// invalid
); + } + } + ", + None, + ), + ( + " + class Comp1 extends Component { + render() { + return (<>// invalid); + } + } + ", + None, + ), + ( + " + class Comp1 extends Component { + render() { + return (
/* invalid */
); + } + } + ", + None, + ), + ( + " + class Comp1 extends Component { + render() { + return ( +
+ // invalid +
+ ); + } + } + ", + None, + ), + ( + " + class Comp1 extends Component { + render() { + return ( +
+ asdjfl + /* invalid */ + foo +
+ ); + } + } + ", + None, + ), + ( + " + class Comp1 extends Component { + render() { + return ( +
+ {'asdjfl'} + // invalid + {'foo'} +
+ ); + } + } + ", + None, + ), + ( + " + const Component2 = () => { + return /*; + }; + ", + None, + ), + ]; + + Tester::new(JsxNoCommentTextNodes::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/jsx_no_comment_text_nodes.snap b/crates/oxc_linter/src/snapshots/jsx_no_comment_text_nodes.snap new file mode 100644 index 000000000..b0a0db8d1 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/jsx_no_comment_text_nodes.snap @@ -0,0 +1,66 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: jsx_no_comment_text_nodes +--- + ⚠ eslint-plugin-react(jsx-no-comment-TextNodes): Comments inside children section of tag should be placed inside braces + ╭─[jsx_no_comment_text_nodes.tsx:3:1] + 3 │ render() { + 4 │ return (
// invalid
); + · ────────── + 5 │ } + ╰──── + + ⚠ eslint-plugin-react(jsx-no-comment-TextNodes): Comments inside children section of tag should be placed inside braces + ╭─[jsx_no_comment_text_nodes.tsx:3:1] + 3 │ render() { + 4 │ return (<>// invalid); + · ────────── + 5 │ } + ╰──── + + ⚠ eslint-plugin-react(jsx-no-comment-TextNodes): Comments inside children section of tag should be placed inside braces + ╭─[jsx_no_comment_text_nodes.tsx:3:1] + 3 │ render() { + 4 │ return (
/* invalid */
); + · ───────────── + 5 │ } + ╰──── + + ⚠ eslint-plugin-react(jsx-no-comment-TextNodes): Comments inside children section of tag should be placed inside braces + ╭─[jsx_no_comment_text_nodes.tsx:4:1] + 4 │ return ( + 5 │ ╭─▶
+ 6 │ │ // invalid + 7 │ ╰─▶
+ 8 │ ); + ╰──── + + ⚠ eslint-plugin-react(jsx-no-comment-TextNodes): Comments inside children section of tag should be placed inside braces + ╭─[jsx_no_comment_text_nodes.tsx:4:1] + 4 │ return ( + 5 │ ╭─▶
+ 6 │ │ asdjfl + 7 │ │ /* invalid */ + 8 │ │ foo + 9 │ ╰─▶
+ 10 │ ); + ╰──── + + ⚠ eslint-plugin-react(jsx-no-comment-TextNodes): Comments inside children section of tag should be placed inside braces + ╭─[jsx_no_comment_text_nodes.tsx:5:1] + 5 │
+ 6 │ ╭─▶ {'asdjfl'} + 7 │ │ // invalid + 8 │ ╰─▶ {'foo'} + 9 │
+ ╰──── + + ⚠ eslint-plugin-react(jsx-no-comment-TextNodes): Comments inside children section of tag should be placed inside braces + ╭─[jsx_no_comment_text_nodes.tsx:2:1] + 2 │ const Component2 = () => { + 3 │ return /*; + · ── + 4 │ }; + ╰──── + +