Skip to content

Commit 042c6c7

Browse files
authored
Merge pull request #845 from bmish/no-get-optional-chaining
Add `useOptionalChaining` option (default false) to `no-get` rule
2 parents 67d12c2 + c3df9cd commit 042c6c7

File tree

3 files changed

+159
-35
lines changed

3 files changed

+159
-35
lines changed

docs/rules/no-get.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ Examples of **correct** code for this rule:
5050
const foo = this.someProperty;
5151
```
5252

53+
```js
54+
const foo = this.nested?.path; // Optional chaining can be useful if the nested path can have null or undefined properties in it.
55+
```
56+
5357
```js
5458
const foo = this.get('some.nested.property'); // Allowed if `ignoreNestedPaths` option is enabled.
5559
```
@@ -89,6 +93,7 @@ This rule takes an optional object containing:
8993
9094
* `boolean` -- `ignoreGetProperties` -- whether the rule should ignore `getProperties` (default `false`)
9195
* `boolean` -- `ignoreNestedPaths` -- whether the rule should ignore `this.get('some.nested.property')` (default `false`)
96+
* `boolean` -- `useOptionalChaining` -- whether the rule should use the [optional chaining operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining) `?.` to autofix nested paths such as `this.get('some.nested.property')` to `this.some?.nested?.property` (when this option is off, these nested paths won't be autofixed at all) (default `false`)
9297
9398
## Related Rules
9499

lib/rules/no-get.js

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@
22

33
const types = require('../utils/types');
44
const emberUtils = require('../utils/ember');
5+
const assert = require('assert');
56

6-
function makeErrorMessageForGet(property, isImportedGet) {
7-
return isImportedGet
8-
? `Use \`this.${property}\` instead of \`get(this, '${property}')\``
9-
: `Use \`this.${property}\` instead of \`this.get('${property}')\``;
7+
function makeErrorMessageForGet(property, { isImportedGet, useOptionalChaining } = {}) {
8+
const original = isImportedGet ? `get(this, '${property}')` : `this.get('${property}')`;
9+
10+
const replacements = [
11+
`this.${property}`,
12+
useOptionalChaining ? `this.${property.replace(/\./g, '?.')}` : undefined,
13+
]
14+
.filter(Boolean)
15+
.join('` or `');
16+
17+
return `Use \`${replacements}\` instead of \`${original}\``;
1018
}
1119

1220
const ERROR_MESSAGE_GET_PROPERTIES =
@@ -16,6 +24,24 @@ const VALID_JS_VARIABLE_NAME_REGEXP = new RegExp('^[a-zA-Z_$][0-9a-zA-Z_$]*$');
1624
function isValidJSVariableName(str) {
1725
return VALID_JS_VARIABLE_NAME_REGEXP.test(str);
1826
}
27+
function isValidJSPath(str) {
28+
return str.split('.').every(isValidJSVariableName);
29+
}
30+
31+
function fix(node, fixer, path, useOptionalChaining) {
32+
if (path.includes('.') && !useOptionalChaining) {
33+
// Not safe to autofix nested properties because some properties in the path might be null or undefined.
34+
return null;
35+
}
36+
37+
if (!isValidJSPath(path)) {
38+
// Do not autofix since the path would not be a valid JS path.
39+
return null;
40+
}
41+
42+
// If we get to this point and there are periods present, convert them to the optional chaining operator.
43+
return fixer.replaceText(node, `this.${path.replace(/\./g, '?.')}`);
44+
}
1945

