diff --git a/lib/rules/require-valid-default-prop.js b/lib/rules/require-valid-default-prop.js index 8d1e05038..0d21fdc01 100644 --- a/lib/rules/require-valid-default-prop.js +++ b/lib/rules/require-valid-default-prop.js @@ -5,6 +5,21 @@ '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', @@ -12,9 +27,52 @@ const NATIVE_TYPES = new Set([ '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 // ------------------------------------------------------------------------------ @@ -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, 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} + */ + 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} 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) + } + } + } } - }) + ) } } diff --git a/lib/utils/index.js b/lib/utils/index.js index 735d182d0..5a07008b8 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -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 @@ -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 diff --git a/tests/lib/rules/require-valid-default-prop.js b/tests/lib/rules/require-valid-default-prop.js index 4f9c59d28..ff7548977 100644 --- a/tests/lib/rules/require-valid-default-prop.js +++ b/tests/lib/rules/require-valid-default-prop.js @@ -12,7 +12,7 @@ const rule = require('../../../lib/rules/require-valid-default-prop') const RuleTester = require('eslint').RuleTester const parserOptions = { - ecmaVersion: 2018, + ecmaVersion: 2020, sourceType: 'module', ecmaFeatures: { jsx: true } } @@ -20,11 +20,17 @@ const parserOptions = { function errorMessage (type) { return [{ message: `Type of the default value for 'foo' prop must be a ${type}.`, - type: 'Property', line: 5 }] } +function errorMessageForFunction (type) { + return [{ + message: `Type of the default value for 'foo' prop must be a ${type}.`, + line: 6 + }] +} + // ------------------------------------------------------------------------------ // Tests // ------------------------------------------------------------------------------ @@ -96,7 +102,12 @@ ruleTester.run('require-valid-default-prop', rule, { foo: { type: Symbol, default: Symbol('a') }, foo: { type: String, default: \`Foo\` }, foo: { type: Foo, default: Foo('a') }, - foo: { type: String, default: \`Foo\` } + foo: { type: String, default: \`Foo\` }, + foo: { type: BigInt, default: 1n }, + foo: { type: String, default: null }, + foo: { type: String, default () { return Foo } }, + foo: { type: Number, default () { return Foo } }, + foo: { type: Object, default () { return Foo } }, } })`, parserOptions @@ -115,6 +126,58 @@ ruleTester.run('require-valid-default-prop', rule, { `, parserOptions: { ecmaVersion: 6, sourceType: 'module' }, parser: require.resolve('@typescript-eslint/parser') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: [Number], + default() { + return 10 + } + } + } + }`, + parserOptions + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: [Function, Number], + default() { + return 's' + } + } + } + }`, + parserOptions + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: [Number], + default: () => 10 + } + } + }`, + parserOptions + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: [Function, Number], + default: () => 's' + } + } + }`, + parserOptions } ], @@ -475,6 +538,190 @@ ruleTester.run('require-valid-default-prop', rule, { message: `Type of the default value for '[baz]' prop must be a function.`, line: 13 }] + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: String, + default: 1n + } + } + }`, + parserOptions, + errors: errorMessage('string') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Number, + default() { + return '' + } + } + } + }`, + parserOptions, + errors: errorMessageForFunction('number') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Object, + default() { + return '' + } + } + } + }`, + parserOptions, + errors: errorMessageForFunction('object') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: String, + default() { + return 123 + } + } + } + }`, + parserOptions, + errors: errorMessageForFunction('string') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Number, + default: () => { + return '' + } + } + } + }`, + parserOptions, + errors: errorMessageForFunction('number') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Object, + default: () => { + return '' + } + } + } + }`, + parserOptions, + errors: errorMessageForFunction('object') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: String, + default: () => { + return 123 + } + } + } + }`, + parserOptions, + errors: errorMessageForFunction('string') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Number, + default: () => '' + } + } + }`, + parserOptions, + errors: errorMessage('number') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Object, + default: () => '' + } + } + }`, + parserOptions, + errors: errorMessage('object') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: String, + default: () => 123 + } + } + }`, + parserOptions, + errors: errorMessage('string') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Function, + default: 1 + } + } + }`, + parserOptions, + errors: errorMessage('function') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: [String, Boolean], + default() { + switch (kind) { + case 1: return 1 + case 2: return '' // OK + case 3: return {} + case 4: return Foo // ignore? + case 5: return () => {} + case 6: return false // OK + } + + function foo () { + return 1 // ignore? + } + } + } + } + }`, + parserOptions, + errors: [ + { message: "Type of the default value for 'foo' prop must be a string or boolean.", line: 7 }, + { message: "Type of the default value for 'foo' prop must be a string or boolean.", line: 9 }, + { message: "Type of the default value for 'foo' prop must be a string or boolean.", line: 11 }] } ] })