diff --git a/lib/rules/no-reserved-keys.js b/lib/rules/no-reserved-keys.js index 38b4f410d..a9fce43ca 100644 --- a/lib/rules/no-reserved-keys.js +++ b/lib/rules/no-reserved-keys.js @@ -40,7 +40,12 @@ module.exports = { }, additionalProperties: false } - ] + ], + messages: { + reserved: "Key '{{name}}' is reserved.", + startsWithUnderscore: + "Keys starting with with '_' are reserved in '{{name}}' group." + } }, /** @param {RuleContext} context */ create(context) { @@ -52,28 +57,44 @@ module.exports = { // Public // ---------------------------------------------------------------------- - return utils.executeOnVue(context, (obj) => { - const properties = utils.iterateProperties(obj, groups) - for (const o of properties) { - if (o.groupName === 'data' && o.name[0] === '_') { - context.report({ - node: o.node, - message: - "Keys starting with with '_' are reserved in '{{name}}' group.", - data: { - name: o.name + return utils.compositingVisitors( + utils.defineScriptSetupVisitor(context, { + onDefinePropsEnter(_node, props) { + for (const { propName, node } of props) { + if (propName && reservedKeys.has(propName)) { + context.report({ + node, + messageId: 'reserved', + data: { + name: propName + } + }) } - }) - } else if (reservedKeys.has(o.name)) { - context.report({ - node: o.node, - message: "Key '{{name}}' is reserved.", - data: { - name: o.name - } - }) + } } - } - }) + }), + utils.executeOnVue(context, (obj) => { + const properties = utils.iterateProperties(obj, groups) + for (const o of properties) { + if (o.groupName === 'data' && o.name[0] === '_') { + context.report({ + node: o.node, + messageId: 'startsWithUnderscore', + data: { + name: o.name + } + }) + } else if (reservedKeys.has(o.name)) { + context.report({ + node: o.node, + messageId: 'reserved', + data: { + name: o.name + } + }) + } + } + }) + ) } } diff --git a/lib/utils/index.js b/lib/utils/index.js index a02bcd6ff..5ecda2868 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -14,6 +14,7 @@ /** * @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 {object} ComponentArrayEmitDetectName @@ -81,6 +82,7 @@ const path = require('path') const vueEslintParser = require('vue-eslint-parser') const traverseNodes = vueEslintParser.AST.traverseNodes const { findVariable } = require('eslint-utils') +const { getComponentPropsFromTypeDefine } = require('./ts-ast-utils') /** * @type { WeakMap } @@ -1105,13 +1107,21 @@ module.exports = { node.callee.type === 'Identifier' && node.callee.name === 'defineProps' ) { - /** @type {(ComponentArrayProp | ComponentObjectProp)[]} */ + /** @type {(ComponentArrayProp | ComponentObjectProp | ComponentTypeProp)[]} */ let props = [] if (node.arguments.length >= 1) { const defNode = getObjectOrArray(node.arguments[0]) if (defNode) { props = getComponentPropsFromDefine(defNode) } + } else if ( + node.typeParameters && + node.typeParameters.params.length >= 1 + ) { + props = getComponentPropsFromTypeDefine( + context, + node.typeParameters.params[0] + ) } definePropsMap.set(node, props) callVisitor('onDefinePropsEnter', node, props) diff --git a/lib/utils/ts-ast-utils.js b/lib/utils/ts-ast-utils.js new file mode 100644 index 000000000..b88c06f87 --- /dev/null +++ b/lib/utils/ts-ast-utils.js @@ -0,0 +1,194 @@ +const { findVariable } = require('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('../../typings/eslint-plugin-vue/util-types/utils').ComponentTypeProp} ComponentTypeProp + */ + +module.exports = { + getComponentPropsFromTypeDefine +} + +/** + * @param {TypeNode} node + * @returns {node is TSTypeLiteral} + */ +function isTSTypeLiteral(node) { + return node.type === 'TSTypeLiteral' +} + +/** + * 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)] +} + +/** + * @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' + ) { + let type + if (m.type === 'TSMethodSignature') { + type = ['Function'] + } else if (m.typeAnnotation) { + type = inferRuntimeType(context, m.typeAnnotation.typeAnnotation) + } + yield { + type: 'type', + key: /** @type {Identifier} */ (m.key), + propName: m.key.name, + value: null, + node: /** @type {TSPropertySignature | TSMethodSignature} */ (m), + + required: !m.optional, + types: type || [`null`] + } + } + } +} + +/** + * @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': + switch (node.literal.type) { + //@ts-ignore ? + case 'StringLiteral': + return ['String'] + //@ts-ignore ? + case 'BooleanLiteral': + return ['Boolean'] + //@ts-ignore ? + case 'NumericLiteral': + //@ts-ignore ? + // eslint-disable-next-line no-fallthrough + case 'BigIntLiteral': + return ['Number'] + default: + 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': + 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/tests/lib/rules/no-reserved-keys.js b/tests/lib/rules/no-reserved-keys.js index fdd5e01f4..49857cd51 100644 --- a/tests/lib/rules/no-reserved-keys.js +++ b/tests/lib/rules/no-reserved-keys.js @@ -8,6 +8,7 @@ // Requirements // ------------------------------------------------------------------------------ +const semver = require('semver') const rule = require('../../../lib/rules/no-reserved-keys') const RuleTester = require('eslint').RuleTester @@ -225,6 +226,94 @@ ruleTester.run('no-reserved-keys', rule, { line: 4 } ] - } + }, + { + filename: 'test.vue', + code: ` + + `, + parser: require.resolve('vue-eslint-parser'), + parserOptions: { ecmaVersion: 6 }, + errors: [ + { + message: "Key '$el' is reserved.", + line: 4 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + parser: require.resolve('vue-eslint-parser'), + parserOptions: { + ecmaVersion: 6, + parser: require.resolve('@typescript-eslint/parser') + }, + errors: [ + { + message: "Key '$el' is reserved.", + line: 3 + } + ] + }, + ...(semver.lt( + require('@typescript-eslint/parser/package.json').version, + '4.0.0' + ) + ? [] + : [ + { + filename: 'test.vue', + code: ` + + `, + parser: require.resolve('vue-eslint-parser'), + parserOptions: { + ecmaVersion: 6, + parser: require.resolve('@typescript-eslint/parser') + }, + errors: [ + { + message: "Key '$el' is reserved.", + line: 4 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + parser: require.resolve('vue-eslint-parser'), + parserOptions: { + ecmaVersion: 6, + parser: require.resolve('@typescript-eslint/parser') + }, + errors: [ + { + message: "Key '$el' is reserved.", + line: 4 + } + ] + } + ]) ] }) diff --git a/typings/eslint-plugin-vue/global.d.ts b/typings/eslint-plugin-vue/global.d.ts index 5b185bb24..543eed56c 100644 --- a/typings/eslint-plugin-vue/global.d.ts +++ b/typings/eslint-plugin-vue/global.d.ts @@ -152,6 +152,9 @@ declare global { // ---- TS Nodes ---- type TSAsExpression = VAST.TSAsExpression + type TSTypeParameterInstantiation = VAST.TSTypeParameterInstantiation + type TSPropertySignature = VAST.TSPropertySignature + type TSMethodSignature = VAST.TSMethodSignature // ---- JSX Nodes ---- diff --git a/typings/eslint-plugin-vue/util-types/ast/es-ast.ts b/typings/eslint-plugin-vue/util-types/ast/es-ast.ts index 1a2519a1a..26c433a2e 100644 --- a/typings/eslint-plugin-vue/util-types/ast/es-ast.ts +++ b/typings/eslint-plugin-vue/util-types/ast/es-ast.ts @@ -495,6 +495,7 @@ export interface CallExpression extends HasParentNode { callee: Expression | Super arguments: (Expression | SpreadElement)[] optional: boolean + typeParameters?: TS.TSTypeParameterInstantiation } export interface Super extends HasParentNode { type: 'Super' 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 91319043b..ef68b4514 100644 --- a/typings/eslint-plugin-vue/util-types/ast/ts-ast.ts +++ b/typings/eslint-plugin-vue/util-types/ast/ts-ast.ts @@ -3,10 +3,70 @@ */ import { HasParentNode } from '../node' import * as ES from './es-ast' -export type TSNode = TSAsExpression +import { TSESTree } from '@typescript-eslint/types' +export type TSNode = + | TSAsExpression + | TSTypeParameterInstantiation + | TSPropertySignature + | TSMethodSignatureBase export interface TSAsExpression extends HasParentNode { type: 'TSAsExpression' expression: ES.Expression - typeAnnotation: any; + typeAnnotation: any +} + +export interface TSTypeParameterInstantiation extends HasParentNode { + type: 'TSTypeParameterInstantiation' + params: TSESTree.TypeNode[] +} + +export type TSPropertySignature = + | TSPropertySignatureComputedName + | TSPropertySignatureNonComputedName +interface TSPropertySignatureBase extends HasParentNode { + type: 'TSPropertySignature' + key: TSESTree.PropertyName + optional?: boolean + computed: boolean + typeAnnotation?: TSESTree.TSTypeAnnotation + initializer?: Expression + readonly?: boolean + static?: boolean + export?: boolean + accessibility?: TSESTree.Accessibility +} +interface TSPropertySignatureComputedName extends TSPropertySignatureBase { + key: TSESTree.PropertyNameComputed + computed: true +} +interface TSPropertySignatureNonComputedName extends TSPropertySignatureBase { + key: TSESTree.PropertyNameNonComputed + computed: false +} + +export type TSMethodSignature = + | TSMethodSignatureComputedName + | TSMethodSignatureNonComputedName +interface TSMethodSignatureBase extends HasParentNode { + type: 'TSMethodSignature' + key: TSESTree.PropertyName + computed: boolean + params: TSESTree.Parameter[] + optional?: boolean + returnType?: TSESTree.TSTypeAnnotation + readonly?: boolean + typeParameters?: TSESTree.TSTypeParameterDeclaration + accessibility?: TSESTree.Accessibility + export?: boolean + static?: boolean + kind: 'get' | 'method' | 'set' +} +interface TSMethodSignatureComputedName extends TSMethodSignatureBase { + key: TSESTree.PropertyNameComputed + computed: true +} +interface TSMethodSignatureNonComputedName extends TSMethodSignatureBase { + key: TSESTree.PropertyNameNonComputed + computed: false } diff --git a/typings/eslint-plugin-vue/util-types/utils.ts b/typings/eslint-plugin-vue/util-types/utils.ts index e1633eb5d..86e2648b3 100644 --- a/typings/eslint-plugin-vue/util-types/utils.ts +++ b/typings/eslint-plugin-vue/util-types/utils.ts @@ -34,11 +34,11 @@ type ScriptSetupVisitorBase = { export interface ScriptSetupVisitor extends ScriptSetupVisitorBase { onDefinePropsEnter?( node: CallExpression, - props: (ComponentArrayProp | ComponentObjectProp)[] + props: (ComponentArrayProp | ComponentObjectProp | ComponentTypeProp)[] ): void onDefinePropsExit?( node: CallExpression, - props: (ComponentArrayProp | ComponentObjectProp)[] + props: (ComponentArrayProp | ComponentObjectProp | ComponentTypeProp)[] ): void [query: string]: | ((node: VAST.ParamNode) => void) @@ -84,3 +84,14 @@ type ComponentObjectPropUnknownName = { export type ComponentObjectProp = | ComponentObjectPropDetectName | ComponentObjectPropUnknownName + +export type ComponentTypeProp = { + type: 'type' + key: Identifier + propName: string + value: null + node: TSPropertySignature | TSMethodSignature + + required: boolean + types: string[] +}