diff --git a/.eslintrc.js b/.eslintrc.js index 0d0779434..4b8396bec 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,7 @@ +'use strict' + +const version = require('./package.json').version + module.exports = { root: true, parserOptions: { @@ -15,8 +19,19 @@ module.exports = { 'eslint-plugin' ], rules: { - 'eslint-plugin/report-message-format': ['error', '^[A-Z].*\\.$'], + 'eslint-plugin/report-message-format': ['error', '^[A-Z`\'].*\\.$'], 'eslint-plugin/prefer-placeholders': 'error', 'eslint-plugin/consistent-output': 'error' - } + }, + + overrides: [{ + files: ['lib/rules/*.js'], + rules: { + "consistent-docs-description": "error", + "no-invalid-meta": "error", + "require-meta-docs-url": ["error", { + "pattern": `https://github.com/vuejs/eslint-plugin-vue/blob/v${version}/docs/rules/{{name}}.md` + }] + } + }] } diff --git a/eslint-internal-rules/require-meta-docs-url.js b/eslint-internal-rules/require-meta-docs-url.js new file mode 100644 index 000000000..1eb9d9ffb --- /dev/null +++ b/eslint-internal-rules/require-meta-docs-url.js @@ -0,0 +1,244 @@ +/** + * @author Toru Nagashima + * @author Teddy Katz + * + * 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 === '' ? 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 + } + }) + } + } + } +} diff --git a/lib/rules/.eslintrc.json b/lib/rules/.eslintrc.json deleted file mode 100644 index 936029a17..000000000 --- a/lib/rules/.eslintrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "rules": { - "consistent-docs-description": "error", - "no-invalid-meta": "error" - } -} diff --git a/lib/rules/attribute-hyphenation.js b/lib/rules/attribute-hyphenation.js index 5dec55b99..c428f0fd5 100644 --- a/lib/rules/attribute-hyphenation.js +++ b/lib/rules/attribute-hyphenation.js @@ -11,56 +11,12 @@ const casing = require('../utils/casing') // Rule Definition // ------------------------------------------------------------------------------ -function create (context) { - const sourceCode = context.getSourceCode() - const options = context.options[0] - const useHyphenated = options !== 'never' - - const caseConverter = casing.getConverter(useHyphenated ? 'kebab-case' : 'camelCase') - - function reportIssue (node, name) { - const text = sourceCode.getText(node.key) - - context.report({ - node: node.key, - loc: node.loc, - message: useHyphenated ? "Attribute '{{text}}' must be hyphenated." : "Attribute '{{text}}' cann't be hyphenated.", - data: { - text - }, - fix: fixer => fixer.replaceText(node.key, text.replace(name, caseConverter(name))) - }) - } - - function isIgnoredAttribute (value) { - if (value.indexOf('data-') !== -1 || value.indexOf('aria-') !== -1) { - return true - } - return useHyphenated ? value.toLowerCase() === value : !/-/.test(value) - } - - // ---------------------------------------------------------------------- - // Public - // ---------------------------------------------------------------------- - - return utils.defineTemplateBodyVisitor(context, { - VAttribute (node) { - if (!utils.isCustomComponent(node.parent.parent)) return - - const name = !node.directive ? node.key.rawName : node.key.name === 'bind' ? node.key.raw.argument : false - if (!name || isIgnoredAttribute(name)) return - - reportIssue(node, name) - } - }) -} - module.exports = { meta: { docs: { description: 'enforce attribute naming style in template', category: 'strongly-recommended', - url: 'https://github.com/vuejs/eslint-plugin-vue/blob/master/docs/rules/attribute-hyphenation.md' + url: 'https://github.com/vuejs/eslint-plugin-vue/blob/v4.2.0/docs/rules/attribute-hyphenation.md' }, fixable: 'code', schema: [ @@ -70,5 +26,47 @@ module.exports = { ] }, - create + create (context) { + const sourceCode = context.getSourceCode() + const options = context.options[0] + const useHyphenated = options !== 'never' + + const caseConverter = casing.getConverter(useHyphenated ? 'kebab-case' : 'camelCase') + + function reportIssue (node, name) { + const text = sourceCode.getText(node.key) + + context.report({ + node: node.key, + loc: node.loc, + message: useHyphenated ? "Attribute '{{text}}' must be hyphenated." : "Attribute '{{text}}' cann't be hyphenated.", + data: { + text + }, + fix: fixer => fixer.replaceText(node.key, text.replace(name, caseConverter(name))) + }) + } + + function isIgnoredAttribute (value) { + if (value.indexOf('data-') !== -1 || value.indexOf('aria-') !== -1) { + return true + } + return useHyphenated ? value.toLowerCase() === value : !/-/.test(value) + } + + // ---------------------------------------------------------------------- + // Public + // ---------------------------------------------------------------------- + + return utils.defineTemplateBodyVisitor(context, { + VAttribute (node) { + if (!utils.isCustomComponent(node.parent.parent)) return + + const name = !node.directive ? node.key.rawName : node.key.name === 'bind' ? node.key.raw.argument : false + if (!name || isIgnoredAttribute(name)) return + + reportIssue(node, name) + } + }) + } } diff --git a/lib/rules/comment-directive.js b/lib/rules/comment-directive.js index 2189d0892..69d381ff8 100644 --- a/lib/rules/comment-directive.js +++ b/lib/rules/comment-directive.js @@ -1,6 +1,7 @@ /** * @author Toru Nagashima */ +/* eslint-disable eslint-plugin/report-message-format, consistent-docs-description */ 'use strict' @@ -99,33 +100,6 @@ function processLine (context, comment) { } } -/** - * The implementation of `vue/comment-directive` rule. - * @param {Program} node The program node to parse. - * @returns {Object} The visitor of this rule. - */ -function create (context) { - return { - Program (node) { - if (!node.templateBody) { - return - } - - // Send directives to the post-process. - for (const comment of node.templateBody.comments) { - processBlock(context, comment) - processLine(context, comment) - } - - // Send a clear mark to the post-process. - context.report({ - loc: node.templateBody.loc.end, - message: 'clear' - }) - } - } -} - // ----------------------------------------------------------------------------- // Rule Definition // ----------------------------------------------------------------------------- @@ -133,11 +107,32 @@ function create (context) { module.exports = { meta: { docs: { - description: 'support comment-directives in `