'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, }, };