diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 2945e4246..5b2d65516 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -73,7 +73,7 @@ const sidebarCategories = [ } ] -const categorizedRules: DefaultTheme.SidebarGroup[] = [] +const categorizedRules: DefaultTheme.SidebarItem[] = [] for (const { title, categoryIds } of sidebarCategories) { const categoryRules = rules .filter((rule) => rule.meta.docs.categories && !rule.meta.deprecated) @@ -84,8 +84,10 @@ for (const { title, categoryIds } of sidebarCategories) { ) const children: DefaultTheme.SidebarItem[] = categoryRules .filter(({ ruleId }) => { - const exists = categorizedRules.some(({ items }) => - items.some(({ text: alreadyRuleId }) => alreadyRuleId === ruleId) + const exists = categorizedRules.some( + ({ items }) => + items && + items.some(({ text: alreadyRuleId }) => alreadyRuleId === ruleId) ) return !exists }) @@ -101,16 +103,16 @@ for (const { title, categoryIds } of sidebarCategories) { } categorizedRules.push({ text: title, - collapsible: false, + collapsed: false, items: children }) } -const extraCategories: DefaultTheme.SidebarGroup[] = [] +const extraCategories: DefaultTheme.SidebarItem[] = [] if (uncategorizedRules.length > 0) { extraCategories.push({ text: 'Uncategorized', - collapsible: false, + collapsed: false, items: uncategorizedRules.map(({ ruleId, name }) => ({ text: ruleId, link: `/rules/${name}` @@ -120,7 +122,7 @@ if (uncategorizedRules.length > 0) { if (uncategorizedExtensionRule.length > 0) { extraCategories.push({ text: 'Extension Rules', - collapsible: false, + collapsed: false, items: uncategorizedExtensionRule.map(({ ruleId, name }) => ({ text: ruleId, link: `/rules/${name}` @@ -130,7 +132,7 @@ if (uncategorizedExtensionRule.length > 0) { if (deprecatedRules.length > 0) { extraCategories.push({ text: 'Deprecated', - collapsible: false, + collapsed: false, items: deprecatedRules.map(({ ruleId, name }) => ({ text: ruleId, link: `/rules/${name}` diff --git a/lib/rules/no-restricted-props.js b/lib/rules/no-restricted-props.js index 564bf0773..db673e4c2 100644 --- a/lib/rules/no-restricted-props.js +++ b/lib/rules/no-restricted-props.js @@ -109,14 +109,17 @@ module.exports = { option.message || `Using \`${prop.propName}\` props is not allowed.` context.report({ - node: prop.key, + node: prop.type !== 'infer-type' ? prop.key : prop.node, messageId: 'restrictedProp', data: { message }, - suggest: createSuggest( - prop.key, - option, - withDefaultsProps && withDefaultsProps[prop.propName] - ) + suggest: + prop.type !== 'infer-type' + ? createSuggest( + prop.key, + option, + withDefaultsProps && withDefaultsProps[prop.propName] + ) + : null }) break } diff --git a/lib/rules/no-unused-properties.js b/lib/rules/no-unused-properties.js index 1421ad0b8..818dcc053 100644 --- a/lib/rules/no-unused-properties.js +++ b/lib/rules/no-unused-properties.js @@ -30,7 +30,7 @@ const { * @typedef {object} ComponentNonObjectPropertyData * @property {string} name * @property {GroupName} groupName - * @property {'array' | 'type'} type + * @property {'array' | 'type' | 'infer-type'} type * @property {ASTNode} node * * @typedef { ComponentNonObjectPropertyData | ComponentObjectPropertyData } ComponentPropertyData @@ -423,7 +423,7 @@ module.exports = { type: prop.type, name: prop.propName, groupName: 'props', - node: prop.key + node: prop.type !== 'infer-type' ? prop.key : prop.node }) } } diff --git a/lib/rules/padding-lines-in-component-definition.js b/lib/rules/padding-lines-in-component-definition.js index b2f1a54dc..1c2b6e889 100644 --- a/lib/rules/padding-lines-in-component-definition.js +++ b/lib/rules/padding-lines-in-component-definition.js @@ -6,6 +6,7 @@ /** * @typedef {import('../utils').ComponentProp} ComponentProp + * @typedef {import('../utils').ComponentEmit} ComponentEmit * @typedef {import('../utils').GroupName} GroupName */ @@ -33,10 +34,19 @@ function isComma(node) { } /** - * @param {string} nodeType + * @typedef {Exclude & { node: {type: 'Property' | 'SpreadElement'} }} ValidComponentPropOrEmit */ -function isValidProperties(nodeType) { - return ['Property', 'SpreadElement'].includes(nodeType) +/** + * @template {ComponentProp | ComponentEmit} T + * @param {T} propOrEmit + * @returns {propOrEmit is ValidComponentPropOrEmit & T} + */ +function isValidProperties(propOrEmit) { + return Boolean( + propOrEmit.type !== 'infer-type' && + propOrEmit.node && + ['Property', 'SpreadElement'].includes(propOrEmit.node.type) + ) } /** @@ -320,11 +330,9 @@ module.exports = { }), utils.defineScriptSetupVisitor(context, { onDefinePropsEnter(_, props) { - const propNodes = /** @type {(Property | SpreadElement)[]} */ ( - props - .filter((prop) => prop.node && isValidProperties(prop.node.type)) - .map((prop) => prop.node) - ) + const propNodes = props + .filter(isValidProperties) + .map((prop) => prop.node) const withinOption = parseOption(options, OptionKeys.WithinOption) const propsOption = withinOption && parseOption(withinOption, 'props') @@ -337,11 +345,9 @@ module.exports = { ) }, onDefineEmitsEnter(_, emits) { - const emitNodes = /** @type {(Property | SpreadElement)[]} */ ( - emits - .filter((emit) => emit.node && isValidProperties(emit.node.type)) - .map((emit) => emit.node) - ) + const emitNodes = emits + .filter(isValidProperties) + .map((emit) => emit.node) const withinOption = parseOption(options, OptionKeys.WithinOption) const emitsOption = withinOption && parseOption(withinOption, 'emits') diff --git a/lib/rules/prefer-prop-type-boolean-first.js b/lib/rules/prefer-prop-type-boolean-first.js index e26f9462e..aa16707c4 100644 --- a/lib/rules/prefer-prop-type-boolean-first.js +++ b/lib/rules/prefer-prop-type-boolean-first.js @@ -67,6 +67,9 @@ module.exports = { * @param {import('../utils').ComponentProp} prop */ function checkProperty(prop) { + if (prop.type !== 'object') { + return + } const { value } = prop if (!value) { return diff --git a/lib/rules/require-emit-validator.js b/lib/rules/require-emit-validator.js index 6fc439fd2..8b478be9d 100644 --- a/lib/rules/require-emit-validator.js +++ b/lib/rules/require-emit-validator.js @@ -34,24 +34,29 @@ module.exports = { * @param {ComponentEmit} emit */ function checker(emit) { - if (emit.type !== 'object' && emit.type !== 'array') { - return - } - const { value, node, emitName } = emit - const hasType = - !!value && - (value.type === 'ArrowFunctionExpression' || + /** @type {Expression|null} */ + let value = null + let hasType = false + if (emit.type === 'object') { + value = emit.value + hasType = + value.type === 'ArrowFunctionExpression' || value.type === 'FunctionExpression' || // validator may from outer scope - value.type === 'Identifier') + value.type === 'Identifier' + } else if (emit.type !== 'array') { + return + } if (!hasType) { + const { node, emitName } = emit const name = emitName || (node.type === 'Identifier' && node.name) || 'Unknown emit' if (value && value.type === 'Literal' && value.value === null) { + const valueNode = value context.report({ node, messageId: 'skipped', @@ -59,7 +64,7 @@ module.exports = { suggest: [ { messageId: 'emptyValidation', - fix: (fixer) => fixer.replaceText(value, '() => true') + fix: (fixer) => fixer.replaceText(valueNode, '() => true') } ] }) diff --git a/lib/rules/require-explicit-emits.js b/lib/rules/require-explicit-emits.js index 167613804..267f4e610 100644 --- a/lib/rules/require-explicit-emits.js +++ b/lib/rules/require-explicit-emits.js @@ -429,7 +429,10 @@ module.exports = { function buildSuggest(define, emits, nameWithLoc, context) { const emitsKind = define.type === 'ObjectExpression' ? '`emits` option' : '`defineEmits`' - const certainEmits = emits.filter((e) => e.key) + const certainEmits = emits.filter( + /** @returns {e is ComponentEmit & {type:'array'|'object'}} */ + (e) => e.type === 'array' || e.type === 'object' + ) if (certainEmits.length > 0) { const last = certainEmits[certainEmits.length - 1] return [ diff --git a/lib/rules/require-prop-comment.js b/lib/rules/require-prop-comment.js index 8dd852cbc..845bba9df 100644 --- a/lib/rules/require-prop-comment.js +++ b/lib/rules/require-prop-comment.js @@ -62,7 +62,7 @@ module.exports = { */ function verifyProps(props) { for (const prop of props) { - if (!prop.propName) { + if (!prop.propName || prop.type === 'infer-type') { continue } diff --git a/lib/rules/require-prop-type-constructor.js b/lib/rules/require-prop-type-constructor.js index a708508e5..45354a1d1 100644 --- a/lib/rules/require-prop-type-constructor.js +++ b/lib/rules/require-prop-type-constructor.js @@ -79,7 +79,7 @@ module.exports = { /** @param {ComponentProp[]} props */ function verifyProps(props) { for (const prop of props) { - if (!prop.value || prop.propName == null) { + if (prop.type !== 'object' || prop.propName == null) { continue } if ( diff --git a/lib/rules/require-prop-types.js b/lib/rules/require-prop-types.js index e4d9fdcc1..f603fd184 100644 --- a/lib/rules/require-prop-types.js +++ b/lib/rules/require-prop-types.js @@ -51,12 +51,12 @@ module.exports = { if (prop.type !== 'object' && prop.type !== 'array') { return } - const { value, node, propName } = prop let hasType = true - if (!value) { + if (prop.type === 'array') { hasType = false } else { + const { value } = prop switch (value.type) { case 'ObjectExpression': { // foo: { @@ -77,6 +77,7 @@ module.exports = { } if (!hasType) { + const { node, propName } = prop const name = propName || (node.type === 'Identifier' && node.name) || diff --git a/lib/rules/require-valid-default-prop.js b/lib/rules/require-valid-default-prop.js index 50a3d191b..9535f51d6 100644 --- a/lib/rules/require-valid-default-prop.js +++ b/lib/rules/require-valid-default-prop.js @@ -7,9 +7,11 @@ const utils = require('../utils') const { capitalize } = require('../utils/casing') /** + * @typedef {import('../utils').ComponentProp} ComponentProp * @typedef {import('../utils').ComponentObjectProp} ComponentObjectProp * @typedef {import('../utils').ComponentArrayProp} ComponentArrayProp * @typedef {import('../utils').ComponentTypeProp} ComponentTypeProp + * @typedef {import('../utils').ComponentInferTypeProp} ComponentInferTypeProp * @typedef {import('../utils').ComponentUnknownProp} ComponentUnknownProp * @typedef {import('../utils').VueObjectData} VueObjectData */ @@ -108,7 +110,7 @@ module.exports = { */ /** * @typedef {object} PropDefaultFunctionContext - * @property {ComponentObjectProp | ComponentTypeProp} prop + * @property {ComponentObjectProp | ComponentTypeProp | ComponentInferTypeProp} prop * @property {Set} types * @property {FunctionValueType} default */ @@ -225,7 +227,7 @@ module.exports = { /** * @param {*} node - * @param {ComponentObjectProp | ComponentTypeProp} prop + * @param {ComponentObjectProp | ComponentTypeProp | ComponentInferTypeProp} prop * @param {Iterable} expectedTypeNames */ function report(node, prop, expectedTypeNames) { @@ -245,7 +247,7 @@ module.exports = { } /** - * @param {(ComponentObjectDefineProp | ComponentTypeProp)[]} props + * @param {(ComponentObjectDefineProp | ComponentTypeProp | ComponentInferTypeProp)[]} props * @param { { [key: string]: Expression | undefined } } withDefaults */ function processPropDefs(props, withDefaults) { @@ -394,15 +396,15 @@ module.exports = { }), utils.defineScriptSetupVisitor(context, { onDefinePropsEnter(node, baseProps) { - /** @type {(ComponentObjectDefineProp | ComponentTypeProp)[]} */ const props = baseProps.filter( /** - * @param {ComponentObjectProp | ComponentArrayProp | ComponentTypeProp | ComponentUnknownProp} prop - * @returns {prop is ComponentObjectDefineProp | ComponentTypeProp} + * @param {ComponentProp} prop + * @returns {prop is ComponentObjectDefineProp | ComponentInferTypeProp | ComponentTypeProp} */ (prop) => Boolean( prop.type === 'type' || + prop.type === 'infer-type' || (prop.type === 'object' && prop.value.type === 'ObjectExpression') ) diff --git a/lib/rules/return-in-emits-validator.js b/lib/rules/return-in-emits-validator.js index 4b8a3b1da..86db20ac9 100644 --- a/lib/rules/return-in-emits-validator.js +++ b/lib/rules/return-in-emits-validator.js @@ -65,7 +65,7 @@ module.exports = { */ function processEmits(emits) { for (const emit of emits) { - if (!emit.value) { + if (emit.type !== 'object' || !emit.value) { continue } if ( diff --git a/lib/utils/indent-ts.js b/lib/utils/indent-ts.js index c7ad7ad63..5bee66889 100644 --- a/lib/utils/indent-ts.js +++ b/lib/utils/indent-ts.js @@ -12,7 +12,7 @@ const { isClosingBracketToken, isOpeningBracketToken } = require('@eslint-community/eslint-utils') -const { isTypeNode } = require('./ts-ast-utils') +const { isTypeNode } = require('./ts-utils') /** * @typedef {import('../../typings/eslint-plugin-vue/util-types/indent-helper').TSNodeListener} TSNodeListener @@ -227,7 +227,7 @@ function defineVisitor({ processSemicolons(node) }, /** - * @param {TSESTreeNode} node + * @param {ASTNode} node */ // eslint-disable-next-line complexity -- ignore '*[type=/^TS/]'(node) { diff --git a/lib/utils/index.js b/lib/utils/index.js index a63891bd9..ef8cd0ee9 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -15,11 +15,13 @@ * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentArrayProp} ComponentArrayProp * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentObjectProp} ComponentObjectProp * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentTypeProp} ComponentTypeProp + * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentInferTypeProp} ComponentInferTypeProp * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentUnknownProp} ComponentUnknownProp * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentProp} ComponentProp * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentArrayEmit} ComponentArrayEmit * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentObjectEmit} ComponentObjectEmit * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentTypeEmit} ComponentTypeEmit + * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentInferTypeEmit} ComponentInferTypeEmit * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentUnknownEmit} ComponentUnknownEmit * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentEmit} ComponentEmit */ @@ -60,7 +62,7 @@ const { getComponentPropsFromTypeDefine, getComponentEmitsFromTypeDefine, isTypeNode -} = require('./ts-ast-utils') +} = require('./ts-utils') /** * @type { WeakMap } @@ -2914,9 +2916,7 @@ function getComponentPropsFromOptions(componentObject) { return [ { type: 'unknown', - key: null, propName: null, - value: null, node: propsNode.value } ] @@ -2949,9 +2949,7 @@ function getComponentEmitsFromOptions(componentObject) { return [ { type: 'unknown', - key: null, emitName: null, - value: null, node: emitsNode.value } ] @@ -2964,7 +2962,7 @@ function getComponentEmitsFromOptions(componentObject) { * Get all props from `defineProps` call expression. * @param {RuleContext} context The rule context object. * @param {CallExpression} node `defineProps` call expression - * @return {(ComponentArrayProp | ComponentObjectProp | ComponentTypeProp | ComponentUnknownProp)[]} Array of component props + * @return {ComponentProp[]} Array of component props */ function getComponentPropsFromDefineProps(context, node) { if (node.arguments.length > 0) { @@ -2975,9 +2973,7 @@ function getComponentPropsFromDefineProps(context, node) { return [ { type: 'unknown', - key: null, propName: null, - value: null, node: node.arguments[0] } ] @@ -2991,9 +2987,7 @@ function getComponentPropsFromDefineProps(context, node) { return [ { type: 'unknown', - key: null, propName: null, - value: null, node: null } ] @@ -3003,7 +2997,7 @@ function getComponentPropsFromDefineProps(context, node) { * Get all emits from `defineEmits` call expression. * @param {RuleContext} context The rule context object. * @param {CallExpression} node `defineEmits` call expression - * @return {(ComponentArrayEmit | ComponentObjectEmit | ComponentTypeEmit | ComponentUnknownEmit)[]} Array of component emits + * @return {ComponentEmit[]} Array of component emits */ function getComponentEmitsFromDefineEmits(context, node) { if (node.arguments.length > 0) { @@ -3014,9 +3008,7 @@ function getComponentEmitsFromDefineEmits(context, node) { return [ { type: 'unknown', - key: null, emitName: null, - value: null, node: node.arguments[0] } ] @@ -3030,9 +3022,7 @@ function getComponentEmitsFromDefineEmits(context, node) { return [ { type: 'unknown', - key: null, emitName: null, - value: null, node: null } ] @@ -3044,34 +3034,35 @@ function getComponentEmitsFromDefineEmits(context, node) { */ function getComponentPropsFromDefine(propsNode) { if (propsNode.type === 'ObjectExpression') { - return propsNode.properties.map((prop) => { - if (!isProperty(prop)) { - return { - type: 'unknown', - key: null, - propName: null, - value: null, - node: prop + return propsNode.properties.map( + /** @returns {ComponentArrayProp | ComponentObjectProp | ComponentUnknownProp} */ + (prop) => { + if (!isProperty(prop)) { + return { + type: 'unknown', + propName: null, + node: prop + } + } + const propName = getStaticPropertyName(prop) + if (propName != null) { + return { + type: 'object', + key: prop.key, + propName, + value: skipTSAsExpression(prop.value), + node: prop + } } - } - const propName = getStaticPropertyName(prop) - if (propName != null) { return { type: 'object', - key: prop.key, - propName, + key: null, + propName: null, value: skipTSAsExpression(prop.value), node: prop } } - return { - type: 'object', - key: null, - propName: null, - value: skipTSAsExpression(prop.value), - node: prop - } - }) + ) } return propsNode.elements.filter(isDef).map((prop) => { diff --git a/lib/utils/ts-ast-utils.js b/lib/utils/ts-ast-utils.js deleted file mode 100644 index 330e6b985..000000000 --- a/lib/utils/ts-ast-utils.js +++ /dev/null @@ -1,354 +0,0 @@ -const { findVariable } = require('@eslint-community/eslint-utils') -/** - * @typedef {import('@typescript-eslint/types').TSESTree.TypeNode} TypeNode - * @typedef {import('@typescript-eslint/types').TSESTree.TSInterfaceBody} TSInterfaceBody - * @typedef {import('@typescript-eslint/types').TSESTree.TSTypeLiteral} TSTypeLiteral - * @typedef {import('@typescript-eslint/types').TSESTree.Parameter} TSESTreeParameter - * @typedef {import('@typescript-eslint/types').TSESTree.Node} Node - * - */ -/** - * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentTypeProp} ComponentTypeProp - * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentTypeEmit} ComponentTypeEmit - * @typedef {import('../../typings/eslint-plugin-vue/util-types/utils').ComponentUnknownEmit} ComponentUnknownEmit - */ - -module.exports = { - isTypeNode, - getComponentPropsFromTypeDefine, - getComponentEmitsFromTypeDefine -} - -/** - * @param {Node | ASTNode} node - * @returns {node is TypeNode} - */ -function isTypeNode(node) { - return ( - node.type === 'TSAnyKeyword' || - node.type === 'TSArrayType' || - node.type === 'TSBigIntKeyword' || - node.type === 'TSBooleanKeyword' || - node.type === 'TSConditionalType' || - node.type === 'TSConstructorType' || - node.type === 'TSFunctionType' || - node.type === 'TSImportType' || - node.type === 'TSIndexedAccessType' || - node.type === 'TSInferType' || - node.type === 'TSIntersectionType' || - node.type === 'TSIntrinsicKeyword' || - node.type === 'TSLiteralType' || - node.type === 'TSMappedType' || - node.type === 'TSNamedTupleMember' || - node.type === 'TSNeverKeyword' || - node.type === 'TSNullKeyword' || - node.type === 'TSNumberKeyword' || - node.type === 'TSObjectKeyword' || - node.type === 'TSOptionalType' || - node.type === 'TSRestType' || - node.type === 'TSStringKeyword' || - node.type === 'TSSymbolKeyword' || - node.type === 'TSTemplateLiteralType' || - node.type === 'TSThisType' || - node.type === 'TSTupleType' || - node.type === 'TSTypeLiteral' || - node.type === 'TSTypeOperator' || - node.type === 'TSTypePredicate' || - node.type === 'TSTypeQuery' || - node.type === 'TSTypeReference' || - node.type === 'TSUndefinedKeyword' || - node.type === 'TSUnionType' || - node.type === 'TSUnknownKeyword' || - node.type === 'TSVoidKeyword' - ) -} - -/** - * @param {TypeNode} node - * @returns {node is TSTypeLiteral} - */ -function isTSTypeLiteral(node) { - return node.type === 'TSTypeLiteral' -} -/** - * @param {TypeNode} node - * @returns {node is TSFunctionType} - */ -function isTSFunctionType(node) { - return node.type === 'TSFunctionType' -} - -/** - * Get all props by looking at all component's properties - * @param {RuleContext} context The ESLint rule context object. - * @param {TypeNode} propsNode Type with props definition - * @return {ComponentTypeProp[]} Array of component props - */ -function getComponentPropsFromTypeDefine(context, propsNode) { - /** @type {TSInterfaceBody | TSTypeLiteral|null} */ - const defNode = resolveQualifiedType(context, propsNode, isTSTypeLiteral) - if (!defNode) { - return [] - } - return [...extractRuntimeProps(context, defNode)] -} - -/** - * Get all emits by looking at all component's properties - * @param {RuleContext} context The ESLint rule context object. - * @param {TypeNode} emitsNode Type with emits definition - * @return {ComponentTypeEmit[]} Array of component emits - */ -function getComponentEmitsFromTypeDefine(context, emitsNode) { - /** @type {TSInterfaceBody | TSTypeLiteral | TSFunctionType | null} */ - const defNode = resolveQualifiedType( - context, - emitsNode, - (n) => isTSTypeLiteral(n) || isTSFunctionType(n) - ) - if (!defNode) { - return [] - } - return [...extractRuntimeEmits(defNode)] -} - -/** - * @see https://github.com/vuejs/vue-next/blob/253ca2729d808fc051215876aa4af986e4caa43c/packages/compiler-sfc/src/compileScript.ts#L1512 - * @param {RuleContext} context The ESLint rule context object. - * @param {TSTypeLiteral | TSInterfaceBody} node - * @returns {IterableIterator} - */ -function* extractRuntimeProps(context, node) { - const members = node.type === 'TSTypeLiteral' ? node.members : node.body - for (const m of members) { - if ( - (m.type === 'TSPropertySignature' || m.type === 'TSMethodSignature') && - (m.key.type === 'Identifier' || m.key.type === 'Literal') - ) { - let type - if (m.type === 'TSMethodSignature') { - type = ['Function'] - } else if (m.typeAnnotation) { - type = inferRuntimeType(context, m.typeAnnotation.typeAnnotation) - } - yield { - type: 'type', - key: /** @type {Identifier | Literal} */ (m.key), - propName: m.key.type === 'Identifier' ? m.key.name : `${m.key.value}`, - value: null, - node: /** @type {TSPropertySignature | TSMethodSignature} */ (m), - - required: !m.optional, - types: type || [`null`] - } - } - } -} - -/** - * @see https://github.com/vuejs/vue-next/blob/348c3b01e56383ffa70b180d1376fdf4ac12e274/packages/compiler-sfc/src/compileScript.ts#L1632 - * @param {TSTypeLiteral | TSInterfaceBody | TSFunctionType} node - * @returns {IterableIterator} - */ -function* extractRuntimeEmits(node) { - if (node.type === 'TSFunctionType') { - yield* extractEventNames(node.params[0], node) - return - } - const members = node.type === 'TSTypeLiteral' ? node.members : node.body - for (const member of members) { - if (member.type === 'TSCallSignatureDeclaration') { - yield* extractEventNames( - member.params[0], - /** @type {TSCallSignatureDeclaration} */ (member) - ) - } else if ( - member.type === 'TSPropertySignature' || - member.type === 'TSMethodSignature' - ) { - if (member.key.type !== 'Identifier' && member.key.type !== 'Literal') { - yield { - type: 'unknown', - node: member.key - } - continue - } - yield { - type: 'type', - key: /** @type {Identifier | Literal} */ (member.key), - emitName: - member.key.type === 'Identifier' - ? member.key.name - : `${member.key.value}`, - value: null, - node: /** @type {TSPropertySignature | TSMethodSignature} */ (member) - } - } - } -} - -/** - * @param {TSESTreeParameter} eventName - * @param {TSCallSignatureDeclaration | TSFunctionType} member - * @returns {IterableIterator} - */ -function* extractEventNames(eventName, member) { - if ( - eventName && - eventName.type === 'Identifier' && - eventName.typeAnnotation && - eventName.typeAnnotation.type === 'TSTypeAnnotation' - ) { - const typeNode = eventName.typeAnnotation.typeAnnotation - if ( - typeNode.type === 'TSLiteralType' && - typeNode.literal.type === 'Literal' - ) { - const emitName = String(typeNode.literal.value) - yield { - type: 'type', - key: /** @type {TSLiteralType} */ (typeNode), - emitName, - value: null, - node: member - } - } else if (typeNode.type === 'TSUnionType') { - for (const t of typeNode.types) { - if (t.type === 'TSLiteralType' && t.literal.type === 'Literal') { - const emitName = String(t.literal.value) - yield { - type: 'type', - key: /** @type {TSLiteralType} */ (t), - emitName, - value: null, - node: member - } - } - } - } - } -} - -/** - * @see https://github.com/vuejs/vue-next/blob/253ca2729d808fc051215876aa4af986e4caa43c/packages/compiler-sfc/src/compileScript.ts#L425 - * - * @param {RuleContext} context The ESLint rule context object. - * @param {TypeNode} node - * @param {(n: TypeNode)=> boolean } qualifier - */ -function resolveQualifiedType(context, node, qualifier) { - if (qualifier(node)) { - return node - } - if (node.type === 'TSTypeReference' && node.typeName.type === 'Identifier') { - const refName = node.typeName.name - const variable = findVariable(context.getScope(), refName) - if (variable && variable.defs.length === 1) { - const def = variable.defs[0] - if (def.node.type === 'TSInterfaceDeclaration') { - return /** @type {any} */ (def.node).body - } - if (def.node.type === 'TSTypeAliasDeclaration') { - const typeAnnotation = /** @type {any} */ (def.node).typeAnnotation - return qualifier(typeAnnotation) ? typeAnnotation : null - } - } - } -} - -/** - * @param {RuleContext} context The ESLint rule context object. - * @param {TypeNode} node - * @param {Set} [checked] - * @returns {string[]} - */ -function inferRuntimeType(context, node, checked = new Set()) { - switch (node.type) { - case 'TSStringKeyword': - return ['String'] - case 'TSNumberKeyword': - return ['Number'] - case 'TSBooleanKeyword': - return ['Boolean'] - case 'TSObjectKeyword': - return ['Object'] - case 'TSTypeLiteral': - return ['Object'] - case 'TSFunctionType': - return ['Function'] - case 'TSArrayType': - case 'TSTupleType': - return ['Array'] - - case 'TSLiteralType': - if (node.literal.type === 'Literal') { - switch (typeof node.literal.value) { - case 'boolean': - return ['Boolean'] - case 'string': - return ['String'] - case 'number': - case 'bigint': - return ['Number'] - } - if (node.literal.value instanceof RegExp) { - return ['RegExp'] - } - } - return [`null`] - case 'TSTypeReference': - if (node.typeName.type === 'Identifier') { - const variable = findVariable(context.getScope(), node.typeName.name) - if (variable && variable.defs.length === 1) { - const def = variable.defs[0] - if (def.node.type === 'TSInterfaceDeclaration') { - return [`Object`] - } - if (def.node.type === 'TSTypeAliasDeclaration') { - const typeAnnotation = /** @type {any} */ (def.node).typeAnnotation - if (!checked.has(typeAnnotation)) { - checked.add(typeAnnotation) - return inferRuntimeType(context, typeAnnotation, checked) - } - } - } - switch (node.typeName.name) { - case 'Array': - case 'Function': - case 'Object': - case 'Set': - case 'Map': - case 'WeakSet': - case 'WeakMap': - case 'Date': - return [node.typeName.name] - case 'Record': - case 'Partial': - case 'Readonly': - case 'Pick': - case 'Omit': - case 'Exclude': - case 'Extract': - case 'Required': - case 'InstanceType': - return ['Object'] - } - } - return [`null`] - - case 'TSUnionType': - const set = new Set() - for (const t of node.types) { - for (const tt of inferRuntimeType(context, t, checked)) { - set.add(tt) - } - } - return [...set] - - case 'TSIntersectionType': - return ['Object'] - - default: - return [`null`] // no runtime check - } -} diff --git a/lib/utils/ts-utils/index.js b/lib/utils/ts-utils/index.js new file mode 100644 index 000000000..8b6c53b26 --- /dev/null +++ b/lib/utils/ts-utils/index.js @@ -0,0 +1,88 @@ +const { + isTypeNode, + extractRuntimeProps, + isTSTypeLiteral, + isTSTypeLiteralOrTSFunctionType, + extractRuntimeEmits, + flattenTypeNodes, + isTSInterfaceBody +} = require('./ts-ast') +const { + getComponentPropsFromTypeDefineTypes, + getComponentEmitsFromTypeDefineTypes +} = require('./ts-types') + +/** + * @typedef {import('@typescript-eslint/types').TSESTree.TypeNode} TSESTreeTypeNode + */ +/** + * @typedef {import('../index').ComponentTypeProp} ComponentTypeProp + * @typedef {import('../index').ComponentInferTypeProp} ComponentInferTypeProp + * @typedef {import('../index').ComponentUnknownProp} ComponentUnknownProp + * @typedef {import('../index').ComponentTypeEmit} ComponentTypeEmit + * @typedef {import('../index').ComponentInferTypeEmit} ComponentInferTypeEmit + * @typedef {import('../index').ComponentUnknownEmit} ComponentUnknownEmit + */ + +module.exports = { + isTypeNode, + getComponentPropsFromTypeDefine, + getComponentEmitsFromTypeDefine +} + +/** + * Get all props by looking at all component's properties + * @param {RuleContext} context The ESLint rule context object. + * @param {TypeNode} propsNode Type with props definition + * @return {(ComponentTypeProp|ComponentInferTypeProp|ComponentUnknownProp)[]} Array of component props + */ +function getComponentPropsFromTypeDefine(context, propsNode) { + /** @type {(ComponentTypeProp|ComponentInferTypeProp|ComponentUnknownProp)[]} */ + const result = [] + for (const defNode of flattenTypeNodes( + context, + /** @type {TSESTreeTypeNode} */ (propsNode) + )) { + if (isTSInterfaceBody(defNode) || isTSTypeLiteral(defNode)) { + result.push(...extractRuntimeProps(context, defNode)) + } else { + result.push( + ...getComponentPropsFromTypeDefineTypes( + context, + /** @type {TypeNode} */ (defNode) + ) + ) + } + } + return result +} + +/** + * Get all emits by looking at all component's properties + * @param {RuleContext} context The ESLint rule context object. + * @param {TypeNode} emitsNode Type with emits definition + * @return {(ComponentTypeEmit|ComponentInferTypeEmit|ComponentUnknownEmit)[]} Array of component emits + */ +function getComponentEmitsFromTypeDefine(context, emitsNode) { + /** @type {(ComponentTypeEmit|ComponentInferTypeEmit|ComponentUnknownEmit)[]} */ + const result = [] + for (const defNode of flattenTypeNodes( + context, + /** @type {TSESTreeTypeNode} */ (emitsNode) + )) { + if ( + isTSInterfaceBody(defNode) || + isTSTypeLiteralOrTSFunctionType(defNode) + ) { + result.push(...extractRuntimeEmits(defNode)) + } else { + result.push( + ...getComponentEmitsFromTypeDefineTypes( + context, + /** @type {TypeNode} */ (defNode) + ) + ) + } + } + return result +} diff --git a/lib/utils/ts-utils/ts-ast.js b/lib/utils/ts-utils/ts-ast.js new file mode 100644 index 000000000..8561bf565 --- /dev/null +++ b/lib/utils/ts-utils/ts-ast.js @@ -0,0 +1,528 @@ +const { findVariable } = require('@eslint-community/eslint-utils') +const { inferRuntimeTypeFromTypeNode } = require('./ts-types') +/** + * @typedef {import('@typescript-eslint/types').TSESTree.TypeNode} TSESTreeTypeNode + * @typedef {import('@typescript-eslint/types').TSESTree.TSInterfaceBody} TSESTreeTSInterfaceBody + * @typedef {import('@typescript-eslint/types').TSESTree.TSTypeLiteral} TSESTreeTSTypeLiteral + * @typedef {import('@typescript-eslint/types').TSESTree.TSFunctionType} TSESTreeTSFunctionType + * @typedef {import('@typescript-eslint/types').TSESTree.Parameter} TSESTreeParameter + * @typedef {import('@typescript-eslint/types').TSESTree.Node} TSESTreeNode + * + */ +/** + * @typedef {import('../index').ComponentTypeProp} ComponentTypeProp + * @typedef {import('../index').ComponentUnknownProp} ComponentUnknownProp + * @typedef {import('../index').ComponentTypeEmit} ComponentTypeEmit + * @typedef {import('../index').ComponentUnknownEmit} ComponentUnknownEmit + */ + +const noop = Function.prototype + +module.exports = { + isTypeNode, + flattenTypeNodes, + isTSInterfaceBody, + isTSTypeLiteral, + isTSTypeLiteralOrTSFunctionType, + extractRuntimeProps, + extractRuntimeEmits +} + +/** + * @param {ASTNode} node + * @returns {node is TypeNode} + */ +function isTypeNode(node) { + if ( + node.type === 'TSAbstractKeyword' || + node.type === 'TSAnyKeyword' || + node.type === 'TSAsyncKeyword' || + node.type === 'TSArrayType' || + node.type === 'TSBigIntKeyword' || + node.type === 'TSBooleanKeyword' || + node.type === 'TSConditionalType' || + node.type === 'TSConstructorType' || + node.type === 'TSDeclareKeyword' || + node.type === 'TSExportKeyword' || + node.type === 'TSFunctionType' || + node.type === 'TSImportType' || + node.type === 'TSIndexedAccessType' || + node.type === 'TSInferType' || + node.type === 'TSIntersectionType' || + node.type === 'TSIntrinsicKeyword' || + node.type === 'TSLiteralType' || + node.type === 'TSMappedType' || + node.type === 'TSNamedTupleMember' || + node.type === 'TSNeverKeyword' || + node.type === 'TSNullKeyword' || + node.type === 'TSNumberKeyword' || + node.type === 'TSObjectKeyword' || + node.type === 'TSOptionalType' || + node.type === 'TSQualifiedName' || + node.type === 'TSPrivateKeyword' || + node.type === 'TSProtectedKeyword' || + node.type === 'TSPublicKeyword' || + node.type === 'TSReadonlyKeyword' || + node.type === 'TSRestType' || + node.type === 'TSStaticKeyword' || + node.type === 'TSStringKeyword' || + node.type === 'TSSymbolKeyword' || + node.type === 'TSTemplateLiteralType' || + node.type === 'TSThisType' || + node.type === 'TSTupleType' || + node.type === 'TSTypeLiteral' || + node.type === 'TSTypeOperator' || + node.type === 'TSTypePredicate' || + node.type === 'TSTypeQuery' || + node.type === 'TSTypeReference' || + node.type === 'TSUndefinedKeyword' || + node.type === 'TSUnionType' || + node.type === 'TSUnknownKeyword' || + node.type === 'TSVoidKeyword' + ) { + /** @type {TypeNode['type']} for type check */ + const type = node.type + noop(type) + return true + } + /** @type {Exclude} for type check */ + const type = node.type + noop(type) + return false +} + +/** + * @param {TSESTreeTypeNode|TSESTreeTSInterfaceBody} node + * @returns {node is TSESTreeTSInterfaceBody} + */ +function isTSInterfaceBody(node) { + return node.type === 'TSInterfaceBody' +} +/** + * @param {TSESTreeTypeNode} node + * @returns {node is TSESTreeTSTypeLiteral} + */ +function isTSTypeLiteral(node) { + return node.type === 'TSTypeLiteral' +} +/** + * @param {TSESTreeTypeNode} node + * @returns {node is TSESTreeTSFunctionType} + */ +function isTSFunctionType(node) { + return node.type === 'TSFunctionType' +} +/** + * @param {TSESTreeTypeNode} node + * @returns {node is TSESTreeTSTypeLiteral | TSESTreeTSFunctionType} + */ +function isTSTypeLiteralOrTSFunctionType(node) { + return isTSTypeLiteral(node) || isTSFunctionType(node) +} + +/** + * @see https://github.com/vuejs/vue-next/blob/253ca2729d808fc051215876aa4af986e4caa43c/packages/compiler-sfc/src/compileScript.ts#L1512 + * @param {RuleContext} context The ESLint rule context object. + * @param {TSESTreeTSTypeLiteral | TSESTreeTSInterfaceBody} node + * @returns {IterableIterator} + */ +function* extractRuntimeProps(context, node) { + const members = node.type === 'TSTypeLiteral' ? node.members : node.body + for (const member of members) { + if ( + member.type === 'TSPropertySignature' || + member.type === 'TSMethodSignature' + ) { + if (member.key.type !== 'Identifier' && member.key.type !== 'Literal') { + yield { + type: 'unknown', + propName: null, + node: /** @type {Expression} */ (member.key) + } + continue + } + /** @type {string[]|undefined} */ + let types + if (member.type === 'TSMethodSignature') { + types = ['Function'] + } else if (member.typeAnnotation) { + types = inferRuntimeType(context, member.typeAnnotation.typeAnnotation) + } + yield { + type: 'type', + key: /** @type {Identifier | Literal} */ (member.key), + propName: + member.key.type === 'Identifier' + ? member.key.name + : `${member.key.value}`, + node: /** @type {TSPropertySignature | TSMethodSignature} */ (member), + + required: !member.optional, + types: types || [`null`] + } + } + } +} + +/** + * @param {TSESTreeTSTypeLiteral | TSESTreeTSInterfaceBody | TSESTreeTSFunctionType} node + * @returns {IterableIterator} + */ +function* extractRuntimeEmits(node) { + if (node.type === 'TSFunctionType') { + yield* extractEventNames( + node.params[0], + /** @type {TSFunctionType} */ (node) + ) + return + } + const members = node.type === 'TSTypeLiteral' ? node.members : node.body + for (const member of members) { + if (member.type === 'TSCallSignatureDeclaration') { + yield* extractEventNames( + member.params[0], + /** @type {TSCallSignatureDeclaration} */ (member) + ) + } else if ( + member.type === 'TSPropertySignature' || + member.type === 'TSMethodSignature' + ) { + if (member.key.type !== 'Identifier' && member.key.type !== 'Literal') { + yield { + type: 'unknown', + emitName: null, + node: /** @type {Expression} */ (member.key) + } + continue + } + yield { + type: 'type', + key: /** @type {Identifier | Literal} */ (member.key), + emitName: + member.key.type === 'Identifier' + ? member.key.name + : `${member.key.value}`, + node: /** @type {TSPropertySignature | TSMethodSignature} */ (member) + } + } + } +} + +/** + * @param {TSESTreeParameter} eventName + * @param {TSCallSignatureDeclaration | TSFunctionType} member + * @returns {IterableIterator} + */ +function* extractEventNames(eventName, member) { + if ( + eventName && + eventName.type === 'Identifier' && + eventName.typeAnnotation && + eventName.typeAnnotation.type === 'TSTypeAnnotation' + ) { + const typeNode = eventName.typeAnnotation.typeAnnotation + if ( + typeNode.type === 'TSLiteralType' && + typeNode.literal.type === 'Literal' + ) { + const emitName = String(typeNode.literal.value) + yield { + type: 'type', + key: /** @type {TSLiteralType} */ (typeNode), + emitName, + node: member + } + } else if (typeNode.type === 'TSUnionType') { + for (const t of typeNode.types) { + if (t.type === 'TSLiteralType' && t.literal.type === 'Literal') { + const emitName = String(t.literal.value) + yield { + type: 'type', + key: /** @type {TSLiteralType} */ (t), + emitName, + node: member + } + } + } + } + } +} + +/** + * @param {RuleContext} context The ESLint rule context object. + * @param {TSESTreeTypeNode} node + * @returns {(TSESTreeTypeNode|TSESTreeTSInterfaceBody)[]} + */ +function flattenTypeNodes(context, node) { + /** + * @typedef {object} TraversedData + * @property {Set} nodes + * @property {boolean} finished + */ + /** @type {Map} */ + const traversed = new Map() + + return [...flattenImpl(node)] + /** + * @param {TSESTreeTypeNode} node + * @returns {Iterable} + */ + function* flattenImpl(node) { + if (node.type === 'TSUnionType' || node.type === 'TSIntersectionType') { + for (const typeNode of node.types) { + yield* flattenImpl(typeNode) + } + return + } + if ( + node.type === 'TSTypeReference' && + node.typeName.type === 'Identifier' + ) { + const refName = node.typeName.name + const variable = findVariable(context.getScope(), refName) + if (variable && variable.defs.length === 1) { + const defNode = /** @type {TSESTreeNode} */ (variable.defs[0].node) + if (defNode.type === 'TSInterfaceDeclaration') { + yield defNode.body + return + } else if (defNode.type === 'TSTypeAliasDeclaration') { + const typeAnnotation = defNode.typeAnnotation + let traversedData = traversed.get(typeAnnotation) + if (traversedData) { + const copy = [...traversedData.nodes] + yield* copy + if (!traversedData.finished) { + // Include the node because it will probably be referenced recursively. + yield typeAnnotation + } + return + } + traversedData = { nodes: new Set(), finished: false } + traversed.set(typeAnnotation, traversedData) + for (const e of flattenImpl(typeAnnotation)) { + traversedData.nodes.add(e) + } + traversedData.finished = true + yield* traversedData.nodes + return + } + } + } + yield node + } +} + +/** + * @param {RuleContext} context The ESLint rule context object. + * @param {TSESTreeTypeNode} node + * @param {Set} [checked] + * @returns {string[]} + */ +function inferRuntimeType(context, node, checked = new Set()) { + switch (node.type) { + case 'TSStringKeyword': + case 'TSTemplateLiteralType': + return ['String'] + case 'TSNumberKeyword': + return ['Number'] + case 'TSBooleanKeyword': + return ['Boolean'] + case 'TSObjectKeyword': + return ['Object'] + case 'TSTypeLiteral': + return inferTypeLiteralType(node) + case 'TSFunctionType': + return ['Function'] + case 'TSArrayType': + case 'TSTupleType': + return ['Array'] + case 'TSSymbolKeyword': + return ['Symbol'] + + case 'TSLiteralType': + if (node.literal.type === 'Literal') { + switch (typeof node.literal.value) { + case 'boolean': + return ['Boolean'] + case 'string': + return ['String'] + case 'number': + case 'bigint': + return ['Number'] + } + if (node.literal.value instanceof RegExp) { + return ['RegExp'] + } + } + return inferRuntimeTypeFromTypeNode( + context, + /** @type {TypeNode} */ (node) + ) + case 'TSTypeReference': + if (node.typeName.type === 'Identifier') { + const variable = findVariable(context.getScope(), node.typeName.name) + if (variable && variable.defs.length === 1) { + const defNode = /** @type {TSESTreeNode} */ (variable.defs[0].node) + if (defNode.type === 'TSInterfaceDeclaration') { + return [`Object`] + } + if (defNode.type === 'TSTypeAliasDeclaration') { + const typeAnnotation = defNode.typeAnnotation + if (!checked.has(typeAnnotation)) { + checked.add(typeAnnotation) + return inferRuntimeType(context, typeAnnotation, checked) + } + } + if (defNode.type === 'TSEnumDeclaration') { + return inferEnumType(context, defNode) + } + } + for (const name of [ + node.typeName.name, + ...(node.typeName.name.startsWith('Readonly') + ? [node.typeName.name.slice(8)] + : []) + ]) { + switch (name) { + case 'Array': + case 'Function': + case 'Object': + case 'Set': + case 'Map': + case 'WeakSet': + case 'WeakMap': + case 'Date': + return [name] + } + } + + switch (node.typeName.name) { + case 'Record': + case 'Partial': + case 'Readonly': + case 'Pick': + case 'Omit': + case 'Required': + case 'InstanceType': + return ['Object'] + case 'Uppercase': + case 'Lowercase': + case 'Capitalize': + case 'Uncapitalize': + return ['String'] + case 'Parameters': + case 'ConstructorParameters': + return ['Array'] + case 'NonNullable': + if (node.typeParameters && node.typeParameters.params[0]) { + return inferRuntimeType( + context, + node.typeParameters.params[0], + checked + ).filter((t) => t !== 'null') + } + break + case 'Extract': + if (node.typeParameters && node.typeParameters.params[1]) { + return inferRuntimeType( + context, + node.typeParameters.params[1], + checked + ) + } + break + case 'Exclude': + case 'OmitThisParameter': + if (node.typeParameters && node.typeParameters.params[0]) { + return inferRuntimeType( + context, + node.typeParameters.params[0], + checked + ) + } + break + } + } + return inferRuntimeTypeFromTypeNode( + context, + /** @type {TypeNode} */ (node) + ) + + case 'TSUnionType': + case 'TSIntersectionType': + return inferUnionType(node) + + default: + return inferRuntimeTypeFromTypeNode( + context, + /** @type {TypeNode} */ (node) + ) + } + + /** + * @param {import('@typescript-eslint/types').TSESTree.TSUnionType|import('@typescript-eslint/types').TSESTree.TSIntersectionType} node + * @returns {string[]} + */ + function inferUnionType(node) { + const types = new Set() + for (const t of node.types) { + for (const tt of inferRuntimeType(context, t, checked)) { + types.add(tt) + } + } + return [...types] + } +} + +/** + * @param {import('@typescript-eslint/types').TSESTree.TSTypeLiteral} node + * @returns {string[]} + */ +function inferTypeLiteralType(node) { + const types = new Set() + for (const m of node.members) { + switch (m.type) { + case 'TSCallSignatureDeclaration': + case 'TSConstructSignatureDeclaration': + types.add('Function') + break + default: + types.add('Object') + } + } + return types.size > 0 ? [...types] : ['Object'] +} +/** + * @param {RuleContext} context The ESLint rule context object. + * @param {import('@typescript-eslint/types').TSESTree.TSEnumDeclaration} node + * @returns {string[]} + */ +function inferEnumType(context, node) { + const types = new Set() + for (const m of node.members) { + if (m.initializer) { + if (m.initializer.type === 'Literal') { + switch (typeof m.initializer.value) { + case 'string': + types.add('String') + break + case 'number': + case 'bigint': // Now it's a syntax error. + types.add('Number') + break + case 'boolean': // Now it's a syntax error. + types.add('Boolean') + break + } + } else { + for (const type of inferRuntimeTypeFromTypeNode( + context, + /** @type {Expression} */ (m.initializer) + )) { + types.add(type) + } + } + } + } + return types.size > 0 ? [...types] : ['Number'] +} diff --git a/lib/utils/ts-utils/ts-types.js b/lib/utils/ts-utils/ts-types.js new file mode 100644 index 000000000..e684831e4 --- /dev/null +++ b/lib/utils/ts-utils/ts-types.js @@ -0,0 +1,275 @@ +const { + getTypeScript, + isAny, + isUnknown, + isNever, + isNull, + isObject, + isFunction, + isStringLike, + isNumberLike, + isBooleanLike, + isBigIntLike, + isArrayLikeObject, + isReferenceObject +} = require('./typescript') +/** + * @typedef {import('@typescript-eslint/types').TSESTree.Node} TSESTreeNode + * @typedef {import('typescript').Type} Type + * @typedef {import('typescript').TypeChecker} TypeChecker + * @typedef {import('typescript').Node} TypeScriptNode + */ +/** + * @typedef {import('../index').ComponentInferTypeProp} ComponentInferTypeProp + * @typedef {import('../index').ComponentUnknownProp} ComponentUnknownProp + * @typedef {import('../index').ComponentInferTypeEmit} ComponentInferTypeEmit + * @typedef {import('../index').ComponentUnknownEmit} ComponentUnknownEmit + */ + +module.exports = { + getComponentPropsFromTypeDefineTypes, + getComponentEmitsFromTypeDefineTypes, + inferRuntimeTypeFromTypeNode +} + +/** + * @typedef {object} Services + * @property {typeof import("typescript")} ts + * @property {Map} tsNodeMap + * @property {import('typescript').TypeChecker} checker + */ + +/** + * Get TypeScript parser services. + * @param {RuleContext} context The ESLint rule context object. + * @returns {Services|null} + */ +function getTSParserServices(context) { + const tsNodeMap = context.parserServices.esTreeNodeToTSNodeMap + if (!tsNodeMap) return null + const hasFullTypeInformation = + context.parserServices.hasFullTypeInformation !== false + const checker = + (hasFullTypeInformation && + context.parserServices.program && + context.parserServices.program.getTypeChecker()) || + null + if (!checker) return null + const ts = getTypeScript() + if (!ts) return null + + return { + ts, + tsNodeMap, + checker + } +} + +/** + * Get all props by looking at all component's properties + * @param {RuleContext} context The ESLint rule context object. + * @param {TypeNode} propsNode Type with props definition + * @return {(ComponentInferTypeProp|ComponentUnknownProp)[]} Array of component props + */ +function getComponentPropsFromTypeDefineTypes(context, propsNode) { + const services = getTSParserServices(context) + const tsNode = services && services.tsNodeMap.get(propsNode) + const type = tsNode && services.checker.getTypeAtLocation(tsNode) + if ( + !type || + isAny(type) || + isUnknown(type) || + isNever(type) || + isNull(type) + ) { + return [ + { + type: 'unknown', + propName: null, + node: propsNode + } + ] + } + return [...extractRuntimeProps(type, tsNode, propsNode, services)] +} + +/** + * Get all emits by looking at all component's properties + * @param {RuleContext} context The ESLint rule context object. + * @param {TypeNode} emitsNode Type with emits definition + * @return {(ComponentInferTypeEmit|ComponentUnknownEmit)[]} Array of component emits + */ +function getComponentEmitsFromTypeDefineTypes(context, emitsNode) { + const services = getTSParserServices(context) + const tsNode = services && services.tsNodeMap.get(emitsNode) + const type = tsNode && services.checker.getTypeAtLocation(tsNode) + if ( + !type || + isAny(type) || + isUnknown(type) || + isNever(type) || + isNull(type) + ) { + return [ + { + type: 'unknown', + emitName: null, + node: emitsNode + } + ] + } + return [...extractRuntimeEmits(type, tsNode, emitsNode, services)] +} + +/** + * @param {RuleContext} context The ESLint rule context object. + * @param {TypeNode|Expression} node + * @returns {string[]} + */ +function inferRuntimeTypeFromTypeNode(context, node) { + const services = getTSParserServices(context) + const tsNode = services && services.tsNodeMap.get(node) + const type = tsNode && services.checker.getTypeAtLocation(tsNode) + if (!type) { + return ['null'] + } + return inferRuntimeTypeInternal(type, services) +} + +/** + * @param {Type} type + * @param {TypeScriptNode} tsNode + * @param {TypeNode} propsNode Type with props definition + * @param {Services} services + * @returns {IterableIterator} + */ +function* extractRuntimeProps(type, tsNode, propsNode, services) { + const { ts, checker } = services + for (const property of type.getProperties()) { + const isOptional = (property.flags & ts.SymbolFlags.Optional) !== 0 + const name = property.getName() + + const type = checker.getTypeOfSymbolAtLocation(property, tsNode) + + yield { + type: 'infer-type', + propName: name, + required: !isOptional, + node: propsNode, + types: inferRuntimeTypeInternal(type, services) + } + } +} + +/** + * @param {Type} type + * @param {Services} services + * @returns {string[]} + */ +function inferRuntimeTypeInternal(type, services) { + const { checker } = services + /** @type {Set} */ + const types = new Set() + for (const targetType of iterateTypes(checker.getNonNullableType(type))) { + if ( + isAny(targetType) || + isUnknown(targetType) || + isNever(targetType) || + isNull(targetType) + ) { + types.add('null') + } else if (isStringLike(targetType)) { + types.add('String') + } else if (isNumberLike(targetType) || isBigIntLike(targetType)) { + types.add('Number') + } else if (isBooleanLike(targetType)) { + types.add('Boolean') + } else if (isFunction(targetType)) { + types.add('Function') + } else if ( + isArrayLikeObject(targetType) || + (targetType.isClassOrInterface() && + ['Array', 'ReadonlyArray'].includes( + checker.getFullyQualifiedName(targetType.symbol) + )) + ) { + types.add('Array') + } else if (isObject(targetType)) { + types.add('Object') + } + } + + if (types.size <= 0) types.add('null') + + return [...types] +} + +/** + * @param {Type} type + * @param {TypeScriptNode} tsNode + * @param {TypeNode} emitsNode Type with emits definition + * @param {Services} services + * @returns {IterableIterator} + */ +function* extractRuntimeEmits(type, tsNode, emitsNode, services) { + const { checker } = services + if (isFunction(type)) { + for (const signature of type.getCallSignatures()) { + const param = signature.getParameters()[0] + if (!param) { + yield { + type: 'unknown', + emitName: null, + node: emitsNode + } + continue + } + const type = checker.getTypeOfSymbolAtLocation(param, tsNode) + + for (const targetType of iterateTypes(type)) { + yield targetType.isStringLiteral() + ? { + type: 'infer-type', + emitName: targetType.value, + node: emitsNode + } + : { + type: 'unknown', + emitName: null, + node: emitsNode + } + } + } + } else if (isObject(type)) { + for (const property of type.getProperties()) { + const name = property.getName() + yield { + type: 'infer-type', + emitName: name, + node: emitsNode + } + } + } else { + yield { + type: 'unknown', + emitName: null, + node: emitsNode + } + } +} + +/** + * @param {Type} type + * @returns {Iterable} + */ +function* iterateTypes(type) { + if (isReferenceObject(type) && type.target !== type) { + yield* iterateTypes(type.target) + } else if (type.isUnion() && !isBooleanLike(type)) { + for (const t of type.types) { + yield* iterateTypes(t) + } + } else { + yield type + } +} diff --git a/lib/utils/ts-utils/typescript.js b/lib/utils/ts-utils/typescript.js new file mode 100644 index 000000000..8d656401e --- /dev/null +++ b/lib/utils/ts-utils/typescript.js @@ -0,0 +1,214 @@ +/** + * @typedef {typeof import("typescript")} TypeScript + * @typedef {import("typescript").Type} Type + * @typedef {import("typescript").ObjectType} ObjectType + * @typedef {import("typescript").InterfaceType} InterfaceType + * @typedef {import("typescript").TypeReference} TypeReference + * @typedef {import("typescript").UnionOrIntersectionType} UnionOrIntersectionType + * @typedef {import("typescript").TypeParameter} TypeParameter + */ + +/** @type {TypeScript | undefined} */ +let cacheTypeScript + +module.exports = { + getTypeScript, + isObject, + isAny, + isUnknown, + isNever, + isNull, + isFunction, + isArrayLikeObject, + isStringLike, + isNumberLike, + isBooleanLike, + isBigIntLike, + isReferenceObject, + extractTypeFlags, + extractObjectFlags +} + +/** + * Get TypeScript instance + */ +function getTypeScript() { + if (cacheTypeScript) { + return cacheTypeScript + } + try { + return (cacheTypeScript = require('typescript')) + } catch (error) { + if (/** @type {any} */ (error).code === 'MODULE_NOT_FOUND') { + return undefined + } + + throw error + } +} +/** + * For debug + * @param {Type} tsType + * @returns {string[]} + */ +function extractTypeFlags(tsType) { + const ts = /** @type {TypeScript} */ (getTypeScript()) + /** @type {string[]} */ + const result = [] + const keys = /** @type {(keyof (typeof ts.TypeFlags))[]} */ ( + Object.keys(ts.TypeFlags) + ) + for (const k of keys) { + if ((tsType.flags & ts.TypeFlags[k]) !== 0) { + result.push(k) + } + } + return result +} +/** + * For debug + * @param {Type} tsType + * @returns {string[]} + */ +function extractObjectFlags(tsType) { + if (!isObject(tsType)) { + return [] + } + const ts = /** @type {TypeScript} */ (getTypeScript()) + /** @type {string[]} */ + const result = [] + const keys = /** @type {(keyof (typeof ts.ObjectFlags))[]} */ ( + Object.keys(ts.ObjectFlags) + ) + for (const k of keys) { + if ((tsType.objectFlags & ts.ObjectFlags[k]) !== 0) { + result.push(k) + } + } + return result +} + +/** + * Check if a given type is an object type or not. + * @param {Type} tsType The type to check. + * @returns {tsType is ObjectType} + */ +function isObject(tsType) { + const ts = /** @type {TypeScript} */ (getTypeScript()) + return (tsType.flags & ts.TypeFlags.Object) !== 0 +} + +/** + * Check if a given type is an array-like type or not. + * @param {Type} tsType The type to check. + */ +function isArrayLikeObject(tsType) { + const ts = /** @type {TypeScript} */ (getTypeScript()) + return ( + isObject(tsType) && + (tsType.objectFlags & + (ts.ObjectFlags.ArrayLiteral | + ts.ObjectFlags.EvolvingArray | + ts.ObjectFlags.Tuple)) !== + 0 + ) +} +/** + * Check if a given type is an any type or not. + * @param {Type} tsType The type to check. + */ +function isAny(tsType) { + const ts = /** @type {TypeScript} */ (getTypeScript()) + return (tsType.flags & ts.TypeFlags.Any) !== 0 +} +/** + * Check if a given type is an unknown type or not. + * @param {Type} tsType The type to check. + */ +function isUnknown(tsType) { + const ts = /** @type {TypeScript} */ (getTypeScript()) + return (tsType.flags & ts.TypeFlags.Unknown) !== 0 +} +/** + * Check if a given type is a never type or not. + * @param {Type} tsType The type to check. + */ +function isNever(tsType) { + const ts = /** @type {TypeScript} */ (getTypeScript()) + return (tsType.flags & ts.TypeFlags.Never) !== 0 +} +/** + * Check if a given type is an null type or not. + * @param {Type} tsType The type to check. + */ +function isNull(tsType) { + const ts = /** @type {TypeScript} */ (getTypeScript()) + return (tsType.flags & ts.TypeFlags.Null) !== 0 +} + +/** + * Check if a given type is a string-like type or not. + * @param {Type} tsType The type to check. + * @returns {boolean} `true` if the type is a string-like type. + */ +function isStringLike(tsType) { + const ts = /** @type {TypeScript} */ (getTypeScript()) + return (tsType.flags & ts.TypeFlags.StringLike) !== 0 +} +/** + * Check if a given type is an number-like type or not. + * @param {Type} tsType The type to check. + * @returns {boolean} `true` if the type is a number-like type. + */ +function isNumberLike(tsType) { + const ts = /** @type {TypeScript} */ (getTypeScript()) + return (tsType.flags & ts.TypeFlags.NumberLike) !== 0 +} +/** + * Check if a given type is an boolean-like type or not. + * @param {Type} tsType The type to check. + * @returns {boolean} `true` if the type is a boolean-like type. + */ +function isBooleanLike(tsType) { + const ts = /** @type {TypeScript} */ (getTypeScript()) + return (tsType.flags & ts.TypeFlags.BooleanLike) !== 0 +} +/** + * Check if a given type is an bigint-like type or not. + * @param {Type} tsType The type to check. + * @returns {boolean} `true` if the type is a bigint-like type. + */ +function isBigIntLike(tsType) { + const ts = /** @type {TypeScript} */ (getTypeScript()) + return (tsType.flags & ts.TypeFlags.BigIntLike) !== 0 +} + +/** + * Check if a given type is a reference type or not. + * @param {Type} tsType The type to check. + * @returns {tsType is TypeReference} `true` if the type is a reference type. + */ +function isReferenceObject(tsType) { + const ts = /** @type {TypeScript} */ (getTypeScript()) + return ( + isObject(tsType) && (tsType.objectFlags & ts.ObjectFlags.Reference) !== 0 + ) +} +/** + * Check if a given type is `function` or not. + * @param {Type} tsType The type to check. + */ +function isFunction(tsType) { + const ts = /** @type {TypeScript} */ (getTypeScript()) + if ( + tsType.symbol && + (tsType.symbol.flags & + (ts.SymbolFlags.Function | ts.SymbolFlags.Method)) !== + 0 + ) { + return true + } + + const signatures = tsType.getCallSignatures() + return signatures.length > 0 +} diff --git a/tests/fixtures/typescript/src/test.vue b/tests/fixtures/typescript/src/test.vue new file mode 100644 index 000000000..5bf03f4a7 --- /dev/null +++ b/tests/fixtures/typescript/src/test.vue @@ -0,0 +1 @@ + diff --git a/tests/fixtures/typescript/src/test01.ts b/tests/fixtures/typescript/src/test01.ts new file mode 100644 index 000000000..d14550843 --- /dev/null +++ b/tests/fixtures/typescript/src/test01.ts @@ -0,0 +1,20 @@ +export type Props1 = { + foo: string + bar?: number + baz?: boolean +} +export type Emits1 = { + (e: 'foo' | 'bar', payload: string): void + (e: 'baz', payload: number): void +} +export type Props2 = { + a: string + b?: number + c?: boolean + d?: boolean + e?: number | string + f?: () => number + g?: { foo?: string } + h?: string[] + i?: readonly string[] +} diff --git a/tests/fixtures/typescript/tsconfig.json b/tests/fixtures/typescript/tsconfig.json new file mode 100644 index 000000000..c13ef64e3 --- /dev/null +++ b/tests/fixtures/typescript/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true + } +} diff --git a/tests/fixtures/utils/ts-utils/src/test.ts b/tests/fixtures/utils/ts-utils/src/test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/utils/ts-utils/src/test.vue b/tests/fixtures/utils/ts-utils/src/test.vue new file mode 100644 index 000000000..5bf03f4a7 --- /dev/null +++ b/tests/fixtures/utils/ts-utils/src/test.vue @@ -0,0 +1 @@ + diff --git a/tests/fixtures/utils/ts-utils/tsconfig.json b/tests/fixtures/utils/ts-utils/tsconfig.json new file mode 100644 index 000000000..c13ef64e3 --- /dev/null +++ b/tests/fixtures/utils/ts-utils/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true + } +} diff --git a/tests/lib/rules/no-restricted-props.js b/tests/lib/rules/no-restricted-props.js index a72d5b246..0f1947ed1 100644 --- a/tests/lib/rules/no-restricted-props.js +++ b/tests/lib/rules/no-restricted-props.js @@ -6,6 +6,9 @@ const semver = require('semver') const RuleTester = require('eslint').RuleTester const rule = require('../../../lib/rules/no-restricted-props') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), @@ -577,6 +580,23 @@ tester.run('no-restricted-props', rule, { ] } ] + }, + { + code: ` + + `, + ...getTypeScriptFixtureTestOptions(), + options: [{ name: 'foo', suggest: 'Foo' }], + errors: [ + { + message: 'Using `foo` props is not allowed.', + line: 4, + suggestions: null + } + ] } ]) ] diff --git a/tests/lib/rules/no-unused-properties.js b/tests/lib/rules/no-unused-properties.js index 8d4b2b215..2db132c48 100644 --- a/tests/lib/rules/no-unused-properties.js +++ b/tests/lib/rules/no-unused-properties.js @@ -7,6 +7,9 @@ const { RuleTester, Linter } = require('eslint') const assert = require('assert') const rule = require('../../../lib/rules/no-unused-properties') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), @@ -2961,6 +2964,24 @@ tester.run('no-unused-properties', rule, { line: 8 } ] + }, + // script setup with typescript + { + code: ` + + `, + ...getTypeScriptFixtureTestOptions(), + errors: [ + { + message: "'baz' of property found, but never used.", + line: 4 + } + ] } ] }) diff --git a/tests/lib/rules/padding-lines-in-component-definition.js b/tests/lib/rules/padding-lines-in-component-definition.js index 586476546..7b2eda6c8 100644 --- a/tests/lib/rules/padding-lines-in-component-definition.js +++ b/tests/lib/rules/padding-lines-in-component-definition.js @@ -6,6 +6,9 @@ const RuleTester = require('eslint').RuleTester const rule = require('../../../lib/rules/padding-lines-in-component-definition') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), @@ -398,6 +401,14 @@ tester.run('padding-lines-in-component-definition', rule, { `, options: [{ betweenOptions: 'always', groupSingleLineProperties: false }] + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() } ], invalid: [ diff --git a/tests/lib/rules/prefer-prop-type-boolean-first.js b/tests/lib/rules/prefer-prop-type-boolean-first.js index 9f67e336b..1c66f8417 100644 --- a/tests/lib/rules/prefer-prop-type-boolean-first.js +++ b/tests/lib/rules/prefer-prop-type-boolean-first.js @@ -6,6 +6,9 @@ const RuleTester = require('eslint').RuleTester const rule = require('../../../lib/rules/prefer-prop-type-boolean-first') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), @@ -62,6 +65,14 @@ tester.run('prefer-prop-type-boolean-first', rule, { }) ` + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() } ], invalid: [ diff --git a/tests/lib/rules/require-emit-validator.js b/tests/lib/rules/require-emit-validator.js index 751f775e7..2b60e8910 100644 --- a/tests/lib/rules/require-emit-validator.js +++ b/tests/lib/rules/require-emit-validator.js @@ -5,6 +5,9 @@ 'use strict' const rule = require('../../../lib/rules/require-emit-validator') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') const RuleTester = require('eslint').RuleTester @@ -169,6 +172,14 @@ ruleTester.run('require-emit-validator', rule, { sourceType: 'module', parser: require.resolve('@typescript-eslint/parser') } + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() } ], diff --git a/tests/lib/rules/require-explicit-emits.js b/tests/lib/rules/require-explicit-emits.js index a5bc5114c..43a3c2599 100644 --- a/tests/lib/rules/require-explicit-emits.js +++ b/tests/lib/rules/require-explicit-emits.js @@ -6,6 +6,9 @@ const RuleTester = require('eslint').RuleTester const rule = require('../../../lib/rules/require-explicit-emits') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), @@ -618,6 +621,17 @@ tester.run('require-explicit-emits', rule, { `, parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() } ], invalid: [ @@ -1950,6 +1964,25 @@ emits: {'foo': null} line: 6 } ] + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions(), + errors: [ + { + message: + 'The "qux" event has been triggered but not declared on `defineEmits`.', + line: 8 + } + ] } ] }) diff --git a/tests/lib/rules/require-prop-comment.js b/tests/lib/rules/require-prop-comment.js index f72d6905b..900f25ec3 100644 --- a/tests/lib/rules/require-prop-comment.js +++ b/tests/lib/rules/require-prop-comment.js @@ -6,6 +6,9 @@ const RuleTester = require('eslint').RuleTester const rule = require('../../../lib/rules/require-prop-comment') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') const tester = new RuleTester({ parser: require.resolve('vue-eslint-parser'), @@ -57,10 +60,10 @@ tester.run('require-prop-comment', rule, { const goodProps = defineProps({ /** JSDoc comment */ a: Number, - + /* block comment */ b: Number, - + // line comment c: Number, }) @@ -93,6 +96,14 @@ tester.run('require-prop-comment', rule, { parserOptions: { parser: require.resolve('@typescript-eslint/parser') } + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() } ], invalid: [ @@ -102,10 +113,10 @@ tester.run('require-prop-comment', rule, { props: { // line comment b: Number, - + /* block comment */ c: Number, - + d: Number, } }) @@ -134,10 +145,10 @@ tester.run('require-prop-comment', rule, { const badProps = defineProps({ /** JSDoc comment */ b: Number, - + // line comment c: Number, - + d: Number, }) @@ -167,10 +178,10 @@ tester.run('require-prop-comment', rule, { const badProps = defineProps({ /** JSDoc comment */ b: Number, - + /* block comment */ c: Number, - + d: Number, }) diff --git a/tests/lib/rules/require-prop-type-constructor.js b/tests/lib/rules/require-prop-type-constructor.js index a662e34d2..9cd4eae6e 100644 --- a/tests/lib/rules/require-prop-type-constructor.js +++ b/tests/lib/rules/require-prop-type-constructor.js @@ -5,6 +5,9 @@ 'use strict' const rule = require('../../../lib/rules/require-prop-type-constructor') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') const RuleTester = require('eslint').RuleTester const ruleTester = new RuleTester({ @@ -85,6 +88,14 @@ ruleTester.run('require-prop-type-constructor', rule, { props: ['name',,,] } ` + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() } ], diff --git a/tests/lib/rules/require-prop-types.js b/tests/lib/rules/require-prop-types.js index bcbff9013..9b7077fc1 100644 --- a/tests/lib/rules/require-prop-types.js +++ b/tests/lib/rules/require-prop-types.js @@ -5,6 +5,9 @@ 'use strict' const rule = require('../../../lib/rules/require-prop-types') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') const RuleTester = require('eslint').RuleTester @@ -176,6 +179,14 @@ ruleTester.run('require-prop-types', rule, { parser: require.resolve('@typescript-eslint/parser') }, parser: require.resolve('vue-eslint-parser') + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() } ], diff --git a/tests/lib/rules/require-valid-default-prop.js b/tests/lib/rules/require-valid-default-prop.js index 2f776ea1d..cec05ba05 100644 --- a/tests/lib/rules/require-valid-default-prop.js +++ b/tests/lib/rules/require-valid-default-prop.js @@ -5,6 +5,9 @@ 'use strict' const rule = require('../../../lib/rules/require-valid-default-prop') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') const RuleTester = require('eslint').RuleTester const parserOptions = { @@ -267,6 +270,24 @@ ruleTester.run('require-valid-default-prop', rule, { parser: require.resolve('@typescript-eslint/parser') }, parser: require.resolve('vue-eslint-parser') + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() } ], @@ -929,6 +950,63 @@ ruleTester.run('require-valid-default-prop', rule, { line: 4 } ] + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions(), + errors: [ + { + message: "Type of the default value for 'a' prop must be a string.", + line: 5 + }, + { + message: "Type of the default value for 'b' prop must be a number.", + line: 6 + }, + { + message: "Type of the default value for 'c' prop must be a boolean.", + line: 7 + }, + { + message: "Type of the default value for 'd' prop must be a boolean.", + line: 8 + }, + { + message: + "Type of the default value for 'e' prop must be a string or number.", + line: 9 + }, + { + message: "Type of the default value for 'f' prop must be a function.", + line: 10 + }, + { + message: "Type of the default value for 'g' prop must be a function.", + line: 11 + }, + { + message: "Type of the default value for 'h' prop must be a function.", + line: 12 + }, + { + message: "Type of the default value for 'i' prop must be a function.", + line: 13 + } + ] } ] }) diff --git a/tests/lib/rules/return-in-emits-validator.js b/tests/lib/rules/return-in-emits-validator.js index d4b5272ea..c72392bda 100644 --- a/tests/lib/rules/return-in-emits-validator.js +++ b/tests/lib/rules/return-in-emits-validator.js @@ -5,6 +5,9 @@ 'use strict' const rule = require('../../../lib/rules/return-in-emits-validator') +const { + getTypeScriptFixtureTestOptions +} = require('../../test-utils/typescript') const RuleTester = require('eslint').RuleTester @@ -123,6 +126,14 @@ ruleTester.run('return-in-emits-validator', rule, { }) ` + }, + { + code: ` + `, + ...getTypeScriptFixtureTestOptions() } ], diff --git a/tests/lib/utils/index.js b/tests/lib/utils/index.js index 9b02ba46b..6afecaeaa 100644 --- a/tests/lib/utils/index.js +++ b/tests/lib/utils/index.js @@ -367,9 +367,9 @@ describe('getComponentProps', () => { assert.equal(props.length, 5, 'it detects all props') - assert.strictEqual(props[0].key, null) + assert.strictEqual(props[0].key, undefined) assert.strictEqual(props[0].node.type, 'SpreadElement') - assert.strictEqual(props[0].value, null) + assert.strictEqual(props[0].value, undefined) assert.strictEqual(props[1].key.type, 'Identifier') assert.strictEqual(props[1].node.type, 'Property') diff --git a/tests/lib/utils/ts-utils/index/get-component-emits.js b/tests/lib/utils/ts-utils/index/get-component-emits.js new file mode 100644 index 000000000..8bba2be7c --- /dev/null +++ b/tests/lib/utils/ts-utils/index/get-component-emits.js @@ -0,0 +1,122 @@ +/** + * Test for getComponentEmitsFromTypeDefineTypes + */ +'use strict' + +const path = require('path') +const fs = require('fs') +const Linter = require('eslint').Linter +const parser = require('vue-eslint-parser') +const tsParser = require('@typescript-eslint/parser') +const utils = require('../../../../../lib/utils/index') +const assert = require('assert') + +const FIXTURES_ROOT = path.resolve( + __dirname, + '../../../../fixtures/utils/ts-utils' +) +const TSCONFIG_PATH = path.resolve(FIXTURES_ROOT, './tsconfig.json') +const SRC_TS_TEST_PATH = path.join(FIXTURES_ROOT, './src/test.ts') + +function extractComponentProps(code, tsFileCode) { + const linter = new Linter() + const config = { + parser: 'vue-eslint-parser', + parserOptions: { + ecmaVersion: 2020, + parser: tsParser, + project: [TSCONFIG_PATH], + extraFileExtensions: ['.vue'] + }, + rules: { + test: 'error' + } + } + linter.defineParser('vue-eslint-parser', parser) + const result = [] + linter.defineRule('test', { + create(context) { + return utils.defineScriptSetupVisitor(context, { + onDefineEmitsEnter(_node, emits) { + result.push( + ...emits.map((emit) => ({ + type: emit.type, + name: emit.emitName + })) + ) + } + }) + } + }) + fs.writeFileSync(SRC_TS_TEST_PATH, tsFileCode || '', 'utf8') + // clean './src/test.ts' cache + tsParser.parseForESLint(tsFileCode || '', { + ...config.parserOptions, + filePath: SRC_TS_TEST_PATH + }) + assert.deepStrictEqual( + linter.verify(code, config, path.join(FIXTURES_ROOT, './src/test.vue')), + [] + ) + // reset + fs.writeFileSync(SRC_TS_TEST_PATH, '', 'utf8') + return result +} + +describe('getComponentEmitsFromTypeDefineTypes', () => { + for (const { scriptCode, tsFileCode, props: expected } of [ + { + scriptCode: `defineEmits<{(e:'foo'):void,(e:'bar'):void}>()`, + props: [ + { type: 'type', name: 'foo' }, + { type: 'type', name: 'bar' } + ] + }, + { + tsFileCode: `export type Emits = {(e:'foo'):void,(e:'bar'):void}`, + scriptCode: `import { Emits } from './test' + defineEmits()`, + props: [ + { type: 'infer-type', name: 'foo' }, + { type: 'infer-type', name: 'bar' } + ] + }, + { + tsFileCode: `export type Emits = any`, + scriptCode: `import { Emits } from './test' + defineEmits()`, + props: [{ type: 'unknown', name: null }] + }, + { + tsFileCode: `export type Emits = {(e:'foo' | 'bar'): void, (e:'baz',payload:number): void}`, + scriptCode: `import { Emits } from './test' + defineEmits()`, + props: [ + { type: 'infer-type', name: 'foo' }, + { type: 'infer-type', name: 'bar' }, + { type: 'infer-type', name: 'baz' } + ] + }, + { + tsFileCode: `export type Emits = { a: [], b: [number], c: [string]}`, + scriptCode: `import { Emits } from './test' + defineEmits()`, + props: [ + { type: 'infer-type', name: 'a' }, + { type: 'infer-type', name: 'b' }, + { type: 'infer-type', name: 'c' } + ] + } + ]) { + const code = `` + it(`should return expected props with :${code}`, () => { + const props = extractComponentProps(code, tsFileCode) + + assert.deepStrictEqual( + props, + expected, + `\n${JSON.stringify(props)}\n === \n${JSON.stringify(expected)}` + ) + }) + } +}) diff --git a/tests/lib/utils/ts-utils/index/get-component-props.js b/tests/lib/utils/ts-utils/index/get-component-props.js new file mode 100644 index 000000000..2ab0070c7 --- /dev/null +++ b/tests/lib/utils/ts-utils/index/get-component-props.js @@ -0,0 +1,213 @@ +/** + * Test for getComponentPropsFromTypeDefineTypes + */ +'use strict' + +const path = require('path') +const fs = require('fs') +const Linter = require('eslint').Linter +const parser = require('vue-eslint-parser') +const tsParser = require('@typescript-eslint/parser') +const utils = require('../../../../../lib/utils/index') +const assert = require('assert') + +const FIXTURES_ROOT = path.resolve( + __dirname, + '../../../../fixtures/utils/ts-utils' +) +const TSCONFIG_PATH = path.resolve(FIXTURES_ROOT, './tsconfig.json') +const SRC_TS_TEST_PATH = path.join(FIXTURES_ROOT, './src/test.ts') + +function extractComponentProps(code, tsFileCode) { + const linter = new Linter() + const config = { + parser: 'vue-eslint-parser', + parserOptions: { + ecmaVersion: 2020, + parser: tsParser, + project: [TSCONFIG_PATH], + extraFileExtensions: ['.vue'] + }, + rules: { + test: 'error' + } + } + linter.defineParser('vue-eslint-parser', parser) + const result = [] + linter.defineRule('test', { + create(context) { + return utils.defineScriptSetupVisitor(context, { + onDefinePropsEnter(_node, props) { + result.push( + ...props.map((prop) => ({ + type: prop.type, + name: prop.propName, + required: prop.required ?? null, + types: prop.types ?? null + })) + ) + } + }) + } + }) + fs.writeFileSync(SRC_TS_TEST_PATH, tsFileCode || '', 'utf8') + // clean './src/test.ts' cache + tsParser.parseForESLint(tsFileCode || '', { + ...config.parserOptions, + filePath: SRC_TS_TEST_PATH + }) + assert.deepStrictEqual( + linter.verify(code, config, path.join(FIXTURES_ROOT, './src/test.vue')), + [] + ) + // reset + fs.writeFileSync(SRC_TS_TEST_PATH, '', 'utf8') + return result +} + +describe('getComponentPropsFromTypeDefineTypes', () => { + for (const { scriptCode, tsFileCode, props: expected } of [ + { + scriptCode: `defineProps<{foo:string,bar?:number}>()`, + props: [ + { type: 'type', name: 'foo', required: true, types: ['String'] }, + { type: 'type', name: 'bar', required: false, types: ['Number'] } + ] + }, + { + scriptCode: `defineProps<{foo:string,bar?:number} & {baz?:string|number}>()`, + props: [ + { type: 'type', name: 'foo', required: true, types: ['String'] }, + { type: 'type', name: 'bar', required: false, types: ['Number'] }, + { + type: 'type', + name: 'baz', + required: false, + types: ['String', 'Number'] + } + ] + }, + { + tsFileCode: `export type Props = {foo:string,bar?:number}`, + scriptCode: `import { Props } from './test' + defineProps()`, + props: [ + { type: 'infer-type', name: 'foo', required: true, types: ['String'] }, + { type: 'infer-type', name: 'bar', required: false, types: ['Number'] } + ] + }, + { + tsFileCode: `export type Props = any`, + scriptCode: `import { Props } from './test' + defineProps()`, + props: [{ type: 'unknown', name: null, required: null, types: null }] + }, + { + tsFileCode: ` + interface Props { + a?: number; + b?: string; + } + export interface Props2 extends Required { + c?: boolean; + }`, + scriptCode: `import { Props2 } from './test' + defineProps()`, + props: [ + { type: 'infer-type', name: 'c', required: false, types: ['Boolean'] }, + { type: 'infer-type', name: 'a', required: true, types: ['Number'] }, + { type: 'infer-type', name: 'b', required: true, types: ['String'] } + ] + }, + { + tsFileCode: ` + export type Props = { + a: string + b?: number + c?: boolean + d?: boolean + e?: number | string + f?: () => number + g?: { foo?: string } + h?: string[] + i?: readonly string[] + }`, + scriptCode: `import { Props } from './test' + defineProps()`, + props: [ + { type: 'infer-type', name: 'a', required: true, types: ['String'] }, + { type: 'infer-type', name: 'b', required: false, types: ['Number'] }, + { type: 'infer-type', name: 'c', required: false, types: ['Boolean'] }, + { type: 'infer-type', name: 'd', required: false, types: ['Boolean'] }, + { + type: 'infer-type', + name: 'e', + required: false, + types: ['String', 'Number'] + }, + { type: 'infer-type', name: 'f', required: false, types: ['Function'] }, + { type: 'infer-type', name: 'g', required: false, types: ['Object'] }, + { type: 'infer-type', name: 'h', required: false, types: ['Array'] }, + { type: 'infer-type', name: 'i', required: false, types: ['Array'] } + ] + }, + { + tsFileCode: ` + export interface Props { + a?: number; + b?: string; + }`, + scriptCode: `import { Props } from './test' +defineProps()`, + props: [ + { type: 'infer-type', name: 'a', required: false, types: ['Number'] }, + { type: 'infer-type', name: 'b', required: false, types: ['String'] }, + { type: 'type', name: 'foo', required: false, types: ['String'] } + ] + }, + { + tsFileCode: ` + export type A = string | number`, + scriptCode: `import { A } from './test' +defineProps<{foo?:A}>()`, + props: [ + { + type: 'type', + name: 'foo', + required: false, + types: ['String', 'Number'] + } + ] + }, + { + scriptCode: `enum A {a = 'a', b = 'b'} +defineProps<{foo?:A}>()`, + props: [{ type: 'type', name: 'foo', required: false, types: ['String'] }] + }, + { + scriptCode: ` +const foo = 42 +enum A {a = foo, b = 'b'} +defineProps<{foo?:A}>()`, + props: [ + { + type: 'type', + name: 'foo', + required: false, + types: ['Number', 'String'] + } + ] + } + ]) { + const code = `` + it(`should return expected props with :${code}`, () => { + const props = extractComponentProps(code, tsFileCode) + + assert.deepStrictEqual( + props, + expected, + `\n${JSON.stringify(props)}\n === \n${JSON.stringify(expected)}` + ) + }) + } +}) diff --git a/tests/test-utils/typescript.js b/tests/test-utils/typescript.js new file mode 100644 index 000000000..e06008a8f --- /dev/null +++ b/tests/test-utils/typescript.js @@ -0,0 +1,26 @@ +const path = require('path') +const tsParser = require('@typescript-eslint/parser') + +const FIXTURES_ROOT = path.resolve(__dirname, '../fixtures/typescript') +const TSCONFIG_PATH = path.resolve(FIXTURES_ROOT, './tsconfig.json') +const SRC_VUE_TEST_PATH = path.join(FIXTURES_ROOT, './src/test.vue') + +module.exports = { + getTypeScriptFixtureTestOptions +} + +function getTypeScriptFixtureTestOptions() { + const parser = require.resolve('vue-eslint-parser') + const parserOptions = { + parser: { ts: tsParser }, + ecmaVersion: 2020, + sourceType: 'module', + project: [TSCONFIG_PATH], + extraFileExtensions: ['.vue'] + } + return { + parser, + parserOptions, + filename: SRC_VUE_TEST_PATH + } +} diff --git a/typings/eslint-plugin-vue/global.d.ts b/typings/eslint-plugin-vue/global.d.ts index c2ae50632..a581b3f62 100644 --- a/typings/eslint-plugin-vue/global.d.ts +++ b/typings/eslint-plugin-vue/global.d.ts @@ -159,6 +159,7 @@ declare global { type TSLiteralType = VAST.TSLiteralType type TSCallSignatureDeclaration = VAST.TSCallSignatureDeclaration type TSFunctionType = VAST.TSFunctionType + type TypeNode = VAST.TypeNode // ---- JSX Nodes ---- diff --git a/typings/eslint-plugin-vue/util-types/ast/ts-ast.ts b/typings/eslint-plugin-vue/util-types/ast/ts-ast.ts index a79d457f4..e3c5f0faf 100644 --- a/typings/eslint-plugin-vue/util-types/ast/ts-ast.ts +++ b/typings/eslint-plugin-vue/util-types/ast/ts-ast.ts @@ -12,6 +12,7 @@ export type TSNode = | TSLiteralType | TSCallSignatureDeclaration | TSFunctionType + | TypeNode export interface TSAsExpression extends HasParentNode { type: 'TSAsExpression' @@ -21,7 +22,7 @@ export interface TSAsExpression extends HasParentNode { export interface TSTypeParameterInstantiation extends HasParentNode { type: 'TSTypeParameterInstantiation' - params: TSESTree.TypeNode[] + params: TypeNode[] } export type TSPropertySignature = @@ -90,3 +91,12 @@ export interface TSCallSignatureDeclaration extends TSFunctionSignatureBase { export interface TSFunctionType extends TSFunctionSignatureBase { type: 'TSFunctionType' } + +type TypeNodeTypes = `${TSESTree.TypeNode['type']}` + +export type TypeNode = + | (HasParentNode & { + type: Exclude + }) + | TSFunctionType + | TSLiteralType diff --git a/typings/eslint-plugin-vue/util-types/parser-services.ts b/typings/eslint-plugin-vue/util-types/parser-services.ts index d59d76d3e..6a9ccc776 100644 --- a/typings/eslint-plugin-vue/util-types/parser-services.ts +++ b/typings/eslint-plugin-vue/util-types/parser-services.ts @@ -1,6 +1,7 @@ import * as VNODE from './node' import * as VAST from './ast' import * as eslint from 'eslint' +import { ESNode, TSNode } from './ast' type TemplateListenerBase = { [T in keyof VAST.VNodeListenerMap]?: (node: VAST.VNodeListenerMap[T]) => void @@ -27,6 +28,13 @@ export interface ParserServices { } ) => eslint.Rule.RuleListener getDocumentFragment?: () => VAST.VDocumentFragment | null + // for typescript-eslint/parser + esTreeNodeToTSNodeMap?: Map< + ESNode | TSNode | import('@typescript-eslint/types').TSESTree.Node, + import('typescript').Node + > + program?: import('typescript').Program + hasFullTypeInformation?: boolean } export namespace ParserServices { export interface TokenStore { diff --git a/typings/eslint-plugin-vue/util-types/utils.ts b/typings/eslint-plugin-vue/util-types/utils.ts index e16d86033..b2704769f 100644 --- a/typings/eslint-plugin-vue/util-types/utils.ts +++ b/typings/eslint-plugin-vue/util-types/utils.ts @@ -55,14 +55,12 @@ type ComponentArrayPropDetectName = { type: 'array' key: Literal | TemplateLiteral propName: string - value: null node: Expression | SpreadElement } type ComponentArrayPropUnknownName = { type: 'array' key: null propName: null - value: null node: Expression | SpreadElement } export type ComponentArrayProp = @@ -89,41 +87,46 @@ export type ComponentObjectProp = export type ComponentUnknownProp = { type: 'unknown' - key: null propName: null - value: null - node: Expression | SpreadElement | null + node: Expression | SpreadElement | TypeNode | null } export type ComponentTypeProp = { type: 'type' key: Identifier | Literal propName: string - value: null node: TSPropertySignature | TSMethodSignature required: boolean types: string[] } +export type ComponentInferTypeProp = { + type: 'infer-type' + propName: string + node: TypeNode + + required: boolean + types: string[] +} + export type ComponentProp = | ComponentArrayProp | ComponentObjectProp | ComponentTypeProp + | ComponentInferTypeProp | ComponentUnknownProp type ComponentArrayEmitDetectName = { type: 'array' key: Literal | TemplateLiteral emitName: string - value: null node: Expression | SpreadElement } type ComponentArrayEmitUnknownName = { type: 'array' key: null emitName: null - value: null node: Expression | SpreadElement } export type ComponentArrayEmit = @@ -150,32 +153,35 @@ export type ComponentObjectEmit = export type ComponentUnknownEmit = { type: 'unknown' - key: null emitName: null - value: null - node: Expression | SpreadElement | null + node: Expression | SpreadElement | TypeNode | null } export type ComponentTypeEmitCallSignature = { type: 'type' key: TSLiteralType emitName: string - value: null node: TSCallSignatureDeclaration | TSFunctionType } export type ComponentTypeEmitPropertySignature = { type: 'type' key: Identifier | Literal emitName: string - value: null node: TSPropertySignature | TSMethodSignature } export type ComponentTypeEmit = | ComponentTypeEmitCallSignature | ComponentTypeEmitPropertySignature +export type ComponentInferTypeEmit = { + type: 'infer-type' + emitName: string + node: TypeNode +} + export type ComponentEmit = | ComponentArrayEmit | ComponentObjectEmit | ComponentTypeEmit + | ComponentInferTypeEmit | ComponentUnknownEmit