Skip to content

Commit c07bf4e

Browse files
authored
Add consistent-optional-chaining rule (#3195)
1 parent a8338ab commit c07bf4e

12 files changed

Lines changed: 1235 additions & 17 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# consistent-optional-chaining
2+
3+
📝 Enforce consistent optional chaining for same-base member access.
4+
5+
💼 This rule is enabled in the following [configs](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config): ✅ `recommended`, ☑️ `unopinionated`.
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+
Enforce consistent optional chaining for same-base member access in logical expressions.
12+
13+
This rule intentionally handles only direct member access on both sides of `&&` or `||`. It does not rewrite broad truthiness checks like `foo && foo.bar` to `foo?.bar`, because that can change runtime values.
14+
15+
## Examples
16+
17+
```js
18+
//
19+
foo?.bar || foo.baz;
20+
21+
//
22+
foo?.bar || foo?.baz;
23+
```
24+
25+
```js
26+
//
27+
foo.bar || foo?.baz;
28+
29+
//
30+
foo.bar || foo.baz;
31+
```
32+
33+
```js
34+
//
35+
foo?.bar && foo?.baz;
36+
37+
//
38+
foo?.bar && foo.baz;
39+
```
40+
41+
```js
42+
//
43+
foo.bar && foo?.baz;
44+
45+
//
46+
foo.bar && foo.baz;
47+
```

docs/rules/prefer-logical-operator-over-ternary.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@ foo?.bar ?? baz
5050
bar ?? foo;
5151
```
5252
53+
```js
54+
//
55+
foo == null ? bar : foo;
56+
57+
//
58+
foo ?? bar;
59+
```
60+
61+
```js
62+
//
63+
foo == null ? undefined : foo.bar;
64+
65+
//
66+
foo?.bar;
67+
```
68+
5369
```js
5470
//
5571
foo ? bar : baz;

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export default [
7373
| [consistent-function-scoping](docs/rules/consistent-function-scoping.md) | Move function definitions to the highest possible scope. || | |
7474
| [consistent-function-style](docs/rules/consistent-function-style.md) | Enforce function syntax by role. | | | 💡 |
7575
| [consistent-json-file-read](docs/rules/consistent-json-file-read.md) | Enforce consistent JSON file reads before `JSON.parse()`. || 🔧 | |
76+
| [consistent-optional-chaining](docs/rules/consistent-optional-chaining.md) | Enforce consistent optional chaining for same-base member access. | ✅ ☑️ | | 💡 |
7677
| [consistent-template-literal-escape](docs/rules/consistent-template-literal-escape.md) | Enforce consistent style for escaping `${` in template literals. || 🔧 | |
7778
| [custom-error-definition](docs/rules/custom-error-definition.md) | Enforce correct `Error` subclassing. | | 🔧 | |
7879
| [dom-node-dataset](docs/rules/dom-node-dataset.md) | Enforce consistent style for DOM element dataset access. | ✅ ☑️ | 🔧 | |
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import {
2+
getParenthesizedRange,
3+
isSameReference,
4+
unwrapTypeScriptExpression,
5+
} from './utils/index.js';
6+
7+
const MESSAGE_ID_REMOVE_OPTIONAL = 'consistent-optional-chaining/remove-optional';
8+
const MESSAGE_ID_USE_OPTIONAL = 'consistent-optional-chaining/use-optional';
9+
const MESSAGE_ID_SUGGEST_REMOVE_OPTIONAL = 'consistent-optional-chaining/suggest-remove-optional';
10+
const MESSAGE_ID_SUGGEST_USE_OPTIONAL = 'consistent-optional-chaining/suggest-use-optional';
11+
12+
const messages = {
13+
[MESSAGE_ID_REMOVE_OPTIONAL]: 'Remove unnecessary optional chaining.',
14+
[MESSAGE_ID_USE_OPTIONAL]: 'Use optional chaining consistently.',
15+
[MESSAGE_ID_SUGGEST_REMOVE_OPTIONAL]: 'Remove optional chaining.',
16+
[MESSAGE_ID_SUGGEST_USE_OPTIONAL]: 'Use optional chaining.',
17+
};
18+
const supportedBaseTypes = new Set([
19+
'Identifier',
20+
'ThisExpression',
21+
'Super',
22+
]);
23+
24+
function unwrapExpression(node) {
25+
let previousNode;
26+
27+
while (node !== previousNode) {
28+
previousNode = node;
29+
node = unwrapTypeScriptExpression(node);
30+
31+
if (node.type === 'ChainExpression') {
32+
node = node.expression;
33+
}
34+
}
35+
36+
return node;
37+
}
38+
39+
function getMemberExpression(node) {
40+
node = unwrapExpression(node);
41+
42+
return node.type === 'MemberExpression' ? node : undefined;
43+
}
44+
45+
function getMemberBase(memberExpression) {
46+
return unwrapExpression(memberExpression.object);
47+
}
48+
49+
function isSupportedMemberBase(node) {
50+
node = unwrapExpression(node);
51+
52+
if (supportedBaseTypes.has(node.type)) {
53+
return true;
54+
}
55+
56+
if (node.type === 'MemberExpression') {
57+
return isSupportedMemberBase(node.object);
58+
}
59+
60+
return false;
61+
}
62+
63+
function getMemberAccessOperatorRange(memberExpression, context) {
64+
const {sourceCode} = context;
65+
const [, start] = getParenthesizedRange(memberExpression.object, context);
66+
const end = memberExpression.computed
67+
? sourceCode.getRange(sourceCode.getTokenBefore(memberExpression.property, token => token.value === '['))[1]
68+
: sourceCode.getRange(memberExpression.property)[0];
69+
70+
return [start, end];
71+
}
72+
73+
function hasCommentInRange(sourceCode, [start, end]) {
74+
return sourceCode.getAllComments().some(comment => {
75+
const [commentStart, commentEnd] = sourceCode.getRange(comment);
76+
77+
return commentStart >= start && commentEnd <= end;
78+
});
79+
}
80+
81+
function canReplaceMemberAccessOperator(memberExpression, context) {
82+
const range = getMemberAccessOperatorRange(memberExpression, context);
83+
84+
return !hasCommentInRange(context.sourceCode, range);
85+
}
86+
87+
function replaceMemberAccessOperator({memberExpression, context, fixer, replacement}) {
88+
const range = getMemberAccessOperatorRange(memberExpression, context);
89+
return fixer.replaceTextRange(range, replacement);
90+
}
91+
92+
function removeOptionalChaining(memberExpression, context, fixer) {
93+
return replaceMemberAccessOperator({
94+
memberExpression,
95+
context,
96+
fixer,
97+
replacement: memberExpression.computed ? '[' : '.',
98+
});
99+
}
100+
101+
function addOptionalChaining(memberExpression, context, fixer) {
102+
return replaceMemberAccessOperator({
103+
memberExpression,
104+
context,
105+
fixer,
106+
replacement: memberExpression.computed ? '?.[' : '?.',
107+
});
108+
}
109+
110+
function getProblem(logicalExpression, context) {
111+
const {left, operator, right} = logicalExpression;
112+
const leftMemberExpression = getMemberExpression(left);
113+
const rightMemberExpression = getMemberExpression(right);
114+
115+
if (
116+
!leftMemberExpression
117+
|| !rightMemberExpression
118+
) {
119+
return;
120+
}
121+
122+
const leftMemberBase = getMemberBase(leftMemberExpression);
123+
const rightMemberBase = getMemberBase(rightMemberExpression);
124+
125+
if (
126+
!isSupportedMemberBase(leftMemberBase)
127+
|| !isSupportedMemberBase(rightMemberBase)
128+
|| !isSameReference(leftMemberBase, rightMemberBase)
129+
) {
130+
return;
131+
}
132+
133+
if (operator === '&&' && rightMemberExpression.optional) {
134+
return {
135+
node: rightMemberExpression,
136+
messageId: MESSAGE_ID_REMOVE_OPTIONAL,
137+
...(canReplaceMemberAccessOperator(rightMemberExpression, context) && {
138+
suggest: [
139+
{
140+
messageId: MESSAGE_ID_SUGGEST_REMOVE_OPTIONAL,
141+
fix: fixer => removeOptionalChaining(rightMemberExpression, context, fixer),
142+
},
143+
],
144+
}),
145+
};
146+
}
147+
148+
if (operator !== '||' || leftMemberExpression.optional === rightMemberExpression.optional) {
149+
return;
150+
}
151+
152+
if (rightMemberExpression.optional) {
153+
return {
154+
node: rightMemberExpression,
155+
messageId: MESSAGE_ID_REMOVE_OPTIONAL,
156+
...(canReplaceMemberAccessOperator(rightMemberExpression, context) && {
157+
suggest: [
158+
{
159+
messageId: MESSAGE_ID_SUGGEST_REMOVE_OPTIONAL,
160+
fix: fixer => removeOptionalChaining(rightMemberExpression, context, fixer),
161+
},
162+
],
163+
}),
164+
};
165+
}
166+
167+
return {
168+
node: rightMemberExpression,
169+
messageId: MESSAGE_ID_USE_OPTIONAL,
170+
...(canReplaceMemberAccessOperator(rightMemberExpression, context) && {
171+
suggest: [
172+
{
173+
messageId: MESSAGE_ID_SUGGEST_USE_OPTIONAL,
174+
fix: fixer => addOptionalChaining(rightMemberExpression, context, fixer),
175+
},
176+
],
177+
}),
178+
};
179+
}
180+
181+
/** @param {import('eslint').Rule.RuleContext} context */
182+
const create = context => {
183+
context.on('LogicalExpression', logicalExpression => getProblem(logicalExpression, context));
184+
};
185+
186+
/** @type {import('eslint').Rule.RuleModule} */
187+
const config = {
188+
create,
189+
meta: {
190+
type: 'suggestion',
191+
docs: {
192+
description: 'Enforce consistent optional chaining for same-base member access.',
193+
recommended: 'unopinionated',
194+
},
195+
hasSuggestions: true,
196+
messages,
197+
languages: [
198+
'js/js',
199+
],
200+
},
201+
};
202+
203+
export default config;

rules/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export {default as 'consistent-export-decorator-position'} from './consistent-ex
1414
export {default as 'consistent-function-scoping'} from './consistent-function-scoping.js';
1515
export {default as 'consistent-function-style'} from './consistent-function-style.js';
1616
export {default as 'consistent-json-file-read'} from './consistent-json-file-read.js';
17+
export {default as 'consistent-optional-chaining'} from './consistent-optional-chaining.js';
1718
export {default as 'consistent-template-literal-escape'} from './consistent-template-literal-escape.js';
1819
export {default as 'custom-error-definition'} from './custom-error-definition.js';
1920
export {default as 'dom-node-dataset'} from './dom-node-dataset.js';

0 commit comments

Comments
 (0)