|
| 1 | +/** |
| 2 | + * @author Toru Nagashima <https://github.com/mysticatea> |
| 3 | + * @author Teddy Katz <https://github.com/not-an-aardvark> |
| 4 | + * |
| 5 | + * Three functions `isNormalFunctionExpression`, `getKeyName`, and `getRuleInfo` |
| 6 | + * are copied from https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/blob/master/lib/utils.js |
| 7 | + * |
| 8 | + * I have a plan to send this rule to that plugin: https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/issues/55 |
| 9 | + */ |
| 10 | + |
| 11 | +'use strict' |
| 12 | + |
| 13 | +// ----------------------------------------------------------------------------- |
| 14 | +// Requirements |
| 15 | +// ----------------------------------------------------------------------------- |
| 16 | + |
| 17 | +const path = require('path') |
| 18 | + |
| 19 | +// ----------------------------------------------------------------------------- |
| 20 | +// Helpers |
| 21 | +// ----------------------------------------------------------------------------- |
| 22 | + |
| 23 | +/** |
| 24 | +* Determines whether a node is a 'normal' (i.e. non-async, non-generator) function expression. |
| 25 | +* @param {ASTNode} node The node in question |
| 26 | +* @returns {boolean} `true` if the node is a normal function expression |
| 27 | +*/ |
| 28 | +function isNormalFunctionExpression (node) { |
| 29 | + return (node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') && !node.generator && !node.async |
| 30 | +} |
| 31 | + |
| 32 | +/** |
| 33 | + * Gets the key name of a Property, if it can be determined statically. |
| 34 | + * @param {ASTNode} node The `Property` node |
| 35 | + * @returns {string|null} The key name, or `null` if the name cannot be determined statically. |
| 36 | + */ |
| 37 | +function getKeyName (property) { |
| 38 | + if (!property.computed && property.key.type === 'Identifier') { |
| 39 | + return property.key.name |
| 40 | + } |
| 41 | + if (property.key.type === 'Literal') { |
| 42 | + return '' + property.key.value |
| 43 | + } |
| 44 | + if (property.key.type === 'TemplateLiteral' && property.key.quasis.length === 1) { |
| 45 | + return property.key.quasis[0].value.cooked |
| 46 | + } |
| 47 | + return null |
| 48 | +} |
| 49 | + |
| 50 | +/** |
| 51 | +* Performs static analysis on an AST to try to determine the final value of `module.exports`. |
| 52 | +* @param {ASTNode} ast The `Program` AST node |
| 53 | +* @returns {Object} An object with keys `meta`, `create`, and `isNewStyle`. `meta` and `create` correspond to the AST nodes |
| 54 | +for the final values of `module.exports.meta` and `module.exports.create`. `isNewStyle` will be `true` if `module.exports` |
| 55 | +is an object, and `false` if module.exports is just the `create` function. If no valid ESLint rule info can be extracted |
| 56 | +from the file, the return value will be `null`. |
| 57 | +*/ |
| 58 | +function getRuleInfo (ast) { |
| 59 | + const INTERESTING_KEYS = new Set(['create', 'meta']) |
| 60 | + let exportsVarOverridden = false |
| 61 | + let exportsIsFunction = false |
| 62 | + |
| 63 | + const exportNodes = ast.body |
| 64 | + .filter(statement => statement.type === 'ExpressionStatement') |
| 65 | + .map(statement => statement.expression) |
| 66 | + .filter(expression => expression.type === 'AssignmentExpression') |
| 67 | + .filter(expression => expression.left.type === 'MemberExpression') |
| 68 | + .reduce((currentExports, node) => { |
| 69 | + if ( |
| 70 | + node.left.object.type === 'Identifier' && node.left.object.name === 'module' && |
| 71 | + node.left.property.type === 'Identifier' && node.left.property.name === 'exports' |
| 72 | + ) { |
| 73 | + exportsVarOverridden = true |
| 74 | + |
| 75 | + if (isNormalFunctionExpression(node.right)) { |
| 76 | + // Check `module.exports = function () {}` |
| 77 | + |
| 78 | + exportsIsFunction = true |
| 79 | + return { create: node.right, meta: null } |
| 80 | + } else if (node.right.type === 'ObjectExpression') { |
| 81 | + // Check `module.exports = { create: function () {}, meta: {} }` |
| 82 | + |
| 83 | + exportsIsFunction = false |
| 84 | + return node.right.properties.reduce((parsedProps, prop) => { |
| 85 | + const keyValue = getKeyName(prop) |
| 86 | + if (INTERESTING_KEYS.has(keyValue)) { |
| 87 | + parsedProps[keyValue] = prop.value |
| 88 | + } |
| 89 | + return parsedProps |
| 90 | + }, {}) |
| 91 | + } |
| 92 | + return {} |
| 93 | + } else if ( |
| 94 | + !exportsIsFunction && |
| 95 | + node.left.object.type === 'MemberExpression' && |
| 96 | + node.left.object.object.type === 'Identifier' && node.left.object.object.name === 'module' && |
| 97 | + node.left.object.property.type === 'Identifier' && node.left.object.property.name === 'exports' && |
| 98 | + node.left.property.type === 'Identifier' && INTERESTING_KEYS.has(node.left.property.name) |
| 99 | + ) { |
| 100 | + // Check `module.exports.create = () => {}` |
| 101 | + |
| 102 | + currentExports[node.left.property.name] = node.right |
| 103 | + } else if ( |
| 104 | + !exportsVarOverridden && |
| 105 | + node.left.object.type === 'Identifier' && node.left.object.name === 'exports' && |
| 106 | + node.left.property.type === 'Identifier' && INTERESTING_KEYS.has(node.left.property.name) |
| 107 | + ) { |
| 108 | + // Check `exports.create = () => {}` |
| 109 | + |
| 110 | + currentExports[node.left.property.name] = node.right |
| 111 | + } |
| 112 | + return currentExports |
| 113 | + }, {}) |
| 114 | + |
| 115 | + return Object.prototype.hasOwnProperty.call(exportNodes, 'create') && isNormalFunctionExpression(exportNodes.create) |
| 116 | + ? Object.assign({ isNewStyle: !exportsIsFunction, meta: null }, exportNodes) |
| 117 | + : null |
| 118 | +} |
| 119 | + |
| 120 | +// ----------------------------------------------------------------------------- |
| 121 | +// Rule Definition |
| 122 | +// ----------------------------------------------------------------------------- |
| 123 | + |
| 124 | +module.exports = { |
| 125 | + meta: { |
| 126 | + docs: { |
| 127 | + description: 'require rules to implement a meta.docs.url property', |
| 128 | + category: 'Rules', |
| 129 | + recommended: false |
| 130 | + }, |
| 131 | + fixable: 'code', |
| 132 | + schema: [{ |
| 133 | + type: 'object', |
| 134 | + properties: { |
| 135 | + pattern: { type: 'string' } |
| 136 | + }, |
| 137 | + additionalProperties: false |
| 138 | + }] |
| 139 | + }, |
| 140 | + |
| 141 | + /** |
| 142 | + * Creates AST event handlers for require-meta-docs-url. |
| 143 | + * @param {RuleContext} context - The rule context. |
| 144 | + * @returns {Object} AST event handlers. |
| 145 | + */ |
| 146 | + create (context) { |
| 147 | + const options = context.options[0] || {} |
| 148 | + const sourceCode = context.getSourceCode() |
| 149 | + const filename = context.getFilename() |
| 150 | + const ruleName = filename === '<input>' ? undefined : path.basename(filename, '.js') |
| 151 | + const expectedUrl = !options.pattern || !ruleName |
| 152 | + ? undefined |
| 153 | + : options.pattern.replace(/{{\s*name\s*}}/g, ruleName) |
| 154 | + |
| 155 | + /** |
| 156 | + * Check whether a given node is the expected URL. |
| 157 | + * @param {Node} node The node of property value to check. |
| 158 | + * @returns {boolean} `true` if the node is the expected URL. |
| 159 | + */ |
| 160 | + function isExpectedUrl (node) { |
| 161 | + return Boolean( |
| 162 | + node && |
| 163 | + node.type === 'Literal' && |
| 164 | + typeof node.value === 'string' && |
| 165 | + ( |
| 166 | + expectedUrl === undefined || |
| 167 | + node.value === expectedUrl |
| 168 | + ) |
| 169 | + ) |
| 170 | + } |
| 171 | + |
| 172 | + /** |
| 173 | + * Insert a given property into a given object literal. |
| 174 | + * @param {SourceCodeFixer} fixer The fixer. |
| 175 | + * @param {Node} node The ObjectExpression node to insert a property. |
| 176 | + * @param {string} propertyText The property code to insert. |
| 177 | + * @returns {void} |
| 178 | + */ |
| 179 | + function insertProperty (fixer, node, propertyText) { |
| 180 | + if (node.properties.length === 0) { |
| 181 | + return fixer.replaceText(node, `{\n${propertyText}\n}`) |
| 182 | + } |
| 183 | + return fixer.insertTextAfter( |
| 184 | + sourceCode.getLastToken(node.properties[node.properties.length - 1]), |
| 185 | + `,\n${propertyText}` |
| 186 | + ) |
| 187 | + } |
| 188 | + |
| 189 | + return { |
| 190 | + Program (node) { |
| 191 | + const info = getRuleInfo(node) |
| 192 | + if (!info) { |
| 193 | + return |
| 194 | + } |
| 195 | + const metaNode = info.meta |
| 196 | + const docsPropNode = |
| 197 | + metaNode && |
| 198 | + metaNode.properties && |
| 199 | + metaNode.properties.find(p => p.type === 'Property' && getKeyName(p) === 'docs') |
| 200 | + const urlPropNode = |
| 201 | + docsPropNode && |
| 202 | + docsPropNode.value.properties && |
| 203 | + docsPropNode.value.properties.find(p => p.type === 'Property' && getKeyName(p) === 'url') |
| 204 | + |
| 205 | + if (isExpectedUrl(urlPropNode && urlPropNode.value)) { |
| 206 | + return |
| 207 | + } |
| 208 | + |
| 209 | + context.report({ |
| 210 | + loc: |
| 211 | + (urlPropNode && urlPropNode.value.loc) || |
| 212 | + (docsPropNode && docsPropNode.value.loc) || |
| 213 | + (metaNode && metaNode.loc) || |
| 214 | + node.loc.start, |
| 215 | + |
| 216 | + message: |
| 217 | + !urlPropNode ? 'Rules should export a `meta.docs.url` property.' |
| 218 | + : !expectedUrl ? '`meta.docs.url` property must be a string.' |
| 219 | + /* otherwise */ : '`meta.docs.url` property must be `{{expectedUrl}}`.', |
| 220 | + |
| 221 | + data: { |
| 222 | + expectedUrl |
| 223 | + }, |
| 224 | + |
| 225 | + fix (fixer) { |
| 226 | + if (expectedUrl) { |
| 227 | + const urlString = JSON.stringify(expectedUrl) |
| 228 | + if (urlPropNode) { |
| 229 | + return fixer.replaceText(urlPropNode.value, urlString) |
| 230 | + } |
| 231 | + if (docsPropNode && docsPropNode.value.type === 'ObjectExpression') { |
| 232 | + return insertProperty(fixer, docsPropNode.value, `url: ${urlString}`) |
| 233 | + } |
| 234 | + if (!docsPropNode && metaNode && metaNode.type === 'ObjectExpression') { |
| 235 | + return insertProperty(fixer, metaNode, `docs: {\nurl: ${urlString}\n}`) |
| 236 | + } |
| 237 | + } |
| 238 | + return null |
| 239 | + } |
| 240 | + }) |
| 241 | + } |
| 242 | + } |
| 243 | + } |
| 244 | +} |
0 commit comments