diff --git a/docs/rules/index.md b/docs/rules/index.md index c3e4b8ed7..9b7d52e76 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -264,6 +264,7 @@ For example: | [vue/require-expose](./require-expose.md) | require declare public properties using `expose` | :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-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. + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/require-typed-ref.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/require-typed-ref.js) diff --git a/lib/index.js b/lib/index.js index 8ac5d9383..42a223de6 100644 --- a/lib/index.js +++ b/lib/index.js @@ -188,6 +188,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-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'), 'return-in-computed-property': require('./rules/return-in-computed-property'), diff --git a/lib/rules/require-typed-ref.js b/lib/rules/require-typed-ref.js new file mode 100644 index 000000000..f8fce39de --- /dev/null +++ b/lib/rules/require-typed-ref.js @@ -0,0 +1,102 @@ +/** + * @author Ivan Demchuk + * See LICENSE file in root directory for full license. + */ +'use strict' + +const { iterateDefineRefs } = require('../utils/ref-object-references') +const utils = require('../utils') + +/** + * @param {Expression|SpreadElement} node + */ +function isNullOrUndefined(node) { + return ( + (node.type === 'Literal' && node.value === null) || + (node.type === 'Identifier' && node.name === 'undefined') + ) +} + +/** + * @typedef {import('../utils/ref-object-references').RefObjectReferences} RefObjectReferences + */ + +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: + 'require `ref` and `shallowRef` functions to be strongly typed', + categories: undefined, + url: 'https://eslint.vuejs.org/rules/require-typed-ref.html' + }, + fixable: null, + messages: { + noType: + 'Specify type parameter for `{{name}}` function, otherwise created variable will not by typechecked.' + }, + schema: [] + }, + /** @param {RuleContext} context */ + create(context) { + const filename = context.getFilename() + if (!utils.isVueFile(filename) && !utils.isTypeScriptFile(filename)) { + return {} + } + + const scriptSetup = utils.getScriptSetupElement(context) + if ( + scriptSetup && + !utils.hasAttribute(scriptSetup, 'lang', 'ts') && + !utils.hasAttribute(scriptSetup, 'lang', 'typescript') + ) { + return {} + } + + const defines = iterateDefineRefs(context.getScope()) + + /** + * @param {string} name + * @param {CallExpression} node + */ + function report(name, node) { + context.report({ + node, + messageId: 'noType', + data: { + name + } + }) + } + + return { + Program() { + for (const ref of defines) { + if (ref.name !== 'ref' && ref.name !== 'shallowRef') { + continue + } + + if ( + ref.node.arguments.length > 0 && + !isNullOrUndefined(ref.node.arguments[0]) + ) { + continue + } + + if (ref.node.typeParameters == null) { + if ( + ref.node.parent.type === 'VariableDeclarator' && + ref.node.parent.id.type === 'Identifier' + ) { + if (ref.node.parent.id.typeAnnotation == null) { + report(ref.name, ref.node) + } + } else { + report(ref.name, ref.node) + } + } + } + } + } + } +} diff --git a/lib/utils/index.js b/lib/utils/index.js index ef8cd0ee9..1c54ef3f0 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -998,6 +998,8 @@ module.exports = { return null }, + isTypeScriptFile, + isVueFile, /** @@ -2416,6 +2418,13 @@ function getVExpressionContainer(node) { return n } +/** + * @param {string} path + */ +function isTypeScriptFile(path) { + return path.endsWith('.ts') || path.endsWith('.tsx') || path.endsWith('.mts') +} + // ------------------------------------------------------------------------------ // Vue Helpers // ------------------------------------------------------------------------------ diff --git a/lib/utils/ref-object-references.js b/lib/utils/ref-object-references.js index ceecd89e3..49404dad0 100644 --- a/lib/utils/ref-object-references.js +++ b/lib/utils/ref-object-references.js @@ -251,6 +251,7 @@ function getGlobalScope(context) { } module.exports = { + iterateDefineRefs, extractRefObjectReferences, extractReactiveVariableReferences } diff --git a/tests/lib/rules/require-typed-ref.js b/tests/lib/rules/require-typed-ref.js new file mode 100644 index 000000000..571b5c5de --- /dev/null +++ b/tests/lib/rules/require-typed-ref.js @@ -0,0 +1,239 @@ +/** + * @author Ivan Demchuk + */ +'use strict' + +const RuleTester = require('eslint').RuleTester +const rule = require('../../../lib/rules/require-typed-ref') + +const tester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { ecmaVersion: 2020, sourceType: 'module' } +}) + +// Note: Need to specify filename for each test, +// as only TypeScript files are being checked +tester.run('require-typed-ref', rule, { + valid: [ + { + filename: 'test.ts', + code: ` + import { shallowRef } from 'vue' + const count = shallowRef(0) + ` + }, + { + filename: 'test.ts', + code: ` + import { ref } from 'vue' + const count = ref() + ` + }, + { + filename: 'test.ts', + code: ` + import { ref } from 'vue' + const count = ref(0) + ` + }, + { + filename: 'test.ts', + code: ` + import { ref } from 'vue' + const counter: Ref = ref() + ` + }, + { + filename: 'test.ts', + code: ` + import { ref } from 'vue' + const count = ref(0) + ` + }, + { + filename: 'test.ts', + code: ` + import { ref } from 'vue' + function useCount() { + return { + count: ref() + } + } + ` + }, + { + filename: 'test.ts', + code: ` + import { ref, defineComponent } from 'vue' + defineComponent({ + setup() { + const count = ref() + return { count } + } + }) + ` + }, + { + filename: 'test.vue', + parser: require.resolve('vue-eslint-parser'), + code: ` + + ` + }, + { + filename: 'test.js', + code: ` + import { ref } from 'vue' + const count = ref() + ` + } + ], + invalid: [ + { + filename: 'test.ts', + code: ` + import { ref } from 'vue' + const count = ref() + `, + errors: [ + { + messageId: 'noType', + line: 3, + column: 23, + endLine: 3, + endColumn: 28 + } + ] + }, + { + filename: 'test.ts', + code: ` + import { ref } from 'vue' + const count = ref(null) + `, + errors: [ + { + messageId: 'noType', + line: 3, + column: 23, + endLine: 3, + endColumn: 32 + } + ] + }, + { + filename: 'test.ts', + code: ` + import { ref } from 'vue' + const count = ref(undefined) + `, + errors: [ + { + messageId: 'noType', + line: 3, + column: 23, + endLine: 3, + endColumn: 37 + } + ] + }, + { + filename: 'test.ts', + code: ` + import { shallowRef } from 'vue' + const count = shallowRef() + `, + errors: [ + { + messageId: 'noType', + line: 3, + column: 23, + endLine: 3, + endColumn: 35 + } + ] + }, + { + filename: 'test.ts', + code: ` + import { ref } from 'vue' + function useCount() { + const count = ref() + return { count } + } + `, + errors: [ + { + messageId: 'noType', + line: 4, + column: 25, + endLine: 4, + endColumn: 30 + } + ] + }, + { + filename: 'test.ts', + code: ` + import { ref } from 'vue' + function useCount() { + return { + count: ref() + } + } + `, + errors: [ + { + messageId: 'noType', + line: 5, + column: 20, + endLine: 5, + endColumn: 25 + } + ] + }, + { + filename: 'test.vue', + parser: require.resolve('vue-eslint-parser'), + code: ` + + `, + errors: [ + { + messageId: 'noType', + line: 4, + column: 25, + endLine: 4, + endColumn: 30 + } + ] + }, + { + filename: 'test.ts', + code: ` + import { ref, defineComponent } from 'vue' + defineComponent({ + setup() { + const count = ref() + return { count } + } + }) + `, + errors: [ + { + messageId: 'noType', + line: 5, + column: 27, + endLine: 5, + endColumn: 32 + } + ] + } + ] +})