diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 89fe952614681..7ec40bbb0e0ed 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -245,6 +245,7 @@ mod react { pub mod react_in_jsx_scope; pub mod require_render_return; pub mod rules_of_hooks; + pub mod self_closing_comp; pub mod void_dom_elements_no_children; } @@ -772,6 +773,7 @@ oxc_macros::declare_all_lint_rules! { react::prefer_es6_class, react::require_render_return, react::rules_of_hooks, + react::self_closing_comp, react::void_dom_elements_no_children, react_perf::jsx_no_jsx_as_prop, react_perf::jsx_no_new_array_as_prop, diff --git a/crates/oxc_linter/src/rules/react/self_closing_comp.rs b/crates/oxc_linter/src/rules/react/self_closing_comp.rs new file mode 100644 index 0000000000000..88279aebafe0e --- /dev/null +++ b/crates/oxc_linter/src/rules/react/self_closing_comp.rs @@ -0,0 +1,349 @@ +use oxc_ast::{ + ast::{JSXChild, JSXElementName}, + AstKind, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::{context::LintContext, globals::HTML_TAG, rule::Rule, AstNode}; + +fn self_closing_comp_diagnostic(span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("Unnecessary closing tag") + .with_help("Make the component self closing") + .with_label(span) +} + +#[derive(Debug, Clone)] +pub struct SelfClosingComp { + component: bool, + html: bool, +} + +impl Default for SelfClosingComp { + fn default() -> Self { + Self { component: true, html: true } + } +} + +declare_oxc_lint!( + /// ### What it does + /// + /// Detects components without children which can be self-closed to avoid unnecessary extra + /// closing tags. + /// + /// A self closing component which contains whitespace is allowed except when it also contains + /// a newline. + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```jsx + /// const elem = + /// const dom_elem =
+ /// const welem =
+ /// + ///
+ /// ``` + /// + /// Examples of **correct** code for this rule: + /// ```jsx + /// const elem = + /// const welem = + /// const dom_elem =
+ /// ``` + SelfClosingComp, + style, + pending +); + +impl Rule for SelfClosingComp { + fn from_configuration(value: serde_json::Value) -> Self { + let obj = value.get(0); + + Self { + component: obj + .and_then(|v| v.get("component")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + html: obj + .and_then(|v| v.get("html")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + } + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXElement(jsx_el) = node.kind() else { + return; + }; + + if jsx_el.opening_element.self_closing { + return; + } + + if jsx_el.children.len() > 1 { + return; + } + + // The eslint react rule disallows multiline whitespace lines, but allows lines with + // whitespace + if jsx_el.children.len() == 1 { + let JSXChild::Text(jsx_text) = &jsx_el.children[0] else { + return; + }; + + if !(jsx_text.value.contains('\n') && jsx_text.value.chars().all(char::is_whitespace)) { + return; + } + } + + let Some(jsx_closing_elem) = &jsx_el.closing_element else { + return; + }; + + let is_comp = matches!( + jsx_el.opening_element.name, + JSXElementName::MemberExpression(_) | JSXElementName::NamespacedName(_) + ); + + let mut is_dom_comp = false; + if !is_comp { + if let Some(tag_name) = jsx_el.opening_element.name.get_identifier_name() { + is_dom_comp = HTML_TAG.contains(&tag_name); + }; + } + + if self.html && is_dom_comp || self.component && !is_dom_comp { + ctx.diagnostic(self_closing_comp_diagnostic(jsx_closing_elem.span)); + } + } + + fn should_run(&self, ctx: &LintContext) -> bool { + ctx.source_type().is_jsx() + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + (r#"var HelloJohn = ;"#, None), + (r#"var HelloJohn = ;"#, None), + (r#"var Profile = ;"#, None), + ( + r#"var Profile = ;"#, + None, + ), + ( + r#" + + + + "#, + None, + ), + ( + r#" + + + + "#, + None, + ), + (r#"var HelloJohn = ;"#, None), + (r#"var HelloJohn = ;"#, None), + (r#"var HelloJohn = ;"#, None), + (r#"var HelloJohn = ;"#, None), + ("var HelloJohn =
 
;", None), + ("var HelloJohn =
{' '}
;", None), + (r#"var HelloJohn =  ;"#, None), + (r#"var HelloJohn =  ;"#, None), + (r#"var HelloJohn = ;"#, Some(serde_json::json!([]))), + (r#"var HelloJohn = ;"#, Some(serde_json::json!([]))), + ( + r#"var Profile = ;"#, + Some(serde_json::json!([])), + ), + ( + r#"var Profile = ;"#, + Some(serde_json::json!([])), + ), + ( + r#" + + + "#, + Some(serde_json::json!([])), + ), + ( + r#" + + + "#, + Some(serde_json::json!([])), + ), + ("var HelloJohn =
;", Some(serde_json::json!([]))), + ("var HelloJohn =
;", Some(serde_json::json!([]))), + ("var HelloJohn =
 
;", Some(serde_json::json!([]))), + ("var HelloJohn =
{' '}
;", Some(serde_json::json!([]))), + (r#"var HelloJohn =  ;"#, Some(serde_json::json!([]))), + ( + r#"var HelloJohn =  ;"#, + Some(serde_json::json!([])), + ), + ( + r#"var HelloJohn = ;"#, + Some(serde_json::json!([{ "component": false }])), + ), + ( + r#"var HelloJohn = ;"#, + Some(serde_json::json!([{ "component": false }])), + ), + ( + r#"var HelloJohn = + ;"#, + Some(serde_json::json!([{ "component": false }])), + ), + ( + r#"var HelloJohn = + ;"#, + Some(serde_json::json!([{ "component": false }])), + ), + ( + r#"var HelloJohn = ;"#, + Some(serde_json::json!([{ "component": false }])), + ), + ( + r#"var HelloJohn = ;"#, + Some(serde_json::json!([{ "component": false }])), + ), + ( + r#"var contentContainer =
;"#, + Some(serde_json::json!([{ "html": true }])), + ), + ( + r#"var contentContainer =
;"#, + Some(serde_json::json!([{ "html": true }])), + ), + ( + r#" +
+
+
+ "#, + Some(serde_json::json!([{ "html": true }])), + ), + ]; + + let fail = vec![ + (r#"var contentContainer =
;"#, None), + (r#"var contentContainer =
;"#, Some(serde_json::json!([]))), + (r#"var HelloJohn = ;"#, None), + (r#"var CompoundHelloJohn = ;"#, None), + ( + r#"const HelloJohn = + ;"#, + None, + ), + ( + r#"var HelloJohn = + ;"#, + None, + ), + (r#"var HelloJohn = ;"#, Some(serde_json::json!([]))), + ( + r#"var HelloJohn = ;"#, + Some(serde_json::json!([])), + ), + ( + r#"var HelloJohn = + ;"#, + Some(serde_json::json!([])), + ), + ( + r#"var HelloJohn = + ;"#, + Some(serde_json::json!([])), + ), + ( + r#"var contentContainer =
;"#, + Some(serde_json::json!([{ "html": true }])), + ), + ( + r#"var contentContainer =
+
;"#, + Some(serde_json::json!([{ "html": true }])), + ), + ]; + + let _fix = vec![ + ( + r#"var contentContainer =
;"#, + r#"var contentContainer =
;"#, + None, + ), + ( + r#"var contentContainer =
;"#, + r#"var contentContainer =
;"#, + Some(serde_json::json!([])), + ), + ( + r#"var HelloJohn = ;"#, + r#"var HelloJohn = ;"#, + None, + ), + ( + r#"var CompoundHelloJohn = ;"#, + r#"var CompoundHelloJohn = ;"#, + None, + ), + ( + r#"var HelloJohn = + ;"#, + r#"var HelloJohn = ;"#, + None, + ), + ( + r#"var HelloJohn = + ;"#, + r#"var HelloJohn = ;"#, + None, + ), + ( + r#"var HelloJohn = ;"#, + r#"var HelloJohn = ;"#, + Some(serde_json::json!([])), + ), + ( + r#"var HelloJohn = ;"#, + r#"var HelloJohn = ;"#, + Some(serde_json::json!([])), + ), + ( + r#"var HelloJohn = + ;"#, + r#"var HelloJohn = ;"#, + Some(serde_json::json!([])), + ), + ( + r#"var HelloJohn = + ;"#, + r#"var HelloJohn = ;"#, + Some(serde_json::json!([])), + ), + ( + r#"var contentContainer =
;"#, + r#"var contentContainer =
;"#, + Some(serde_json::json!([{ "html": true }])), + ), + ( + r#"var contentContainer =
+
;"#, + r#"var contentContainer =
;"#, + Some(serde_json::json!([{ "html": true }])), + ), + ]; + Tester::new(SelfClosingComp::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/self_closing_comp.snap b/crates/oxc_linter/src/snapshots/self_closing_comp.snap new file mode 100644 index 0000000000000..68b3cc54d13b9 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/self_closing_comp.snap @@ -0,0 +1,91 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag + ╭─[self_closing_comp.tsx:1:49] + 1 │ var contentContainer =
; + · ────── + ╰──── + help: Make the component self closing + + ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag + ╭─[self_closing_comp.tsx:1:49] + 1 │ var contentContainer =
; + · ────── + ╰──── + help: Make the component self closing + + ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag + ╭─[self_closing_comp.tsx:1:36] + 1 │ var HelloJohn = ; + · ──────── + ╰──── + help: Make the component self closing + + ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag + ╭─[self_closing_comp.tsx:1:53] + 1 │ var CompoundHelloJohn = ; + · ───────────────── + ╰──── + help: Make the component self closing + + ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag + ╭─[self_closing_comp.tsx:2:4] + 1 │ const HelloJohn = + 2 │ ; + · ──────── + ╰──── + help: Make the component self closing + + ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag + ╭─[self_closing_comp.tsx:2:4] + 1 │ var HelloJohn = + 2 │ ; + · ───────────────── + ╰──── + help: Make the component self closing + + ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag + ╭─[self_closing_comp.tsx:1:36] + 1 │ var HelloJohn = ; + · ──────── + ╰──── + help: Make the component self closing + + ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag + ╭─[self_closing_comp.tsx:1:45] + 1 │ var HelloJohn = ; + · ───────────────── + ╰──── + help: Make the component self closing + + ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag + ╭─[self_closing_comp.tsx:2:4] + 1 │ var HelloJohn = + 2 │ ; + · ──────── + ╰──── + help: Make the component self closing + + ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag + ╭─[self_closing_comp.tsx:2:4] + 1 │ var HelloJohn = + 2 │ ; + · ───────────────── + ╰──── + help: Make the component self closing + + ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag + ╭─[self_closing_comp.tsx:1:49] + 1 │ var contentContainer =
; + · ────── + ╰──── + help: Make the component self closing + + ⚠ eslint-plugin-react(self-closing-comp): Unnecessary closing tag + ╭─[self_closing_comp.tsx:2:4] + 1 │ var contentContainer =
+ 2 │
; + · ────── + ╰──── + help: Make the component self closing