diff --git a/docs/rules/README.md b/docs/rules/README.md index 3b99cc240..5a1421472 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -286,6 +286,7 @@ For example: | [vue/no-duplicate-attr-inheritance](./no-duplicate-attr-inheritance.md) | enforce `inheritAttrs` to be set to `false` when using `v-bind="$attrs"` | | | [vue/no-empty-pattern](./no-empty-pattern.md) | disallow empty destructuring patterns | | | [vue/no-irregular-whitespace](./no-irregular-whitespace.md) | disallow irregular whitespace | | +| [vue/no-potential-component-option-typo](./no-potential-component-option-typo.md) | disallow a potential typo in your component property | | | [vue/no-reserved-component-names](./no-reserved-component-names.md) | disallow the use of reserved names in component definitions | | | [vue/no-restricted-syntax](./no-restricted-syntax.md) | disallow specified syntax | | | [vue/no-static-inline-styles](./no-static-inline-styles.md) | disallow static inline `style` attributes | | diff --git a/docs/rules/no-potential-component-option-typo.md b/docs/rules/no-potential-component-option-typo.md new file mode 100644 index 000000000..91256a7d0 --- /dev/null +++ b/docs/rules/no-potential-component-option-typo.md @@ -0,0 +1,121 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/no-potential-component-option-typo +description: disallow a potential typo in your component property +--- +# vue/no-potential-component-option-typo +> disallow a potential typo in your component property + +## :book: Rule Details + +This Rule disallow a potential typo in your component options + +**Here is the config** +```js +{'vue/no-potential-component-option-typo': ['error', {presets: ['all'], custom: ['test']}]} +``` + + + +```vue + +``` + + + +> we use editdistance to compare two string similarity, threshold is an option to control upper bound of editdistance to report + +**Here is the another example about config option `threshold`** +```js +{'vue/no-potential-component-option-typo': ['error', {presets: ['vue', 'nuxt'], threshold: 5}]} +``` + + + +```vue + +``` + + + +## :wrench: Options +```js +{ + "vue/no-unsed-vars": [{ + presets: { + type: 'array', + items: { + type: 'string', + enum: ['all', 'vue', 'vue-router', 'nuxt'] + }, + uniqueItems: true, + minItems: 0 + }, + custom: { + type: 'array', + minItems: 0, + items: { type: 'string' }, + uniqueItems: true + }, + threshold: { + type: 'number', + 'minimum': 1 + } + }] +} +``` +- `presets` ... `enum type`, contains several common vue component option set, `['all']` is the same as `['vue', 'vue-router', 'nuxt']`. **default** `[]` +- `custom` ... `array type`, a list store your custom component option want to detect. **default** `[]` +- `threshold` ... `number type`, a number used to control the upper limit of the reported editing distance, we recommend don't change this config option, even if it is required, not bigger than `2`. **default** `1` +## :rocket: Suggestion +- We provide all the possible component option that editdistance between your vue component option and configuration options is greater than 0 and lessEqual than threshold + +## :books: Further reading +- [Edit_distance](https://en.wikipedia.org/wiki/Edit_distance) +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-potential-component-option-typo.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-potential-component-option-typo.js) diff --git a/lib/index.js b/lib/index.js index ed1b93103..5573e998d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -67,6 +67,7 @@ module.exports = { 'no-multi-spaces': require('./rules/no-multi-spaces'), 'no-multiple-template-root': require('./rules/no-multiple-template-root'), 'no-parsing-error': require('./rules/no-parsing-error'), + 'no-potential-component-option-typo': require('./rules/no-potential-component-option-typo'), 'no-ref-as-operand': require('./rules/no-ref-as-operand'), 'no-reserved-component-names': require('./rules/no-reserved-component-names'), 'no-reserved-keys': require('./rules/no-reserved-keys'), diff --git a/lib/rules/no-potential-component-option-typo.js b/lib/rules/no-potential-component-option-typo.js new file mode 100644 index 000000000..13d18f57e --- /dev/null +++ b/lib/rules/no-potential-component-option-typo.js @@ -0,0 +1,108 @@ +/** + * @fileoverview detect if there is a potential typo in your component property + * @author IWANABETHATGUY + */ +'use strict' + +const utils = require('../utils') +const vueComponentOptions = require('../utils/vue-component-options.json') +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'disallow a potential typo in your component property', + categories: undefined, + recommended: false, + url: 'https://eslint.vuejs.org/rules/no-potential-component-option-typo.html' + }, + fixable: null, + schema: [ + { + type: 'object', + properties: { + presets: { + type: 'array', + items: { + type: 'string', + enum: ['all', 'vue', 'vue-router', 'nuxt'] + }, + uniqueItems: true, + minItems: 0 + }, + custom: { + type: 'array', + minItems: 0, + items: { type: 'string' }, + uniqueItems: true + }, + threshold: { + type: 'number', + 'minimum': 1 + } + } + } + ] + }, + + create: function (context) { + const option = context.options[0] || {} + const custom = option['custom'] || [] + const presets = option['presets'] || ['vue'] + const threshold = option['threshold'] || 1 + let candidateOptions + if (presets.includes('all')) { + candidateOptions = Object.keys(vueComponentOptions).reduce((pre, cur) => { + return [...pre, ...vueComponentOptions[cur]] + }, []) + } else { + candidateOptions = presets.reduce((pre, cur) => { + return [...pre, ...vueComponentOptions[cur]] + }, []) + } + const candidateOptionSet = new Set([...candidateOptions, ...custom]) + const candidateOptionList = [...candidateOptionSet] + if (!candidateOptionList.length) { + return {} + } + return utils.executeOnVue(context, obj => { + const componentInstanceOptions = obj.properties.filter( + p => p.type === 'Property' && p.key.type === 'Identifier' + ) + if (!componentInstanceOptions.length) { + return {} + } + componentInstanceOptions.forEach(option => { + const id = option.key + const name = id.name + if (candidateOptionSet.has(name)) { + return + } + const potentialTypoList = candidateOptionList + .map(o => ({ option: o, distance: utils.editDistance(o, name) })) + .filter(({ distance, option }) => distance <= threshold && distance > 0) + .sort((a, b) => a.distance - b.distance) + if (potentialTypoList.length) { + context.report({ + node: id, + loc: id.loc, + message: `'{{name}}' may be a typo, which is similar to option [{{option}}].`, + data: { + name, + option: potentialTypoList.map(({ option }) => option).join(',') + }, + suggest: potentialTypoList.map(({ option }) => ({ + desc: `Replace property '${name}' to '${option}'`, + fix (fixer) { + return fixer.replaceText(id, option) + } + })) + }) + } + }) + }) + } +} diff --git a/lib/utils/index.js b/lib/utils/index.js index 7787c1389..296acd87a 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -914,6 +914,38 @@ module.exports = { return parsedCallee.reverse().join('.').replace(/\.\[/g, '[') }, + /** + * return two string editdistance + * @param {string} a string a to compare + * @param {string} b string b to compare + * @returns {number} + */ + editDistance (a, b) { + if (a === b) { + return 0 + } + const alen = a.length + const blen = b.length + const dp = Array.from({ length: alen + 1 }).map(_ => + Array.from({ length: blen + 1 }).fill(0) + ) + for (let i = 0; i <= alen; i++) { + dp[i][0] = i + } + for (let j = 0; j <= blen; j++) { + dp[0][j] = j + } + for (let i = 1; i <= alen; i++) { + for (let j = 1; j <= blen; j++) { + if (a[i - 1] === b[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + } else { + dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1 + } + } + } + return dp[alen][blen] + }, /** * Unwrap typescript types like "X as F" * @template T diff --git a/lib/utils/vue-component-options.json b/lib/utils/vue-component-options.json new file mode 100644 index 000000000..643e1299e --- /dev/null +++ b/lib/utils/vue-component-options.json @@ -0,0 +1,47 @@ +{ + "nuxt": ["asyncData", "fetch", "head", "key", "layout", "loading", "middleware", "scrollToTop", "transition", "validate", "watchQuery"], + "vue-router": [ + "beforeRouteEnter", + "beforeRouteUpdate", + "beforeRouteLeave" + ], + "vue": [ + "data", + "props", + "propsData", + "computed", + "methods", + "watch", + "el", + "template", + "render", + "renderError", + "staticRenderFns", + "beforeCreate", + "created", + "beforeDestroy", + "destroyed", + "beforeMount", + "mounted", + "beforeUpdate", + "updated", + "activated", + "deactivated", + "errorCaptured", + "serverPrefetch", + "directives", + "components", + "transitions", + "filters", + "provide", + "inject", + "model", + "parent", + "mixins", + "name", + "extends", + "delimiters", + "comments", + "inheritAttrs" + ] +} \ No newline at end of file diff --git a/tests/lib/rules/no-potential-component-option-typo.js b/tests/lib/rules/no-potential-component-option-typo.js new file mode 100644 index 000000000..12f8ed04e --- /dev/null +++ b/tests/lib/rules/no-potential-component-option-typo.js @@ -0,0 +1,452 @@ +/** + * @fileoverview detect if there is a potential typo in your component property + * @author IWANABETHATGUY + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/no-potential-component-option-typo') + +const RuleTester = require('eslint').RuleTester + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const tester = new RuleTester({ + parser: require.resolve('vue-eslint-parser'), + parserOptions: { ecmaVersion: 2018, sourceType: 'module' } +}) + +tester.run('no-potential-component-option-typo', rule, { + valid: [ + { + filename: 'test.vue', + code: ` + + `, + // because vue preset is include by default, set the presets to empty + options: [{ presets: [] }] + }, + { + filename: 'test.vue', + code: ` + + `, + options: [{ presets: ['vue'] }] + }, + // test if give preset and the potentialTypoList length is zero, just for 100% test cover + { + filename: 'test.vue', + code: ` + + `, + options: [{ presets: ['vue'] }] + }, + // multi preset that won't report + { + filename: 'test.vue', + code: ` + + `, + options: [{ presets: ['vue', 'vue-router'] }] + }, + // test custom option that is not available in the presets + { + filename: 'test.vue', + code: ` + + `, + options: [{ custom: ['custom', 'foo'] }] + }, + { + filename: 'test.vue', + code: ` + + `, + options: [{ custom: ['abcde', 'abcd'] }] + }, + // valid test case set custom and threshold + { + filename: 'test.vue', + code: ` + + `, + options: [{ custom: ['custom', 'foo'], threshold: 2 }] + }, + // test all valid vue options + { + filename: 'test.vue', + code: ` + + `, + options: [{ presets: ['all'] }] + } + ], + invalid: [ + { + filename: 'test.vue', + code: ` + `, + errors: [ + { + message: "'dat' may be a typo, which is similar to option [data].", + line: 4, + column: 9, + suggestions: [ + { + desc: `Replace property 'dat' to 'data'`, + output: ` + ` + } + ] + }, + { + message: `'method' may be a typo, which is similar to option [methods].`, + line: 5, + column: 9, + suggestions: [ + { + desc: `Replace property 'method' to 'methods'`, + output: ` + ` + } + ] + } + ], + options: [{ custom: ['data', 'methods'] }] + }, + // test if user define custom rule is duplicate with presets + // test custom option that is not available in the presets + { + filename: 'test.vue', + code: ` + `, + errors: [ + { + message: "'dat' may be a typo, which is similar to option [data].", + line: 4, + column: 9, + suggestions: [ + { + desc: `Replace property 'dat' to 'data'`, + output: ` + ` + } + ] + }, + { + message: `'method' may be a typo, which is similar to option [methods].`, + line: 5, + column: 9, + suggestions: [ + { + desc: `Replace property 'method' to 'methods'`, + output: ` + ` + } + ] + }, + { + message: `'custo' may be a typo, which is similar to option [custom].`, + line: 6, + column: 9, + suggestions: [ + { + desc: `Replace property 'custo' to 'custom'`, + output: ` + ` + } + ] + } + ], + options: [ + { custom: ['data', 'methods', 'custom', 'foo'], presets: ['all'] } + ] + }, + // test if report correctly, only have preset option + { + filename: 'test.vue', + code: ` + `, + errors: [ + { + message: "'dat' may be a typo, which is similar to option [data].", + line: 4, + column: 9, + suggestions: [ + { + desc: `Replace property 'dat' to 'data'`, + output: ` + ` + } + ] + }, + { + message: `'method' may be a typo, which is similar to option [methods].`, + line: 5, + column: 9, + suggestions: [ + { + desc: `Replace property 'method' to 'methods'`, + output: ` + ` + } + ] + } + ], + options: [{ presets: ['vue'] }] + }, + // multi preset report typo + { + filename: 'test.vue', + code: ` + `, + errors: [ + { + message: "'dat' may be a typo, which is similar to option [data].", + line: 4, + column: 9, + suggestions: [ + { + desc: `Replace property 'dat' to 'data'`, + output: ` + ` + } + ] + }, + { + message: + "'beforeRouteEntr' may be a typo, which is similar to option [beforeRouteEnter].", + line: 5, + column: 9, + suggestions: [ + { + desc: `Replace property 'beforeRouteEntr' to 'beforeRouteEnter'`, + output: ` + ` + } + ] + }, + { + message: `'method' may be a typo, which is similar to option [methods].`, + line: 6, + column: 9, + suggestions: [ + { + desc: `Replace property 'method' to 'methods'`, + output: ` + ` + } + ] + } + ], + options: [{ presets: ['vue', 'vue-router'] }] + }, + // test multi suggestion + { + filename: 'test.vue', + code: ` + `, + errors: [ + { + message: `'method' may be a typo, which is similar to option [methods,data].`, + line: 4, + column: 9, + suggestions: [ + { + desc: `Replace property 'method' to 'methods'`, + output: ` + ` + }, + { + desc: `Replace property 'method' to 'data'`, + output: ` + ` + } + ] + } + ], + options: [{ custom: ['data', 'methods'], threshold: 10, presets: [] }] + } + ] +}) diff --git a/tests/lib/utils/index.js b/tests/lib/utils/index.js index f698a98ef..219c94d35 100644 --- a/tests/lib/utils/index.js +++ b/tests/lib/utils/index.js @@ -367,3 +367,17 @@ describe('getComponentProps', () => { assert.notOk(props[3].value) }) }) + +describe('editdistance', () => { + const editDistance = utils.editDistance + it('should return editDistance beteen two string', () => { + assert.equal(editDistance('book', 'back'), 2) + assert.equal(editDistance('methods', 'metho'), 2) + assert.equal(editDistance('methods', 'metds'), 2) + assert.equal(editDistance('computed', 'comput'), 2) + assert.equal(editDistance('book', 'back'), 2) + assert.equal(editDistance('methods', 'method'), 1) + assert.equal(editDistance('methods', 'methds'), 1) + assert.equal(editDistance('computed', 'computd'), 1) + }) +})