2046
module.exports = {
2147
makeErrorMessageForGet,
@@ -41,6 +67,10 @@ module.exports = {
4167
type: 'boolean',
4268
default: false,
4369
},
70+
useOptionalChaining: {
71+
type: 'boolean',
72+
default: false,
73+
},
4474
},
4575
additionalProperties: false,
4676
},
@@ -50,6 +80,14 @@ module.exports = {
5080
// Options:
5181
const ignoreGetProperties = context.options[0] && context.options[0].ignoreGetProperties;
5282
const ignoreNestedPaths = context.options[0] && context.options[0].ignoreNestedPaths;
83+
const useOptionalChaining = context.options[0] && context.options[0].useOptionalChaining;
84+
85+
if (ignoreNestedPaths && useOptionalChaining) {
86+
assert(
87+
false,
88+
'Do not enable both the `ignoreNestedPaths` and `useOptionalChaining` options on this rule at the same time.'
89+
);
90+
}
5391

5492
let currentProxyObject = null;
5593
let currentClassWithUnknownPropertyMethod = null;
@@ -124,19 +162,9 @@ module.exports = {
124162
const path = node.arguments[0].value;
125163
context.report({
126164
node,
127-
message: makeErrorMessageForGet(path, false),
165+
message: makeErrorMessageForGet(path, { isImportedGet: false, useOptionalChaining }),
128166
fix(fixer) {
129-
if (path.includes('.')) {
130-
// Not safe to autofix nested properties because some properties in the path might be null.
131-
return null;
132-
}
133-
134-
if (!isValidJSVariableName(path)) {
135-
// Do not autofix since the path would not be a valid JS variable name.
136-
return null;
137-
}
138-
139-
return fixer.replaceText(node, `this.${path}`);
167+
return fix(node, fixer, path, useOptionalChaining);
140168
},
141169
});
142170
}
@@ -153,19 +181,9 @@ module.exports = {
153181
const path = node.arguments[1].value;
154182
context.report({
155183
node,
156-
message: makeErrorMessageForGet(path, true),
184+
message: makeErrorMessageForGet(path, { isImportedGet: true, useOptionalChaining }),
157185
fix(fixer) {
158-
if (path.includes('.')) {
159-
// Not safe to autofix nested properties because some properties in the path might be null.
160-
return null;
161-
}
162-
163-
if (!isValidJSVariableName(path)) {
164-
// Do not autofix since the path would not be a valid JS variable name.
165-
return null;
166-
}
167-
168-
return fixer.replaceText(node, `this.${path}`);
186+
return fix(node, fixer, path, useOptionalChaining);
169187
},
170188
});
171189
}

tests/lib/rules/no-get.js

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const RuleTester = require('eslint').RuleTester;
55
const { makeErrorMessageForGet, ERROR_MESSAGE_GET_PROPERTIES } = rule;
66

77
const ruleTester = new RuleTester({
8+
parser: require.resolve('babel-eslint'),
89
parserOptions: {
910
ecmaVersion: 2015,
1011
sourceType: 'module',
@@ -156,6 +157,9 @@ ruleTester.run('no-get', rule, {
156157
}
157158
}
158159
`,
160+
161+
// Optional chaining:
162+
'this.foo?.bar',
159163
],
160164
invalid: [
161165
// **************************
@@ -165,29 +169,46 @@ ruleTester.run('no-get', rule, {
165169
{
166170
code: "this.get('foo');",
167171
output: 'this.foo;',
168-
errors: [{ message: makeErrorMessageForGet('foo', false), type: 'CallExpression' }],
172+
// Error message intentionally written out to ensure it looks right.
173+
errors: [{ message: "Use `this.foo` instead of `this.get('foo')`", type: 'CallExpression' }],
169174
},
170175
{
171176
code: "get(this, 'foo');",
172177
output: 'this.foo;',
173-
errors: [{ message: makeErrorMessageForGet('foo', true), type: 'CallExpression' }],
178+
// Error message intentionally written out to ensure it looks right.
179+
errors: [{ message: "Use `this.foo` instead of `get(this, 'foo')`", type: 'CallExpression' }],
174180
},
175181
{
176182
code: "this.get('foo').someFunction();",
177183
output: 'this.foo.someFunction();',
178-
errors: [{ message: makeErrorMessageForGet('foo', false), type: 'CallExpression' }],
184+
errors: [
185+
{
186+
message: makeErrorMessageForGet('foo', { isImportedGet: false }),
187+
type: 'CallExpression',
188+
},
189+
],
179190
},
180191
{
181192
// With invalid JS variable name:
182193
code: "this.get('foo-bar');",
183194
output: null,
184-
errors: [{ message: makeErrorMessageForGet('foo-bar', false), type: 'CallExpression' }],
195+
errors: [
196+
{
197+
message: makeErrorMessageForGet('foo-bar', { isImportedGet: false }),
198+
type: 'CallExpression',
199+
},
200+
],
185201
},
186202
{
187203
// With invalid JS variable name:
188204
code: "get(this, 'foo-bar');",
189205
output: null,
190-
errors: [{ message: makeErrorMessageForGet('foo-bar', true), type: 'CallExpression' }],
206+
errors: [
207+
{
208+
message: makeErrorMessageForGet('foo-bar', { isImportedGet: true }),
209+
type: 'CallExpression',
210+
},
211+
],
191212
},
192213

193214
// **************************
@@ -219,12 +240,22 @@ ruleTester.run('no-get', rule, {
219240
{
220241
code: "this.get('foo.bar');",
221242
output: null,
222-
errors: [{ message: makeErrorMessageForGet('foo.bar', false), type: 'CallExpression' }],
243+
errors: [
244+
{
245+
message: makeErrorMessageForGet('foo.bar', { isImportedGet: false }),
246+
type: 'CallExpression',
247+
},
248+
],
223249
},
224250
{
225251
code: "get(this, 'foo.bar');",
226252
output: null,
227-
errors: [{ message: makeErrorMessageForGet('foo.bar', true), type: 'CallExpression' }],
253+
errors: [
254+
{
255+
message: makeErrorMessageForGet('foo.bar', { isImportedGet: true }),
256+
type: 'CallExpression',
257+
},
258+
],
228259
},
229260
{
230261
code: "this.getProperties('foo.bar');",
@@ -237,6 +268,76 @@ ruleTester.run('no-get', rule, {
237268
errors: [{ message: ERROR_MESSAGE_GET_PROPERTIES, type: 'CallExpression' }],
238269
},
239270

271+
// Nested paths with optional chaining:
272+
{
273+
code: "this.get('foo.bar');",
274+
options: [{ useOptionalChaining: true }],
275+
output: 'this.foo?.bar;',
276+
errors: [
277+
{
278+
// Error message intentionally written out to ensure it looks right.
279+
message: "Use `this.foo.bar` or `this.foo?.bar` instead of `this.get('foo.bar')`",
280+
type: 'CallExpression',
281+
},
282+
],
283+
},
284+
{
285+
code: "this.get('very.long.path');",
286+
options: [{ useOptionalChaining: true }],
287+
output: 'this.very?.long?.path;',
288+
errors: [
289+
{
290+
message: makeErrorMessageForGet('very.long.path', {
291+
isImportedGet: false,
292+
useOptionalChaining: true,
293+
}),
294+
type: 'CallExpression',
295+
},
296+
],
297+
},
298+
{
299+
code: "get(this, 'foo.bar');",
300+
options: [{ useOptionalChaining: true }],
301+
output: 'this.foo?.bar;',
302+
errors: [
303+
{
304+
message: makeErrorMessageForGet('foo.bar', {
305+
isImportedGet: true,
306+
useOptionalChaining: true,
307+
}),
308+
type: 'CallExpression',
309+
},
310+
],
311+
},
312+
{
313+
code: "get(this, 'very.long.path');",
314+
options: [{ useOptionalChaining: true }],
315+
output: 'this.very?.long?.path;',
316+
errors: [
317+
{
318+
message: makeErrorMessageForGet('very.long.path', {
319+
isImportedGet: true,
320+
useOptionalChaining: true,
321+
}),
322+
type: 'CallExpression',
323+
},
324+
],
325+
},
326+
{
327+
code: "this.get('foo');", // No nested path.
328+
options: [{ useOptionalChaining: true }],
329+
output: 'this.foo;',
330+
errors: [
331+
{
332+
message: makeErrorMessageForGet('foo', {
333+
isImportedGet: false,
334+
useOptionalChaining: true,
335+
}),
336+
type: 'CallExpression',
337+
},
338+
],
339+
},
340+
240341
{
241342
// Reports violation after (classic) proxy class.
242343
code: `

0 commit comments

Comments
 (0)