Skip to content

Add allowProps option to vue/require-explicit-emits rule. #1259

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 31, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion docs/rules/require-explicit-emits.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,37 @@ export default {

## :wrench: Options

Nothing.
```json
{
"vue/require-explicit-emits": ["error", {
"allowProps": false
}]
}
```

- `"allowProps"` ... If `true`, allow event names defined in `props`. default `false`

### `"allowProps": true`

<eslint-code-block fix :rules="{'vue/require-explicit-emits': ['error', {allowProps: true}]}">

```vue
<script>
export default {
props: ['onGood', 'bad'],
methods: {
foo () {
// ✓ GOOD
this.$emit('good')
// ✗ BAD
this.$emit('bad')
}
}
}
</script>
```

</eslint-code-block>

## :books: Further Reading

Expand Down
10 changes: 3 additions & 7 deletions lib/rules/no-reserved-component-names.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,10 @@ const vue3BuiltInComponents = ['teleport', 'suspense']
function isLowercase(word) {
return /^[a-z]*$/.test(word)
}
/** @param {string} word */
function capitalizeFirstLetter(word) {
return word[0].toUpperCase() + word.substring(1, word.length)
}

const RESERVED_NAMES_IN_HTML = new Set([
...htmlElements,
...htmlElements.map(capitalizeFirstLetter)
...htmlElements.map(casing.capitalize)
])
const RESERVED_NAMES_IN_VUE = new Set([
...vueBuiltInComponents,
Expand All @@ -57,11 +53,11 @@ const RESERVED_NAMES_IN_VUE3 = new Set([
])
const RESERVED_NAMES_IN_OTHERS = new Set([
...deprecatedHtmlElements,
...deprecatedHtmlElements.map(capitalizeFirstLetter),
...deprecatedHtmlElements.map(casing.capitalize),
...kebabCaseElements,
...kebabCaseElements.map(casing.pascalCase),
...svgElements,
...svgElements.filter(isLowercase).map(capitalizeFirstLetter)
...svgElements.filter(isLowercase).map(casing.capitalize)
])

// ------------------------------------------------------------------------------
Expand Down
131 changes: 74 additions & 57 deletions lib/rules/require-explicit-emits.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
/**
* @typedef {import('../utils').ComponentArrayEmit} ComponentArrayEmit
* @typedef {import('../utils').ComponentObjectEmit} ComponentObjectEmit
* @typedef {import('../utils').ComponentArrayProp} ComponentArrayProp
* @typedef {import('../utils').ComponentObjectProp} ComponentObjectProp
* @typedef {import('../utils').VueObjectData} VueObjectData
*/

Expand All @@ -16,6 +18,7 @@

const { findVariable } = require('eslint-utils')
const utils = require('../utils')
const { capitalize } = require('../utils/casing')

// ------------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -89,7 +92,17 @@ module.exports = {
url: 'https://eslint.vuejs.org/rules/require-explicit-emits.html'
},
fixable: null,
schema: [],
schema: [
{
type: 'object',
properties: {
allowProps: {
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
missing:
'The "{{name}}" event has been triggered but not declared on `emits` option.',
Expand All @@ -102,49 +115,49 @@ module.exports = {
},
/** @param {RuleContext} context */
create(context) {
/** @typedef { { node: Literal, name: string } } EmitCellName */
const options = context.options[0] || {}
const allowProps = !!options.allowProps
/** @type {Map<ObjectExpression, { contextReferenceIds: Set<Identifier>, emitReferenceIds: Set<Identifier> }>} */
const setupContexts = new Map()
/** @type {Map<ObjectExpression, (ComponentArrayEmit | ComponentObjectEmit)[]>} */
const vueEmitsDeclarations = new Map()

/** @type {EmitCellName[]} */
const templateEmitCellNames = []
/** @type { { type: 'export' | 'mark' | 'definition', object: ObjectExpression, emits: (ComponentArrayEmit | ComponentObjectEmit)[] } | null } */
let vueObjectData = null
/** @type {Map<ObjectExpression, (ComponentArrayProp | ComponentObjectProp)[]>} */
const vuePropsDeclarations = new Map()

/**
* @param {Literal} nameLiteralNode
* @typedef {object} VueTemplateObjectData
* @property {'export' | 'mark' | 'definition'} type
* @property {ObjectExpression} object
* @property {(ComponentArrayEmit | ComponentObjectEmit)[]} emits
* @property {(ComponentArrayProp | ComponentObjectProp)[]} props
*/
function addTemplateEmitCellName(nameLiteralNode) {
templateEmitCellNames.push({
node: nameLiteralNode,
name: `${nameLiteralNode.value}`
})
}
/** @type {VueTemplateObjectData | null} */
let vueTemplateObjectData = null

/**
* @param {(ComponentArrayEmit | ComponentObjectEmit)[]} emitsDeclarations
* @param {(ComponentArrayEmit | ComponentObjectEmit)[]} emits
* @param {(ComponentArrayProp | ComponentObjectProp)[]} props
* @param {Literal} nameLiteralNode
* @param {ObjectExpression} vueObjectNode
*/
function verify(emitsDeclarations, nameLiteralNode, vueObjectNode) {
function verifyEmit(emits, props, nameLiteralNode, vueObjectNode) {
const name = `${nameLiteralNode.value}`
if (emitsDeclarations.some((e) => e.emitName === name)) {
if (emits.some((e) => e.emitName === name)) {
return
}
if (allowProps) {
const key = `on${capitalize(name)}`
if (props.some((e) => e.propName === key)) {
return
}
}
context.report({
node: nameLiteralNode,
messageId: 'missing',
data: {
name
},
suggest: buildSuggest(
vueObjectNode,
emitsDeclarations,
nameLiteralNode,
context
)
suggest: buildSuggest(vueObjectNode, emits, nameLiteralNode, context)
})
}

Expand All @@ -153,47 +166,31 @@ module.exports = {
{
/** @param { CallExpression & { argument: [Literal, ...Expression] } } node */
'CallExpression[arguments.0.type=Literal]'(node) {
const callee = node.callee
const callee = utils.skipChainExpression(node.callee)
const nameLiteralNode = /** @type {Literal} */ (node.arguments[0])
if (!nameLiteralNode || typeof nameLiteralNode.value !== 'string') {
// cannot check
return
}
if (callee.type === 'Identifier' && callee.name === '$emit') {
addTemplateEmitCellName(nameLiteralNode)
}
},
"VElement[parent.type!='VElement']:exit"() {
if (!vueObjectData) {
if (!vueTemplateObjectData) {
return
}
const emitsDeclarationNames = new Set(
vueObjectData.emits.map((e) => e.emitName)
)

for (const { name, node } of templateEmitCellNames) {
if (emitsDeclarationNames.has(name)) {
continue
}
context.report({
node,
messageId: 'missing',
data: {
name
},
suggest: buildSuggest(
vueObjectData.object,
vueObjectData.emits,
node,
context
)
})
if (callee.type === 'Identifier' && callee.name === '$emit') {
verifyEmit(
vueTemplateObjectData.emits,
vueTemplateObjectData.props,
nameLiteralNode,
vueTemplateObjectData.object
)
}
}
},
utils.defineVueVisitor(context, {
onVueObjectEnter(node) {
vueEmitsDeclarations.set(node, utils.getComponentEmits(node))
if (allowProps) {
vuePropsDeclarations.set(node, utils.getComponentProps(node))
}
},
onSetupFunctionEnter(node, { node: vueNode }) {
const contextParam = node.params[1]
Expand Down Expand Up @@ -286,15 +283,25 @@ module.exports = {
const { contextReferenceIds, emitReferenceIds } = setupContext
if (callee.type === 'Identifier' && emitReferenceIds.has(callee)) {
// verify setup(props,{emit}) {emit()}
verify(emitsDeclarations, nameLiteralNode, vueNode)
verifyEmit(
emitsDeclarations,
vuePropsDeclarations.get(vueNode) || [],
nameLiteralNode,
vueNode
)
} else if (emit && emit.name === 'emit') {
const memObject = utils.skipChainExpression(emit.member.object)
if (
memObject.type === 'Identifier' &&
contextReferenceIds.has(memObject)
) {
// verify setup(props,context) {context.emit()}
verify(emitsDeclarations, nameLiteralNode, vueNode)
verifyEmit(
emitsDeclarations,
vuePropsDeclarations.get(vueNode) || [],
nameLiteralNode,
vueNode
)
}
}
}
Expand All @@ -304,26 +311,36 @@ module.exports = {
const memObject = utils.skipChainExpression(emit.member.object)
if (utils.isThis(memObject, context)) {
// verify this.$emit()
verify(emitsDeclarations, nameLiteralNode, vueNode)
verifyEmit(
emitsDeclarations,
vuePropsDeclarations.get(vueNode) || [],
nameLiteralNode,
vueNode
)
}
}
},
onVueObjectExit(node, { type }) {
const emits = vueEmitsDeclarations.get(node)
if (!vueObjectData || vueObjectData.type !== 'export') {
if (
!vueTemplateObjectData ||
vueTemplateObjectData.type !== 'export'
) {
if (
emits &&
(type === 'mark' || type === 'export' || type === 'definition')
) {
vueObjectData = {
vueTemplateObjectData = {
type,
object: node,
emits
emits,
props: vuePropsDeclarations.get(node) || []
}
}
}
setupContexts.delete(node)
vueEmitsDeclarations.delete(node)
vuePropsDeclarations.delete(node)
}
})
)
Expand Down
8 changes: 1 addition & 7 deletions lib/rules/require-valid-default-prop.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
'use strict'
const utils = require('../utils')
const { capitalize } = require('../utils/casing')

/**
* @typedef {import('../utils').ComponentObjectProp} ComponentObjectProp
Expand Down Expand Up @@ -68,13 +69,6 @@ function getTypes(node) {
return []
}

/**
* @param {string} text
*/
function capitalize(text) {
return text[0].toUpperCase() + text.slice(1)
}

// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------
Expand Down
4 changes: 3 additions & 1 deletion lib/utils/casing.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,5 +197,7 @@ module.exports = {
isCamelCase,
isPascalCase,
isKebabCase,
isSnakeCase
isSnakeCase,

capitalize
}
Loading