Skip to content

Commit

Permalink
Improved vue/require-valid-default-prop rule (#1160)
Browse files Browse the repository at this point in the history
* WIP

* Improved `require-valid-default-prop` rule.

- Change `vue/require-valid-default-prop` rule to track the` return` statement in the `function` defined in `default`.
- Change `vue/require-valid-default-prop` rule to check `BigInt`.
- Improved the location of reporting errors in `vue/require-valid-default-prop` rule.

* Add testcases
  • Loading branch information
ota-meshi committed May 23, 2020
1 parent 2606a02 commit 0c2ecc8
Show file tree
Hide file tree
Showing 3 changed files with 493 additions and 68 deletions.
290 changes: 233 additions & 57 deletions lib/rules/require-valid-default-prop.js
Expand Up @@ -5,16 +5,74 @@
'use strict'
const utils = require('../utils')

/**
* @typedef {import('vue-eslint-parser').AST.ESLintObjectExpression} ObjectExpression
* @typedef {import('vue-eslint-parser').AST.ESLintExpression} Expression
* @typedef {import('vue-eslint-parser').AST.ESLintProperty} Property
* @typedef {import('vue-eslint-parser').AST.ESLintBlockStatement} BlockStatement
* @typedef {import('vue-eslint-parser').AST.ESLintPattern} Pattern
*/
/**
* @typedef {import('../utils').ComponentObjectProp} ComponentObjectProp
*/

// ----------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------

const NATIVE_TYPES = new Set([
'String',
'Number',
'Boolean',
'Function',
'Object',
'Array',
'Symbol'
'Symbol',
'BigInt'
])

const FUNCTION_VALUE_TYPES = new Set([
'Function',
'Object',
'Array'
])

/**
* @param {ObjectExpression} obj
* @param {string} name
* @returns {Property | null}
*/
function getPropertyNode (obj, name) {
for (const p of obj.properties) {
if (p.type === 'Property' &&
!p.computed &&
p.key.type === 'Identifier' &&
p.key.name === name) {
return p
}
}
return null
}

/**
* @param {Expression | Pattern} node
* @returns {string[]}
*/
function getTypes (node) {
if (node.type === 'Identifier') {
return [node.name]
} else if (node.type === 'ArrayExpression') {
return node.elements
.filter(item => item.type === 'Identifier')
.map(item => item.name)
}
return []
}

function capitalize (text) {
return text[0].toUpperCase() + text.slice(1)
}

// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------
Expand All @@ -32,93 +90,211 @@ module.exports = {
},

create (context) {
// ----------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------

function isPropertyIdentifier (node) {
return node.type === 'Property' && node.key.type === 'Identifier'
}
/**
* @typedef { { type: string, function: false } } StandardValueType
* @typedef { { type: 'Function', function: true, expression: true, functionBody: BlockStatement, returnType: string | null } } FunctionExprValueType
* @typedef { { type: 'Function', function: true, expression: false, functionBody: BlockStatement, returnTypes: ReturnType[] } } FunctionValueType
* @typedef { ComponentObjectProp & { value: ObjectExpression } } ComponentObjectDefineProp
* @typedef { { prop: ComponentObjectDefineProp, type: Set<string>, default: FunctionValueType } } PropDefaultFunctionContext
* @typedef { { type: string, node: Expression } } ReturnType
*/

function getPropertyNode (obj, name) {
return obj.properties.find(p =>
isPropertyIdentifier(p) &&
p.key.name === name
)
}
/**
* @type {Map<ObjectExpression, PropDefaultFunctionContext[]>}
*/
const vueObjectPropsContexts = new Map()

function getTypes (node) {
if (node.type === 'Identifier') {
return [node.name]
} else if (node.type === 'ArrayExpression') {
return node.elements
.filter(item => item.type === 'Identifier')
.map(item => item.name)
}
return []
/** @type { { upper: any, body: null | BlockStatement, returnTypes?: null | ReturnType[] } } */
let scopeStack = { upper: null, body: null, returnTypes: null }
function onFunctionEnter (node) {
scopeStack = { upper: scopeStack, body: node.body, returnTypes: null }
}

function ucFirst (text) {
return text[0].toUpperCase() + text.slice(1)
function onFunctionExit () {
scopeStack = scopeStack.upper
}

/**
* @param {Expression | Pattern} node
* @returns { StandardValueType | FunctionExprValueType | FunctionValueType | null }
*/
function getValueType (node) {
if (node.type === 'CallExpression') { // Symbol(), Number() ...
if (node.callee.type === 'Identifier' && NATIVE_TYPES.has(node.callee.name)) {
return node.callee.name
return {
function: false,
type: node.callee.name
}
}
} else if (node.type === 'TemplateLiteral') { // String
return 'String'
return {
function: false,
type: 'String'
}
} else if (node.type === 'Literal') { // String, Boolean, Number
if (node.value === null) return null
const type = ucFirst(typeof node.value)
if (node.value === null && !node.bigint) return null
const type = node.bigint ? 'BigInt' : capitalize(typeof node.value)
if (NATIVE_TYPES.has(type)) {
return type
return {
function: false,
type
}
}
} else if (node.type === 'ArrayExpression') { // Array
return 'Array'
return {
function: false,
type: 'Array'
}
} else if (node.type === 'ObjectExpression') { // Object
return 'Object'
return {
function: false,
type: 'Object'
}
} else if (node.type === 'FunctionExpression') {
return {
function: true,
expression: false,
type: 'Function',
functionBody: node.body,
returnTypes: []
}
} else if (node.type === 'ArrowFunctionExpression') {
if (node.expression) {
const valueType = getValueType(node.body)
return {
function: true,
expression: true,
type: 'Function',
functionBody: node.body,
returnType: valueType ? valueType.type : null
}
} else {
return {
function: true,
expression: false,
type: 'Function',
functionBody: node.body,
returnTypes: []
}
}
}
// FunctionExpression, ArrowFunctionExpression
return null
}

/**
* @param {*} node
* @param {ComponentObjectProp} prop
* @param {Iterable<string>} expectedTypeNames
*/
function report (node, prop, expectedTypeNames) {
const propName = prop.propName != null ? prop.propName : `[${context.getSourceCode().getText(prop.key)}]`
context.report({
node,
message: "Type of the default value for '{{name}}' prop must be a {{types}}.",
data: {
name: propName,
types: Array.from(expectedTypeNames)
.join(' or ')
.toLowerCase()
}
})
}

// ----------------------------------------------------------------------
// Public
// ----------------------------------------------------------------------

return utils.executeOnVue(context, obj => {
const props = utils.getComponentProps(obj)
.filter(prop => prop.key && prop.value && prop.value.type === 'ObjectExpression')
return utils.defineVueVisitor(context,
{
onVueObjectEnter (obj) {
/** @type {ComponentObjectDefineProp[]} */
const props = utils.getComponentProps(obj)
.filter(prop => prop.key && prop.value && prop.value.type === 'ObjectExpression')
/** @type {PropDefaultFunctionContext[]} */
const propContexts = []
for (const prop of props) {
const type = getPropertyNode(prop.value, 'type')
if (!type) continue

for (const prop of props) {
const type = getPropertyNode(prop.value, 'type')
if (!type) continue
const typeNames = new Set(getTypes(type.value)
.filter(item => NATIVE_TYPES.has(item)))

const typeNames = new Set(getTypes(type.value)
.map(item => item === 'Object' || item === 'Array' ? 'Function' : item) // Object and Array require function
.filter(item => NATIVE_TYPES.has(item)))
// There is no native types detected
if (typeNames.size === 0) continue

// There is no native types detected
if (typeNames.size === 0) continue
const def = getPropertyNode(prop.value, 'default')
if (!def) continue

const def = getPropertyNode(prop.value, 'default')
if (!def) continue
const defType = getValueType(def.value)

const defType = getValueType(def.value)
if (!defType || typeNames.has(defType)) continue
if (!defType) continue

const propName = prop.propName != null ? prop.propName : `[${context.getSourceCode().getText(prop.key)}]`
context.report({
node: def,
message: "Type of the default value for '{{name}}' prop must be a {{types}}.",
data: {
name: propName,
types: Array.from(typeNames).join(' or ').toLowerCase()
if (!defType.function) {
if (typeNames.has(defType.type)) {
if (!FUNCTION_VALUE_TYPES.has(defType.type)) {
continue
}
}
report(
def.value,
prop,
Array.from(typeNames).map(type => FUNCTION_VALUE_TYPES.has(type) ? 'Function' : type)
)
} else {
if (typeNames.has('Function')) {
continue
}
if (defType.expression) {
if (!defType.returnType || typeNames.has(defType.returnType)) {
continue
}
report(
defType.functionBody,
prop,
typeNames
)
} else {
propContexts.push({
prop,
type: typeNames,
default: defType
})
}
}
}
})
vueObjectPropsContexts.set(obj, propContexts)
},
':function' (node, { node: vueNode }) {
onFunctionEnter(node)

for (const { default: defType } of vueObjectPropsContexts.get(vueNode)) {
if (node.body === defType.functionBody) {
scopeStack.returnTypes = defType.returnTypes
}
}
},
ReturnStatement (node) {
if (scopeStack.returnTypes && node.argument) {
const type = getValueType(node.argument)
if (type) {
scopeStack.returnTypes.push({
type: type.type,
node: node.argument
})
}
}
},
':function:exit': onFunctionExit,
onVueObjectExit (obj) {
for (const { prop, type: typeNames, default: defType } of vueObjectPropsContexts.get(obj)) {
for (const returnType of defType.returnTypes) {
if (typeNames.has(returnType.type)) continue

report(returnType.node, prop, typeNames)
}
}
}
}
})
)
}
}
18 changes: 10 additions & 8 deletions lib/utils/index.js
Expand Up @@ -28,7 +28,7 @@

/**
* @typedef { {key: Literal | null, value: null, node: ArrayExpression['elements'][0], propName: string} } ComponentArrayProp
* @typedef { {key: Property['key'], value: Property['value'], node: Property, propName: string} } ComponentObjectProp
* @typedef { {key: Property['key'], value: Expression, node: Property, propName: string} } ComponentObjectProp
*/
/**
* @typedef { {key: Literal | null, value: null, node: ArrayExpression['elements'][0], emitName: string} } ComponentArrayEmit
Expand Down Expand Up @@ -664,15 +664,17 @@ module.exports = {
vueStack = vueStack.parent
}
}
vueVisitor['Property[value.type=/^(Arrow)?FunctionExpression$/] > :function'] = (node) => {
/** @type {Property} */
const prop = node.parent
if (vueStack && prop.parent === vueStack.node) {
if (getStaticPropertyName(prop) === 'setup' && prop.value === node) {
callVisitor('onSetupFunctionEnter', node)
if (visitor.onSetupFunctionEnter) {
vueVisitor['Property[value.type=/^(Arrow)?FunctionExpression$/] > :function'] = (node) => {
/** @type {Property} */
const prop = node.parent
if (vueStack && prop.parent === vueStack.node) {
if (getStaticPropertyName(prop) === 'setup' && prop.value === node) {
callVisitor('onSetupFunctionEnter', node)
}
}
callVisitor('Property[value.type=/^(Arrow)?FunctionExpression$/] > :function', node)
}
callVisitor('Property[value.type=/^(Arrow)?FunctionExpression$/] > :function', node)
}

return vueVisitor
Expand Down

0 comments on commit 0c2ecc8

Please sign in to comment.