Skip to content

Commit 5f275e2

Browse files
authored
Add require-module-attributes rule (#2725)
1 parent d12fc01 commit 5f275e2

File tree

9 files changed

+598
-0
lines changed

9 files changed

+598
-0
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Require non-empty module attributes for imports and exports
2+
3+
💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config).
4+
5+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
6+
7+
<!-- end auto-generated rule header -->
8+
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->
9+
10+
Enforce non-empty attribute list in `import`/`export` statements and `import()` expressions.
11+
12+
## Examples
13+
14+
```js
15+
//
16+
import foo from 'foo' with {};
17+
18+
//
19+
import foo from 'foo';
20+
```
21+
22+
```js
23+
//
24+
export {foo} from 'foo' with {};
25+
26+
//
27+
export {foo} from 'foo';
28+
```
29+
30+
```js
31+
//
32+
const foo = await import('foo', {});
33+
34+
//
35+
const foo = await import('foo');
36+
```
37+
38+
```js
39+
//
40+
const foo = await import('foo', {with: {}});
41+
42+
//
43+
const foo = await import('foo');
44+
```

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export default [
184184
| [prevent-abbreviations](docs/rules/prevent-abbreviations.md) | Prevent abbreviations. || 🔧 | |
185185
| [relative-url-style](docs/rules/relative-url-style.md) | Enforce consistent relative URL style. || 🔧 | 💡 |
186186
| [require-array-join-separator](docs/rules/require-array-join-separator.md) | Enforce using the separator argument with `Array#join()`. || 🔧 | |
187+
| [require-module-attributes](docs/rules/require-module-attributes.md) | Require non-empty module attributes for imports and exports || 🔧 | |
187188
| [require-module-specifiers](docs/rules/require-module-specifiers.md) | Require non-empty specifier list in import and export statements. || 🔧 | 💡 |
188189
| [require-number-to-fixed-digits-argument](docs/rules/require-number-to-fixed-digits-argument.md) | Enforce using the digits argument with `Number#toFixed()`. || 🔧 | |
189190
| [require-post-message-target-origin](docs/rules/require-post-message-target-origin.md) | Enforce using the `targetOrigin` argument with `window.postMessage()`. | | | 💡 |

rules/fix/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export {default as renameVariable} from './rename-variable.js';
1313
export {default as replaceNodeOrTokenAndSpacesBefore} from './replace-node-or-token-and-spaces-before.js';
1414
export {default as removeSpacesAfter} from './remove-spaces-after.js';
1515
export {default as removeSpecifier} from './remove-specifier.js';
16+
export {default as removeObjectProperty} from './remove-object-property.js';
1617
export {default as fixSpaceAroundKeyword} from './fix-space-around-keywords.js';
1718
export {default as replaceStringRaw} from './replace-string-raw.js';
1819
export {default as addParenthesizesToReturnOrThrowExpression} from './add-parenthesizes-to-return-or-throw-expression.js';
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {isCommaToken} from '@eslint-community/eslint-utils';
2+
3+
export default function * removeObjectProperty(fixer, property, context) {
4+
const {sourceCode} = context;
5+
for (const token of sourceCode.getTokens(property)) {
6+
yield fixer.remove(token);
7+
}
8+
9+
const tokenAfter = sourceCode.getTokenAfter(property);
10+
if (isCommaToken(tokenAfter)) {
11+
yield fixer.remove(tokenAfter);
12+
} else {
13+
// If the property is the last one and there is no trailing comma
14+
// remove the previous comma
15+
const {properties} = property.parent;
16+
if (properties.length > 1 && properties.at(-1) === property) {
17+
const commaTokenBefore = sourceCode.getTokenBefore(property);
18+
yield fixer.remove(commaTokenBefore);
19+
}
20+
}
21+
}

rules/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export {default as 'prefer-type-error'} from './prefer-type-error.js';
128128
export {default as 'prevent-abbreviations'} from './prevent-abbreviations.js';
129129
export {default as 'relative-url-style'} from './relative-url-style.js';
130130
export {default as 'require-array-join-separator'} from './require-array-join-separator.js';
131+
export {default as 'require-module-attributes'} from './require-module-attributes.js';
131132
export {default as 'require-module-specifiers'} from './require-module-specifiers.js';
132133
export {default as 'require-number-to-fixed-digits-argument'} from './require-number-to-fixed-digits-argument.js';
133134
export {default as 'require-post-message-target-origin'} from './require-post-message-target-origin.js';

rules/require-module-attributes.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import {isCommaToken} from '@eslint-community/eslint-utils';
2+
import {removeObjectProperty} from './fix/index.js';
3+
import {getParentheses} from './utils/index.js';
4+
5+
const MESSAGE_ID = 'require-module-attributes';
6+
const messages = {
7+
[MESSAGE_ID]: '{{type}} with empty attribute list is not allowed.',
8+
};
9+
10+
const isWithToken = token => token?.type === 'Keyword' && token.value === 'with';
11+
12+
/** @param {import('eslint').Rule.RuleContext} context */
13+
const create = context => {
14+
const {sourceCode} = context;
15+
16+
context.on(['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'], declaration => {
17+
const {source, attributes} = declaration;
18+
19+
if (!source || (Array.isArray(attributes) && attributes.length > 0)) {
20+
return;
21+
}
22+
23+
const withToken = sourceCode.getTokenAfter(source);
24+
25+
if (!isWithToken(withToken)) {
26+
return;
27+
}
28+
29+
// `WithStatement` is not possible in modules, so we don't need worry it's not attributes
30+
31+
const openingBraceToken = sourceCode.getTokenAfter(withToken);
32+
const closingBraceToken = sourceCode.getTokenAfter(openingBraceToken);
33+
34+
return {
35+
node: declaration,
36+
loc: {
37+
start: sourceCode.getLoc(openingBraceToken).start,
38+
end: sourceCode.getLoc(closingBraceToken).end,
39+
},
40+
messageId: MESSAGE_ID,
41+
data: {
42+
type: declaration.type === 'ImportDeclaration' ? 'import statement' : 'export statement',
43+
},
44+
/** @param {import('eslint').Rule.RuleFixer} fixer */
45+
fix: fixer => [withToken, closingBraceToken, openingBraceToken].map(token => fixer.remove(token)),
46+
};
47+
});
48+
49+
context.on('ImportExpression', importExpression => {
50+
const {options: optionsNode} = importExpression;
51+
52+
if (optionsNode?.type !== 'ObjectExpression') {
53+
return;
54+
}
55+
56+
const emptyWithProperty = optionsNode.properties.find(
57+
property =>
58+
property.type === 'Property'
59+
&& !property.method
60+
&& !property.shorthand
61+
&& !property.computed
62+
&& property.kind === 'init'
63+
&& (
64+
(
65+
property.key.type === 'Identifier'
66+
&& property.key.name === 'with'
67+
)
68+
|| (
69+
property.key.type === 'Literal'
70+
&& property.key.value === 'with'
71+
)
72+
)
73+
&& property.value.type === 'ObjectExpression'
74+
&& property.value.properties.length === 0,
75+
);
76+
77+
const nodeToRemove = optionsNode.properties.length === 0 || (emptyWithProperty && optionsNode.properties.length === 1)
78+
? optionsNode
79+
: emptyWithProperty;
80+
81+
if (!nodeToRemove) {
82+
return;
83+
}
84+
85+
const isProperty = nodeToRemove.type === 'Property';
86+
87+
return {
88+
node: emptyWithProperty?.value ?? nodeToRemove,
89+
messageId: MESSAGE_ID,
90+
data: {
91+
type: 'import expression',
92+
},
93+
/** @param {import('eslint').Rule.RuleFixer} fixer */
94+
fix: fixer => isProperty
95+
? removeObjectProperty(fixer, nodeToRemove, context)
96+
: [
97+
// Comma token before
98+
sourceCode.getTokenBefore(nodeToRemove, isCommaToken),
99+
...sourceCode.getTokens(nodeToRemove),
100+
...getParentheses(nodeToRemove, sourceCode),
101+
].map(token => fixer.remove(token)),
102+
};
103+
});
104+
};
105+
106+
/** @type {import('eslint').Rule.RuleModule} */
107+
const config = {
108+
create,
109+
meta: {
110+
type: 'suggestion',
111+
docs: {
112+
description: 'Require non-empty module attributes for imports and exports',
113+
recommended: true,
114+
},
115+
fixable: 'code',
116+
messages,
117+
},
118+
};
119+
120+
export default config;

test/require-module-attributes.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {getTester} from './utils/test.js';
2+
3+
const {test} = getTester(import.meta);
4+
5+
// Statements
6+
test.snapshot({
7+
valid: [
8+
'import foo from "foo"',
9+
'export {foo} from "foo"',
10+
'export * from "foo"',
11+
'import foo from "foo" with {type: "json"}',
12+
'export {foo} from "foo" with {type: "json"}',
13+
'export * from "foo" with {type: "json"}',
14+
'export {}',
15+
],
16+
invalid: [
17+
'import "foo" with {}',
18+
'import foo from "foo" with {}',
19+
'export {foo} from "foo" with {}',
20+
'export * from "foo" with {}',
21+
'export * from "foo"with{}',
22+
'export * from "foo"/* comment 1 */with/* comment 2 */{/* comment 3 */}/* comment 4 */',
23+
],
24+
});
25+
26+
// `ImportExpression`
27+
test.snapshot({
28+
valid: [
29+
'import("foo")',
30+
'import("foo", {unknown: "unknown"})',
31+
'import("foo", {with: {type: "json"}})',
32+
'not_import("foo", {})',
33+
'not_import("foo", {with:{}})',
34+
],
35+
invalid: [
36+
'import("foo", {})',
37+
'import("foo", (( {} )))',
38+
'import("foo", {},)',
39+
'import("foo", {with:{},},)',
40+
'import("foo", {with:{}, unknown:"unknown"},)',
41+
'import("foo", {"with":{}, unknown:"unknown"},)',
42+
'import("foo", {unknown:"unknown", with:{}, },)',
43+
'import("foo", {unknown:"unknown", with:{} },)',
44+
'import("foo", {unknown:"unknown", with:{}, unknown2:"unknown2", },)',
45+
'import("foo"/* comment 1 */, /* comment 2 */{/* comment 3 */}/* comment 4 */,/* comment 5 */)',
46+
'import("foo", {/* comment 1 */"with"/* comment 2 */:/* comment 3 */{/* comment 4 */}, }/* comment 5 */,)',
47+
],
48+
});

0 commit comments

Comments
 (0)