/** * @author Toru Nagashima <https://github.com/mysticatea> * @author Teddy Katz <https://github.com/not-an-aardvark> * * Three functions `isNormalFunctionExpression`, `getKeyName`, and `getRuleInfo` * are copied from https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/blob/master/lib/utils.js * * I have a plan to send this rule to that plugin: https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/issues/55 */ 'use strict' // ----------------------------------------------------------------------------- // Requirements // ----------------------------------------------------------------------------- const path = require('path') // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- /** * Determines whether a node is a 'normal' (i.e. non-async, non-generator) function expression. * @param {ASTNode} node The node in question * @returns {boolean} `true` if the node is a normal function expression */ function isNormalFunctionExpression (node) { return (node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') && !node.generator && !node.async } /** * Gets the key name of a Property, if it can be determined statically. * @param {ASTNode} node The `Property` node * @returns {string|null} The key name, or `null` if the name cannot be determined statically. */ function getKeyName (property) { if (!property.computed && property.key.type === 'Identifier') { return property.key.name } if (property.key.type === 'Literal') { return '' + property.key.value } if (property.key.type === 'TemplateLiteral' && property.key.quasis.length === 1) { return property.key.quasis[0].value.cooked } return null } /** * Performs static analysis on an AST to try to determine the final value of `module.exports`. * @param {ASTNode} ast The `Program` AST node * @returns {Object} An object with keys `meta`, `create`, and `isNewStyle`. `meta` and `create` correspond to the AST nodes for the final values of `module.exports.meta` and `module.exports.create`. `isNewStyle` will be `true` if `module.exports` is an object, and `false` if module.exports is just the `create` function. If no valid ESLint rule info can be extracted from the file, the return value will be `null`. */ function getRuleInfo (ast) { const INTERESTING_KEYS = new Set(['create', 'meta']) let exportsVarOverridden = false let exportsIsFunction = false const exportNodes = ast.body .filter(statement => statement.type === 'ExpressionStatement') .map(statement => statement.expression) .filter(expression => expression.type === 'AssignmentExpression') .filter(expression => expression.left.type === 'MemberExpression') .reduce((currentExports, node) => { if ( node.left.object.type === 'Identifier' && node.left.object.name === 'module' && node.left.property.type === 'Identifier' && node.left.property.name === 'exports' ) { exportsVarOverridden = true if (isNormalFunctionExpression(node.right)) { // Check `module.exports = function () {}` exportsIsFunction = true return { create: node.right, meta: null } } else if (node.right.type === 'ObjectExpression') { // Check `module.exports = { create: function () {}, meta: {} }` exportsIsFunction = false return node.right.properties.reduce((parsedProps, prop) => { const keyValue = getKeyName(prop) if (INTERESTING_KEYS.has(keyValue)) { parsedProps[keyValue] = prop.value } return parsedProps }, {}) } return {} } else if ( !exportsIsFunction && node.left.object.type === 'MemberExpression' && node.left.object.object.type === 'Identifier' && node.left.object.object.name === 'module' && node.left.object.property.type === 'Identifier' && node.left.object.property.name === 'exports' && node.left.property.type === 'Identifier' && INTERESTING_KEYS.has(node.left.property.name) ) { // Check `module.exports.create = () => {}` currentExports[node.left.property.name] = node.right } else if ( !exportsVarOverridden && node.left.object.type === 'Identifier' && node.left.object.name === 'exports' && node.left.property.type === 'Identifier' && INTERESTING_KEYS.has(node.left.property.name) ) { // Check `exports.create = () => {}` currentExports[node.left.property.name] = node.right } return currentExports }, {}) return Object.prototype.hasOwnProperty.call(exportNodes, 'create') && isNormalFunctionExpression(exportNodes.create) ? Object.assign({ isNewStyle: !exportsIsFunction, meta: null }, exportNodes) : null } // ----------------------------------------------------------------------------- // Rule Definition // ----------------------------------------------------------------------------- module.exports = { meta: { docs: { description: 'require rules to implement a meta.docs.url property', category: 'Rules', recommended: false }, fixable: 'code', schema: [{ type: 'object', properties: { pattern: { type: 'string' } }, additionalProperties: false }] }, /** * Creates AST event handlers for require-meta-docs-url. * @param {RuleContext} context - The rule context. * @returns {Object} AST event handlers. */ create (context) { const options = context.options[0] || {} const sourceCode = context.getSourceCode() const filename = context.getFilename() const ruleName = filename === '<input>' ? undefined : path.basename(filename, '.js') const expectedUrl = !options.pattern || !ruleName ? undefined : options.pattern.replace(/{{\s*name\s*}}/g, ruleName) /** * Check whether a given node is the expected URL. * @param {Node} node The node of property value to check. * @returns {boolean} `true` if the node is the expected URL. */ function isExpectedUrl (node) { return Boolean( node && node.type === 'Literal' && typeof node.value === 'string' && ( expectedUrl === undefined || node.value === expectedUrl ) ) } /** * Insert a given property into a given object literal. * @param {SourceCodeFixer} fixer The fixer. * @param {Node} node The ObjectExpression node to insert a property. * @param {string} propertyText The property code to insert. * @returns {void} */ function insertProperty (fixer, node, propertyText) { if (node.properties.length === 0) { return fixer.replaceText(node, `{\n${propertyText}\n}`) } return fixer.insertTextAfter( sourceCode.getLastToken(node.properties[node.properties.length - 1]), `,\n${propertyText}` ) } return { Program (node) { const info = getRuleInfo(node) if (!info) { return } const metaNode = info.meta const docsPropNode = metaNode && metaNode.properties && metaNode.properties.find(p => p.type === 'Property' && getKeyName(p) === 'docs') const urlPropNode = docsPropNode && docsPropNode.value.properties && docsPropNode.value.properties.find(p => p.type === 'Property' && getKeyName(p) === 'url') if (isExpectedUrl(urlPropNode && urlPropNode.value)) { return } context.report({ loc: (urlPropNode && urlPropNode.value.loc) || (docsPropNode && docsPropNode.value.loc) || (metaNode && metaNode.loc) || node.loc.start, message: !urlPropNode ? 'Rules should export a `meta.docs.url` property.' : !expectedUrl ? '`meta.docs.url` property must be a string.' /* otherwise */ : '`meta.docs.url` property must be `{{expectedUrl}}`.', data: { expectedUrl }, fix (fixer) { if (expectedUrl) { const urlString = JSON.stringify(expectedUrl) if (urlPropNode) { return fixer.replaceText(urlPropNode.value, urlString) } if (docsPropNode && docsPropNode.value.type === 'ObjectExpression') { return insertProperty(fixer, docsPropNode.value, `url: ${urlString}`) } if (!docsPropNode && metaNode && metaNode.type === 'ObjectExpression') { return insertProperty(fixer, metaNode, `docs: {\nurl: ${urlString}\n}`) } } return null } }) } } } }