diff --git a/README.md b/README.md index 04a8425e6..b1e96c050 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,7 @@ Enforce all the rules in this category, as well as all higher priority rules, wi | | Rule ID | Description | |:---|:--------|:------------| +| | [vue/match-component-file-name](./docs/rules/match-component-file-name.md) | require component name property to match its file name | | :wrench: | [vue/script-indent](./docs/rules/script-indent.md) | enforce consistent indentation in ` +``` + +```js +// file name: src/MyComponent.js +// options: {extensions: ["js"]} +new Vue({ + name: 'MComponent', + template: '
' +}) +``` + +```js +// file name: src/MyComponent.js +// options: {extensions: ["js"]} +Vue.component('MComponent', { + template: '
' +}) +``` + +```jsx +// file name: src/MyComponent.jsx +// options: {shouldMatchCase: true} +export default { + name: 'my-component', + render() { return
} +} +``` + +```jsx +// file name: src/my-component.jsx +// options: {shouldMatchCase: true} +export default { + name: 'MyComponent', + render() { return
} +} +``` + +:+1: Examples of **correct** code for this rule: + +```jsx +// file name: src/MyComponent.jsx +export default { + name: 'MyComponent', + render: () { + return

Hello world

+ } +} +``` + +```jsx +// file name: src/MyComponent.jsx +// no name property defined +export default { + render: () { + return

Hello world

+ } +} +``` + +```vue +// file name: src/MyComponent.vue + +``` + +```vue +// file name: src/MyComponent.vue + +``` + +```js +// file name: src/MyComponent.js +new Vue({ + name: 'MyComponent', + template: '
' +}) +``` + +```js +// file name: src/MyComponent.js +new Vue({ + template: '
' +}) +``` + +```js +// file name: src/MyComponent.js +Vue.component('MyComponent', { + template: '
' +}) +``` + +```js +// file name: src/components.js +// defines multiple components, so this rule is ignored +Vue.component('MyComponent', { + template: '
' +}) + +Vue.component('OtherComponent', { + template: '
' +}) + +new Vue({ + name: 'ThirdComponent', + template: '
' +}) +``` + +```jsx +// file name: src/MyComponent.jsx +// options: {shouldMatchCase: true} +export default { + name: 'MyComponent', + render() { return
} +} +``` + +```jsx +// file name: src/my-component.jsx +// options: {shouldMatchCase: true} +export default { + name: 'my-component', + render() { return
} +} +``` + +## :wrench: Options + +```json +{ + "vue/match-component-file-name": ["error", { + "extensions": ["jsx"], + "shouldMatchCase": false + }] +} +``` + +- `"extensions": []` ... array of file extensions to be verified. Default is set to `["jsx"]`. +- `"shouldMatchCase": false` ... boolean indicating if component's name + should also match its file name case. Default is set to `false`. + +## :books: Further reading + + - [Style guide - Single-file component filename casing](https://vuejs.org/v2/style-guide/#Single-file-component-filename-casing-strongly-recommended) + diff --git a/lib/index.js b/lib/index.js index 5952af45a..e6e5a8904 100644 --- a/lib/index.js +++ b/lib/index.js @@ -18,6 +18,7 @@ module.exports = { 'html-quotes': require('./rules/html-quotes'), 'html-self-closing': require('./rules/html-self-closing'), 'jsx-uses-vars': require('./rules/jsx-uses-vars'), + 'match-component-file-name': require('./rules/match-component-file-name'), 'max-attributes-per-line': require('./rules/max-attributes-per-line'), 'multiline-html-element-content-newline': require('./rules/multiline-html-element-content-newline'), 'mustache-interpolation-spacing': require('./rules/mustache-interpolation-spacing'), diff --git a/lib/rules/match-component-file-name.js b/lib/rules/match-component-file-name.js new file mode 100644 index 000000000..b206d46bf --- /dev/null +++ b/lib/rules/match-component-file-name.js @@ -0,0 +1,140 @@ +/** + * @fileoverview Require component name property to match its file name + * @author Rodrigo Pedra Brum + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const utils = require('../utils') +const casing = require('../utils/casing') +const path = require('path') + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +module.exports = { + meta: { + docs: { + description: 'require component name property to match its file name', + category: undefined, + url: 'https://github.com/vuejs/eslint-plugin-vue/blob/v5.0.0-beta.5/docs/rules/match-component-file-name.md' + }, + fixable: null, + schema: [ + { + type: 'object', + properties: { + extensions: { + type: 'array', + items: { + type: 'string' + }, + uniqueItems: true, + additionalItems: false + }, + shouldMatchCase: { + type: 'boolean' + } + }, + additionalProperties: false + } + ] + }, + + create (context) { + const options = context.options[0] + const shouldMatchCase = (options && options.shouldMatchCase) || false + const extensionsArray = options && options.extensions + const allowedExtensions = Array.isArray(extensionsArray) ? extensionsArray : ['jsx'] + + const extension = path.extname(context.getFilename()) + const filename = path.basename(context.getFilename(), extension) + + const errors = [] + let componentCount = 0 + + if (!allowedExtensions.includes(extension.replace(/^\./, ''))) { + return {} + } + + // ---------------------------------------------------------------------- + // Private + // ---------------------------------------------------------------------- + + function compareNames (name, filename) { + if (shouldMatchCase) { + return name === filename + } + + return casing.pascalCase(name) === filename || casing.kebabCase(name) === filename + } + + function verifyName (node) { + let name + if (node.type === 'TemplateLiteral') { + const quasis = node.quasis[0] + name = quasis.value.cooked + } else { + name = node.value + } + + if (!compareNames(name, filename)) { + errors.push({ + node: node, + message: 'Component name `{{name}}` should match file name `{{filename}}`.', + data: { filename, name } + }) + } + } + + function canVerify (node) { + return node.type === 'Literal' || ( + node.type === 'TemplateLiteral' && + node.expressions.length === 0 && + node.quasis.length === 1 + ) + } + + return Object.assign({}, + { + "CallExpression > MemberExpression > Identifier[name='component']" (node) { + const parent = node.parent.parent + const calleeObject = utils.unwrapTypes(parent.callee.object) + + if (calleeObject.type === 'Identifier' && calleeObject.name === 'Vue') { + if (parent.arguments && parent.arguments.length === 2) { + const argument = parent.arguments[0] + if (canVerify(argument)) { + verifyName(argument) + } + } + } + } + }, + utils.executeOnVue(context, (object) => { + const node = object.properties + .find(item => ( + item.type === 'Property' && + item.key.name === 'name' && + canVerify(item.value) + )) + + componentCount++ + + if (!node) return + verifyName(node.value) + }), + { + 'Program:exit' () { + if (componentCount > 1) return + + errors.forEach((error) => context.report(error)) + } + } + ) + } +} diff --git a/tests/lib/rules/match-component-file-name.js b/tests/lib/rules/match-component-file-name.js new file mode 100644 index 000000000..1a56e45ee --- /dev/null +++ b/tests/lib/rules/match-component-file-name.js @@ -0,0 +1,729 @@ +/** + * @fileoverview Require component name property to match its file name + * @author Rodrigo Pedra Brum + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/match-component-file-name') +const RuleTester = require('eslint').RuleTester + +const jsxParserOptions = { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { jsx: true } +} + +const parserOptions = { + ecmaVersion: 2018, + sourceType: 'module' +} + +const ruleTester = new RuleTester() + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +ruleTester.run('match-component-file-name', rule, { + valid: [ + // .jsx + { + filename: 'MyComponent.jsx', + code: ` + export default { + render() { return
} + } + `, + parserOptions: jsxParserOptions + }, + { + filename: 'MyComponent.jsx', + code: ` + export default { + name: 'MyComponent', + render() { return
} + } + `, + parserOptions: jsxParserOptions + }, + { + filename: 'MyComponent.jsx', + code: ` + export default { + name: 'MyComponent', + render() { return
} + } + `, + options: [{ extensions: ['jsx'] }], + parserOptions: jsxParserOptions + }, + { + filename: 'MyComponent.jsx', + code: ` + export default { + name: myComponent, + render() { return
} + } + `, + options: [{ extensions: ['jsx'] }], + parserOptions: jsxParserOptions + }, + { + filename: 'MyComponent.jsx', + code: ` + export default { + name, + render() { return
} + } + `, + options: [{ extensions: ['jsx'] }], + parserOptions: jsxParserOptions + }, + { + filename: 'MyComponent.jsx', + code: ` + export default { + name: \`MyComponent\`, + render() { return
} + } + `, + options: [{ extensions: ['jsx'] }], + parserOptions: jsxParserOptions + }, + { + filename: 'MyComponent.jsx', + code: ` + export default { + name: \`My\${foo}\`, + render() { return
} + } + `, + options: [{ extensions: ['jsx'] }], + parserOptions: jsxParserOptions + }, + { + filename: 'MyComponent.jsx', + code: ` + export default { + name: 'MComponent', + render() { return
} + } + `, + options: [{ extensions: ['vue'] }], // missing jsx in options + parserOptions: jsxParserOptions + }, + + // .vue + { + filename: 'MyComponent.vue', + code: ` + + `, + parser: 'vue-eslint-parser', + parserOptions // options default to [['jsx']] + }, + { + filename: 'MyComponent.vue', + code: ` + + `, + options: [{ extensions: ['jsx'] }], // missing jsx in options + parser: 'vue-eslint-parser', + parserOptions + }, + { + filename: 'MyComponent.vue', + code: ` + + `, + options: [{ extensions: ['vue'] }], + parser: 'vue-eslint-parser', + parserOptions + }, + { + filename: 'MyComponent.vue', + code: ` + + `, + options: [{ extensions: ['vue'] }], + parser: 'vue-eslint-parser', + parserOptions + }, + { + filename: 'MyComponent.vue', + code: ` + + `, + options: [{ extensions: ['vue'] }], + parser: 'vue-eslint-parser', + parserOptions + }, + { + filename: 'MyComponent.vue', + code: ` + + `, + options: [{ extensions: ['vue'] }], + parser: 'vue-eslint-parser', + parserOptions + }, + { + filename: 'MyComponent.vue', + code: ` + + `, + options: [{ extensions: ['vue'] }], + parser: 'vue-eslint-parser', + parserOptions + }, + { + filename: 'MyComponent.vue', + code: ` + + `, + options: [{ extensions: ['vue'] }], + parser: 'vue-eslint-parser', + parserOptions + }, + { + filename: 'MyComponent.vue', + code: ` + + `, + options: [{ extensions: ['vue'] }], + parser: 'vue-eslint-parser', + parserOptions + }, + + // .js + { + filename: 'MyComponent.js', + code: ` + new Vue({ + name: 'MComponent', + template: '
' + }) + `, + parserOptions // options default to [['jsx']] + }, + { + filename: 'MyComponent.js', + code: ` + Vue.mixin({}) + `, + parserOptions // options default to [['jsx']] + }, + { + filename: 'MyComponent.js', + code: ` + Vue.component('MComponent', { + template: '
' + }) + `, + parserOptions // options default to [['jsx']] + }, + { + filename: 'MyComponent.js', + code: ` + new Vue({ + name: 'MComponent', + template: '
' + }) + `, + options: [{ extensions: ['vue'] }], // missing 'js' in options + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + Vue.mixin({}) + `, + options: [{ extensions: ['vue'] }], // missing 'js' in options + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + Vue.component('MComponent', { + template: '
' + }) + `, + options: [{ extensions: ['vue'] }], // missing 'js' in options + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + new Vue({ + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + new Vue({ + name: 'MyComponent', + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + new Vue({ + name: myComponent, + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + new Vue({ + name, + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + new Vue({ + name: \`MyComponent\`, + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + new Vue({ + name: \`My\${foo}\`, + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + Vue.mixin({}) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + new Vue.mixin({ + name: 'MyComponent', + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + new Vue.mixin({ + name: myComponent, + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + new Vue.mixin({ + name + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + new Vue.mixin({ + name: \`MyComponent\`, + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + new Vue.mixin({ + name: \`My\${foo}\`, + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + Vue.component('MyComponent', { + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + Vue.component(myComponent, { + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + Vue.component(\`MyComponent\`, { + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'MyComponent.js', + code: ` + Vue.component(\`My\${foo}\`, { + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + { + filename: 'index.js', + code: ` + Vue.component('MyComponent', { + template: '
' + }) + + Vue.component('OtherComponent', { + template: '
' + }) + + new Vue('OtherComponent', { + name: 'ThirdComponent', + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, + + // casing + { + filename: 'my-component.jsx', + code: ` + export default { + name: 'my-component', + render() { return
} + } + `, + parserOptions: jsxParserOptions + }, + { + filename: 'my-component.jsx', + code: ` + export default { + name: 'MyComponent', + render() { return
} + } + `, + parserOptions: jsxParserOptions + }, + { + filename: 'MyComponent.jsx', + code: ` + export default { + name: 'my-component', + render() { return
} + } + `, + parserOptions: jsxParserOptions + }, + { + filename: 'my-component.jsx', + code: ` + export default { + name: 'my-component', + render() { return
} + } + `, + options: [{ shouldMatchCase: true }], + parserOptions: jsxParserOptions + }, + { + filename: 'MyComponent.jsx', + code: ` + export default { + name: 'MyComponent', + render() { return
} + } + `, + options: [{ shouldMatchCase: true }], + parserOptions: jsxParserOptions + } + ], + + invalid: [ + // .jsx + { + filename: 'MyComponent.jsx', + code: ` + export default { + name: 'MComponent', + render() { return
} + } + `, + parserOptions: jsxParserOptions, + errors: [{ + message: 'Component name `MComponent` should match file name `MyComponent`.' + }] + }, + { + filename: 'MyComponent.jsx', + code: ` + export default { + name: 'MComponent', + render() { return
} + } + `, + options: [{ extensions: ['jsx'] }], + parserOptions: jsxParserOptions, + errors: [{ + message: 'Component name `MComponent` should match file name `MyComponent`.' + }] + }, + { + filename: 'MyComponent.jsx', + code: ` + export default { + name: \`MComponent\`, + render() { return
} + } + `, + options: [{ extensions: ['jsx'] }], + parserOptions: jsxParserOptions, + errors: [{ + message: 'Component name `MComponent` should match file name `MyComponent`.' + }] + }, + + // .vue + { + filename: 'MyComponent.vue', + code: ` + + `, + options: [{ extensions: ['vue'] }], + parser: 'vue-eslint-parser', + parserOptions, + errors: [{ + message: 'Component name `MComponent` should match file name `MyComponent`.' + }] + }, + { + filename: 'MyComponent.vue', + code: ` + + `, + options: [{ extensions: ['vue'] }], + parser: 'vue-eslint-parser', + parserOptions, + errors: [{ + message: 'Component name `MComponent` should match file name `MyComponent`.' + }] + }, + + // .js + { + filename: 'MyComponent.js', + code: ` + new Vue({ + name: 'MComponent', + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions, + errors: [{ + message: 'Component name `MComponent` should match file name `MyComponent`.' + }] + }, + { + filename: 'MyComponent.js', + code: ` + new Vue({ + name: \`MComponent\`, + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions, + errors: [{ + message: 'Component name `MComponent` should match file name `MyComponent`.' + }] + }, + { + filename: 'MyComponent.js', + code: ` + Vue.mixin({ + name: 'MComponent', + }) + `, + options: [{ extensions: ['js'] }], + parserOptions, + errors: [{ + message: 'Component name `MComponent` should match file name `MyComponent`.' + }] + }, + { + filename: 'MyComponent.js', + code: ` + Vue.mixin({ + name: \`MComponent\`, + }) + `, + options: [{ extensions: ['js'] }], + parserOptions, + errors: [{ + message: 'Component name `MComponent` should match file name `MyComponent`.' + }] + }, + { + filename: 'MyComponent.js', + code: ` + Vue.component('MComponent', { + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions, + errors: [{ + message: 'Component name `MComponent` should match file name `MyComponent`.' + }] + }, + { + filename: 'MyComponent.js', + code: ` + Vue.component(\`MComponent\`, { + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions, + errors: [{ + message: 'Component name `MComponent` should match file name `MyComponent`.' + }] + }, + + // casing + { + filename: 'MyComponent.jsx', + code: ` + export default { + name: 'my-component', + render() { return
} + } + `, + options: [{ shouldMatchCase: true }], + parserOptions: jsxParserOptions, + errors: [{ + message: 'Component name `my-component` should match file name `MyComponent`.' + }] + }, + { + filename: 'my-component.jsx', + code: ` + export default { + name: 'MyComponent', + render() { return
} + } + `, + options: [{ shouldMatchCase: true }], + parserOptions: jsxParserOptions, + errors: [{ + message: 'Component name `MyComponent` should match file name `my-component`.' + }] + } + ] +})