From a634e3cf1e31c96400d19233cdab4732ee099701 Mon Sep 17 00:00:00 2001 From: Yosuke Ota Date: Sat, 14 Mar 2020 17:09:10 +0900 Subject: [PATCH] Updated to detect Vue3 components. (#1073) --- docs/user-guide/README.md | 3 + lib/utils/index.js | 162 +++++++++++------- .../rules/component-definition-name-casing.js | 34 ++++ tests/lib/rules/match-component-file-name.js | 23 +++ .../lib/rules/no-reserved-component-names.js | 29 ++++ tests/lib/rules/no-shared-component-data.js | 24 +++ .../no-side-effects-in-computed-properties.js | 19 ++ tests/lib/rules/order-in-components.js | 32 ++++ tests/lib/rules/require-render-return.js | 19 +- tests/lib/utils/vue-component.js | 31 ++++ 10 files changed, 312 insertions(+), 64 deletions(-) diff --git a/docs/user-guide/README.md b/docs/user-guide/README.md index 01710c07a..1c4d0e38d 100644 --- a/docs/user-guide/README.md +++ b/docs/user-guide/README.md @@ -85,6 +85,9 @@ All component-related rules are applied to code that passes any of the following * `Vue.component()` expression * `Vue.extend()` expression * `Vue.mixin()` expression +* `app.component()` expression +* `app.mixin()` expression +* `createApp()` expression * `export default {}` in `.vue` or `.jsx` file However, if you want to take advantage of the rules in any of your custom objects that are Vue components, you might need to use the special comment `// @vue/component` that marks an object in the next line as a Vue component in any file, e.g.: diff --git a/lib/utils/index.js b/lib/utils/index.js index 011ec6571..d0c918299 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -264,7 +264,7 @@ module.exports = { return componentsNode.value.properties .filter(p => p.type === 'Property') .map(node => { - const name = this.getStaticPropertyName(node) + const name = getStaticPropertyName(node) return name ? { node, name } : null }) .filter(comp => comp != null) @@ -402,42 +402,7 @@ module.exports = { * @param {Property|MethodDefinition|MemberExpression|Literal|TemplateLiteral|Identifier} 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 - }, + getStaticPropertyName, /** * Get all props by looking at all component's properties @@ -464,8 +429,8 @@ module.exports = { .filter(prop => prop.type === 'Property') .map(prop => { return { - key: prop.key, value: this.unwrapTypes(prop.value), node: prop, - propName: this.getStaticPropertyName(prop) + key: prop.key, value: unwrapTypes(prop.value), node: prop, + propName: getStaticPropertyName(prop) } }) } else { @@ -548,28 +513,52 @@ module.exports = { const callee = node.callee if (callee.type === 'MemberExpression') { - const calleeObject = this.unwrapTypes(callee.object) + const calleeObject = unwrapTypes(callee.object) + + if (calleeObject.type === 'Identifier') { + const propName = getStaticPropertyName(callee.property) + if (calleeObject.name === 'Vue') { + // for Vue.js 2.x + // Vue.component('xxx', {}) || Vue.mixin({}) || Vue.extend('xxx', {}) + const isFullVueComponentForVue2 = + ['component', 'mixin', 'extend'].includes(propName) && + isObjectArgument(node) + + return isFullVueComponentForVue2 + } - 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' + // for Vue.js 3.x + // app.component('xxx', {}) || app.mixin({}) + const isFullVueComponent = + ['component', 'mixin'].includes(propName) && + isObjectArgument(node) - return isFullVueComponent + return isFullVueComponent + } } if (callee.type === 'Identifier') { - const isDestructedVueComponent = callee.name === 'component' && - node.arguments.length >= 1 && - node.arguments.slice(-1)[0].type === 'ObjectExpression' - - return isDestructedVueComponent + if (callee.name === 'component') { + // for Vue.js 2.x + // component('xxx', {}) + const isDestructedVueComponent = isObjectArgument(node) + return isDestructedVueComponent + } + if (callee.name === 'createApp') { + // for Vue.js 3.x + // createApp({}) + const isAppVueComponent = isObjectArgument(node) + return isAppVueComponent + } } } return false + + function isObjectArgument (node) { + return node.arguments.length > 0 && + unwrapTypes(node.arguments.slice(-1)[0]).type === 'ObjectExpression' + } }, /** @@ -584,7 +573,7 @@ module.exports = { callee.type === 'Identifier' && callee.name === 'Vue' && node.arguments.length && - node.arguments[0].type === 'ObjectExpression' + unwrapTypes(node.arguments[0]).type === 'ObjectExpression' }, /** @@ -647,7 +636,7 @@ module.exports = { '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]) + cb(unwrapTypes(node.arguments.slice(-1)[0])) } } }, @@ -664,10 +653,10 @@ module.exports = { const callee = callExpr.callee if (callee.type === 'MemberExpression') { - const calleeObject = this.unwrapTypes(callee.object) + const calleeObject = unwrapTypes(callee.object) if (calleeObject.type === 'Identifier' && - calleeObject.name === 'Vue' && + // calleeObject.name === 'Vue' && // Any names can be used in Vue.js 3.x. e.g. app.component() callee.property === node && callExpr.arguments.length >= 1) { cb(callExpr) @@ -682,9 +671,9 @@ module.exports = { * @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))) + const nodes = node.properties.filter(p => p.type === 'Property' && groups.has(getStaticPropertyName(p.key))) for (const item of nodes) { - const name = this.getStaticPropertyName(item.key) + const name = getStaticPropertyName(item.key) if (!name) continue if (item.value.type === 'ArrayExpression') { @@ -705,7 +694,7 @@ module.exports = { * iterateArrayExpression (node, groupName) { assert(node.type === 'ArrayExpression') for (const item of node.elements) { - const name = this.getStaticPropertyName(item) + const name = getStaticPropertyName(item) if (name) { const obj = { name, groupName, node: item } yield obj @@ -721,7 +710,7 @@ module.exports = { * iterateObjectExpression (node, groupName) { assert(node.type === 'ObjectExpression') for (const item of node.properties) { - const name = this.getStaticPropertyName(item) + const name = getStaticPropertyName(item) if (name) { const obj = { name, groupName, node: item.key } yield obj @@ -865,7 +854,56 @@ module.exports = { * @param {T} node * @return {T} */ - unwrapTypes (node) { - return node.type === 'TSAsExpression' ? node.expression : node + unwrapTypes +} +/** +* Unwrap typescript types like "X as F" +* @template T +* @param {T} node +* @return {T} +*/ +function unwrapTypes (node) { + return node.type === 'TSAsExpression' ? node.expression : node +} + +/** + * Gets the property name of a given node. + * @param {Property|MethodDefinition|MemberExpression|Literal|TemplateLiteral|Identifier} node - The node to get. + * @return {string|null} The property name if static. Otherwise, null. + */ +function 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 } diff --git a/tests/lib/rules/component-definition-name-casing.js b/tests/lib/rules/component-definition-name-casing.js index 98cb43679..d14eedb7a 100644 --- a/tests/lib/rules/component-definition-name-casing.js +++ b/tests/lib/rules/component-definition-name-casing.js @@ -116,6 +116,12 @@ ruleTester.run('component-definition-name-casing', rule, { options: ['kebab-case'], parserOptions }, + { + filename: 'test.vue', + code: `app.component('FooBar', component)`, + options: ['PascalCase'], + parserOptions + }, { filename: 'test.vue', code: `Vue.mixin({})`, @@ -137,6 +143,12 @@ ruleTester.run('component-definition-name-casing', rule, { options: ['kebab-case'], parserOptions }, + { + filename: 'test.vue', + code: `app.component(\`fooBar\${foo}\`, component)`, + options: ['kebab-case'], + parserOptions + }, // https://github.com/vuejs/eslint-plugin-vue/issues/1018 { filename: 'test.js', @@ -292,6 +304,17 @@ ruleTester.run('component-definition-name-casing', rule, { line: 1 }] }, + { + filename: 'test.vue', + code: `app.component('foo-bar', component)`, + output: `app.component('FooBar', component)`, + parserOptions, + errors: [{ + message: 'Property name "foo-bar" is not PascalCase.', + type: 'Literal', + line: 1 + }] + }, { filename: 'test.vue', code: `(Vue as VueConstructor).component('foo-bar', component)`, @@ -315,6 +338,17 @@ ruleTester.run('component-definition-name-casing', rule, { line: 1 }] }, + { + filename: 'test.vue', + code: `app.component('foo-bar', {})`, + output: `app.component('FooBar', {})`, + parserOptions, + errors: [{ + message: 'Property name "foo-bar" is not PascalCase.', + type: 'Literal', + line: 1 + }] + }, { filename: 'test.js', code: `Vue.component('foo_bar', {})`, diff --git a/tests/lib/rules/match-component-file-name.js b/tests/lib/rules/match-component-file-name.js index d8a1b141d..a97c5941e 100644 --- a/tests/lib/rules/match-component-file-name.js +++ b/tests/lib/rules/match-component-file-name.js @@ -429,6 +429,16 @@ ruleTester.run('match-component-file-name', rule, { options: [{ extensions: ['js'] }], parserOptions }, + { + filename: 'MyComponent.js', + code: ` + app.component('MyComponent', { + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions + }, { filename: 'MyComponent.js', code: ` @@ -701,6 +711,19 @@ ruleTester.run('match-component-file-name', rule, { message: 'Component name `MComponent` should match file name `MyComponent`.' }] }, + { + filename: 'MyComponent.js', + code: ` + app.component(\`MComponent\`, { + template: '
' + }) + `, + options: [{ extensions: ['js'] }], + parserOptions, + errors: [{ + message: 'Component name `MComponent` should match file name `MyComponent`.' + }] + }, // casing { diff --git a/tests/lib/rules/no-reserved-component-names.js b/tests/lib/rules/no-reserved-component-names.js index 43a34f153..0585b5c92 100644 --- a/tests/lib/rules/no-reserved-component-names.js +++ b/tests/lib/rules/no-reserved-component-names.js @@ -274,6 +274,11 @@ ruleTester.run('no-reserved-component-names', rule, { code: `Vue.component('FooBar', {})`, parserOptions }, + { + filename: 'test.vue', + code: `app.component('FooBar', {})`, + parserOptions + }, { filename: 'test.js', code: ` @@ -349,6 +354,18 @@ ruleTester.run('no-reserved-component-names', rule, { }] } }), + ...invalidElements.map(name => { + return { + filename: 'test.vue', + code: `app.component('${name}', component)`, + parserOptions, + errors: [{ + message: `Name "${name}" is reserved.`, + type: 'Literal', + line: 1 + }] + } + }), ...invalidElements.map(name => { return { filename: 'test.vue', @@ -361,6 +378,18 @@ ruleTester.run('no-reserved-component-names', rule, { }] } }), + ...invalidElements.map(name => { + return { + filename: 'test.vue', + code: `app.component(\`${name}\`, {})`, + parserOptions, + errors: [{ + message: `Name "${name}" is reserved.`, + type: 'TemplateLiteral', + line: 1 + }] + } + }), ...invalidElements.map(name => { return { filename: 'test.vue', diff --git a/tests/lib/rules/no-shared-component-data.js b/tests/lib/rules/no-shared-component-data.js index ce0e0062b..ade9e9efe 100644 --- a/tests/lib/rules/no-shared-component-data.js +++ b/tests/lib/rules/no-shared-component-data.js @@ -135,6 +135,30 @@ ruleTester.run('no-shared-component-data', rule, { return { foo: 'bar' }; +} + }) + `, + parserOptions, + errors: [{ + message: '`data` property in component must be a function.', + line: 3 + }] + }, + { + filename: 'test.js', + code: ` + app.component('some-comp', { + data: { + foo: 'bar' + } + }) + `, + output: ` + app.component('some-comp', { + data: function() { +return { + foo: 'bar' + }; } }) `, diff --git a/tests/lib/rules/no-side-effects-in-computed-properties.js b/tests/lib/rules/no-side-effects-in-computed-properties.js index df186b3a9..fa14ef248 100644 --- a/tests/lib/rules/no-side-effects-in-computed-properties.js +++ b/tests/lib/rules/no-side-effects-in-computed-properties.js @@ -296,6 +296,25 @@ ruleTester.run('no-side-effects-in-computed-properties', rule, { message: 'Unexpected side effect in "test1" computed property.' }], parser: require.resolve('@typescript-eslint/parser') + }, + + { + code: `app.component('test', { + computed: { + test1() { + this.firstName = 'lorem' + asd.qwe.zxc = 'lorem' + return this.firstName + ' ' + this.lastName + }, + } + })`, + parserOptions, + errors: [ + { + line: 4, + message: 'Unexpected side effect in "test1" computed property.' + } + ] } ] }) diff --git a/tests/lib/rules/order-in-components.js b/tests/lib/rules/order-in-components.js index b022508fa..f94b4ec67 100644 --- a/tests/lib/rules/order-in-components.js +++ b/tests/lib/rules/order-in-components.js @@ -243,6 +243,38 @@ ruleTester.run('order-in-components', rule, { line: 9 }] }, + { + filename: 'test.js', + code: ` + app.component('smart-list', { + name: 'app', + data () { + return { + msg: 'Welcome to Your Vue.js App' + } + }, + components: {}, + template: '
' + }) + `, + parserOptions: { ecmaVersion: 6 }, + output: ` + app.component('smart-list', { + name: 'app', + components: {}, + data () { + return { + msg: 'Welcome to Your Vue.js App' + } + }, + template: '
' + }) + `, + errors: [{ + message: 'The "components" property should be above the "data" property on line 4.', + line: 9 + }] + }, { filename: 'test.js', code: ` diff --git a/tests/lib/rules/require-render-return.js b/tests/lib/rules/require-render-return.js index e7fde4e60..580a6b89e 100644 --- a/tests/lib/rules/require-render-return.js +++ b/tests/lib/rules/require-render-return.js @@ -86,7 +86,7 @@ ruleTester.run('require-render-return', rule, { render() { if (a) { if (b) { - + } if (c) { return true @@ -180,6 +180,21 @@ ruleTester.run('require-render-return', rule, { line: 2 }] }, + { + code: `app.component('test', { + render: function () { + if (a) { + return + } + } + })`, + parserOptions, + errors: [{ + message: 'Expected to return a value in render function.', + type: 'Identifier', + line: 2 + }] + }, { code: `Vue.component('test2', { render: function () { @@ -199,7 +214,7 @@ ruleTester.run('require-render-return', rule, { code: `Vue.component('test2', { render: function () { if (a) { - + } else { return h('div', 'hello') } diff --git a/tests/lib/utils/vue-component.js b/tests/lib/utils/vue-component.js index db5d97337..69d663e92 100644 --- a/tests/lib/utils/vue-component.js +++ b/tests/lib/utils/vue-component.js @@ -108,6 +108,12 @@ function validTests (ext) { code: `export default Foo.extend({})`, parser: require.resolve('@typescript-eslint/parser'), parserOptions + }, + { + filename: `test.${ext}`, + code: `export default Foo.extend({} as ComponentOptions)`, + parser: require.resolve('@typescript-eslint/parser'), + parserOptions } ] } @@ -144,6 +150,18 @@ function invalidTests (ext) { parserOptions, errors: [makeError(1)] }, + { + filename: `test.${ext}`, + code: `app.component('name', {})`, + parserOptions, + errors: [makeError(1)] + }, + { + filename: `test.${ext}`, + code: `app.mixin({})`, + parserOptions, + errors: [makeError(1)] + }, { filename: `test.${ext}`, code: `export default (Vue as VueConstructor).extend({})`, @@ -158,6 +176,19 @@ function invalidTests (ext) { parserOptions, errors: [makeError(1)] }, + { + filename: `test.${ext}`, + code: `export default Vue.extend({} as ComponentOptions)`, + parser: require.resolve('@typescript-eslint/parser'), + parserOptions, + errors: [makeError(1)] + }, + { + filename: `test.${ext}`, + code: `createApp({})`, + parserOptions, + errors: [makeError(1)] + }, { filename: `test.${ext}`, code: `