Skip to content

Commit d116a6b

Browse files
authored
Add consistent-class-member-order rule (#3226)
1 parent 174d5a5 commit d116a6b

7 files changed

Lines changed: 1130 additions & 0 deletions
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# consistent-class-member-order
2+
3+
📝 Enforce consistent class member order.
4+
5+
💼🚫 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config). This rule is _disabled_ in the ☑️ `unopinionated` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config).
6+
7+
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
8+
9+
<!-- end auto-generated rule header -->
10+
11+
This rule enforces the following class member order:
12+
13+
1. Static fields
14+
2. Static blocks
15+
3. Private instance fields
16+
4. Public instance fields
17+
5. Constructors
18+
6. Static methods
19+
7. Private instance methods
20+
8. Public instance methods
21+
22+
Static members are grouped by `static` before privacy, so `static #foo` belongs with other static fields or methods.
23+
24+
TypeScript `protected` members follow the same order as public members.
25+
26+
This rule does not autofix because fields and static blocks run in declaration order, and method order is observable through property reflection.
27+
28+
It may offer a manual suggestion for simple class bodies without comments, decorators, computed keys, or unsupported members.
29+
30+
This rule is intentionally simple. It only enforces group order and will not add options for sorting within groups, newline handling, or more detailed member categories. Use a dedicated sorting rule if you need that.
31+
32+
## Options
33+
34+
Type: `object`
35+
36+
### order
37+
38+
Type: `string[]`
39+
40+
Default:
41+
42+
```js
43+
[
44+
'static-field',
45+
'static-block',
46+
'private-field',
47+
'public-field',
48+
'constructor',
49+
'static-method',
50+
'private-method',
51+
'public-method',
52+
]
53+
```
54+
55+
The array must contain each group exactly once.
56+
57+
## Examples
58+
59+
```js
60+
//
61+
class Foo {
62+
constructor() {}
63+
#privateField = 1;
64+
publicField = 1;
65+
}
66+
```
67+
68+
```js
69+
//
70+
class Foo {
71+
static staticField = 1;
72+
#privateField = 1;
73+
publicField = 1;
74+
75+
constructor() {}
76+
}
77+
```
78+
79+
```js
80+
//
81+
class Foo {
82+
publicMethod() {}
83+
#privateMethod() {}
84+
}
85+
```
86+
87+
```js
88+
//
89+
class Foo {
90+
static staticMethod() {}
91+
#privateMethod() {}
92+
publicMethod() {}
93+
}
94+
```

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export default [
6464
| [class-reference-in-static-methods](docs/rules/class-reference-in-static-methods.md) | Enforce consistent class references in static methods. || | 💡 |
6565
| [comment-content](docs/rules/comment-content.md) | Enforce better comment content. || 🔧 | |
6666
| [consistent-assert](docs/rules/consistent-assert.md) | Enforce consistent assertion style with `node:assert`. || 🔧 | |
67+
| [consistent-class-member-order](docs/rules/consistent-class-member-order.md) | Enforce consistent class member order. || | 💡 |
6768
| [consistent-compound-words](docs/rules/consistent-compound-words.md) | Enforce consistent spelling of compound words in identifiers. | ✅ ☑️ | | 💡 |
6869
| [consistent-date-clone](docs/rules/consistent-date-clone.md) | Prefer passing `Date` directly to the constructor when cloning. | ✅ ☑️ | 🔧 | |
6970
| [consistent-destructuring](docs/rules/consistent-destructuring.md) | Use destructured variables over properties. | | | 💡 |
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
const MESSAGE_ID = 'consistent-class-member-order';
2+
const MESSAGE_ID_SUGGESTION = 'consistent-class-member-order-suggestion';
3+
const messages = {
4+
[MESSAGE_ID]: 'Expected {{current}} to come before {{previous}}.',
5+
[MESSAGE_ID_SUGGESTION]: 'Reorder class members by group.',
6+
};
7+
8+
const GROUP_STATIC_FIELD = 'static-field';
9+
const GROUP_STATIC_BLOCK = 'static-block';
10+
const GROUP_PRIVATE_FIELD = 'private-field';
11+
const GROUP_PUBLIC_FIELD = 'public-field';
12+
const GROUP_CONSTRUCTOR = 'constructor';
13+
const GROUP_STATIC_METHOD = 'static-method';
14+
const GROUP_PRIVATE_METHOD = 'private-method';
15+
const GROUP_PUBLIC_METHOD = 'public-method';
16+
17+
const defaultOrder = [
18+
GROUP_STATIC_FIELD,
19+
GROUP_STATIC_BLOCK,
20+
GROUP_PRIVATE_FIELD,
21+
GROUP_PUBLIC_FIELD,
22+
GROUP_CONSTRUCTOR,
23+
GROUP_STATIC_METHOD,
24+
GROUP_PRIVATE_METHOD,
25+
GROUP_PUBLIC_METHOD,
26+
];
27+
28+
const fieldTypes = new Set([
29+
'AccessorProperty',
30+
'PropertyDefinition',
31+
'TSAbstractAccessorProperty',
32+
'TSAbstractPropertyDefinition',
33+
]);
34+
35+
const methodTypes = new Set([
36+
'MethodDefinition',
37+
'TSAbstractMethodDefinition',
38+
]);
39+
40+
const isPrivateMember = member =>
41+
member.key?.type === 'PrivateIdentifier'
42+
|| member.accessibility === 'private';
43+
44+
const getGroupLabel = group => group.replaceAll('-', ' ');
45+
46+
const getMemberGroup = member => {
47+
if (member.type === 'StaticBlock') {
48+
return GROUP_STATIC_BLOCK;
49+
}
50+
51+
if (
52+
methodTypes.has(member.type)
53+
&& member.kind === 'constructor'
54+
) {
55+
return GROUP_CONSTRUCTOR;
56+
}
57+
58+
if (fieldTypes.has(member.type)) {
59+
if (member.static) {
60+
return GROUP_STATIC_FIELD;
61+
}
62+
63+
return isPrivateMember(member)
64+
? GROUP_PRIVATE_FIELD
65+
: GROUP_PUBLIC_FIELD;
66+
}
67+
68+
if (methodTypes.has(member.type)) {
69+
if (member.static) {
70+
return GROUP_STATIC_METHOD;
71+
}
72+
73+
return isPrivateMember(member)
74+
? GROUP_PRIVATE_METHOD
75+
: GROUP_PUBLIC_METHOD;
76+
}
77+
};
78+
79+
const getReorderedMembers = (classBody, order) => {
80+
const members = classBody.body.map((member, index) => ({
81+
member,
82+
index,
83+
group: getMemberGroup(member),
84+
}));
85+
86+
if (
87+
members.some(({member, group}) =>
88+
group === undefined
89+
|| member.computed
90+
|| member.decorators?.length > 0)
91+
) {
92+
return;
93+
}
94+
95+
return members.toSorted((first, second) =>
96+
order.get(first.group) - order.get(second.group)
97+
|| first.index - second.index);
98+
};
99+
100+
const getMemberLineStart = (member, sourceCode) => {
101+
const {line} = sourceCode.getLoc(member).start;
102+
return sourceCode.getIndexFromLoc({line, column: 0});
103+
};
104+
105+
const getMemberLineLeadingText = (member, sourceCode) => {
106+
const [memberStart] = sourceCode.getRange(member);
107+
const lineStart = getMemberLineStart(member, sourceCode);
108+
109+
return sourceCode.text.slice(lineStart, memberStart);
110+
};
111+
112+
const getMemberStart = (member, sourceCode) => {
113+
const [memberStart] = sourceCode.getRange(member);
114+
const lineStart = getMemberLineStart(member, sourceCode);
115+
const leadingText = getMemberLineLeadingText(member, sourceCode);
116+
117+
return /^\s+$/v.test(leadingText)
118+
? lineStart
119+
: memberStart;
120+
};
121+
122+
const isLineInitialMember = (member, sourceCode) =>
123+
/^\s*$/v.test(getMemberLineLeadingText(member, sourceCode));
124+
125+
const getMemberText = (member, sourceCode) => {
126+
const [, end] = sourceCode.getRange(member);
127+
return sourceCode.text.slice(getMemberStart(member, sourceCode), end);
128+
};
129+
130+
const getSuggestion = (classBody, order, sourceCode) => {
131+
if (
132+
classBody.body.length === 0
133+
|| sourceCode.getCommentsInside(classBody).length > 0
134+
|| classBody.body.some(member => !isLineInitialMember(member, sourceCode))
135+
) {
136+
return;
137+
}
138+
139+
const reorderedMembers = getReorderedMembers(classBody, order);
140+
if (!reorderedMembers) {
141+
return;
142+
}
143+
144+
const firstMember = classBody.body[0];
145+
const lastMember = classBody.body.at(-1);
146+
const start = getMemberStart(firstMember, sourceCode);
147+
const [, end] = sourceCode.getRange(lastMember);
148+
const replacement = reorderedMembers
149+
.map(({member}) => getMemberText(member, sourceCode))
150+
.join('\n\n');
151+
152+
return [
153+
{
154+
messageId: MESSAGE_ID_SUGGESTION,
155+
fix: fixer => fixer.replaceTextRange([start, end], replacement),
156+
},
157+
];
158+
};
159+
160+
/** @param {import('eslint').Rule.RuleContext} context */
161+
const create = context => {
162+
const {sourceCode} = context;
163+
const order = new Map(context.options[0].order.map((group, index) => [group, index]));
164+
165+
context.on('ClassBody', function * (classBody) {
166+
const suggestion = getSuggestion(classBody, order, sourceCode);
167+
let shouldSuggest = suggestion !== undefined;
168+
let highestGroupIndex;
169+
let highestGroup;
170+
171+
for (const member of classBody.body) {
172+
const group = getMemberGroup(member);
173+
if (group === undefined) {
174+
continue;
175+
}
176+
177+
const groupIndex = order.get(group);
178+
if (
179+
highestGroupIndex !== undefined
180+
&& groupIndex < highestGroupIndex
181+
) {
182+
yield {
183+
node: member,
184+
messageId: MESSAGE_ID,
185+
data: {
186+
current: getGroupLabel(group),
187+
previous: getGroupLabel(highestGroup),
188+
},
189+
...(shouldSuggest && {suggest: suggestion}),
190+
};
191+
192+
shouldSuggest = false;
193+
194+
continue;
195+
}
196+
197+
highestGroupIndex = groupIndex;
198+
highestGroup = group;
199+
}
200+
});
201+
};
202+
203+
const schema = [
204+
{
205+
type: 'object',
206+
additionalProperties: false,
207+
properties: {
208+
order: {
209+
type: 'array',
210+
description: 'The class member group order.',
211+
items: {
212+
enum: defaultOrder,
213+
},
214+
minItems: defaultOrder.length,
215+
maxItems: defaultOrder.length,
216+
uniqueItems: true,
217+
},
218+
},
219+
},
220+
];
221+
222+
/** @type {import('eslint').Rule.RuleModule} */
223+
const config = {
224+
create,
225+
meta: {
226+
type: 'suggestion',
227+
docs: {
228+
description: 'Enforce consistent class member order.',
229+
recommended: true,
230+
},
231+
hasSuggestions: true,
232+
schema,
233+
defaultOptions: [{order: defaultOrder}],
234+
messages,
235+
languages: [
236+
'js/js',
237+
],
238+
},
239+
};
240+
241+
export default config;

rules/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {default as 'catch-error-name'} from './catch-error-name.js';
55
export {default as 'class-reference-in-static-methods'} from './class-reference-in-static-methods.js';
66
export {default as 'comment-content'} from './comment-content.js';
77
export {default as 'consistent-assert'} from './consistent-assert.js';
8+
export {default as 'consistent-class-member-order'} from './consistent-class-member-order.js';
89
export {default as 'consistent-compound-words'} from './consistent-compound-words.js';
910
export {default as 'consistent-date-clone'} from './consistent-date-clone.js';
1011
export {default as 'consistent-destructuring'} from './consistent-destructuring.js';

0 commit comments

Comments
 (0)