diff --git a/docs/rules/README.md b/docs/rules/README.md index a242514bd..68677d252 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -302,6 +302,7 @@ For example: | [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-component-options](./no-restricted-component-options.md) | disallow specific component option | | +| [vue/no-restricted-custom-event](./no-restricted-custom-event.md) | disallow specific custom event | | | [vue/no-restricted-props](./no-restricted-props.md) | disallow specific props | | | [vue/no-restricted-static-attribute](./no-restricted-static-attribute.md) | disallow specific attribute | | | [vue/no-restricted-v-bind](./no-restricted-v-bind.md) | disallow specific argument in `v-bind` | | diff --git a/docs/rules/no-restricted-custom-event.md b/docs/rules/no-restricted-custom-event.md new file mode 100644 index 000000000..73cf09679 --- /dev/null +++ b/docs/rules/no-restricted-custom-event.md @@ -0,0 +1,97 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/no-restricted-custom-event +description: disallow specific custom event +--- +# vue/no-restricted-custom-event +> disallow specific custom event + +## :book: Rule Details + +This rule allows you to specify custom event that you don't want to use in your application. + +## :wrench: Options + +This rule takes a list of strings, where each string is a custom event name or pattern to be restricted: + +```json +{ + "vue/no-restricted-custom-event": ["error", "input", "/^forbidden/"] +} +``` + + + +```vue + + +``` + + + + +Alternatively, the rule also accepts objects. + +```json +{ + "vue/no-restricted-custom-event": ["error", + { + "event": "input", + "message": "If you intend a prop for v-model, it should be 'update:modelValue' in Vue 3.", + "suggest": "update:modelValue" + }, + ] +} +``` + +The following properties can be specified for the object. + +- `event` ... Specify the event name or pattern. +- `message` ... Specify an optional custom message. +- `suggest` ... Specify an optional name to suggest changes. + + + +```vue + + +``` + + + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-restricted-custom-event.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-restricted-custom-event.js) diff --git a/lib/index.js b/lib/index.js index 9c396cb6b..95ffd1dd6 100644 --- a/lib/index.js +++ b/lib/index.js @@ -92,6 +92,7 @@ module.exports = { 'no-reserved-component-names': require('./rules/no-reserved-component-names'), 'no-reserved-keys': require('./rules/no-reserved-keys'), 'no-restricted-component-options': require('./rules/no-restricted-component-options'), + 'no-restricted-custom-event': require('./rules/no-restricted-custom-event'), 'no-restricted-props': require('./rules/no-restricted-props'), 'no-restricted-static-attribute': require('./rules/no-restricted-static-attribute'), 'no-restricted-syntax': require('./rules/no-restricted-syntax'), diff --git a/lib/rules/no-restricted-custom-event.js b/lib/rules/no-restricted-custom-event.js new file mode 100644 index 000000000..72835ddd0 --- /dev/null +++ b/lib/rules/no-restricted-custom-event.js @@ -0,0 +1,293 @@ +/** + * @author Yosuke Ota + * See LICENSE file in root directory for full license. + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const { findVariable } = require('eslint-utils') +const utils = require('../utils') +const regexp = require('../utils/regexp') + +// ------------------------------------------------------------------------------ +// Helpers +// ------------------------------------------------------------------------------ + +/** + * @typedef {object} ParsedOption + * @property { (name: string) => boolean } test + * @property {string} [message] + * @property {string} [suggest] + */ + +/** + * @param {string} str + * @returns {(str: string) => boolean} + */ +function buildMatcher(str) { + if (regexp.isRegExp(str)) { + const re = regexp.toRegExp(str) + return (s) => { + re.lastIndex = 0 + return re.test(s) + } + } + return (s) => s === str +} +/** + * @param {string|{event: string, message?: string, suggest?: string}} option + * @returns {ParsedOption} + */ +function parseOption(option) { + if (typeof option === 'string') { + const matcher = buildMatcher(option) + return { + test(name) { + return matcher(name) + } + } + } + const parsed = parseOption(option.event) + parsed.message = option.message + parsed.suggest = option.suggest + return parsed +} + +/** + * Get the name param node from the given CallExpression + * @param {CallExpression} node CallExpression + * @returns { Literal & { value: string } | null } + */ +function getNameParamNode(node) { + const nameLiteralNode = node.arguments[0] + if ( + !nameLiteralNode || + nameLiteralNode.type !== 'Literal' || + typeof nameLiteralNode.value !== 'string' + ) { + // cannot check + return null + } + + return /** @type {Literal & { value: string }} */ (nameLiteralNode) +} +/** + * Get the callee member node from the given CallExpression + * @param {CallExpression} node CallExpression + */ +function getCalleeMemberNode(node) { + const callee = utils.skipChainExpression(node.callee) + + if (callee.type === 'MemberExpression') { + const name = utils.getStaticPropertyName(callee) + if (name) { + return { name, member: callee } + } + } + return null +} +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'disallow specific custom event', + categories: undefined, + url: 'https://eslint.vuejs.org/rules/no-restricted-custom-event.html' + }, + fixable: null, + schema: { + type: 'array', + items: { + oneOf: [ + { type: ['string'] }, + { + type: 'object', + properties: { + event: { type: 'string' }, + message: { type: 'string', minLength: 1 }, + suggest: { type: 'string' } + }, + required: ['event'], + additionalProperties: false + } + ] + }, + uniqueItems: true, + minItems: 0 + }, + + messages: { + // eslint-disable-next-line eslint-plugin/report-message-format + restrictedEvent: '{{message}}', + instead: 'Instead, change to `{{suggest}}`.' + } + }, + /** @param {RuleContext} context */ + create(context) { + /** @type {Map,emitReferenceIds:Set}>} */ + const setupContexts = new Map() + /** @type {ParsedOption[]} */ + const options = context.options.map(parseOption) + + /** + * @param { Literal & { value: string } } nameLiteralNode + */ + function verify(nameLiteralNode) { + const name = nameLiteralNode.value + + for (const option of options) { + if (option.test(name)) { + const message = + option.message || `Using \`${name}\` event is not allowed.` + context.report({ + node: nameLiteralNode, + messageId: 'restrictedEvent', + data: { message }, + suggest: option.suggest + ? [ + { + fix(fixer) { + const sourceCode = context.getSourceCode() + return fixer.replaceText( + nameLiteralNode, + `${ + sourceCode.text[nameLiteralNode.range[0]] + }${JSON.stringify(option.suggest) + .slice(1, -1) + .replace(/'/gu, "\\'")}${ + sourceCode.text[nameLiteralNode.range[1] - 1] + }` + ) + }, + messageId: 'instead', + data: { suggest: option.suggest } + } + ] + : [] + }) + break + } + } + } + + return utils.defineTemplateBodyVisitor( + context, + { + CallExpression(node) { + const callee = node.callee + const nameLiteralNode = getNameParamNode(node) + if (!nameLiteralNode) { + // cannot check + return + } + if (callee.type === 'Identifier' && callee.name === '$emit') { + verify(nameLiteralNode) + } + } + }, + utils.compositingVisitors( + utils.defineVueVisitor(context, { + onSetupFunctionEnter(node, { node: vueNode }) { + const contextParam = utils.skipDefaultParamValue(node.params[1]) + if (!contextParam) { + // no arguments + return + } + if ( + contextParam.type === 'RestElement' || + contextParam.type === 'ArrayPattern' + ) { + // cannot check + return + } + const contextReferenceIds = new Set() + const emitReferenceIds = new Set() + if (contextParam.type === 'ObjectPattern') { + const emitProperty = utils.findAssignmentProperty( + contextParam, + 'emit' + ) + if (!emitProperty || emitProperty.value.type !== 'Identifier') { + return + } + const emitParam = emitProperty.value + // `setup(props, {emit})` + const variable = findVariable(context.getScope(), emitParam) + if (!variable) { + return + } + for (const reference of variable.references) { + emitReferenceIds.add(reference.identifier) + } + } else { + // `setup(props, context)` + const variable = findVariable(context.getScope(), contextParam) + if (!variable) { + return + } + for (const reference of variable.references) { + contextReferenceIds.add(reference.identifier) + } + } + setupContexts.set(vueNode, { + contextReferenceIds, + emitReferenceIds + }) + }, + CallExpression(node, { node: vueNode }) { + const nameLiteralNode = getNameParamNode(node) + if (!nameLiteralNode) { + // cannot check + return + } + + // verify setup context + const setupContext = setupContexts.get(vueNode) + if (setupContext) { + const { contextReferenceIds, emitReferenceIds } = setupContext + if ( + node.callee.type === 'Identifier' && + emitReferenceIds.has(node.callee) + ) { + // verify setup(props,{emit}) {emit()} + verify(nameLiteralNode) + } else { + const emit = getCalleeMemberNode(node) + if ( + emit && + emit.name === 'emit' && + emit.member.object.type === 'Identifier' && + contextReferenceIds.has(emit.member.object) + ) { + // verify setup(props,context) {context.emit()} + verify(nameLiteralNode) + } + } + } + }, + onVueObjectExit(node) { + setupContexts.delete(node) + } + }), + { + CallExpression(node) { + const nameLiteralNode = getNameParamNode(node) + if (!nameLiteralNode) { + // cannot check + return + } + const emit = getCalleeMemberNode(node) + // verify $emit + if (emit && emit.name === '$emit') { + // verify this.$emit() + verify(nameLiteralNode) + } + } + } + ) + ) + } +} diff --git a/tests/lib/rules/no-restricted-custom-event.js b/tests/lib/rules/no-restricted-custom-event.js new file mode 100644 index 000000000..447f8122c --- /dev/null +++ b/tests/lib/rules/no-restricted-custom-event.js @@ -0,0 +1,296 @@ +/** + * @author Yosuke Ota + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const RuleTester = require('eslint').RuleTester +const rule = require('../../../lib/rules/no-restricted-custom-event') + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const tester = new RuleTester({ + parser: require.resolve('vue-eslint-parser'), + parserOptions: { ecmaVersion: 2020, sourceType: 'module' } +}) + +tester.run('no-restricted-custom-event', rule, { + valid: [ + { + filename: 'test.vue', + code: ` + + + ` + }, + { + filename: 'test.vue', + code: ` + + + `, + options: ['bad'] + }, + { + filename: 'test.vue', + code: ` + + + `, + options: ['ignore', '0'] + }, + { + filename: 'test.vue', + code: ` + + `, + options: ['ignore', '0'] + }, + { + filename: 'test.vue', + code: ` + + `, + options: ['ignore', '0'] + }, + { + filename: 'test.vue', + code: ` + + `, + options: ['ignore', '0'] + }, + { + filename: 'test.vue', + code: ` + + `, + options: ['ignore', '0'] + } + ], + invalid: [ + { + filename: 'test.vue', + code: ` + + `, + options: ['bad'], + errors: [ + { + message: 'Using `bad` event is not allowed.', + line: 7, + column: 24 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + options: [ + { event: 'foo' }, + { event: 'bar', message: 'Use Bar instead', suggest: 'Bar' } + ], + errors: [ + { + message: 'Using `foo` event is not allowed.', + line: 6 + }, + { + message: 'Use Bar instead', + line: 7, + suggestions: [ + { + desc: 'Instead, change to `Bar`.', + output: ` + + ` + } + ] + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + options: ['/^regexp/'], + errors: [ + { + message: 'Using `regexp1` event is not allowed.', + line: 6 + }, + { + message: 'Using `regexp2` event is not allowed.', + line: 7 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + options: [{ event: 'bad', suggest: "foo'" }], + errors: [ + { + message: 'Using `bad` event is not allowed.', + line: 3, + suggestions: [ + { + desc: "Instead, change to `foo'`.", + output: ` + + ` + } + ] + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + options: ['foo'], + errors: [ + { + message: 'Using `foo` event is not allowed.', + line: 5 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + options: ['foo'], + errors: [ + { + message: 'Using `foo` event is not allowed.', + line: 5 + } + ] + } + ] +})