Skip to content

Commit 16cf66c

Browse files
committed
feat: add new rule no-implicit-service-injection-argument
1 parent dc96909 commit 16cf66c

7 files changed

+269
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ Rules are grouped by category to help you understand their purpose. Each rule ha
175175

176176
| Name | Description | :white_check_mark: | :wrench: |
177177
|:--------|:------------|:---------------|:-----------|
178+
| [no-implicit-service-injection-argument](./docs/rules/no-implicit-service-injection-argument.md) | disallow omitting the injected service name argument | | :wrench: |
178179
| [no-restricted-service-injections](./docs/rules/no-restricted-service-injections.md) | disallow injecting certain services under certain paths | | |
179180
| [no-unnecessary-service-injection-argument](./docs/rules/no-unnecessary-service-injection-argument.md) | disallow unnecessary argument when injecting services | | :wrench: |
180181
| [no-unused-services](./docs/rules/no-unused-services.md) | disallow unused service injections (see rule doc for limitations) | | |
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# no-implicit-service-injection-argument
2+
3+
:wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
4+
5+
This rule disallows omitting the service name argument when injecting a service. Instead, the filename of the service should be used (i.e. `service-name` when the service lives at `app/services/service-name.js`).
6+
7+
Some developers may prefer to be more explicit about what service is being injected instead of relying on the implicit and potentially costly lookup/normalization of the service from the property name.
8+
9+
Note: this rule is not in the `recommended` configuration because it is somewhat of a stylistic preference and it's not always necessary to explicitly include the service injection argument.
10+
11+
## Examples
12+
13+
Examples of **incorrect** code for this rule:
14+
15+
```js
16+
import Component from '@ember/component';
17+
import { inject as service } from '@ember/service';
18+
19+
export default class Page extends Component {
20+
@service() serviceName;
21+
}
22+
```
23+
24+
Examples of **correct** code for this rule:
25+
26+
```js
27+
import Component from '@ember/component';
28+
import { inject as service } from '@ember/service';
29+
30+
export default class Page extends Component {
31+
@service('service-name') serviceName;
32+
}
33+
```
34+
35+
## Related Rules
36+
37+
* [no-unnecessary-service-injection-argument](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/no-unnecessary-service-injection-argument.md) is the opposite of this rule
38+
39+
## References
40+
41+
* Ember [Services](https://guides.emberjs.com/release/applications/services/) guide
42+
* Ember [inject](https://emberjs.com/api/ember/release/functions/@ember%2Fservice/inject) function spec

docs/rules/no-unnecessary-service-injection-argument.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export default Component.extend({
4141
});
4242
```
4343

44+
## Related Rules
45+
46+
* [no-implicit-service-injection-argument](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/no-implicit-service-injection-argument.md) is the opposite of this rule
47+
4448
## References
4549

4650
* Ember [Services](https://guides.emberjs.com/release/applications/services/) guide
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
'use strict';
2+
3+
const types = require('../utils/types');
4+
const emberUtils = require('../utils/ember');
5+
const { getImportIdentifier } = require('../utils/import');
6+
7+
//------------------------------------------------------------------------------
8+
// Rule Definition
9+
//------------------------------------------------------------------------------
10+
11+
const ERROR_MESSAGE = "Don't omit the argument for the injected service name.";
12+
13+
module.exports = {
14+
meta: {
15+
type: 'suggestion',
16+
docs: {
17+
description: 'disallow omitting the injected service name argument',
18+
category: 'Services',
19+
recommended: false,
20+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/no-implicit-service-injection-argument.md',
21+
},
22+
fixable: 'code',
23+
schema: [],
24+
},
25+
26+
ERROR_MESSAGE,
27+
28+
create(context) {
29+
let importedInjectName;
30+
let importedEmberName;
31+
32+
return {
33+
ImportDeclaration(node) {
34+
if (node.source.value === 'ember') {
35+
importedEmberName = importedEmberName || getImportIdentifier(node, 'ember');
36+
}
37+
if (node.source.value === '@ember/service') {
38+
importedInjectName =
39+
importedInjectName || getImportIdentifier(node, '@ember/service', 'inject');
40+
}
41+
},
42+
Property(node) {
43+
// Classic classes.
44+
45+
if (
46+
!emberUtils.isInjectedServiceProp(node, importedEmberName, importedInjectName) ||
47+
node.value.arguments.length > 0
48+
) {
49+
// Already has the service name argument.
50+
return;
51+
}
52+
53+
if (node.value.arguments.length === 0) {
54+
context.report({
55+
node: node.value,
56+
message: ERROR_MESSAGE,
57+
fix(fixer) {
58+
const sourceCode = context.getSourceCode();
59+
60+
// Ideally, we want to match the service's filename, and kebab-case filenames are most common.
61+
const serviceName = emberUtils.convertServiceNameToKebabCase(
62+
node.key.name || node.key.value
63+
);
64+
65+
return fixer.insertTextAfter(
66+
sourceCode.getTokenAfter(node.value.callee),
67+
`'${serviceName}'`
68+
);
69+
},
70+
});
71+
}
72+
},
73+
74+
ClassProperty(node) {
75+
// Native classes.
76+
77+
if (
78+
!emberUtils.isInjectedServiceProp(node, importedEmberName, importedInjectName) ||
79+
node.decorators.length !== 1
80+
) {
81+
return;
82+
}
83+
84+
if (
85+
types.isCallExpression(node.decorators[0].expression) &&
86+
node.decorators[0].expression.arguments.length > 0
87+
) {
88+
// Already has the service name argument.
89+
return;
90+
}
91+
92+
context.report({
93+
node: node.decorators[0].expression,
94+
message: ERROR_MESSAGE,
95+
fix(fixer) {
96+
const sourceCode = context.getSourceCode();
97+
98+
// Ideally, we want to match the service's filename, and kebab-case filenames are most common.
99+
const serviceName = emberUtils.convertServiceNameToKebabCase(
100+
node.key.name || node.key.value
101+
);
102+
103+
return node.decorators[0].expression.type === 'CallExpression'
104+
? // Add after parenthesis.
105+
fixer.insertTextAfter(
106+
sourceCode.getTokenAfter(node.decorators[0].expression.callee),
107+
`'${serviceName}'`
108+
)
109+
: // No parenthesis yet so we need to add them.
110+
fixer.insertTextAfter(node.decorators[0].expression, `('${serviceName}')`);
111+
},
112+
});
113+
},
114+
};
115+
},
116+
};

lib/rules/no-restricted-service-injections.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
'use strict';
22

3-
const kebabCase = require('lodash.kebabcase');
43
const assert = require('assert');
54
const emberUtils = require('../utils/ember');
65
const decoratorUtils = require('../utils/decorators');
@@ -74,7 +73,7 @@ module.exports = {
7473
}
7574

7675
function checkForViolationAndReport(node, serviceName) {
77-
const serviceNameKebabCase = serviceName.split('/').map(kebabCase).join('/'); // splitting is used to avoid converting folder/ to folder-
76+
const serviceNameKebabCase = emberUtils.convertServiceNameToKebabCase(serviceName); // splitting is used to avoid converting folder/ to folder-
7877

7978
for (const denylist of denylists) {
8079
// Denylist services are always passed in in kebab-case, so we can do a kebab-case comparison.

lib/utils/ember.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const assert = require('assert');
88
const decoratorUtils = require('../utils/decorators');
99
const { getNodeOrNodeFromVariable } = require('../utils/utils');
1010
const { flatMap } = require('../utils/javascript');
11+
const kebabCase = require('lodash.kebabcase');
1112

1213
module.exports = {
1314
isDSModel,
@@ -68,6 +69,8 @@ module.exports = {
6869
isEmberObjectImplementingUnknownProperty,
6970

7071
isObserverDecorator,
72+
73+
convertServiceNameToKebabCase,
7174
};
7275

7376
// Private
@@ -765,3 +768,7 @@ function isObserverDecorator(node, importedObservesName) {
765768
node.expression.callee.name === importedObservesName
766769
);
767770
}
771+
772+
function convertServiceNameToKebabCase(serviceName) {
773+
return serviceName.split('/').map(kebabCase).join('/'); // splitting is used to avoid converting folder/ to folder-
774+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//------------------------------------------------------------------------------
2+
// Requirements
3+
//------------------------------------------------------------------------------
4+
5+
const rule = require('../../../lib/rules/no-implicit-service-injection-argument');
6+
const RuleTester = require('eslint').RuleTester;
7+
8+
const { ERROR_MESSAGE } = rule;
9+
10+
const EMBER_IMPORT = "import Ember from 'ember';";
11+
const INJECT_IMPORT = "import {inject} from '@ember/service';";
12+
const SERVICE_IMPORT = "import {inject as service} from '@ember/service';";
13+
14+
//------------------------------------------------------------------------------
15+
// Tests
16+
//------------------------------------------------------------------------------
17+
18+
const ruleTester = new RuleTester({
19+
parserOptions: {
20+
ecmaVersion: 6,
21+
sourceType: 'module',
22+
ecmaFeatures: { legacyDecorators: true },
23+
},
24+
parser: require.resolve('@babel/eslint-parser'),
25+
});
26+
27+
ruleTester.run('no-implicit-service-injection-argument', rule, {
28+
valid: [
29+
// With argument (classic class):
30+
`${EMBER_IMPORT} export default Component.extend({ serviceName: Ember.inject.service('serviceName') });`,
31+
`${INJECT_IMPORT} export default Component.extend({ serviceName: inject('serviceName') });`,
32+
`${SERVICE_IMPORT} export default Component.extend({ serviceName: service('serviceName') });`,
33+
`${SERVICE_IMPORT} export default Component.extend({ serviceName: service('service-name') });`,
34+
`${SERVICE_IMPORT} export default Component.extend({ serviceName: service('random') });`,
35+
`${SERVICE_IMPORT} export default Component.extend({ serviceName: service(\`service-name\`) });`,
36+
37+
// With argument (native class)
38+
`${SERVICE_IMPORT} class Test { @service('service-name') serviceName }`,
39+
40+
// Not Ember's `service()` function (classic class):
41+
'export default Component.extend({ serviceName: otherFunction() });',
42+
`${SERVICE_IMPORT} export default Component.extend({ serviceName: service.foo() });`,
43+
44+
// Not Ember's `service()` function (native class):
45+
`${SERVICE_IMPORT} class Test { @otherDecorator() name }`,
46+
`${SERVICE_IMPORT} class Test { @service.foo() name }`,
47+
`${SERVICE_IMPORT} class Test { @foo.service() name }`,
48+
49+
// Spread syntax
50+
'export default Component.extend({ ...foo });',
51+
],
52+
invalid: [
53+
// Classic class
54+
{
55+
// `service` import
56+
code: `${SERVICE_IMPORT} export default Component.extend({ serviceName: service() });`,
57+
output: `${SERVICE_IMPORT} export default Component.extend({ serviceName: service('service-name') });`,
58+
errors: [{ message: ERROR_MESSAGE, type: 'CallExpression' }],
59+
},
60+
{
61+
// `inject` import
62+
code: `${INJECT_IMPORT} export default Component.extend({ serviceName: inject() });`,
63+
output: `${INJECT_IMPORT} export default Component.extend({ serviceName: inject('service-name') });`,
64+
errors: [{ message: ERROR_MESSAGE, type: 'CallExpression' }],
65+
},
66+
{
67+
// Property name in string literal.
68+
code: `${SERVICE_IMPORT} export default Component.extend({ 'serviceName': service() });`,
69+
output: `${SERVICE_IMPORT} export default Component.extend({ 'serviceName': service('service-name') });`,
70+
errors: [{ message: ERROR_MESSAGE, type: 'CallExpression' }],
71+
},
72+
73+
// Decorator:
74+
{
75+
code: `${SERVICE_IMPORT} class Test { @service() serviceName }`,
76+
output: `${SERVICE_IMPORT} class Test { @service('service-name') serviceName }`,
77+
errors: [{ message: ERROR_MESSAGE, type: 'CallExpression' }],
78+
},
79+
{
80+
// Decorator with no parenthesis
81+
code: `${SERVICE_IMPORT} class Test { @service serviceName }`,
82+
output: `${SERVICE_IMPORT} class Test { @service('service-name') serviceName }`,
83+
errors: [{ message: ERROR_MESSAGE, type: 'Identifier' }],
84+
},
85+
{
86+
// No normalization needed.
87+
code: `${SERVICE_IMPORT} class Test { @service() foo }`,
88+
output: `${SERVICE_IMPORT} class Test { @service('foo') foo }`,
89+
errors: [{ message: ERROR_MESSAGE, type: 'CallExpression' }],
90+
},
91+
{
92+
// Scoped/nested service name with property name in string literal.
93+
code: `${SERVICE_IMPORT} class Test { @service() 'myScope/myService' }`,
94+
output: `${SERVICE_IMPORT} class Test { @service('my-scope/my-service') 'myScope/myService' }`,
95+
errors: [{ message: ERROR_MESSAGE, type: 'CallExpression' }],
96+
},
97+
],
98+
});

0 commit comments

Comments
 (0)