diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ac9f23ea7..f61d43d986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- 🙌 Autocomplete for custom events. Thanks to contribution from [@sapphi-red](https://github.com/sapphi-red) #2392. + ### 0.31.1 | 2020-12-09 | [VSIX](https://marketplace.visualstudio.com/_apis/public/gallery/publishers/octref/vsextensions/vetur/0.31.1/vspackage) - Fix `Vetur` can't format. #2535 #2538 #2531 #2532 diff --git a/server/src/modes/script/componentInfo.ts b/server/src/modes/script/componentInfo.ts index 48c0ad889d..5498d73263 100644 --- a/server/src/modes/script/componentInfo.ts +++ b/server/src/modes/script/componentInfo.ts @@ -1,8 +1,10 @@ +import _ from 'lodash'; import type ts from 'typescript'; import { BasicComponentInfo } from '../../config'; import { RuntimeLibrary } from '../../services/dependencyService'; import { VueFileInfo, + EmitInfo, PropInfo, ComputedInfo, DataInfo, @@ -87,6 +89,7 @@ export function analyzeDefaultExportExpr( const defaultExportType = checker.getTypeAtLocation(defaultExportNode); const insertInOptionAPIPos = getInsertInOptionAPIPos(tsModule, defaultExportType, checker); + const emits = getEmits(tsModule, defaultExportType, checker); const props = getProps(tsModule, defaultExportType, checker); const data = getData(tsModule, defaultExportType, checker); const computed = getComputed(tsModule, defaultExportType, checker); @@ -95,6 +98,7 @@ export function analyzeDefaultExportExpr( return { componentInfo: { insertInOptionAPIPos, + emits, props, data, computed, @@ -137,6 +141,184 @@ function getInsertInOptionAPIPos( return undefined; } +function getEmits( + tsModule: RuntimeLibrary['typescript'], + defaultExportType: ts.Type, + checker: ts.TypeChecker +): EmitInfo[] | undefined { + // When there is @Emit and emits option both, use only emits option. + const result: EmitInfo[] = getClassAndObjectInfo( + tsModule, + defaultExportType, + checker, + getClassEmits, + getObjectEmits, + true + ); + + return result.length === 0 ? undefined : result; + + function getEmitValidatorInfo(propertyValue: ts.Node): { hasValidator: boolean; typeString?: string } { + /** + * case `foo: null` + */ + if (propertyValue.kind === tsModule.SyntaxKind.NullKeyword) { + return { hasValidator: false }; + } + + /** + * case `foo: function() {}` or `foo: () => {}` + */ + if (tsModule.isFunctionExpression(propertyValue) || tsModule.isArrowFunction(propertyValue)) { + let typeParameterText = ''; + if (propertyValue.typeParameters) { + typeParameterText = `<${propertyValue.typeParameters.map(tp => tp.getText()).join(', ')}>`; + } + const parameterText = `(${propertyValue.parameters + .map(p => `${p.getText()}${p.type ? '' : ': any'}`) + .join(', ')})`; + const typeString = `${typeParameterText}${parameterText} => any`; + return { hasValidator: true, typeString }; + } + + return { hasValidator: false }; + } + + function getClassEmits(type: ts.Type) { + const emitDecoratorNames = ['Emit']; + const emitsSymbols = type + .getProperties() + .filter( + property => + validPropertySyntaxKind(property, tsModule.SyntaxKind.MethodDeclaration) && + getPropertyDecoratorNames(property).some(decoratorName => emitDecoratorNames.includes(decoratorName)) + ); + if (emitsSymbols.length === 0) { + return undefined; + } + + // There maybe same emit name because @Emit can be put on multiple methods. + const emitInfoMap = new Map(); + emitsSymbols.forEach(emitSymbol => { + const emit = emitSymbol.valueDeclaration as ts.MethodDeclaration; + const decoratorExpr = emit.decorators?.find(decorator => + tsModule.isCallExpression(decorator.expression) + ? emitDecoratorNames.includes(decorator.expression.expression.getText()) + : false + )?.expression as ts.CallExpression; + const decoratorArgs = decoratorExpr.arguments; + + let name = _.kebabCase(emitSymbol.name); + if (decoratorArgs.length > 0) { + const firstNode = decoratorArgs[0]; + if (tsModule.isStringLiteral(firstNode)) { + name = firstNode.text; + } + } + + let typeString: string | undefined = undefined; + const signature = checker.getSignatureFromDeclaration(emit); + if (signature) { + const returnType = checker.getReturnTypeOfSignature(signature); + typeString = `(${checker.typeToString(returnType)})`; + if (typeString === '(void)') { + typeString = '(undefined)'; + } + } + + if (emitInfoMap.has(name)) { + const oldEmitInfo = emitInfoMap.get(name)!; + if (typeString) { + // create union type + oldEmitInfo.typeString += ` | ${typeString}`; + } else { + // remove type (because it failed to obtain the type) + oldEmitInfo.typeString = undefined; + } + oldEmitInfo.documentation += `\n\n${buildDocumentation(tsModule, emitSymbol, checker)}`; + emitInfoMap.set(name, oldEmitInfo); + } else { + emitInfoMap.set(name, { + name, + hasValidator: false, + typeString, + documentation: buildDocumentation(tsModule, emitSymbol, checker) + }); + } + }); + + const emitInfo = [...emitInfoMap.values()]; + emitInfo.forEach(info => { + if (info.typeString) { + info.typeString = `(arg: ${info.typeString}) => any`; + } + }); + + return emitInfo; + } + + function getObjectEmits(type: ts.Type) { + const emitsSymbol = checker.getPropertyOfType(type, 'emits'); + if (!emitsSymbol || !emitsSymbol.valueDeclaration) { + return undefined; + } + + const emitsDeclaration = getLastChild(emitsSymbol.valueDeclaration); + if (!emitsDeclaration) { + return undefined; + } + + /** + * Plain array emits like `emits: ['foo', 'bar']` + */ + if (emitsDeclaration.kind === tsModule.SyntaxKind.ArrayLiteralExpression) { + return (emitsDeclaration as ts.ArrayLiteralExpression).elements + .filter(expr => expr.kind === tsModule.SyntaxKind.StringLiteral) + .map(expr => { + return { + name: (expr as ts.StringLiteral).text, + hasValidator: false, + documentation: `\`\`\`js\n${formatJSLikeDocumentation( + emitsDeclaration.parent.getFullText().trim() + )}\n\`\`\`\n` + }; + }); + } + + /** + * Object literal emits like + * ``` + * { + * emits: { + * foo: () => true, + * bar: (arg1: string, arg2: number) => arg1.startsWith('s') || arg2 > 0, + * car: null + * } + * } + * ``` + */ + if (emitsDeclaration.kind === tsModule.SyntaxKind.ObjectLiteralExpression) { + const emitsType = checker.getTypeOfSymbolAtLocation(emitsSymbol, emitsDeclaration); + + return checker.getPropertiesOfType(emitsType).map(s => { + const node = getNodeFromSymbol(s); + const status = + node !== undefined && tsModule.isPropertyAssignment(node) + ? getEmitValidatorInfo(node.initializer) + : { hasValidator: false }; + + return { + name: s.name, + ...status, + documentation: buildDocumentation(tsModule, s, checker) + }; + }); + } + + return undefined; + } +} + function getProps( tsModule: RuntimeLibrary['typescript'], defaultExportType: ts.Type, @@ -646,15 +828,18 @@ function getClassAndObjectInfo( defaultExportType: ts.Type, checker: ts.TypeChecker, getClassResult: (type: ts.Type) => C[] | undefined, - getObjectResult: (type: ts.Type) => O[] | undefined + getObjectResult: (type: ts.Type) => O[] | undefined, + onlyUseObjectResultIfExists = false ) { const result: Array = []; if (isClassType(tsModule, defaultExportType)) { - result.push.apply(result, getClassResult(defaultExportType) || []); const decoratorArgumentType = getClassDecoratorArgumentType(tsModule, defaultExportType, checker); if (decoratorArgumentType) { result.push.apply(result, getObjectResult(decoratorArgumentType) || []); } + if (result.length === 0 || !onlyUseObjectResultIfExists) { + result.push.apply(result, getClassResult(defaultExportType) || []); + } } else { result.push.apply(result, getObjectResult(defaultExportType) || []); } diff --git a/server/src/modes/template/tagProviders/componentInfoTagProvider.ts b/server/src/modes/template/tagProviders/componentInfoTagProvider.ts index 4d3091428e..1ce588e721 100644 --- a/server/src/modes/template/tagProviders/componentInfoTagProvider.ts +++ b/server/src/modes/template/tagProviders/componentInfoTagProvider.ts @@ -16,10 +16,13 @@ export function getComponentInfoTagProvider(childComponents: ChildComponent[]): const tagSet: ITagSet = {}; for (const cc of childComponents) { - const props: Attribute[] = []; - if (cc.info && cc.info.componentInfo.props) { - cc.info.componentInfo.props.forEach(p => { - props.push(genAttribute(`:${p.name}`, undefined, { kind: 'markdown', value: p.documentation || '' })); + const attributes: Attribute[] = []; + if (cc.info) { + cc.info.componentInfo.props?.forEach(p => { + attributes.push(genAttribute(`:${p.name}`, undefined, { kind: 'markdown', value: p.documentation || '' })); + }); + cc.info.componentInfo.emits?.forEach(e => { + attributes.push(genAttribute(e.name, 'event', { kind: 'markdown', value: e.documentation || '' })); }); } tagSet[cc.name] = new HTMLTagSpecification( @@ -27,7 +30,7 @@ export function getComponentInfoTagProvider(childComponents: ChildComponent[]): kind: 'markdown', value: cc.documentation || '' }, - props + attributes ); } diff --git a/server/src/services/typescriptService/preprocess.ts b/server/src/services/typescriptService/preprocess.ts index d344195804..b7a10c7ba7 100644 --- a/server/src/services/typescriptService/preprocess.ts +++ b/server/src/services/typescriptService/preprocess.ts @@ -322,9 +322,25 @@ function convertChildComponentsInfoToSource(childComponents: ChildComponent[]) { }); propTypeStrings.push('[other: string]: any'); + const onTypeStrings: string[] = []; + c.info?.componentInfo.emits?.forEach(e => { + let typeKey = kebabCase(e.name); + if (typeKey.includes('-')) { + typeKey = `'` + typeKey + `'`; + } + typeKey += '?'; + + if (e.typeString) { + onTypeStrings.push(`${typeKey}: ($event: any) => (${e.typeString})`); + } else { + onTypeStrings.push(`${typeKey}: ($event: any) => any`); + } + }); + src += ` interface ${componentDataInterfaceName} extends ${componentDataName} { props: { ${propTypeStrings.join(', ')} } + on: { ${onTypeStrings.join(', ')} } & { [K in keyof T]?: ($event: T[K]) => any; } } declare const ${componentHelperInterfaceName}: { ( diff --git a/server/src/services/typescriptService/transformTemplate.ts b/server/src/services/typescriptService/transformTemplate.ts index 2657b9e439..bb00180c47 100644 --- a/server/src/services/typescriptService/transformTemplate.ts +++ b/server/src/services/typescriptService/transformTemplate.ts @@ -210,7 +210,7 @@ export function getTemplateTransformFunctions( const newScope = scope.concat(vOnScope); const statements = !vOnExp || vOnExp.type !== 'VOnExpression' - ? [tsModule.createExpressionStatement(transformExpressionContainer(vOn.value, code, newScope))] + ? [tsModule.createReturn(transformExpressionContainer(vOn.value, code, newScope))] : vOnExp.body.map(st => transformStatement(st, code, newScope)); exp = tsModule.createFunctionExpression( diff --git a/server/src/services/vueInfoService.ts b/server/src/services/vueInfoService.ts index d0816941e3..e0b147081b 100644 --- a/server/src/services/vueInfoService.ts +++ b/server/src/services/vueInfoService.ts @@ -26,6 +26,7 @@ export interface ComponentInfo { }; childComponents?: ChildComponent[]; + emits?: EmitInfo[]; /** * Todo: Extract type info in cases like * props: { @@ -50,6 +51,24 @@ export interface ChildComponent { info?: VueFileInfo; } +export interface EmitInfo { + name: string; + /** + * `true` if + * emits: { + * foo: (...) => {...} + * } + * + * `false` if + * - `emits: ['foo']` + * - `@Emit()` + * - `emits: { foo: null }` + */ + hasValidator: boolean; + documentation?: string; + typeString?: string; +} + export interface PropInfo { name: string; /** diff --git a/test/interpolation/features/completion/property.test.ts b/test/interpolation/features/completion/property.test.ts index 1a9a3d69fb..a812773e2e 100644 --- a/test/interpolation/features/completion/property.test.ts +++ b/test/interpolation/features/completion/property.test.ts @@ -100,6 +100,37 @@ describe('Should autocomplete interpolation for