373 lines
8.6 KiB
JavaScript
373 lines
8.6 KiB
JavaScript
|
'use strict';
|
||
|
const {defaultsDeep} = require('lodash');
|
||
|
const {getStringIfConstant} = require('@eslint-community/eslint-utils');
|
||
|
const {callExpressionSelector} = require('./selectors/index.js');
|
||
|
|
||
|
const MESSAGE_ID = 'importStyle';
|
||
|
const messages = {
|
||
|
[MESSAGE_ID]: 'Use {{allowedStyles}} import for module `{{moduleName}}`.',
|
||
|
};
|
||
|
|
||
|
const getActualImportDeclarationStyles = importDeclaration => {
|
||
|
const {specifiers} = importDeclaration;
|
||
|
|
||
|
if (specifiers.length === 0) {
|
||
|
return ['unassigned'];
|
||
|
}
|
||
|
|
||
|
const styles = new Set();
|
||
|
|
||
|
for (const specifier of specifiers) {
|
||
|
if (specifier.type === 'ImportDefaultSpecifier') {
|
||
|
styles.add('default');
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (specifier.type === 'ImportNamespaceSpecifier') {
|
||
|
styles.add('namespace');
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (specifier.type === 'ImportSpecifier') {
|
||
|
if (specifier.imported.type === 'Identifier' && specifier.imported.name === 'default') {
|
||
|
styles.add('default');
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
styles.add('named');
|
||
|
continue;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return [...styles];
|
||
|
};
|
||
|
|
||
|
const getActualExportDeclarationStyles = exportDeclaration => {
|
||
|
const {specifiers} = exportDeclaration;
|
||
|
|
||
|
if (specifiers.length === 0) {
|
||
|
return ['unassigned'];
|
||
|
}
|
||
|
|
||
|
const styles = new Set();
|
||
|
|
||
|
for (const specifier of specifiers) {
|
||
|
if (specifier.type === 'ExportSpecifier') {
|
||
|
if (specifier.exported.type === 'Identifier' && specifier.exported.name === 'default') {
|
||
|
styles.add('default');
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
styles.add('named');
|
||
|
continue;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return [...styles];
|
||
|
};
|
||
|
|
||
|
const getActualAssignmentTargetImportStyles = assignmentTarget => {
|
||
|
if (assignmentTarget.type === 'Identifier' || assignmentTarget.type === 'ArrayPattern') {
|
||
|
return ['namespace'];
|
||
|
}
|
||
|
|
||
|
if (assignmentTarget.type === 'ObjectPattern') {
|
||
|
if (assignmentTarget.properties.length === 0) {
|
||
|
return ['unassigned'];
|
||
|
}
|
||
|
|
||
|
const styles = new Set();
|
||
|
|
||
|
for (const property of assignmentTarget.properties) {
|
||
|
if (property.type === 'RestElement') {
|
||
|
styles.add('named');
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (property.key.type === 'Identifier') {
|
||
|
if (property.key.name === 'default') {
|
||
|
styles.add('default');
|
||
|
} else {
|
||
|
styles.add('named');
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return [...styles];
|
||
|
}
|
||
|
|
||
|
// Next line is not test-coverable until unforceable changes to the language
|
||
|
// like an addition of new AST node types usable in `const __HERE__ = foo;`.
|
||
|
// An exotic custom parser or a bug in one could cover it too.
|
||
|
/* c8 ignore next */
|
||
|
return [];
|
||
|
};
|
||
|
|
||
|
const joinOr = words => words
|
||
|
.map((word, index) => {
|
||
|
if (index === words.length - 1) {
|
||
|
return word;
|
||
|
}
|
||
|
|
||
|
if (index === words.length - 2) {
|
||
|
return word + ' or';
|
||
|
}
|
||
|
|
||
|
return word + ',';
|
||
|
})
|
||
|
.join(' ');
|
||
|
|
||
|
// Keep this alphabetically sorted for easier maintenance
|
||
|
const defaultStyles = {
|
||
|
chalk: {
|
||
|
default: true,
|
||
|
},
|
||
|
path: {
|
||
|
default: true,
|
||
|
},
|
||
|
util: {
|
||
|
named: true,
|
||
|
},
|
||
|
};
|
||
|
|
||
|
const assignedDynamicImportSelector = [
|
||
|
'VariableDeclarator',
|
||
|
'[init.type="AwaitExpression"]',
|
||
|
'[init.argument.type="ImportExpression"]',
|
||
|
].join('');
|
||
|
|
||
|
const assignedRequireSelector = [
|
||
|
'VariableDeclarator',
|
||
|
'[init.type="CallExpression"]',
|
||
|
'[init.callee.type="Identifier"]',
|
||
|
'[init.callee.name="require"]',
|
||
|
].join('');
|
||
|
|
||
|
/** @param {import('eslint').Rule.RuleContext} context */
|
||
|
const create = context => {
|
||
|
let [
|
||
|
{
|
||
|
styles = {},
|
||
|
extendDefaultStyles = true,
|
||
|
checkImport = true,
|
||
|
checkDynamicImport = true,
|
||
|
checkExportFrom = false,
|
||
|
checkRequire = true,
|
||
|
} = {},
|
||
|
] = context.options;
|
||
|
|
||
|
styles = extendDefaultStyles
|
||
|
? defaultsDeep({}, styles, defaultStyles)
|
||
|
: styles;
|
||
|
|
||
|
styles = new Map(
|
||
|
Object.entries(styles).map(
|
||
|
([moduleName, styles]) =>
|
||
|
[moduleName, new Set(Object.entries(styles).filter(([, isAllowed]) => isAllowed).map(([style]) => style))],
|
||
|
),
|
||
|
);
|
||
|
|
||
|
const report = (node, moduleName, actualImportStyles, allowedImportStyles, isRequire = false) => {
|
||
|
if (!allowedImportStyles || allowedImportStyles.size === 0) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
let effectiveAllowedImportStyles = allowedImportStyles;
|
||
|
|
||
|
// For `require`, `'default'` style allows both `x = require('x')` (`'namespace'` style) and
|
||
|
// `{default: x} = require('x')` (`'default'` style) since we don't know in advance
|
||
|
// whether `'x'` is a compiled ES6 module (with `default` key) or a CommonJS module and `require`
|
||
|
// does not provide any automatic interop for this, so the user may have to use either of these.
|
||
|
if (isRequire && allowedImportStyles.has('default') && !allowedImportStyles.has('namespace')) {
|
||
|
effectiveAllowedImportStyles = new Set(allowedImportStyles);
|
||
|
effectiveAllowedImportStyles.add('namespace');
|
||
|
}
|
||
|
|
||
|
if (actualImportStyles.every(style => effectiveAllowedImportStyles.has(style))) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const data = {
|
||
|
allowedStyles: joinOr([...allowedImportStyles.keys()]),
|
||
|
moduleName,
|
||
|
};
|
||
|
|
||
|
context.report({
|
||
|
node,
|
||
|
messageId: MESSAGE_ID,
|
||
|
data,
|
||
|
});
|
||
|
};
|
||
|
|
||
|
let visitor = {};
|
||
|
|
||
|
if (checkImport) {
|
||
|
visitor = {
|
||
|
...visitor,
|
||
|
|
||
|
ImportDeclaration(node) {
|
||
|
const moduleName = getStringIfConstant(node.source, context.getScope());
|
||
|
|
||
|
const allowedImportStyles = styles.get(moduleName);
|
||
|
const actualImportStyles = getActualImportDeclarationStyles(node);
|
||
|
|
||
|
report(node, moduleName, actualImportStyles, allowedImportStyles);
|
||
|
},
|
||
|
};
|
||
|
}
|
||
|
|
||
|
if (checkDynamicImport) {
|
||
|
visitor = {
|
||
|
...visitor,
|
||
|
|
||
|
'ExpressionStatement > ImportExpression'(node) {
|
||
|
const moduleName = getStringIfConstant(node.source, context.getScope());
|
||
|
const allowedImportStyles = styles.get(moduleName);
|
||
|
const actualImportStyles = ['unassigned'];
|
||
|
|
||
|
report(node, moduleName, actualImportStyles, allowedImportStyles);
|
||
|
},
|
||
|
|
||
|
[assignedDynamicImportSelector](node) {
|
||
|
const assignmentTargetNode = node.id;
|
||
|
const moduleNameNode = node.init.argument.source;
|
||
|
const moduleName = getStringIfConstant(moduleNameNode, context.getScope());
|
||
|
|
||
|
if (!moduleName) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const allowedImportStyles = styles.get(moduleName);
|
||
|
const actualImportStyles = getActualAssignmentTargetImportStyles(assignmentTargetNode);
|
||
|
|
||
|
report(node, moduleName, actualImportStyles, allowedImportStyles);
|
||
|
},
|
||
|
};
|
||
|
}
|
||
|
|
||
|
if (checkExportFrom) {
|
||
|
visitor = {
|
||
|
...visitor,
|
||
|
|
||
|
ExportAllDeclaration(node) {
|
||
|
const moduleName = getStringIfConstant(node.source, context.getScope());
|
||
|
|
||
|
const allowedImportStyles = styles.get(moduleName);
|
||
|
const actualImportStyles = ['namespace'];
|
||
|
|
||
|
report(node, moduleName, actualImportStyles, allowedImportStyles);
|
||
|
},
|
||
|
|
||
|
ExportNamedDeclaration(node) {
|
||
|
const moduleName = getStringIfConstant(node.source, context.getScope());
|
||
|
|
||
|
const allowedImportStyles = styles.get(moduleName);
|
||
|
const actualImportStyles = getActualExportDeclarationStyles(node);
|
||
|
|
||
|
report(node, moduleName, actualImportStyles, allowedImportStyles);
|
||
|
},
|
||
|
};
|
||
|
}
|
||
|
|
||
|
if (checkRequire) {
|
||
|
visitor = {
|
||
|
...visitor,
|
||
|
|
||
|
[`ExpressionStatement > ${callExpressionSelector({name: 'require', argumentsLength: 1})}.expression`](node) {
|
||
|
const moduleName = getStringIfConstant(node.arguments[0], context.getScope());
|
||
|
const allowedImportStyles = styles.get(moduleName);
|
||
|
const actualImportStyles = ['unassigned'];
|
||
|
|
||
|
report(node, moduleName, actualImportStyles, allowedImportStyles, true);
|
||
|
},
|
||
|
|
||
|
[assignedRequireSelector](node) {
|
||
|
const assignmentTargetNode = node.id;
|
||
|
const moduleNameNode = node.init.arguments[0];
|
||
|
const moduleName = getStringIfConstant(moduleNameNode, context.getScope());
|
||
|
|
||
|
if (!moduleName) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const allowedImportStyles = styles.get(moduleName);
|
||
|
const actualImportStyles = getActualAssignmentTargetImportStyles(assignmentTargetNode);
|
||
|
|
||
|
report(node, moduleName, actualImportStyles, allowedImportStyles, true);
|
||
|
},
|
||
|
};
|
||
|
}
|
||
|
|
||
|
return visitor;
|
||
|
};
|
||
|
|
||
|
const schema = {
|
||
|
type: 'array',
|
||
|
additionalItems: false,
|
||
|
items: [
|
||
|
{
|
||
|
type: 'object',
|
||
|
additionalProperties: false,
|
||
|
properties: {
|
||
|
checkImport: {
|
||
|
type: 'boolean',
|
||
|
},
|
||
|
checkDynamicImport: {
|
||
|
type: 'boolean',
|
||
|
},
|
||
|
checkExportFrom: {
|
||
|
type: 'boolean',
|
||
|
},
|
||
|
checkRequire: {
|
||
|
type: 'boolean',
|
||
|
},
|
||
|
extendDefaultStyles: {
|
||
|
type: 'boolean',
|
||
|
},
|
||
|
styles: {
|
||
|
$ref: '#/definitions/moduleStyles',
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
],
|
||
|
definitions: {
|
||
|
moduleStyles: {
|
||
|
type: 'object',
|
||
|
additionalProperties: {
|
||
|
$ref: '#/definitions/styles',
|
||
|
},
|
||
|
},
|
||
|
styles: {
|
||
|
anyOf: [
|
||
|
{
|
||
|
enum: [
|
||
|
false,
|
||
|
],
|
||
|
},
|
||
|
{
|
||
|
$ref: '#/definitions/booleanObject',
|
||
|
},
|
||
|
],
|
||
|
},
|
||
|
booleanObject: {
|
||
|
type: 'object',
|
||
|
additionalProperties: {
|
||
|
type: 'boolean',
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
};
|
||
|
|
||
|
/** @type {import('eslint').Rule.RuleModule} */
|
||
|
module.exports = {
|
||
|
create,
|
||
|
meta: {
|
||
|
type: 'problem',
|
||
|
docs: {
|
||
|
description: 'Enforce specific import styles per module.',
|
||
|
},
|
||
|
schema,
|
||
|
messages,
|
||
|
},
|
||
|
};
|