diff --git a/.changeset/soft-boats-repair.md b/.changeset/soft-boats-repair.md new file mode 100644 index 000000000..554fc40b2 --- /dev/null +++ b/.changeset/soft-boats-repair.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-vue': patch +--- + +Fixed false positive in `vue/return-in-computed-property` and `vue/require-render-return` for exhaustive switch statements when TypeScript type information is available diff --git a/lib/rules/require-render-return.js b/lib/rules/require-render-return.js index 084e63e5b..b68a20b1c 100644 --- a/lib/rules/require-render-return.js +++ b/lib/rules/require-render-return.js @@ -31,7 +31,7 @@ module.exports = { renderFunctions.set(node, node.parent.key) } }), - utils.executeOnFunctionsWithoutReturn(true, (node) => { + utils.executeOnFunctionsWithoutReturn(context, true, (node) => { const key = renderFunctions.get(node) if (key) { context.report({ diff --git a/lib/rules/return-in-computed-property.js b/lib/rules/return-in-computed-property.js index 0728d0c5d..855ad5f14 100644 --- a/lib/rules/return-in-computed-property.js +++ b/lib/rules/return-in-computed-property.js @@ -86,6 +86,7 @@ module.exports = { } }), utils.executeOnFunctionsWithoutReturn( + context, treatUndefinedAsUnspecified, (node) => { for (const cp of computedProperties) { diff --git a/lib/utils/index.js b/lib/utils/index.js index 4b030cf2f..777289548 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -76,6 +76,7 @@ const { getComponentPropsFromTypeDefine, getComponentEmitsFromTypeDefine, getComponentSlotsFromTypeDefine, + hasExhaustiveSwitchReturn, isTypeNode } = require('./ts-utils') @@ -1815,11 +1816,12 @@ module.exports = { /** * Find all functions which do not always return values + * @param {RuleContext} context The ESLint rule context object. * @param {boolean} treatUndefinedAsUnspecified * @param { (node: ArrowFunctionExpression | FunctionExpression | FunctionDeclaration) => void } cb Callback function * @returns {RuleListener} */ - executeOnFunctionsWithoutReturn(treatUndefinedAsUnspecified, cb) { + executeOnFunctionsWithoutReturn(context, treatUndefinedAsUnspecified, cb) { /** * @typedef {object} FuncInfo * @property {FuncInfo | null} funcInfo @@ -1837,7 +1839,10 @@ module.exports = { if (!funcInfo) { return true } - if (funcInfo.currentSegments.some((segment) => segment.reachable)) { + if ( + funcInfo.currentSegments.some((segment) => segment.reachable) && + !hasExhaustiveSwitchReturn(context, funcInfo.node) + ) { return false } return !treatUndefinedAsUnspecified || funcInfo.hasReturnValue diff --git a/lib/utils/ts-utils/index.js b/lib/utils/ts-utils/index.js index 3db610d1c..3a9395e2e 100644 --- a/lib/utils/ts-utils/index.js +++ b/lib/utils/ts-utils/index.js @@ -11,7 +11,8 @@ const { const { getComponentPropsFromTypeDefineTypes, getComponentEmitsFromTypeDefineTypes, - getComponentSlotsFromTypeDefineTypes + getComponentSlotsFromTypeDefineTypes, + hasExhaustiveSwitchReturn } = require('./ts-types') /** @@ -31,6 +32,7 @@ const { module.exports = { isTypeNode, + hasExhaustiveSwitchReturn, getComponentPropsFromTypeDefine, getComponentEmitsFromTypeDefine, getComponentSlotsFromTypeDefine diff --git a/lib/utils/ts-utils/ts-types.js b/lib/utils/ts-utils/ts-types.js index 0f94b7c53..d28126b47 100644 --- a/lib/utils/ts-utils/ts-types.js +++ b/lib/utils/ts-utils/ts-types.js @@ -32,7 +32,8 @@ module.exports = { getComponentPropsFromTypeDefineTypes, getComponentEmitsFromTypeDefineTypes, getComponentSlotsFromTypeDefineTypes, - inferRuntimeTypeFromTypeNode + inferRuntimeTypeFromTypeNode, + hasExhaustiveSwitchReturn } /** @@ -334,3 +335,130 @@ function* iterateTypes(type) { yield type } } + +/** + * @param {Type} type + * @returns {Type[]} + */ +function unionConstituents(type) { + return type.isUnion() ? type.types : [type] +} + +/** + * @param {Type} type + * @returns {Type[]} + */ +function intersectionConstituents(type) { + return type.isIntersection() ? type.types : [type] +} + +/** + * @param {Type} type + * @param {typeof import('typescript')} ts + * @returns {boolean} + */ +function isTypeLiteralLike(type, ts) { + return ( + (type.flags & + (ts.TypeFlags.Literal | + ts.TypeFlags.Undefined | + ts.TypeFlags.Null | + ts.TypeFlags.UniqueESSymbol)) !== + 0 + ) +} + +/** + * @param {ESNode} stmt + * @returns {boolean} + */ +function hasTerminalReturn(stmt) { + if (stmt.type === 'ReturnStatement' || stmt.type === 'ThrowStatement') { + return true + } + if (stmt.type === 'BlockStatement') { + const stmts = stmt.body + if (stmts.length === 0) return false + return hasTerminalReturn(/** @type {ESNode} */ (stmts.at(-1))) + } + return false +} + +/** + * @param {RuleContext} context + * @param {SwitchStatement} switchNode + * @returns {boolean} + */ +function isExhaustiveSwitch(context, switchNode) { + const services = getTSParserServices(context) + if (!services) return false + + const { ts, tsNodeMap, checker } = services + + const discriminantTSNode = tsNodeMap.get(switchNode.discriminant) + if (!discriminantTSNode) return false + + let discriminantType = checker.getTypeAtLocation(discriminantTSNode) + if (!discriminantType) return false + discriminantType = + checker.getBaseConstraintOfType(discriminantType) ?? discriminantType + + /** @type {Set} */ + const caseTypes = new Set() + for (const switchCase of switchNode.cases) { + if (switchCase.test === null) return true + const caseTSNode = tsNodeMap.get(switchCase.test) + if (!caseTSNode) return false + let caseType = checker.getTypeAtLocation(caseTSNode) + if (!caseType) return false + caseType = checker.getBaseConstraintOfType(caseType) ?? caseType + caseTypes.add(caseType) + } + + let hasLiteralConstituent = false + const caseHasUndefined = [...caseTypes].some( + (t) => (t.flags & ts.TypeFlags.Undefined) !== 0 + ) + for (const unionPart of unionConstituents(discriminantType)) { + for (const intersectionPart of intersectionConstituents(unionPart)) { + if (!isTypeLiteralLike(intersectionPart, ts)) continue + hasLiteralConstituent = true + if (caseTypes.has(intersectionPart)) continue + // multiple TS objects can represent undefined + if ( + caseHasUndefined && + (intersectionPart.flags & ts.TypeFlags.Undefined) !== 0 + ) { + continue + } + return false + } + } + if (!hasLiteralConstituent) return false + + for (const switchCase of switchNode.cases) { + if (switchCase.consequent.length === 0) continue + const last = switchCase.consequent.at(-1) + if (!last || !hasTerminalReturn(last)) return false + } + return true +} + +/** + * Check if a function body's last statement is a type-exhaustive switch + * where every non-fallthrough case returns or throws. + * @param {RuleContext} context The ESLint rule context object. + * @param {ArrowFunctionExpression | FunctionExpression | FunctionDeclaration} node + * @returns {boolean} + */ +function hasExhaustiveSwitchReturn(context, node) { + const body = node.body + const stmts = body && body.type === 'BlockStatement' ? body.body : null + if (!stmts || stmts.length === 0) return false + const lastStmt = stmts.at(-1) + return ( + lastStmt != null && + lastStmt.type === 'SwitchStatement' && + isExhaustiveSwitch(context, lastStmt) + ) +} diff --git a/tests/fixtures/typescript/src/test01.ts b/tests/fixtures/typescript/src/test01.ts index 3d2e059e5..31aeb8ef5 100644 --- a/tests/fixtures/typescript/src/test01.ts +++ b/tests/fixtures/typescript/src/test01.ts @@ -30,3 +30,28 @@ export type Slots1 = { default(props: { msg: string }): any foo(props: { msg: string }): any } + +export type Status = 'active' | 'inactive' | 'pending' +export enum Color { + Red, + Green, + Blue +} +export type NullableKind = 'a' | 'b' | null +export type UndefinedKind = 'a' | 'b' | undefined +export type FullyNullable = 'x' | 'y' | null | undefined +export type NumericUnion = 1 | 2 | 3 +export enum StringStatus { + Active = 'active', + Inactive = 'inactive' +} + +type Brand = T & { __brand: B } +export type BrandedStatus = Brand<'active', 'status'> | Brand<'inactive', 'status'> +export type BigIntUnion = 1n | 2n | 3n +export type MixedLiterals = 0 | 1 | 'two' | true + +export type ClickEvent = { type: 'click'; x: number; y: number } +export type HoverEvent = { type: 'hover'; target: string } +export type KeyEvent = { type: 'key'; code: number } +export type AppEvent = ClickEvent | HoverEvent | KeyEvent diff --git a/tests/lib/rules/require-render-return.test.ts b/tests/lib/rules/require-render-return.test.ts index e0ed9a7d2..46c560990 100644 --- a/tests/lib/rules/require-render-return.test.ts +++ b/tests/lib/rules/require-render-return.test.ts @@ -125,6 +125,20 @@ ruleTester.run('require-render-return', rule, { } }`, languageOptions + }, + // Switch with all cases returning AND a default + { + filename: 'test.vue', + code: `export default { + render() { + switch (this.type) { + case 'a': return h('div') + case 'b': return h('span') + default: return h('p') + } + } + }`, + languageOptions } ], @@ -243,6 +257,98 @@ ruleTester.run('require-render-return', rule, { endColumn: 15 } ] + }, + // JS: Switch with all cases returning but no default — no type info, must error + { + filename: 'test.vue', + code: `export default { + render() { + switch (this.type) { + case 'a': return h('div') + case 'b': return h('span') + } + } + }`, + languageOptions, + errors: [ + { + message: 'Expected to return a value in render function.', + line: 2 + } + ] + }, + // JS: Vue.component switch without default — must error + { + code: `Vue.component('test', { + render() { + switch (this.type) { + case 'a': return h('div') + case 'b': return h('span') + } + } + })`, + languageOptions, + errors: [ + { + message: 'Expected to return a value in render function.', + line: 2 + } + ] + }, + // Switch where one case uses break + { + filename: 'test.vue', + code: `export default { + render() { + switch (this.type) { + case 'a': return h('div') + case 'b': break + } + } + }`, + languageOptions, + errors: [ + { + message: 'Expected to return a value in render function.', + line: 2 + } + ] + }, + // Empty switch + { + filename: 'test.vue', + code: `export default { + render() { + switch (this.type) { + } + } + }`, + languageOptions, + errors: [ + { + message: 'Expected to return a value in render function.', + line: 2 + } + ] + }, + // Switch with only fallthrough cases + { + filename: 'test.vue', + code: `export default { + render() { + switch (this.type) { + case 'a': + case 'b': + } + } + }`, + languageOptions, + errors: [ + { + message: 'Expected to return a value in render function.', + line: 2 + } + ] } ] }) diff --git a/tests/lib/rules/return-in-computed-property.test.ts b/tests/lib/rules/return-in-computed-property.test.ts index 63334a7d1..7786d6aa0 100644 --- a/tests/lib/rules/return-in-computed-property.test.ts +++ b/tests/lib/rules/return-in-computed-property.test.ts @@ -4,6 +4,7 @@ */ import type { Linter } from 'eslint' import rule from '../../../lib/rules/return-in-computed-property' +import { getTypeScriptFixtureTestOptions } from '../../test-utils/typescript' import { RuleTester } from '../../eslint-compat' const languageOptions: Linter.LanguageOptions = { @@ -144,6 +145,277 @@ ruleTester.run('return-in-computed-property', rule, { `, options: [{ treatUndefinedAsUnspecified: false }], languageOptions + }, + // Switch with all cases returning AND a default (ESLint handles this via code path analysis) + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo () { + switch (this.type) { + case 'a': return 1 + case 'b': return 2 + default: return 3 + } + } + } + } + `, + languageOptions + }, + // TS: Union type — all literal cases covered + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: Enum — all members covered + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: Boolean — true and false covered + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: Nullable union — all cases including null covered + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: Union with mix of return and throw + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: Union with fallthrough cases + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: Undefined in union — all cases including undefined covered + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: Both null and undefined in union — all cases covered + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: Numeric literal union — all cases covered + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: String enum — all members covered + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: Generic component with constrained type parameter — all cases covered + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: Branded/intersection type — all cases covered + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: BigInt literal union — all cases covered + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: Heterogeneous literal union — mixed number, string, boolean + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() + }, + // TS: Discriminated union — all cases covered via property access + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() } ], @@ -446,6 +718,421 @@ ruleTester.run('return-in-computed-property', rule, { endColumn: 12 } ] + }, + // JS: Switch with all cases returning but no default — no type info, must error + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo () { + switch (this.type) { + case 'a': return 1 + case 'b': return 2 + case 'c': return 3 + } + } + } + } + `, + languageOptions, + errors: [ + { + message: 'Expected to return a value in "foo" computed property.', + line: 4 + } + ] + }, + // JS: Composition API switch without default — no type info, must error + { + filename: 'test.vue', + code: ` + import {computed} from 'vue' + export default { + setup() { + const foo = computed(() => { + switch (type) { + case 'a': return 1 + case 'b': return 2 + } + }) + } + } + `, + languageOptions, + errors: [ + { + message: 'Expected to return a value in computed function.', + line: 5 + } + ] + }, + // TS: Union type — missing a case + { + code: ` + `, + ...getTypeScriptFixtureTestOptions(), + errors: [ + { + message: 'Expected to return a value in computed function.', + line: 6 + } + ] + }, + // TS: Wide type (string) — not a finite union, must error + { + code: ` + `, + ...getTypeScriptFixtureTestOptions(), + errors: [ + { + message: 'Expected to return a value in computed function.', + line: 5 + } + ] + }, + // TS: Exhaustive union but case uses break — must still error + { + code: ` + `, + ...getTypeScriptFixtureTestOptions(), + errors: [ + { + message: 'Expected to return a value in computed function.', + line: 6 + } + ] + }, + // TS: Missing null in nullable union — must error + { + code: ` + `, + ...getTypeScriptFixtureTestOptions(), + errors: [ + { + message: 'Expected to return a value in computed function.', + line: 6 + } + ] + }, + // TS: Missing undefined in union — must error + { + code: ` + `, + ...getTypeScriptFixtureTestOptions(), + errors: [ + { + message: 'Expected to return a value in computed function.', + line: 6 + } + ] + }, + // TS: Generic component with constrained type parameter — missing a case + { + code: ` + `, + ...getTypeScriptFixtureTestOptions(), + errors: [ + { + message: 'Expected to return a value in computed function.', + line: 6 + } + ] + }, + // TS: Discriminated union — missing a case + { + code: ` + `, + ...getTypeScriptFixtureTestOptions(), + errors: [ + { + message: 'Expected to return a value in computed function.', + line: 6 + } + ] + }, + // TS: BigInt literal union — missing a case + { + code: ` + `, + ...getTypeScriptFixtureTestOptions(), + errors: [ + { + message: 'Expected to return a value in computed function.', + line: 6 + } + ] + }, + // Switch where one case uses break instead of return + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo () { + switch (this.type) { + case 'a': return 1 + case 'b': break + case 'c': return 3 + } + } + } + } + `, + languageOptions, + errors: [ + { + message: 'Expected to return a value in "foo" computed property.', + line: 4 + } + ] + }, + // Switch where one non-fallthrough case has no return + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo () { + switch (this.type) { + case 'a': return 1 + case 'b': console.log('b') + case 'c': return 3 + } + } + } + } + `, + languageOptions, + errors: [ + { + message: 'Expected to return a value in "foo" computed property.', + line: 4 + } + ] + }, + // Empty switch (no cases) + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo () { + switch (this.type) { + } + } + } + } + `, + languageOptions, + errors: [ + { + message: 'Expected to return a value in "foo" computed property.', + line: 4 + } + ] + }, + // Code after the switch — switch is not the last statement + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo () { + switch (this.type) { + case 'a': return 1 + case 'b': return 2 + } + console.log('after switch') + } + } + } + `, + languageOptions, + errors: [ + { + message: 'Expected to return a value in "foo" computed property.', + line: 4 + } + ] + }, + // Conditional return inside a case (conservative — still flagged) + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo () { + switch (this.type) { + case 'a': + if (this.x) return 1 + case 'b': return 2 + } + } + } + } + `, + languageOptions, + errors: [ + { + message: 'Expected to return a value in "foo" computed property.', + line: 4 + } + ] + }, + // Switch with only fallthrough cases and no terminal returning case + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo () { + switch (this.type) { + case 'a': + case 'b': + case 'c': + } + } + } + } + `, + languageOptions, + errors: [ + { + message: 'Expected to return a value in "foo" computed property.', + line: 4 + } + ] + }, + // Return inside a nested function in a case (doesn't count) + { + filename: 'test.vue', + code: ` + export default { + computed: { + foo () { + switch (this.type) { + case 'a': + return 1 + case 'b': + const fn = () => { return 2 } + fn() + } + } + } + } + `, + languageOptions, + errors: [ + { + message: 'Expected to return a value in "foo" computed property.', + line: 4 + } + ] + }, + // Composition API — switch with break (invalid) + { + filename: 'test.vue', + code: ` + import {computed} from 'vue' + export default { + setup() { + const foo = computed(() => { + switch (type) { + case 'a': return 1 + case 'b': break + } + }) + } + } + `, + languageOptions, + errors: [ + { + message: 'Expected to return a value in computed function.', + line: 5 + } + ] } ] })