Skip to content
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

Improved vue/require-valid-default-prop rule #1160

Merged
merged 3 commits into from May 23, 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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