/** * @author Toru Nagashima * @copyright 2017 Toru Nagashima. All rights reserved. * See LICENSE file in root directory for full license. */ 'use strict' // ------------------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------------------ const HTML_ELEMENT_NAMES = new Set(require('./html-elements.json')) const SVG_ELEMENT_NAMES = new Set(require('./svg-elements.json')) const VOID_ELEMENT_NAMES = new Set(require('./void-elements.json')) const assert = require('assert') const path = require('path') const vueEslintParser = require('vue-eslint-parser') /** * Wrap the rule context object to override methods which access to tokens (such as getTokenAfter). * @param {RuleContext} context The rule context object. * @param {TokenStore} tokenStore The token store object for template. */ function wrapContextToOverrideTokenMethods (context, tokenStore) { const sourceCode = new Proxy(context.getSourceCode(), { get (object, key) { return key in tokenStore ? tokenStore[key] : object[key] } }) return { __proto__: context, getSourceCode () { return sourceCode } } } // ------------------------------------------------------------------------------ // Exports // ------------------------------------------------------------------------------ module.exports = { /** * Register the given visitor to parser services. * If the parser service of `vue-eslint-parser` was not found, * this generates a warning. * * @param {RuleContext} context The rule context to use parser services. * @param {Object} templateBodyVisitor The visitor to traverse the template body. * @param {Object} [scriptVisitor] The visitor to traverse the script. * @returns {Object} The merged visitor. */ defineTemplateBodyVisitor (context, templateBodyVisitor, scriptVisitor) { if (context.parserServices.defineTemplateBodyVisitor == null) { context.report({ loc: { line: 1, column: 0 }, message: 'Use the latest vue-eslint-parser. See also https://vuejs.github.io/eslint-plugin-vue/user-guide/#what-is-the-use-the-latest-vue-eslint-parser-error' }) return {} } return context.parserServices.defineTemplateBodyVisitor(templateBodyVisitor, scriptVisitor) }, /** * Wrap a given core rule to apply it to Vue.js template. * @param {Rule} coreRule The core rule implementation to wrap. * @param {string|undefined} category The category of this rule. * @returns {Rule} The wrapped rule implementation. */ wrapCoreRule (coreRule, category) { return { create (context) { const tokenStore = context.parserServices.getTemplateBodyTokenStore && context.parserServices.getTemplateBodyTokenStore() // The `context.getSourceCode()` cannot access the tokens of templates. // So override the methods which access to tokens by the `tokenStore`. if (tokenStore) { context = wrapContextToOverrideTokenMethods(context, tokenStore) } // Move `Program` handlers to `VElement[parent.type!='VElement']` const handlers = coreRule.create(context) if (handlers.Program) { handlers["VElement[parent.type!='VElement']"] = handlers.Program delete handlers.Program } if (handlers['Program:exit']) { handlers["VElement[parent.type!='VElement']:exit"] = handlers['Program:exit'] delete handlers['Program:exit'] } // Apply the handlers to templates. return module.exports.defineTemplateBodyVisitor(context, handlers) }, meta: Object.assign({}, coreRule.meta, { docs: Object.assign({}, coreRule.meta.docs, { category, url: `https://vuejs.github.io/eslint-plugin-vue/rules/${path.basename(coreRule.meta.docs.url || '')}.html` }) }) } }, /** * Check whether the given node is the root element or not. * @param {ASTNode} node The element node to check. * @returns {boolean} `true` if the node is the root element. */ isRootElement (node) { assert(node && node.type === 'VElement') return ( node.parent.type === 'VDocumentFragment' || node.parent.parent.type === 'VDocumentFragment' ) }, /** * Get the previous sibling element of the given element. * @param {ASTNode} node The element node to get the previous sibling element. * @returns {ASTNode|null} The previous sibling element. */ prevSibling (node) { assert(node && node.type === 'VElement') let prevElement = null for (const siblingNode of (node.parent && node.parent.children) || []) { if (siblingNode === node) { return prevElement } if (siblingNode.type === 'VElement') { prevElement = siblingNode } } return null }, /** * Finds attribute in the given start tag * @param {ASTNode} node The start tag node to check. * @param {string} name The attribute name to check. * @param {string} [value] The attribute value to check. * @returns {ASTNode} attribute node */ findAttribute (node, name, value) { assert(node && node.type === 'VElement') return node.startTag.attributes.find(attr => ( !attr.directive && attr.key.name === name && ( value === undefined || (attr.value != null && attr.value.value === value) ) )) }, /** * Check whether the given start tag has specific directive. * @param {ASTNode} node The start tag node to check. * @param {string} name The attribute name to check. * @param {string} [value] The attribute value to check. * @returns {boolean} `true` if the start tag has the attribute. */ hasAttribute (node, name, value) { assert(node && node.type === 'VElement') return Boolean(this.findAttribute(node, name, value)) }, /** * Finds directive in the given start tag * @param {ASTNode} node The start tag node to check. * @param {string} name The directive name to check. * @param {string} [argument] The directive argument to check. * @returns {ASTNode} directive node */ findDirective (node, name, argument) { assert(node && node.type === 'VElement') return node.startTag.attributes.find(a => a.directive && a.key.name === name && (argument === undefined || a.key.argument === argument) ) }, /** * Check whether the given start tag has specific directive. * @param {ASTNode} node The start tag node to check. * @param {string} name The directive name to check. * @param {string} [argument] The directive argument to check. * @returns {boolean} `true` if the start tag has the directive. */ hasDirective (node, name, argument) { assert(node && node.type === 'VElement') return Boolean(this.findDirective(node, name, argument)) }, /** * Check whether the given attribute has their attribute value. * @param {ASTNode} node The attribute node to check. * @returns {boolean} `true` if the attribute has their value. */ hasAttributeValue (node) { assert(node && node.type === 'VAttribute') return ( node.value != null && (node.value.expression != null || node.value.syntaxError != null) ) }, /** * Get the attribute which has the given name. * @param {ASTNode} node The start tag node to check. * @param {string} name The attribute name to check. * @param {string} [value] The attribute value to check. * @returns {ASTNode} The found attribute. */ getAttribute (node, name, value) { assert(node && node.type === 'VElement') return node.startTag.attributes.find(a => !a.directive && a.key.name === name && ( value === undefined || (a.value != null && a.value.value === value) ) ) }, /** * Get the directive which has the given name. * @param {ASTNode} node The start tag node to check. * @param {string} name The directive name to check. * @param {string} [argument] The directive argument to check. * @returns {ASTNode} The found directive. */ getDirective (node, name, argument) { assert(node && node.type === 'VElement') return node.startTag.attributes.find(a => a.directive && a.key.name === name && (argument === undefined || a.key.argument === argument) ) }, /** * Returns the list of all registered components * @param {ASTNode} componentObject * @returns {Array} Array of ASTNodes */ getRegisteredComponents (componentObject) { const componentsNode = componentObject.properties .find(p => p.type === 'Property' && p.key.type === 'Identifier' && p.key.name === 'components' && p.value.type === 'ObjectExpression' ) if (!componentsNode) { return [] } return componentsNode.value.properties .filter(p => p.type === 'Property') .map(node => { const name = this.getStaticPropertyName(node) return name ? { node, name } : null }) .filter(comp => comp != null) }, /** * Check whether the previous sibling element has `if` or `else-if` directive. * @param {ASTNode} node The element node to check. * @returns {boolean} `true` if the previous sibling element has `if` or `else-if` directive. */ prevElementHasIf (node) { assert(node && node.type === 'VElement') const prev = this.prevSibling(node) return ( prev != null && prev.startTag.attributes.some(a => a.directive && (a.key.name === 'if' || a.key.name === 'else-if') ) ) }, /** * Check whether the given node is a custom component or not. * @param {ASTNode} node The start tag node to check. * @returns {boolean} `true` if the node is a custom component. */ isCustomComponent (node) { assert(node && node.type === 'VElement') return ( (this.isHtmlElementNode(node) && !this.isHtmlWellKnownElementName(node.rawName)) || this.hasAttribute(node, 'is') || this.hasDirective(node, 'bind', 'is') ) }, /** * Check whether the given node is a HTML element or not. * @param {ASTNode} node The node to check. * @returns {boolean} `true` if the node is a HTML element. */ isHtmlElementNode (node) { assert(node && node.type === 'VElement') return node.namespace === vueEslintParser.AST.NS.HTML }, /** * Check whether the given node is a SVG element or not. * @param {ASTNode} node The node to check. * @returns {boolean} `true` if the name is a SVG element. */ isSvgElementNode (node) { assert(node && node.type === 'VElement') return node.namespace === vueEslintParser.AST.NS.SVG }, /** * Check whether the given name is a MathML element or not. * @param {ASTNode} node The node to check. * @returns {boolean} `true` if the node is a MathML element. */ isMathMLElementNode (node) { assert(node && node.type === 'VElement') return node.namespace === vueEslintParser.AST.NS.MathML }, /** * Check whether the given name is an well-known element or not. * @param {string} name The name to check. * @returns {boolean} `true` if the name is an well-known element name. */ isHtmlWellKnownElementName (name) { assert(typeof name === 'string') return HTML_ELEMENT_NAMES.has(name) }, /** * Check whether the given name is an well-known SVG element or not. * @param {string} name The name to check. * @returns {boolean} `true` if the name is an well-known SVG element name. */ isSvgWellKnownElementName (name) { assert(typeof name === 'string') return SVG_ELEMENT_NAMES.has(name) }, /** * Check whether the given name is a void element name or not. * @param {string} name The name to check. * @returns {boolean} `true` if the name is a void element name. */ isHtmlVoidElementName (name) { assert(typeof name === 'string') return VOID_ELEMENT_NAMES.has(name) }, /** * Check whether the given attribute node is a binding * @param {ASTNode} attribute The attribute to check. * @returns {boolean} */ isBindingAttribute (attribute) { return attribute.directive && attribute.key.name === 'bind' && attribute.key.argument }, /** * Check whether the given attribute node is an event * @param {ASTNode} name The attribute to check. * @returns {boolean} */ isEventAttribute (attribute) { return attribute.directive && attribute.key.name === 'on' }, /** * Parse member expression node to get array with all of its parts * @param {ASTNode} node MemberExpression * @returns {Array} */ parseMemberExpression (node) { const members = [] let memberExpression if (node.type === 'MemberExpression') { memberExpression = node while (memberExpression.type === 'MemberExpression') { if (memberExpression.property.type === 'Identifier') { members.push(memberExpression.property.name) } memberExpression = memberExpression.object } if (memberExpression.type === 'ThisExpression') { members.push('this') } else if (memberExpression.type === 'Identifier') { members.push(memberExpression.name) } } return members.reverse() }, /** * Gets the property name of a given node. * @param {ASTNode} node - The node to get. * @return {string|null} The property name if static. Otherwise, null. */ getStaticPropertyName (node) { let prop switch (node && node.type) { case 'Property': case 'MethodDefinition': prop = node.key break case 'MemberExpression': prop = node.property break case 'Literal': case 'TemplateLiteral': case 'Identifier': prop = node break // no default } switch (prop && prop.type) { case 'Literal': return String(prop.value) case 'TemplateLiteral': if (prop.expressions.length === 0 && prop.quasis.length === 1) { return prop.quasis[0].value.cooked } break case 'Identifier': if (!node.computed) { return prop.name } break // no default } return null }, /** * Get all props by looking at all component's properties * @param {ObjectExpression} componentObject Object with component definition * @return {Array} Array of component props in format: [{key?: String, value?: ASTNode, node: ASTNod}] */ getComponentProps (componentObject) { const propsNode = componentObject.properties .find(p => p.type === 'Property' && p.key.type === 'Identifier' && p.key.name === 'props' && (p.value.type === 'ObjectExpression' || p.value.type === 'ArrayExpression') ) if (!propsNode) { return [] } let props if (propsNode.value.type === 'ObjectExpression') { props = propsNode.value.properties .filter(prop => prop.type === 'Property') .map(prop => { return { key: prop.key, value: this.unwrapTypes(prop.value), node: prop, propName: this.getStaticPropertyName(prop) } }) } else { props = propsNode.value.elements .map(prop => { const key = prop.type === 'Literal' && typeof prop.value === 'string' ? prop : null return { key, value: null, node: prop, propName: key != null ? prop.value : null } }) } return props }, /** * Get all computed properties by looking at all component's properties * @param {ObjectExpression} componentObject Object with component definition * @return {Array} Array of computed properties in format: [{key: String, value: ASTNode}] */ getComputedProperties (componentObject) { const computedPropertiesNode = componentObject.properties .find(p => p.type === 'Property' && p.key.type === 'Identifier' && p.key.name === 'computed' && p.value.type === 'ObjectExpression' ) if (!computedPropertiesNode) { return [] } return computedPropertiesNode.value.properties .filter(cp => cp.type === 'Property') .map(cp => { const key = cp.key.name let value if (cp.value.type === 'FunctionExpression') { value = cp.value.body } else if (cp.value.type === 'ObjectExpression') { value = cp.value.properties .filter(p => p.type === 'Property' && p.key.type === 'Identifier' && p.key.name === 'get' && p.value.type === 'FunctionExpression' ) .map(p => p.value.body)[0] } return { key, value } }) }, isVueFile (path) { return path.endsWith('.vue') || path.endsWith('.jsx') }, /** * Check whether the given node is a Vue component based * on the filename and default export type * export default {} in .vue || .jsx * @param {ASTNode} node Node to check * @param {string} path File name with extension * @returns {boolean} */ isVueComponentFile (node, path) { return this.isVueFile(path) && node.type === 'ExportDefaultDeclaration' && node.declaration.type === 'ObjectExpression' }, /** * Check whether given node is Vue component * Vue.component('xxx', {}) || component('xxx', {}) * @param {ASTNode} node Node to check * @returns {boolean} */ isVueComponent (node) { if (node.type === 'CallExpression') { const callee = node.callee if (callee.type === 'MemberExpression') { const calleeObject = this.unwrapTypes(callee.object) const isFullVueComponent = calleeObject.type === 'Identifier' && calleeObject.name === 'Vue' && callee.property.type === 'Identifier' && ['component', 'mixin', 'extend'].indexOf(callee.property.name) > -1 && node.arguments.length >= 1 && node.arguments.slice(-1)[0].type === 'ObjectExpression' return isFullVueComponent } if (callee.type === 'Identifier') { const isDestructedVueComponent = callee.name === 'component' && node.arguments.length >= 1 && node.arguments.slice(-1)[0].type === 'ObjectExpression' return isDestructedVueComponent } } return false }, /** * Check whether given node is new Vue instance * new Vue({}) * @param {ASTNode} node Node to check * @returns {boolean} */ isVueInstance (node) { const callee = node.callee return node.type === 'NewExpression' && callee.type === 'Identifier' && callee.name === 'Vue' && node.arguments.length && node.arguments[0].type === 'ObjectExpression' }, /** * Check if current file is a Vue instance or component and call callback * @param {RuleContext} context The ESLint rule context object. * @param {Function} cb Callback function */ executeOnVue (context, cb) { return Object.assign( this.executeOnVueComponent(context, cb), this.executeOnVueInstance(context, cb) ) }, /** * Check if current file is a Vue instance (new Vue) and call callback * @param {RuleContext} context The ESLint rule context object. * @param {Function} cb Callback function */ executeOnVueInstance (context, cb) { const _this = this return { 'NewExpression:exit' (node) { // new Vue({}) if (!_this.isVueInstance(node)) return cb(node.arguments[0]) } } }, /** * Check if current file is a Vue component and call callback * @param {RuleContext} context The ESLint rule context object. * @param {Function} cb Callback function */ executeOnVueComponent (context, cb) { const filePath = context.getFilename() const sourceCode = context.getSourceCode() const _this = this const componentComments = sourceCode.getAllComments().filter(comment => /@vue\/component/g.test(comment.value)) const foundNodes = [] const isDuplicateNode = (node) => { if (foundNodes.some(el => el.loc.start.line === node.loc.start.line)) return true foundNodes.push(node) return false } return { 'ObjectExpression:exit' (node) { if (!componentComments.some(el => el.loc.end.line === node.loc.start.line - 1) || isDuplicateNode(node)) return cb(node) }, 'ExportDefaultDeclaration:exit' (node) { // export default {} in .vue || .jsx if (!_this.isVueComponentFile(node, filePath) || isDuplicateNode(node.declaration)) return cb(node.declaration) }, 'CallExpression:exit' (node) { // Vue.component('xxx', {}) || component('xxx', {}) if (!_this.isVueComponent(node) || isDuplicateNode(node.arguments.slice(-1)[0])) return cb(node.arguments.slice(-1)[0]) } } }, /** * Return generator with all properties * @param {ASTNode} node Node to check * @param {Set} groups Name of parent group */ * iterateProperties (node, groups) { const nodes = node.properties.filter(p => p.type === 'Property' && groups.has(this.getStaticPropertyName(p.key))) for (const item of nodes) { const name = this.getStaticPropertyName(item.key) if (!name) continue if (item.value.type === 'ArrayExpression') { yield * this.iterateArrayExpression(item.value, name) } else if (item.value.type === 'ObjectExpression') { yield * this.iterateObjectExpression(item.value, name) } else if (item.value.type === 'FunctionExpression') { yield * this.iterateFunctionExpression(item.value, name) } } }, /** * Return generator with all elements inside ArrayExpression * @param {ASTNode} node Node to check * @param {string} groupName Name of parent group */ * iterateArrayExpression (node, groupName) { assert(node.type === 'ArrayExpression') for (const item of node.elements) { const name = this.getStaticPropertyName(item) if (name) { const obj = { name, groupName, node: item } yield obj } } }, /** * Return generator with all elements inside ObjectExpression * @param {ASTNode} node Node to check * @param {string} groupName Name of parent group */ * iterateObjectExpression (node, groupName) { assert(node.type === 'ObjectExpression') for (const item of node.properties) { const name = this.getStaticPropertyName(item) if (name) { const obj = { name, groupName, node: item.key } yield obj } } }, /** * Return generator with all elements inside FunctionExpression * @param {ASTNode} node Node to check * @param {string} groupName Name of parent group */ * iterateFunctionExpression (node, groupName) { assert(node.type === 'FunctionExpression') if (node.body.type === 'BlockStatement') { for (const item of node.body.body) { if (item.type === 'ReturnStatement' && item.argument && item.argument.type === 'ObjectExpression') { yield * this.iterateObjectExpression(item.argument, groupName) } } } }, /** * Find all functions which do not always return values * @param {boolean} treatUndefinedAsUnspecified * @param {Function} cb Callback function */ executeOnFunctionsWithoutReturn (treatUndefinedAsUnspecified, cb) { let funcInfo = { funcInfo: null, codePath: null, hasReturn: false, hasReturnValue: false, node: null } function isReachable (segment) { return segment.reachable } function isValidReturn () { if (funcInfo.codePath.currentSegments.some(isReachable)) { return false } return !treatUndefinedAsUnspecified || funcInfo.hasReturnValue } return { onCodePathStart (codePath, node) { funcInfo = { codePath, funcInfo: funcInfo, hasReturn: false, hasReturnValue: false, node } }, onCodePathEnd () { funcInfo = funcInfo.funcInfo }, ReturnStatement (node) { funcInfo.hasReturn = true funcInfo.hasReturnValue = Boolean(node.argument) }, 'ArrowFunctionExpression:exit' (node) { if (!isValidReturn() && !node.expression) { cb(funcInfo.node) } }, 'FunctionExpression:exit' (node) { if (!isValidReturn()) { cb(funcInfo.node) } } } }, /** * Check whether the component is declared in a single line or not. * @param {ASTNode} node * @returns {boolean} */ isSingleLine (node) { return node.loc.start.line === node.loc.end.line }, /** * Check whether the templateBody of the program has invalid EOF or not. * @param {Program} node The program node to check. * @returns {boolean} `true` if it has invalid EOF. */ hasInvalidEOF (node) { const body = node.templateBody if (body == null || body.errors == null) { return } return body.errors.some(error => typeof error.code === 'string' && error.code.startsWith('eof-')) }, /** * Parse CallExpression or MemberExpression to get simplified version without arguments * * @param {ASTNode} node The node to parse (MemberExpression | CallExpression) * @return {String} eg. 'this.asd.qwe().map().filter().test.reduce()' */ parseMemberOrCallExpression (node) { const parsedCallee = [] let n = node let isFunc while (n.type === 'MemberExpression' || n.type === 'CallExpression') { if (n.type === 'CallExpression') { n = n.callee isFunc = true } else { if (n.computed) { parsedCallee.push('[]') } else if (n.property.type === 'Identifier') { parsedCallee.push(n.property.name + (isFunc ? '()' : '')) } isFunc = false n = n.object } } if (n.type === 'Identifier') { parsedCallee.push(n.name) } if (n.type === 'ThisExpression') { parsedCallee.push('this') } return parsedCallee.reverse().join('.').replace(/\.\[/g, '[') }, /** * Unwrap typescript types like "X as F" * @param {ASTNode} node * @return {ASTNode} */ unwrapTypes (node) { return node.type === 'TSAsExpression' ? node.expression : node } }