Skip to content

Commit 3c0c709

Browse files
authored
feat(linter): add typescript-eslint/no-extraneous-class (#4357)
Added rule for https://typescript-eslint.io/rules/no-extraneous-class/ Also, I chose to make the match the node against the class and derive the body from the node, rather than matching against the body and using the context to go back up to the parent class node as in the original source.
1 parent ea33f94 commit 3c0c709

File tree

4 files changed

+438
-0
lines changed

4 files changed

+438
-0
lines changed

crates/oxc_ast/src/ast_impl/js.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,11 @@ impl<'a> FormalParameter<'a> {
10451045
pub fn is_public(&self) -> bool {
10461046
matches!(self.accessibility, Some(TSAccessibility::Public))
10471047
}
1048+
1049+
#[inline]
1050+
pub fn has_modifier(&self) -> bool {
1051+
self.accessibility.is_some() || self.readonly || self.r#override
1052+
}
10481053
}
10491054

10501055
impl FormalParameterKind {

crates/oxc_linter/src/rules.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ mod typescript {
141141
pub mod no_empty_interface;
142142
pub mod no_explicit_any;
143143
pub mod no_extra_non_null_assertion;
144+
pub mod no_extraneous_class;
144145
pub mod no_import_type_side_effects;
145146
pub mod no_misused_new;
146147
pub mod no_namespace;
@@ -571,6 +572,7 @@ oxc_macros::declare_all_lint_rules! {
571572
typescript::no_non_null_asserted_nullish_coalescing,
572573
typescript::no_confusing_non_null_assertion,
573574
typescript::no_dynamic_delete,
575+
typescript::no_extraneous_class,
574576
jest::consistent_test_it,
575577
jest::expect_expect,
576578
jest::max_expects,
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
use oxc_ast::{
2+
ast::{ClassElement, FormalParameter},
3+
AstKind,
4+
};
5+
use oxc_diagnostics::OxcDiagnostic;
6+
use oxc_macros::declare_oxc_lint;
7+
use oxc_span::Span;
8+
9+
use crate::{context::LintContext, rule::Rule, AstNode};
10+
11+
#[derive(Debug, Default, Clone)]
12+
pub struct NoExtraneousClass {
13+
allow_constructor_only: bool,
14+
allow_empty: bool,
15+
allow_static_only: bool,
16+
allow_with_decorator: bool,
17+
}
18+
19+
declare_oxc_lint!(
20+
/// ### What it does
21+
///
22+
/// This rule reports when a class has no non-static members,
23+
/// such as for a class used exclusively as a static namespace.
24+
/// This rule also reports classes that have only a constructor and no fields.
25+
/// Those classes can generally be replaced with a standalone function.
26+
///
27+
/// ### Why is this bad?
28+
///
29+
/// Users who come from a OOP paradigm may wrap their utility functions in an extra class,
30+
/// instead of putting them at the top level of an ECMAScript module.
31+
/// Doing so is generally unnecessary in JavaScript and TypeScript projects.
32+
///
33+
/// Wrapper classes add extra cognitive complexity to code without adding any structural improvements
34+
///
35+
/// Whatever would be put on them, such as utility functions, are already organized by virtue of being in a module.
36+
///
37+
/// As an alternative, you can import * as ... the module to get all of them in a single object.
38+
/// IDEs can't provide as good suggestions for static class or namespace imported properties when you start typing property names
39+
///
40+
/// It's more difficult to statically analyze code for unused variables, etc.
41+
/// when they're all on the class (see: Finding dead code (and dead types) in TypeScript).
42+
///
43+
/// ### Example
44+
/// ```javascript
45+
/// class StaticConstants {
46+
/// static readonly version = 42;
47+
///
48+
/// static isProduction() {
49+
/// return process.env.NODE_ENV === 'production';
50+
/// }
51+
/// }
52+
///
53+
/// class HelloWorldLogger {
54+
/// constructor() {
55+
/// console.log('Hello, world!');
56+
/// }
57+
/// }
58+
///
59+
/// abstract class Foo {}
60+
/// ```
61+
NoExtraneousClass,
62+
suspicious
63+
);
64+
65+
fn empty_no_extraneous_class_diagnostic(span: Span) -> OxcDiagnostic {
66+
OxcDiagnostic::warn("typescript-eslint(no-extraneous-class): Unexpected empty class.")
67+
.with_label(span)
68+
}
69+
70+
fn only_static_no_extraneous_class_diagnostic(span: Span) -> OxcDiagnostic {
71+
OxcDiagnostic::warn(
72+
"typescript-eslint(no-extraneous-class): Unexpected class with only static properties.",
73+
)
74+
.with_label(span)
75+
}
76+
77+
fn only_constructor_no_extraneous_class_diagnostic(span: Span) -> OxcDiagnostic {
78+
OxcDiagnostic::warn(
79+
"typescript-eslint(no-extraneous-class): Unexpected class with only a constructor.",
80+
)
81+
.with_label(span)
82+
}
83+
84+
impl Rule for NoExtraneousClass {
85+
fn from_configuration(value: serde_json::Value) -> Self {
86+
use serde_json::Value;
87+
let Some(config) = value.get(0).and_then(Value::as_object) else {
88+
return Self::default();
89+
};
90+
Self {
91+
allow_constructor_only: config
92+
.get("allowConstructorOnly")
93+
.and_then(Value::as_bool)
94+
.unwrap_or(false),
95+
allow_empty: config
96+
.get("allowEmpty") // lb
97+
.and_then(Value::as_bool)
98+
.unwrap_or(false),
99+
allow_static_only: config
100+
.get("allowStaticOnly")
101+
.and_then(Value::as_bool)
102+
.unwrap_or(false),
103+
allow_with_decorator: config
104+
.get("allowWithDecorator")
105+
.and_then(Value::as_bool)
106+
.unwrap_or(false),
107+
}
108+
}
109+
110+
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
111+
let AstKind::Class(class) = node.kind() else {
112+
return;
113+
};
114+
if class.super_class.is_some()
115+
|| (self.allow_with_decorator && !class.decorators.is_empty())
116+
{
117+
return;
118+
}
119+
let span = class.id.as_ref().map_or(class.span, |id| id.span);
120+
let body = &class.body.body;
121+
match body.as_slice() {
122+
[] => {
123+
if !self.allow_empty {
124+
ctx.diagnostic(empty_no_extraneous_class_diagnostic(class.span));
125+
}
126+
}
127+
[ClassElement::MethodDefinition(constructor)] if constructor.kind.is_constructor() => {
128+
let only_constructor =
129+
!constructor.value.params.items.iter().any(FormalParameter::has_modifier);
130+
if only_constructor && !self.allow_constructor_only {
131+
ctx.diagnostic(only_constructor_no_extraneous_class_diagnostic(span));
132+
}
133+
}
134+
_ => {
135+
let only_static = body.iter().all(|prop| prop.r#static() && !prop.is_abstract());
136+
if only_static && !self.allow_static_only {
137+
ctx.diagnostic(only_static_no_extraneous_class_diagnostic(span));
138+
}
139+
}
140+
};
141+
}
142+
}
143+
144+
#[test]
145+
fn test() {
146+
use crate::tester::Tester;
147+
148+
let pass = vec![
149+
(
150+
"
151+
class Foo {
152+
public prop = 1;
153+
constructor() {}
154+
}
155+
",
156+
None,
157+
),
158+
(
159+
"
160+
export class CClass extends BaseClass {
161+
public static helper(): void {}
162+
private static privateHelper(): boolean {
163+
return true;
164+
}
165+
constructor() {}
166+
}
167+
",
168+
None,
169+
),
170+
(
171+
"
172+
class Foo {
173+
constructor(public bar: string) {}
174+
}
175+
",
176+
None,
177+
),
178+
("class Foo {}", Some(serde_json::json!([{ "allowEmpty": true }]))),
179+
(
180+
"
181+
class Foo {
182+
constructor() {}
183+
}
184+
",
185+
Some(serde_json::json!([{ "allowConstructorOnly": true }])),
186+
),
187+
(
188+
"
189+
export class Bar {
190+
public static helper(): void {}
191+
private static privateHelper(): boolean {
192+
return true;
193+
}
194+
}
195+
",
196+
Some(serde_json::json!([{ "allowStaticOnly": true }])),
197+
),
198+
(
199+
"
200+
export default class {
201+
hello() {
202+
return 'I am foo!';
203+
}
204+
}
205+
",
206+
None,
207+
),
208+
(
209+
"
210+
@FooDecorator
211+
class Foo {}
212+
",
213+
Some(serde_json::json!([{ "allowWithDecorator": true }])),
214+
),
215+
(
216+
"
217+
@FooDecorator
218+
class Foo {
219+
constructor(foo: Foo) {
220+
foo.subscribe(a => {
221+
console.log(a);
222+
});
223+
}
224+
}
225+
",
226+
Some(serde_json::json!([{ "allowWithDecorator": true }])),
227+
),
228+
(
229+
"
230+
abstract class Foo {
231+
abstract property: string;
232+
}
233+
",
234+
None,
235+
),
236+
(
237+
"
238+
abstract class Foo {
239+
abstract method(): string;
240+
}
241+
",
242+
None,
243+
),
244+
];
245+
246+
let fail = vec![
247+
("class Foo {}", None),
248+
(
249+
"
250+
class Foo {
251+
public prop = 1;
252+
constructor() {
253+
class Bar {
254+
static PROP = 2;
255+
}
256+
}
257+
}
258+
export class Bar {
259+
public static helper(): void {}
260+
private static privateHelper(): boolean {
261+
return true;
262+
}
263+
}
264+
",
265+
None,
266+
),
267+
(
268+
"
269+
class Foo {
270+
constructor() {}
271+
}
272+
",
273+
None,
274+
),
275+
(
276+
"
277+
export class AClass {
278+
public static helper(): void {}
279+
private static privateHelper(): boolean {
280+
return true;
281+
}
282+
constructor() {
283+
class nestedClass {}
284+
}
285+
}
286+
",
287+
None,
288+
),
289+
(
290+
"
291+
export default class {
292+
static hello() {}
293+
}
294+
",
295+
None,
296+
),
297+
(
298+
"
299+
@FooDecorator
300+
class Foo {}
301+
",
302+
Some(serde_json::json!([{ "allowWithDecorator": false }])),
303+
),
304+
(
305+
"
306+
@FooDecorator
307+
class Foo {
308+
constructor(foo: Foo) {
309+
foo.subscribe(a => {
310+
console.log(a);
311+
});
312+
}
313+
}
314+
",
315+
Some(serde_json::json!([{ "allowWithDecorator": false }])),
316+
),
317+
(
318+
"
319+
abstract class Foo {}
320+
",
321+
None,
322+
),
323+
(
324+
"
325+
abstract class Foo {
326+
static property: string;
327+
}
328+
",
329+
None,
330+
),
331+
(
332+
"
333+
abstract class Foo {
334+
constructor() {}
335+
}
336+
",
337+
None,
338+
),
339+
];
340+
341+
Tester::new(NoExtraneousClass::NAME, pass, fail).test_and_snapshot();
342+
}

0 commit comments

Comments
 (0)