diff --git a/.changeset/lucky-waves-obey.md b/.changeset/lucky-waves-obey.md new file mode 100644 index 000000000..402eaadb4 --- /dev/null +++ b/.changeset/lucky-waves-obey.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-vue": patch +--- + +feat: extend `require-default-prop` and `require-valid-default-prop` to check `defineModel` diff --git a/docs/rules/require-default-prop.md b/docs/rules/require-default-prop.md index 78b35456c..7b6393873 100644 --- a/docs/rules/require-default-prop.md +++ b/docs/rules/require-default-prop.md @@ -62,6 +62,15 @@ export default { } } + ``` diff --git a/docs/rules/require-valid-default-prop.md b/docs/rules/require-valid-default-prop.md index c7b291c87..6afd8f049 100644 --- a/docs/rules/require-valid-default-prop.md +++ b/docs/rules/require-valid-default-prop.md @@ -81,6 +81,13 @@ export default { } } + ``` diff --git a/lib/rules/require-default-prop.js b/lib/rules/require-default-prop.js index eeb2ae9bb..b73c84dbc 100644 --- a/lib/rules/require-default-prop.js +++ b/lib/rules/require-default-prop.js @@ -218,6 +218,33 @@ module.exports = { } return Boolean(defaultsByAssignmentPatterns[prop.propName]) }) + }, + onDefineModelEnter(node, model) { + if (model.options && model.options.type === 'ObjectExpression') { + if ( + propIsRequired(model.options) || + propHasDefault(model.options) || + model.options.properties.some( + (p) => + p.type === 'Property' && + utils.getStaticPropertyName(p) === 'type' && + isValueNodeOfBooleanType(p.value) + ) + ) { + return + } + context.report({ + node: model.options, + messageId: 'missingDefault', + data: { propName: model.name.modelName } + }) + } else { + context.report({ + node: model.options || node, + messageId: 'missingDefault', + data: { propName: model.name.modelName } + }) + } } }), utils.executeOnVue(context, (obj) => { diff --git a/lib/rules/require-valid-default-prop.ts b/lib/rules/require-valid-default-prop.ts index fce3d904b..83b3b38ca 100644 --- a/lib/rules/require-valid-default-prop.ts +++ b/lib/rules/require-valid-default-prop.ts @@ -10,6 +10,7 @@ import type { } from '../utils/index.js' import utils from '../utils/index.js' import { capitalize } from '../utils/casing.ts' +import tsTypes from '../utils/ts-utils/ts-types.js' const NATIVE_TYPES = new Set([ 'String', @@ -112,6 +113,10 @@ export default { node: CallExpression props: PropDefaultFunctionContext[] }[] = [] + const defineModelPropsContexts: { + node: CallExpression + props: PropDefaultFunctionContext[] + }[] = [] interface ScopeStack { upper: ScopeStack | null @@ -445,14 +450,24 @@ export default { | FunctionDeclaration | ArrowFunctionExpression ) { - const data = scriptSetupPropsContexts.at(-1) - if (!data || !scopeStack) { + if (!scopeStack) { return } - for (const { default: defType } of data.props) { - if (node.body === defType.functionBody) { - scopeStack.returnTypes = defType.returnTypes + const propsData = scriptSetupPropsContexts.at(-1) + if (propsData) { + for (const { default: defType } of propsData.props) { + if (node.body === defType.functionBody) { + scopeStack.returnTypes = defType.returnTypes + } + } + } + const modelData = defineModelPropsContexts.at(-1) + if (modelData) { + for (const { default: defType } of modelData.props) { + if (node.body === defType.functionBody) { + scopeStack.returnTypes = defType.returnTypes + } } } }, @@ -469,6 +484,72 @@ export default { for (const returnType of defType.returnTypes) { if (typeNames.has(returnType.type)) continue + report(returnType.node, prop, typeNames) + } + } + }, + onDefineModelEnter(node, model) { + let syntheticProp: + | ComponentObjectProp + | ComponentInferTypeProp + | null = null + let defaultFromOptions: Property | null = null + + if (model.typeNode) { + syntheticProp = { + type: 'infer-type', + propName: model.name.modelName, + node: model.typeNode, + required: false, + types: tsTypes.inferRuntimeTypeFromTypeNode( + context, + model.typeNode + ) + } + if (model.options && model.options.type === 'ObjectExpression') { + defaultFromOptions = getPropertyNode(model.options, 'default') + } + } else if ( + model.options && + model.options.type === 'ObjectExpression' + ) { + syntheticProp = { + type: 'object', + propName: model.name.modelName, + key: model.options, + value: model.options, + // `node` is only accessed in report() when propName is null, + // which never occurs here since propName is always modelName. + node: node as any + } + } + + if (!syntheticProp) { + return + } + + const propContexts = processPropDefs([syntheticProp], function* () { + if (defaultFromOptions) { + yield { + src: 'defaultProperty', + expression: defaultFromOptions.value + } + } + }) + defineModelPropsContexts.push({ node, props: propContexts }) + }, + onDefineModelExit() { + const data = defineModelPropsContexts.pop() + if (!data) { + return + } + for (const { + prop, + types: typeNames, + default: defType + } of data.props) { + for (const returnType of defType.returnTypes) { + if (typeNames.has(returnType.type)) continue report(returnType.node, prop, typeNames) } } diff --git a/tests/lib/rules/require-default-prop.test.ts b/tests/lib/rules/require-default-prop.test.ts index 554f9de22..ea5d536f0 100644 --- a/tests/lib/rules/require-default-prop.test.ts +++ b/tests/lib/rules/require-default-prop.test.ts @@ -413,6 +413,106 @@ ruleTester.run('require-default-prop', rule, { ...languageOptions, parserOptions: { parser: require.resolve('@typescript-eslint/parser') } } + }, + // defineModel — valid cases + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions + } + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions + } + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions + } + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions + } + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions + } + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions + } + }, + // defineModel — TypeScript syntax (valid) + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions, + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions, + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + } } ], @@ -738,6 +838,117 @@ ruleTester.run('require-default-prop', rule, { line: 3 } ] + }, + // defineModel — invalid cases + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions + }, + errors: [ + { + message: "Prop 'modelValue' requires default value to be set.", + line: 3 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions + }, + errors: [ + { + message: "Prop 'modelValue' requires default value to be set.", + line: 3 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions + }, + errors: [ + { + message: "Prop 'modelValue' requires default value to be set.", + line: 3 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions + }, + errors: [ + { + message: "Prop 'count' requires default value to be set.", + line: 3 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions + }, + errors: [ + { + message: "Prop 'modelValue' requires default value to be set.", + line: 3 + } + ] + }, + // defineModel — TypeScript syntax + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { + parser: vueEslintParser, + ...languageOptions, + parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + errors: [ + { + message: "Prop 'modelValue' requires default value to be set.", + line: 3 + } + ] } ] }) diff --git a/tests/lib/rules/require-valid-default-prop.test.ts b/tests/lib/rules/require-valid-default-prop.test.ts index 0009d1a31..cf2255e2e 100644 --- a/tests/lib/rules/require-valid-default-prop.test.ts +++ b/tests/lib/rules/require-valid-default-prop.test.ts @@ -340,6 +340,96 @@ ruleTester.run('require-valid-default-prop', rule, { `, ...getTypeScriptFixtureTestOptions() + }, + // defineModel — no default (nothing to check) + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { parser: vueEslintParser } + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { parser: vueEslintParser } + }, + // defineModel — correct runtime type match + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { parser: vueEslintParser } + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { parser: vueEslintParser } + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { parser: vueEslintParser } + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { parser: vueEslintParser } + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { parser: vueEslintParser } + }, + // defineModel — TypeScript syntax (valid) + { + code: ` + + `, + ...getTypeScriptFixtureTestOptions() + }, + { + code: ` + + `, + ...getTypeScriptFixtureTestOptions() + }, + { + code: ` + + `, + ...getTypeScriptFixtureTestOptions() } ], @@ -1224,6 +1314,105 @@ ruleTester.run('require-valid-default-prop', rule, { } ], ...getTypeScriptFixtureTestOptions() + }, + // defineModel — wrong type + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { parser: vueEslintParser }, + errors: [ + { + message: + "Type of the default value for 'modelValue' prop must be a boolean.", + line: 3 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { parser: vueEslintParser }, + errors: [ + { + message: + "Type of the default value for 'count' prop must be a number.", + line: 3 + } + ] + }, + // defineModel — Array/Object must use factory + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { parser: vueEslintParser }, + errors: [ + { + message: + "Type of the default value for 'modelValue' prop must be a function.", + line: 3 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { parser: vueEslintParser }, + errors: [ + { + message: + "Type of the default value for 'modelValue' prop must be a function.", + line: 3 + } + ] + }, + // defineModel — factory returns wrong type + { + filename: 'test.vue', + code: ` + + `, + languageOptions: { parser: vueEslintParser }, + errors: [ + { + message: + "Type of the default value for 'modelValue' prop must be a string.", + line: 3 + } + ] + }, + // defineModel — TypeScript syntax (invalid) + { + code: ` + + `, + ...getTypeScriptFixtureTestOptions(), + errors: [ + { + message: + "Type of the default value for 'modelValue' prop must be a string.", + line: 3 + } + ] } ] })