Skip to content

Commit acb7df3

Browse files
feat: add new enforce option to lines-between-class-members (#17462)
* feat: add new \`enforce\` option (`lines-between-class-members`) * test: add cases for enforce with exceptAfterSingleLine option * docs: add `enforce` option * docs: fix example * fix: update schema to make enforce option required * refactor: remove redundant if condition * refactor: remove redundant if condition * docs: add suggestions Co-authored-by: Milos Djermanovic <[email protected]> * test: add cases where multiple config objects match a pair --------- Co-authored-by: Milos Djermanovic <[email protected]>
1 parent 032c4b1 commit acb7df3

3 files changed

Lines changed: 2714 additions & 41 deletions

File tree

docs/src/rules/lines-between-class-members.md

Lines changed: 182 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,19 @@ class MyClass {
6969

7070
### Options
7171

72-
This rule has a string option and an object option.
72+
This rule has two options, first option can be string or object, second option is object.
7373

74-
String option:
74+
First option can be string `"always"` or `"never"` or an object with a property named `enforce`:
7575

7676
* `"always"`(default) require an empty line after class members
7777
* `"never"` disallows an empty line after class members
78+
* `Object`: An object with a property named `enforce`. The enforce property should be an array of objects, each specifying the configuration for enforcing empty lines between specific pairs of class members.
79+
* **enforce**: You can supply any number of configurations. If a member pair matches multiple configurations, the last matched configuration will be used. If a member pair does not match any configurations, it will be ignored. Each object should have the following properties:
80+
* **blankLine**: Can be set to either `"always"` or `"never"`, indicating whether a blank line should be required or disallowed between the specified members.
81+
* **prev**: Specifies the type of the preceding class member. It can be `"method"` for class methods, `"field"` for class fields, or `"*"` for any class member.
82+
* **next**: Specifies the type of the following class member. It follows the same options as `prev`.
7883

79-
Object option:
84+
Second option is an object with a property named `exceptAfterSingleLine`:
8085

8186
* `"exceptAfterSingleLine": false`(default) **do not** skip checking empty lines after single-line class members
8287
* `"exceptAfterSingleLine": true` skip checking empty lines after single-line class members
@@ -129,6 +134,146 @@ class Foo{
129134

130135
:::
131136

137+
Examples of **incorrect** code for this rule with the array of configurations option:
138+
139+
::: incorrect
140+
141+
```js
142+
// disallows blank lines between methods
143+
/*eslint lines-between-class-members: [
144+
"error",
145+
{
146+
enforce: [
147+
{ blankLine: "never", prev: "method", next: "method" }
148+
]
149+
},
150+
]*/
151+
152+
class MyClass {
153+
constructor(height, width) {
154+
this.height = height;
155+
this.width = width;
156+
}
157+
158+
fieldA = 'Field A';
159+
#fieldB = 'Field B';
160+
161+
method1() {}
162+
163+
get area() {
164+
return this.method1();
165+
}
166+
167+
method2() {}
168+
}
169+
```
170+
171+
:::
172+
173+
::: incorrect
174+
175+
```js
176+
// requires blank lines around fields, disallows blank lines between methods
177+
/*eslint lines-between-class-members: [
178+
"error",
179+
{
180+
enforce: [
181+
{ blankLine: "always", prev: "*", next: "field" },
182+
{ blankLine: "always", prev: "field", next: "*" },
183+
{ blankLine: "never", prev: "method", next: "method" }
184+
]
185+
},
186+
]*/
187+
188+
class MyClass {
189+
constructor(height, width) {
190+
this.height = height;
191+
this.width = width;
192+
}
193+
fieldA = 'Field A';
194+
#fieldB = 'Field B';
195+
method1() {}
196+
197+
get area() {
198+
return this.method1();
199+
}
200+
201+
method2() {}
202+
}
203+
```
204+
205+
:::
206+
207+
Examples of **correct** code for this rule with the array of configurations option:
208+
209+
::: correct
210+
211+
```js
212+
// disallows blank lines between methods
213+
/*eslint lines-between-class-members: [
214+
"error",
215+
{
216+
enforce: [
217+
{ blankLine: "never", prev: "method", next: "method" }
218+
]
219+
},
220+
]*/
221+
222+
class MyClass {
223+
constructor(height, width) {
224+
this.height = height;
225+
this.width = width;
226+
}
227+
228+
fieldA = 'Field A';
229+
230+
#fieldB = 'Field B';
231+
232+
method1() {}
233+
get area() {
234+
return this.method1();
235+
}
236+
method2() {}
237+
}
238+
```
239+
240+
:::
241+
242+
::: correct
243+
244+
```js
245+
// requires blank lines around fields, disallows blank lines between methods
246+
/*eslint lines-between-class-members: [
247+
"error",
248+
{
249+
enforce: [
250+
{ blankLine: "always", prev: "*", next: "field" },
251+
{ blankLine: "always", prev: "field", next: "*" },
252+
{ blankLine: "never", prev: "method", next: "method" }
253+
]
254+
},
255+
]*/
256+
257+
class MyClass {
258+
constructor(height, width) {
259+
this.height = height;
260+
this.width = width;
261+
}
262+
263+
fieldA = 'Field A';
264+
265+
#fieldB = 'Field B';
266+
267+
method1() {}
268+
get area() {
269+
return this.method1();
270+
}
271+
method2() {}
272+
}
273+
```
274+
275+
:::
276+
132277
Examples of **correct** code for this rule with the object option:
133278

134279
::: correct
@@ -148,6 +293,40 @@ class Foo{
148293

149294
:::
150295

296+
::: correct
297+
298+
```js
299+
/*eslint lines-between-class-members: [
300+
"error",
301+
{
302+
enforce: [
303+
{ blankLine: "always", prev: "*", next: "method" },
304+
{ blankLine: "always", prev: "method", next: "*" },
305+
{ blankLine: "always", prev: "field", next: "field" }
306+
]
307+
},
308+
{ exceptAfterSingleLine: true }
309+
]*/
310+
311+
class MyClass {
312+
constructor(height, width) {
313+
this.height = height;
314+
this.width = width;
315+
}
316+
317+
fieldA = 'Field A';
318+
#fieldB = 'Field B';
319+
method1() {}
320+
get area() {
321+
return this.method1();
322+
}
323+
324+
method2() {}
325+
}
326+
```
327+
328+
:::
329+
151330
## When Not To Use It
152331

153332
If you don't want to enforce empty lines between class members, you can disable this rule.

lib/rules/lines-between-class-members.js

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@
1010

1111
const astUtils = require("./utils/ast-utils");
1212

13+
//------------------------------------------------------------------------------
14+
// Helpers
15+
//------------------------------------------------------------------------------
16+
17+
/**
18+
* Types of class members.
19+
* Those have `test` method to check it matches to the given class member.
20+
* @private
21+
*/
22+
const ClassMemberTypes = {
23+
"*": { test: () => true },
24+
field: { test: node => node.type === "PropertyDefinition" },
25+
method: { test: node => node.type === "MethodDefinition" }
26+
};
27+
1328
//------------------------------------------------------------------------------
1429
// Rule Definition
1530
//------------------------------------------------------------------------------
@@ -29,7 +44,32 @@ module.exports = {
2944

3045
schema: [
3146
{
32-
enum: ["always", "never"]
47+
anyOf: [
48+
{
49+
type: "object",
50+
properties: {
51+
enforce: {
52+
type: "array",
53+
items: {
54+
type: "object",
55+
properties: {
56+
blankLine: { enum: ["always", "never"] },
57+
prev: { enum: ["method", "field", "*"] },
58+
next: { enum: ["method", "field", "*"] }
59+
},
60+
additionalProperties: false,
61+
required: ["blankLine", "prev", "next"]
62+
},
63+
minItems: 1
64+
}
65+
},
66+
additionalProperties: false,
67+
required: ["enforce"]
68+
},
69+
{
70+
enum: ["always", "never"]
71+
}
72+
]
3373
},
3474
{
3575
type: "object",
@@ -55,6 +95,7 @@ module.exports = {
5595
options[0] = context.options[0] || "always";
5696
options[1] = context.options[1] || { exceptAfterSingleLine: false };
5797

98+
const configureList = typeof options[0] === "object" ? options[0].enforce : [{ blankLine: options[0], prev: "*", next: "*" }];
5899
const sourceCode = context.sourceCode;
59100

60101
/**
@@ -144,6 +185,38 @@ module.exports = {
144185
return sourceCode.getTokensBetween(before, after, { includeComments: true }).length !== 0;
145186
}
146187

188+
/**
189+
* Checks whether the given node matches the given type.
190+
* @param {ASTNode} node The class member node to check.
191+
* @param {string} type The class member type to check.
192+
* @returns {boolean} `true` if the class member node matched the type.
193+
* @private
194+
*/
195+
function match(node, type) {
196+
return ClassMemberTypes[type].test(node);
197+
}
198+
199+
/**
200+
* Finds the last matched configuration from the configureList.
201+
* @param {ASTNode} prevNode The previous node to match.
202+
* @param {ASTNode} nextNode The current node to match.
203+
* @returns {string|null} Padding type or `null` if no matches were found.
204+
* @private
205+
*/
206+
function getPaddingType(prevNode, nextNode) {
207+
for (let i = configureList.length - 1; i >= 0; --i) {
208+
const configure = configureList[i];
209+
const matched =
210+
match(prevNode, configure.prev) &&
211+
match(nextNode, configure.next);
212+
213+
if (matched) {
214+
return configure.blankLine;
215+
}
216+
}
217+
return null;
218+
}
219+
147220
return {
148221
ClassBody(node) {
149222
const body = node.body;
@@ -158,22 +231,34 @@ module.exports = {
158231
const isPadded = afterPadding.loc.start.line - beforePadding.loc.end.line > 1;
159232
const hasTokenInPadding = hasTokenOrCommentBetween(beforePadding, afterPadding);
160233
const curLineLastToken = findLastConsecutiveTokenAfter(curLast, nextFirst, 0);
234+
const paddingType = getPaddingType(body[i], body[i + 1]);
235+
236+
if (paddingType === "never" && isPadded) {
237+
context.report({
238+
node: body[i + 1],
239+
messageId: "never",
161240

162-
if ((options[0] === "always" && !skip && !isPadded) ||
163-
(options[0] === "never" && isPadded)) {
241+
fix(fixer) {
242+
if (hasTokenInPadding) {
243+
return null;
244+
}
245+
return fixer.replaceTextRange([beforePadding.range[1], afterPadding.range[0]], "\n");
246+
}
247+
});
248+
} else if (paddingType === "always" && !skip && !isPadded) {
164249
context.report({
165250
node: body[i + 1],
166-
messageId: isPadded ? "never" : "always",
251+
messageId: "always",
252+
167253
fix(fixer) {
168254
if (hasTokenInPadding) {
169255
return null;
170256
}
171-
return isPadded
172-
? fixer.replaceTextRange([beforePadding.range[1], afterPadding.range[0]], "\n")
173-
: fixer.insertTextAfter(curLineLastToken, "\n");
257+
return fixer.insertTextAfter(curLineLastToken, "\n");
174258
}
175259
});
176260
}
261+
177262
}
178263
}
179264
};

0 commit comments

Comments
 (0)