Skip to content

Commit 8b7c784

Browse files
baevmcamc314
andauthored
feat(linter): add react/jsx-pascal-case rule (#12165)
Hi! this PR adds react/jsx-pascal-case rule, issue #1022. Note: eslint-plugin-react/jsx-pascal-case uses minimatch globs for the ignored components name array, i used glob_match to replicate this, but there might be a better approach? --------- Co-authored-by: Cameron Clark <cameron.clark@hey.com>
1 parent e55ffe0 commit 8b7c784

File tree

4 files changed

+458
-0
lines changed

4 files changed

+458
-0
lines changed

crates/oxc_linter/src/generated/rule_runner_impls.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1690,6 +1690,11 @@ impl RuleRunner for crate::rules::react::jsx_no_useless_fragment::JsxNoUselessFr
16901690
Some(&AstTypesBitset::from_types(&[AstType::JSXElement, AstType::JSXFragment]));
16911691
}
16921692

1693+
impl RuleRunner for crate::rules::react::jsx_pascal_case::JsxPascalCase {
1694+
const NODE_TYPES: Option<&AstTypesBitset> =
1695+
Some(&AstTypesBitset::from_types(&[AstType::JSXOpeningElement]));
1696+
}
1697+
16931698
impl RuleRunner for crate::rules::react::jsx_props_no_spread_multi::JsxPropsNoSpreadMulti {
16941699
const NODE_TYPES: Option<&AstTypesBitset> =
16951700
Some(&AstTypesBitset::from_types(&[AstType::JSXOpeningElement]));

crates/oxc_linter/src/rules.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ pub(crate) mod react {
352352
pub mod jsx_no_target_blank;
353353
pub mod jsx_no_undef;
354354
pub mod jsx_no_useless_fragment;
355+
pub mod jsx_pascal_case;
355356
pub mod jsx_props_no_spread_multi;
356357
pub mod no_array_index_key;
357358
pub mod no_children_prop;
@@ -985,6 +986,7 @@ oxc_macros::declare_all_lint_rules! {
985986
react::forbid_elements,
986987
react::forward_ref_uses_ref,
987988
react::iframe_missing_sandbox,
989+
react::jsx_pascal_case,
988990
react::jsx_fragments,
989991
react::jsx_filename_extension,
990992
react::jsx_boolean_value,
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
use fast_glob::glob_match;
2+
use oxc_ast::{AstKind, ast::JSXElementName};
3+
use oxc_diagnostics::OxcDiagnostic;
4+
use oxc_macros::declare_oxc_lint;
5+
use oxc_span::{CompactStr, Span};
6+
7+
use crate::{AstNode, context::LintContext, rule::Rule};
8+
9+
fn jsx_pascal_case_diagnostic(
10+
span: Span,
11+
component_name: &str,
12+
allow_all_caps: bool,
13+
) -> OxcDiagnostic {
14+
let message = if allow_all_caps {
15+
format!("JSX component {component_name} must be in PascalCase or SCREAMING_SNAKE_CASE")
16+
} else {
17+
format!("JSX component {component_name} must be in PascalCase")
18+
};
19+
20+
OxcDiagnostic::warn(message).with_label(span)
21+
}
22+
23+
#[derive(Debug, Default, Clone)]
24+
pub struct JsxPascalCase(Box<JsxPascalCaseConfig>);
25+
26+
#[derive(Debug, Default, Clone)]
27+
pub struct JsxPascalCaseConfig {
28+
pub allow_all_caps: bool,
29+
pub allow_namespace: bool,
30+
pub allow_leading_underscore: bool,
31+
pub ignore: Vec<CompactStr>,
32+
}
33+
34+
impl std::ops::Deref for JsxPascalCase {
35+
type Target = JsxPascalCaseConfig;
36+
37+
fn deref(&self) -> &Self::Target {
38+
&self.0
39+
}
40+
}
41+
42+
declare_oxc_lint!(
43+
/// ### What it does
44+
///
45+
/// Enforce PascalCase for user-defined JSX components
46+
///
47+
/// ### Why is this bad?
48+
///
49+
/// It enforces coding style that user-defined JSX components are defined and referenced in PascalCase. Note that since React's JSX uses the upper vs. lower case convention
50+
/// to distinguish between local component classes and HTML tags this rule will not warn on components that start with a lower case letter.
51+
///
52+
/// ### Examples
53+
///
54+
/// Examples of **incorrect** code for this rule:
55+
/// ```jsx
56+
/// <Test_component />
57+
/// <TEST_COMPONENT />
58+
/// ```
59+
///
60+
/// Examples of **correct** code for this rule:
61+
/// ```jsx
62+
/// <div />
63+
///
64+
/// <TestComponent />
65+
///
66+
/// <TestComponent>
67+
/// <div />
68+
/// </TestComponent>
69+
///
70+
/// <CSSTransitionGroup />
71+
/// ```
72+
///
73+
/// Examples of **correct** code for the "allowAllCaps" option:
74+
/// ```jsx
75+
/// <ALLOWED />
76+
///
77+
/// <TEST_COMPONENT />
78+
/// ```
79+
///
80+
/// Examples of **correct** code for the "allowNamespace" option:
81+
/// ```jsx
82+
/// <Allowed.div />
83+
///
84+
/// <TestComponent.p />
85+
/// ```
86+
///
87+
/// Examples of **correct** code for the "allowLeadingUnderscore" option:
88+
/// ```jsx
89+
/// <_AllowedComponent />
90+
///
91+
/// <_AllowedComponent>
92+
/// <div />
93+
/// </_AllowedComponent>
94+
/// ```
95+
///
96+
/// ### Options
97+
///
98+
/// #### allowAllCaps
99+
///
100+
/// `{ type: boolean, default: false }`
101+
///
102+
/// Optional boolean set to true to allow components name in all caps
103+
///
104+
/// #### allowLeadingUnderscore
105+
///
106+
/// `{ type: boolean, default: false }`
107+
///
108+
/// Optional boolean set to true to allow components name with that starts with an underscore
109+
///
110+
/// #### allowNamespace
111+
///
112+
/// `{ type: boolean, default: false }`
113+
///
114+
/// Optional boolean set to true to ignore namespaced components
115+
///
116+
/// #### ignore
117+
///
118+
/// `{ type: Array<string | RegExp>, default: [] }`
119+
///
120+
/// Optional string-array of component names to ignore during validation
121+
///
122+
JsxPascalCase,
123+
react,
124+
style
125+
);
126+
127+
impl Rule for JsxPascalCase {
128+
fn from_configuration(value: serde_json::Value) -> Self {
129+
let config = value.get(0);
130+
131+
let allow_all_caps = config
132+
.and_then(|v| v.get("allowAllCaps"))
133+
.and_then(serde_json::Value::as_bool)
134+
.unwrap_or_default();
135+
136+
let allow_namespace = config
137+
.and_then(|v| v.get("allowNamespace"))
138+
.and_then(serde_json::Value::as_bool)
139+
.unwrap_or_default();
140+
141+
let allow_leading_underscore = config
142+
.and_then(|v| v.get("allowLeadingUnderscore"))
143+
.and_then(serde_json::Value::as_bool)
144+
.unwrap_or_default();
145+
146+
let ignore = config
147+
.and_then(|v| v.get("ignore"))
148+
.and_then(serde_json::Value::as_array)
149+
.map(|v| v.iter().filter_map(serde_json::Value::as_str).map(CompactStr::from).collect())
150+
.unwrap_or_default();
151+
152+
Self(Box::new(JsxPascalCaseConfig {
153+
allow_all_caps,
154+
allow_namespace,
155+
allow_leading_underscore,
156+
ignore,
157+
}))
158+
}
159+
160+
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
161+
let AstKind::JSXOpeningElement(jsx_elem) = node.kind() else {
162+
return;
163+
};
164+
165+
let mut is_namespaced_name = false;
166+
let mut is_member_expression = false;
167+
168+
let name = match &jsx_elem.name {
169+
JSXElementName::IdentifierReference(id) => id.name.as_str(),
170+
JSXElementName::NamespacedName(namespaced) => {
171+
is_namespaced_name = true;
172+
&namespaced.to_string()
173+
}
174+
JSXElementName::MemberExpression(member_expr) => {
175+
is_member_expression = true;
176+
&member_expr.to_string()
177+
}
178+
JSXElementName::Identifier(id) if !id.name.chars().next().unwrap().is_lowercase() => {
179+
id.name.as_str()
180+
}
181+
_ => return,
182+
};
183+
184+
if name.chars().nth(0).is_some_and(char::is_lowercase) {
185+
return;
186+
}
187+
188+
let check_names: Vec<&str> = if is_namespaced_name {
189+
name.split(':').collect()
190+
} else if is_member_expression {
191+
name.split('.').collect()
192+
} else {
193+
vec![name]
194+
};
195+
196+
for split_name in check_names {
197+
if split_name.len() == 1 {
198+
return;
199+
}
200+
201+
let is_ignored = check_ignore(&self.ignore, split_name);
202+
203+
let check_name = if self.allow_leading_underscore && split_name.starts_with('_') {
204+
split_name.strip_prefix('_').unwrap_or(split_name)
205+
} else {
206+
split_name
207+
};
208+
209+
let is_pascal_case = check_pascal_case(check_name);
210+
let is_allowed_all_caps = self.allow_all_caps && check_all_caps(check_name);
211+
212+
if !is_pascal_case && !is_allowed_all_caps && !is_ignored {
213+
ctx.diagnostic(jsx_pascal_case_diagnostic(
214+
jsx_elem.span,
215+
split_name,
216+
self.allow_all_caps,
217+
));
218+
}
219+
220+
// if namespaces allowed check only first part of component name
221+
if self.allow_namespace {
222+
return;
223+
}
224+
}
225+
}
226+
}
227+
228+
fn check_all_caps(check_name: &str) -> bool {
229+
let len = check_name.len();
230+
231+
for (idx, letter) in check_name.chars().enumerate() {
232+
if idx == 0 || idx == len - 1 {
233+
if !(letter.is_uppercase() || letter.is_ascii_digit()) {
234+
return false;
235+
}
236+
} else if !(letter.is_uppercase() || letter.is_ascii_digit() || letter == '_') {
237+
return false;
238+
}
239+
}
240+
241+
true
242+
}
243+
244+
fn check_pascal_case(check_name: &str) -> bool {
245+
let mut chars = check_name.chars();
246+
247+
match chars.next() {
248+
Some(c) if c.is_uppercase() => (),
249+
_ => return false,
250+
}
251+
252+
let mut has_lower_or_digit = false;
253+
254+
for c in chars {
255+
if !c.is_alphanumeric() {
256+
return false;
257+
}
258+
if c.is_lowercase() || c.is_ascii_digit() {
259+
has_lower_or_digit = true;
260+
}
261+
}
262+
263+
has_lower_or_digit
264+
}
265+
266+
fn check_ignore(ignore: &[CompactStr], check_name: &str) -> bool {
267+
ignore.iter().any(|entry| entry == check_name || glob_match(entry.as_str(), check_name))
268+
}
269+
270+
#[test]
271+
fn test() {
272+
use crate::tester::Tester;
273+
274+
let pass = vec![
275+
("<div />", None),
276+
("<div></div>", None),
277+
("<testcomponent />", None),
278+
("<testComponent />", None),
279+
("<test_component />", None),
280+
("<TestComponent />", None),
281+
("<CSSTransitionGroup />", None),
282+
("<BetterThanCSS />", None),
283+
("<TestComponent><div /></TestComponent>", None),
284+
("<Test1Component />", None),
285+
("<TestComponent1 />", None),
286+
("<T3StComp0Nent />", None),
287+
("<Éurströmming />", None),
288+
("<Año />", None),
289+
("<Søknad />", None),
290+
("<T />", None),
291+
("<YMCA />", Some(serde_json::json!([{ "allowAllCaps": true }]))),
292+
("<TEST_COMPONENT />", Some(serde_json::json!([{ "allowAllCaps": true }]))),
293+
("<Modal.Header />", None),
294+
("<qualification.T3StComp0Nent />", None),
295+
("<Modal:Header />", None),
296+
("<IGNORED />", Some(serde_json::json!([{ "ignore": ["IGNORED"] }]))),
297+
("<Foo_DEPRECATED />", Some(serde_json::json!([{ "ignore": ["*_DEPRECATED"] }]))),
298+
(
299+
"<Foo_DEPRECATED />",
300+
Some(serde_json::json!([{ "ignore": ["*_*[DEPRECATED,IGNORED]"] }])),
301+
),
302+
("<$ />", None),
303+
("<_ />", None),
304+
("<H1>Hello!</H1>", None),
305+
("<Typography.P />", None),
306+
("<Styled.h1 />", Some(serde_json::json!([{ "allowNamespace": true }]))),
307+
("<Styled.H1.H2 />", None),
308+
(
309+
"<_TEST_COMPONENT />",
310+
Some(serde_json::json!([{ "allowAllCaps": true, "allowLeadingUnderscore": true }])),
311+
),
312+
("<_TestComponent />", Some(serde_json::json!([{ "allowLeadingUnderscore": true }]))),
313+
("<Component_ />", Some(serde_json::json!([{ "ignore": ["Component_"] }]))),
314+
];
315+
316+
let fail = vec![
317+
("<Test_component />", None),
318+
("<TEST_COMPONENT />", None),
319+
("<YMCA />", None),
320+
("<_TEST_COMPONENT />", Some(serde_json::json!([{ "allowAllCaps": true }]))),
321+
("<TEST_COMPONENT_ />", Some(serde_json::json!([{ "allowAllCaps": true }]))),
322+
("<TEST-COMPONENT />", Some(serde_json::json!([{ "allowAllCaps": true }]))),
323+
("<__ />", Some(serde_json::json!([{ "allowAllCaps": true }]))),
324+
("<_div />", Some(serde_json::json!([{ "allowLeadingUnderscore": true }]))),
325+
(
326+
"<__ />",
327+
Some(serde_json::json!([{ "allowAllCaps": true, "allowLeadingUnderscore": true }])),
328+
),
329+
("<$a />", None),
330+
("<Foo_DEPRECATED />", Some(serde_json::json!([{ "ignore": ["*_FOO"] }]))),
331+
("<Styled.h1 />", None),
332+
("<Styled.H1.h2 />", None),
333+
("<Styled.h1.H2 />", None),
334+
("<$Typography.P />", None),
335+
("<STYLED.h1 />", Some(serde_json::json!([{ "allowNamespace": true }]))),
336+
("<_camelCase />", Some(serde_json::json!([{ "allowLeadingUnderscore": true }]))),
337+
("<_Test_Component />", Some(serde_json::json!([{ "allowLeadingUnderscore": true }]))),
338+
];
339+
340+
Tester::new(JsxPascalCase::NAME, JsxPascalCase::PLUGIN, pass, fail).test_and_snapshot();
341+
}

0 commit comments

Comments
 (0)