diff --git a/docs/rules/index.md b/docs/rules/index.md index c2093a8c4..cc94b716b 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -267,6 +267,7 @@ For example: | [vue/require-macro-variable-name](./require-macro-variable-name.md) | require a certain macro variable name | :bulb: | :hammer: | | [vue/require-name-property](./require-name-property.md) | require a name property in Vue components | :bulb: | :hammer: | | [vue/require-prop-comment](./require-prop-comment.md) | require props to have a comment | | :hammer: | +| [vue/require-typed-object-prop](./require-typed-object-prop.md) | enforce adding type declarations to object props | :bulb: | :hammer: | | [vue/require-typed-ref](./require-typed-ref.md) | require `ref` and `shallowRef` functions to be strongly typed | | :hammer: | | [vue/script-indent](./script-indent.md) | enforce consistent indentation in ` +``` + + + +## :wrench: Options + +Nothing. + +## :mute: When Not To Use It + +When you're not using TypeScript in the project. + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/require-typed-object-prop.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/require-typed-object-prop.js) diff --git a/lib/index.js b/lib/index.js index 66ab217bd..c7aa8c961 100644 --- a/lib/index.js +++ b/lib/index.js @@ -191,6 +191,7 @@ module.exports = { 'require-render-return': require('./rules/require-render-return'), 'require-slots-as-functions': require('./rules/require-slots-as-functions'), 'require-toggle-inside-transition': require('./rules/require-toggle-inside-transition'), + 'require-typed-object-prop': require('./rules/require-typed-object-prop'), 'require-typed-ref': require('./rules/require-typed-ref'), 'require-v-for-key': require('./rules/require-v-for-key'), 'require-valid-default-prop': require('./rules/require-valid-default-prop'), diff --git a/lib/rules/require-typed-object-prop.js b/lib/rules/require-typed-object-prop.js new file mode 100644 index 000000000..dda7b8346 --- /dev/null +++ b/lib/rules/require-typed-object-prop.js @@ -0,0 +1,150 @@ +/** + * @author Przemysław Jan Beigert + * See LICENSE file in root directory for full license. + */ +'use strict' + +const utils = require('../utils') + +/** + * @param {RuleContext} context + * @param {Identifier} identifierNode + */ +const checkPropIdentifierType = (context, identifierNode) => { + if (identifierNode.name === 'Object' || identifierNode.name === 'Array') { + const arrayTypeSuggestion = identifierNode.name === 'Array' ? '[]' : '' + context.report({ + node: identifierNode, + messageId: 'expectedTypeAnnotation', + suggest: [ + { + messageId: 'addTypeAnnotation', + data: { type: `any${arrayTypeSuggestion}` }, + fix(fixer) { + return fixer.insertTextAfter( + identifierNode, + ` as PropType` + ) + } + }, + { + messageId: 'addTypeAnnotation', + data: { type: `unknown${arrayTypeSuggestion}` }, + fix(fixer) { + return fixer.insertTextAfter( + identifierNode, + ` as PropType` + ) + } + } + ] + }) + } +} + +/** + * @param {RuleContext} context + * @param {ArrayExpression} arrayNode + */ +const checkPropArrayType = (context, arrayNode) => { + for (const elementNode of arrayNode.elements) { + if (elementNode?.type === 'Identifier') { + checkPropIdentifierType(context, elementNode) + } + } +} + +/** + * @param {RuleContext} context + * @param {ObjectExpression} objectNode + */ +const checkPropObjectType = (context, objectNode) => { + const typeProperty = objectNode.properties.find( + (prop) => + prop.type === 'Property' && + prop.key.type === 'Identifier' && + prop.key.name === 'type' + ) + if (!typeProperty || typeProperty.type !== 'Property') { + return + } + + if (typeProperty.value.type === 'Identifier') { + // `foo: { type: String }` + checkPropIdentifierType(context, typeProperty.value) + } else if (typeProperty.value.type === 'ArrayExpression') { + // `foo: { type: [String, Boolean] }` + checkPropArrayType(context, typeProperty.value) + } +} + +/** + * @param {import('../utils').ComponentProp} prop + * @param {RuleContext} context + */ +const checkProp = (prop, context) => { + if (prop.type !== 'object') { + return + } + + switch (prop.node.value.type) { + case 'Identifier': { + // e.g. `foo: String` + checkPropIdentifierType(context, prop.node.value) + break + } + case 'ArrayExpression': { + // e.g. `foo: [String, Boolean]` + checkPropArrayType(context, prop.node.value) + break + } + case 'ObjectExpression': { + // e.g. `foo: { type: … }` + checkPropObjectType(context, prop.node.value) + return + } + } +} + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'enforce adding type declarations to object props', + categories: undefined, + recommended: false, + url: 'https://eslint.vuejs.org/rules/require-typed-object-prop.html' + }, + fixable: null, + schema: [], + // eslint-disable-next-line eslint-plugin/require-meta-has-suggestions -- `context.report` with suggestion is not recognized in `checkPropIdentifierType` + hasSuggestions: true, + messages: { + expectedTypeAnnotation: 'Expected type annotation on object prop.', + addTypeAnnotation: 'Add `{{ type }}` type annotation.' + } + }, + /** @param {RuleContext} context */ + create(context) { + return utils.compositingVisitors( + utils.defineScriptSetupVisitor(context, { + onDefinePropsEnter(_node, props) { + for (const prop of props) { + checkProp(prop, context) + } + } + }), + utils.executeOnVue(context, (obj) => { + const props = utils.getComponentPropsFromOptions(obj) + + for (const prop of props) { + checkProp(prop, context) + } + }) + ) + } +} diff --git a/tests/lib/rules/require-typed-object-prop.js b/tests/lib/rules/require-typed-object-prop.js new file mode 100644 index 000000000..5d93965a5 --- /dev/null +++ b/tests/lib/rules/require-typed-object-prop.js @@ -0,0 +1,621 @@ +/** + * @author Przemysław Jan Beigert + * See LICENSE file in root directory for full license. + */ +'use strict' + +const RuleTester = require('eslint').RuleTester +const rule = require('../../../lib/rules/require-typed-object-prop') + +const ruleTester = new RuleTester() + +ruleTester.run('require-typed-object-prop', rule, { + valid: [ + // empty + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + export default { + props: {} + } + ` + }, + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + export default Vue.extend({ + props: {} + }); + ` + }, + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + + `, + parser: require.resolve('vue-eslint-parser') + }, + // array props + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + export default { + props: ['foo'] + } + ` + }, + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + export default Vue.extend({ + props: ['foo'] + }); + ` + }, + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + + `, + parser: require.resolve('vue-eslint-parser') + }, + // primitive props + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + export default { + props: { foo: String } + } + ` + }, + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + export default Vue.extend({ + props: { foo: String } + }); + ` + }, + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + + `, + parser: require.resolve('vue-eslint-parser') + }, + // union + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + export default { + props: { foo: [Number, String, Boolean] } + } + ` + }, + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + export default Vue.extend({ + props: { foo: [Number, String, Boolean] } + }); + ` + }, + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + + `, + parser: require.resolve('vue-eslint-parser') + }, + // function + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + export default { + props: { foo: someFunction() } + } + ` + }, + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + export default Vue.extend({ + props: { foo: someFunction() } + }); + ` + }, + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + + `, + parser: require.resolve('vue-eslint-parser') + }, + // typed object + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + export default { + props: { foo: Object as PropType } + } + `, + parser: require.resolve('@typescript-eslint/parser') + }, + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + export default { + props: { foo: Array as PropType } + } + `, + parser: require.resolve('@typescript-eslint/parser') + }, + { + filename: 'test.vue', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + code: ` + export default Vue.extend({ + props: { foo: Object as PropType } + }); + `, + parser: require.resolve('@typescript-eslint/parser') + }, + { + filename: 'test.vue', + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + parser: require.resolve('@typescript-eslint/parser') + }, + code: ` + + `, + parser: require.resolve('vue-eslint-parser') + }, + + { + filename: 'test.vue', + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + parser: require.resolve('@typescript-eslint/parser') + }, + code: ` + export default { + props: { foo: Object as () => User } + } + `, + parser: require.resolve('@typescript-eslint/parser') + }, + { + filename: 'test.vue', + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + parser: require.resolve('@typescript-eslint/parser') + }, + code: ` + export default Vue.extend({ + props: { foo: Object as () => User } + }); + `, + parser: require.resolve('@typescript-eslint/parser') + }, + { + filename: 'test.vue', + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + parser: require.resolve('@typescript-eslint/parser') + }, + code: ` + + `, + parser: require.resolve('vue-eslint-parser') + }, + // any + { + filename: 'test.vue', + code: ` + + `, + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + parser: require.resolve('@typescript-eslint/parser') + }, + parser: require.resolve('vue-eslint-parser') + }, + { + filename: 'test.vue', + code: ` + + `, + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + parser: require.resolve('@typescript-eslint/parser') + }, + parser: require.resolve('vue-eslint-parser') + }, + { + filename: 'test.vue', + code: ` + + `, + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + parser: require.resolve('@typescript-eslint/parser') + }, + parser: require.resolve('vue-eslint-parser') + }, + // unknown + { + filename: 'test.vue', + code: ` + + `, + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + parser: require.resolve('@typescript-eslint/parser') + }, + parser: require.resolve('vue-eslint-parser') + }, + { + filename: 'test.vue', + code: ` + + `, + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + parser: require.resolve('@typescript-eslint/parser') + }, + parser: require.resolve('vue-eslint-parser') + }, + { + filename: 'test.vue', + code: ` + + `, + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + parser: require.resolve('@typescript-eslint/parser') + }, + parser: require.resolve('vue-eslint-parser') + } + ], + invalid: [ + { + filename: 'test.vue', + code: ` + + `, + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + parser: require.resolve('vue-eslint-parser'), + errors: [ + { + messageId: 'expectedTypeAnnotation', + line: 3, + column: 26, + endLine: 3, + endColumn: 32, + suggestions: [ + { + messageId: 'addTypeAnnotation', + data: { type: 'any' }, + output: ` + + ` + }, + { + messageId: 'addTypeAnnotation', + data: { type: 'unknown' }, + output: ` + + ` + } + ] + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + parser: require.resolve('vue-eslint-parser'), + errors: [ + { + messageId: 'expectedTypeAnnotation', + line: 3, + column: 26, + endLine: 3, + endColumn: 31, + suggestions: [ + { + messageId: 'addTypeAnnotation', + data: { type: 'any[]' }, + output: ` + + ` + }, + { + messageId: 'addTypeAnnotation', + data: { type: 'unknown[]' }, + output: ` + + ` + } + ] + } + ] + }, + { + filename: 'test.vue', + code: ` + export default { + props: { foo: Object } + } + `, + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + errors: [ + { + messageId: 'expectedTypeAnnotation', + line: 3, + column: 23, + endLine: 3, + endColumn: 29, + suggestions: [ + { + messageId: 'addTypeAnnotation', + data: { type: 'any' }, + output: ` + export default { + props: { foo: Object as PropType } + } + ` + }, + { + messageId: 'addTypeAnnotation', + data: { type: 'unknown' }, + output: ` + export default { + props: { foo: Object as PropType } + } + ` + } + ] + } + ] + }, + { + filename: 'test.vue', + code: ` + export default Vue.extend({ + props: { foo: Object } + }); + `, + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + errors: [ + { + messageId: 'expectedTypeAnnotation', + line: 3, + column: 23, + endLine: 3, + endColumn: 29, + suggestions: [ + { + messageId: 'addTypeAnnotation', + data: { type: 'any' }, + output: ` + export default Vue.extend({ + props: { foo: Object as PropType } + }); + ` + }, + { + messageId: 'addTypeAnnotation', + data: { type: 'unknown' }, + output: ` + export default Vue.extend({ + props: { foo: Object as PropType } + }); + ` + } + ] + } + ] + }, + { + filename: 'test.vue', + code: ` + export default Vue.extend({ + props: { foo: { type: Object } } + }); + `, + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + errors: [ + { + messageId: 'expectedTypeAnnotation', + line: 3, + column: 31, + endLine: 3, + endColumn: 37, + suggestions: [ + { + messageId: 'addTypeAnnotation', + data: { type: 'any' }, + output: ` + export default Vue.extend({ + props: { foo: { type: Object as PropType } } + }); + ` + }, + { + messageId: 'addTypeAnnotation', + data: { type: 'unknown' }, + output: ` + export default Vue.extend({ + props: { foo: { type: Object as PropType } } + }); + ` + } + ] + } + ] + }, + { + filename: 'test.vue', + code: ` + export default { + props: { foo: { type: Object } } + } + `, + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + errors: [ + { + messageId: 'expectedTypeAnnotation', + line: 3, + column: 31, + endLine: 3, + endColumn: 37, + suggestions: [ + { + messageId: 'addTypeAnnotation', + data: { type: 'any' }, + output: ` + export default { + props: { foo: { type: Object as PropType } } + } + ` + }, + { + messageId: 'addTypeAnnotation', + data: { type: 'unknown' }, + output: ` + export default { + props: { foo: { type: Object as PropType } } + } + ` + } + ] + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + parser: require.resolve('vue-eslint-parser'), + errors: [ + { + messageId: 'expectedTypeAnnotation', + line: 3, + column: 34, + endLine: 3, + endColumn: 40, + suggestions: [ + { + messageId: 'addTypeAnnotation', + data: { type: 'any' }, + output: ` + + ` + }, + { + messageId: 'addTypeAnnotation', + data: { type: 'unknown' }, + output: ` + + ` + } + ] + } + ] + } + ] +})