diff --git a/docs/rules/require-valid-default-prop.md b/docs/rules/require-valid-default-prop.md new file mode 100644 index 000000000..a571ecf33 --- /dev/null +++ b/docs/rules/require-valid-default-prop.md @@ -0,0 +1,63 @@ +# Enforces props default values to be valid (require-valid-default-prop) + +This rule checks whether the default value of each prop is valid for the given type. It should report an error when default value for type `Array` or `Object` is not returned using function. + +## :book: Rule Details + +:-1: Examples of **incorrect** code for this rule: + +```js +Vue.component('example', { + props: { + propA: { + type: String, + default: {} + }, + propB: { + type: String, + default: [] + }, + propC: { + type: Object, + default: [] + }, + propD: { + type: Array, + default: [] + }, + propE: { + type: Object, + default: { message: 'hello' } + } + } +}) +``` + +:+1: Examples of **correct** code for this rule: + +```js +Vue.component('example', { + props: { + // basic type check (`null` means accept any type) + propA: Number, + // multiple possible types + propB: [String, Number], + // a number with default value + propD: { + type: Number, + default: 100 + }, + // object/array defaults should be returned from a factory function + propE: { + type: Object, + default: function () { + return { message: 'hello' } + } + } + } +}) +``` + +## :wrench: Options + +Nothing. diff --git a/lib/rules/require-valid-default-prop.js b/lib/rules/require-valid-default-prop.js new file mode 100644 index 000000000..22dbe5b4d --- /dev/null +++ b/lib/rules/require-valid-default-prop.js @@ -0,0 +1,134 @@ +/** + * @fileoverview Enforces props default values to be valid. + * @author Armano + */ +'use strict' +const utils = require('../utils') + +const NATIVE_TYPES = new Set([ + 'String', + 'Number', + 'Boolean', + 'Function', + 'Object', + 'Array', + 'Symbol' +]) + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +module.exports = { + meta: { + docs: { + description: 'Enforces props default values to be valid.', + category: 'Possible Errors', + recommended: false + }, + fixable: null, + schema: [] + }, + + create (context) { + // ---------------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------------- + + function isPropertyIdentifier (node) { + return node.type === 'Property' && node.key.type === 'Identifier' + } + + function getPropertyNode (obj, name) { + return obj.properties.find(p => + isPropertyIdentifier(p) && + p.key.name === name + ) + } + + 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 ucFirst (text) { + return text[0].toUpperCase() + text.slice(1) + } + + function getValueType (node) { + if (node.type === 'CallExpression') { // Symbol(), Number() ... + if (node.callee.type === 'Identifier' && NATIVE_TYPES.has(node.callee.name)) { + return node.callee.name + } + } else if (node.type === 'TemplateLiteral') { // String + return 'String' + } else if (node.type === 'Literal') { // String, Boolean, Number + if (node.value === null) return null + const type = ucFirst(typeof node.value) + if (NATIVE_TYPES.has(type)) { + return type + } + } else if (node.type === 'ArrayExpression') { // Array + return 'Array' + } else if (node.type === 'ObjectExpression') { // Object + return 'Object' + } + // FunctionExpression, ArrowFunctionExpression + return null + } + + // ---------------------------------------------------------------------- + // Public + // ---------------------------------------------------------------------- + + return utils.executeOnVue(context, obj => { + const props = obj.properties.find(p => + isPropertyIdentifier(p) && + p.key.name === 'props' && + p.value.type === 'ObjectExpression' + ) + if (!props) return + + const properties = props.value.properties.filter(p => + isPropertyIdentifier(p) && + p.value.type === 'ObjectExpression' + ) + + for (const prop of properties) { + const type = getPropertyNode(prop.value, 'type') + if (!type) { + return + } + + 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))) + + if (typeNames.size === 0) { // There is no native types detected + return + } + + const def = getPropertyNode(prop.value, 'default') + if (!def) return + + const defType = getValueType(def.value) + if (typeNames.has(defType)) return + + context.report({ + node: def, + message: "Type of the default value for '{{name}}' prop must be a {{types}}.", + data: { + name: prop.key.name, + types: Array.from(typeNames).join(' or ').toLowerCase() + } + }) + } + }) + } +} diff --git a/tests/lib/rules/require-valid-default-prop.js b/tests/lib/rules/require-valid-default-prop.js new file mode 100644 index 000000000..8c4a84abf --- /dev/null +++ b/tests/lib/rules/require-valid-default-prop.js @@ -0,0 +1,403 @@ +/** + * @fileoverview Enforces props default values to be valid. + * @author Armano + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/require-valid-default-prop') +const RuleTester = require('eslint').RuleTester + +const parserOptions = { + ecmaVersion: 6, + sourceType: 'module' +} + +function errorMessage (type) { + return [{ + message: `Type of the default value for 'foo' prop must be a ${type}.`, + type: 'Property', + line: 5 + }] +} + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester() +ruleTester.run('require-valid-default-prop', rule, { + + valid: [ + { + filename: 'test.vue', + code: `export default { + props: { foo: null } + }`, + parserOptions + }, + { + filename: 'test.vue', + code: `export default { + props: ['foo'] + }`, + parserOptions + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: [Object, Number], + default: 10 + } + } + }`, + parserOptions + }, + { + filename: 'test.vue', + code: `Vue.component('example', { + props: { + foo: null, + foo: Number, + foo: [String, Number], + foo: { type: Number, default: 100 }, + foo: { type: [String, Number], default: '' }, + foo: { type: [String, Number], default: 0 }, + foo: { type: String, default: '' }, + foo: { type: String, default: \`\` }, + foo: { type: Boolean, default: false }, + foo: { type: Object, default: () => { } }, + foo: { type: Array, default () { } }, + foo: { type: String, default () { } }, + foo: { type: Number, default () { } }, + foo: { type: Boolean, default () { } }, + foo: { type: Symbol, default () { } }, + foo: { type: Array, default () { } }, + foo: { type: Symbol, default: Symbol('a') }, + foo: { type: String, default: \`Foo\` } + } + })`, + parserOptions + } + ], + + invalid: [ + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: [Number, String], + default: {} + } + } + }`, + parserOptions, + errors: errorMessage('number or string') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: [Number, Object], + default: {} + } + } + }`, + parserOptions, + errors: errorMessage('number or function') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Number, + default: '' + } + } + }`, + parserOptions, + errors: errorMessage('number') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Number, + default: false + } + } + }`, + parserOptions, + errors: errorMessage('number') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Number, + default: {} + } + } + }`, + parserOptions, + errors: errorMessage('number') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Number, + default: [] + } + } + }`, + parserOptions, + errors: errorMessage('number') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: String, + default: 2 + } + } + }`, + parserOptions, + errors: errorMessage('string') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: String, + default: {} + } + } + }`, + parserOptions, + errors: errorMessage('string') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: String, + default: [] + } + } + }`, + parserOptions, + errors: errorMessage('string') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Boolean, + default: '' + } + } + }`, + parserOptions, + errors: errorMessage('boolean') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Boolean, + default: 5 + } + } + }`, + parserOptions, + errors: errorMessage('boolean') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Boolean, + default: {} + } + } + }`, + parserOptions, + errors: errorMessage('boolean') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Boolean, + default: [] + } + } + }`, + parserOptions, + errors: errorMessage('boolean') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Object, + default: '' + } + } + }`, + parserOptions, + errors: errorMessage('function') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Object, + default: 55 + } + } + }`, + parserOptions, + errors: errorMessage('function') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Object, + default: false + } + } + }`, + parserOptions, + errors: errorMessage('function') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Object, + default: {} + } + } + }`, + parserOptions, + errors: errorMessage('function') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Object, + default: [] + } + } + }`, + parserOptions, + errors: errorMessage('function') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Array, + default: '' + } + } + }`, + parserOptions, + errors: errorMessage('function') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Array, + default: 55 + } + } + }`, + parserOptions, + errors: errorMessage('function') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Array, + default: false + } + } + }`, + parserOptions, + errors: errorMessage('function') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Array, + default: {} + } + } + }`, + parserOptions, + errors: errorMessage('function') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: Array, + default: [] + } + } + }`, + parserOptions, + errors: errorMessage('function') + }, + { + filename: 'test.vue', + code: `export default { + props: { + foo: { + type: [Object, Number], + default: {} + } + } + }`, + parserOptions, + errors: errorMessage('function or number') + } + ] +})