From d60c091b24b956b03784bd0f0021c7e804f91cd8 Mon Sep 17 00:00:00 2001 From: Travis Arnold Date: Mon, 17 Jun 2024 11:58:51 -0700 Subject: [PATCH] support function type documentation (#23) --- .changeset/stupid-boxes-dream.md | 5 + .../src/types/getTypeDocumentation.test.ts | 102 ++++++++++ .../utils/src/types/getTypeDocumentation.ts | 178 +++++++++++------- 3 files changed, 220 insertions(+), 65 deletions(-) create mode 100644 .changeset/stupid-boxes-dream.md diff --git a/.changeset/stupid-boxes-dream.md b/.changeset/stupid-boxes-dream.md new file mode 100644 index 0000000..5aba45b --- /dev/null +++ b/.changeset/stupid-boxes-dream.md @@ -0,0 +1,5 @@ +--- +"@tsxmod/utils": minor +--- + +Adds support for function type documentation in `getTypeDocumentation`. diff --git a/packages/utils/src/types/getTypeDocumentation.test.ts b/packages/utils/src/types/getTypeDocumentation.test.ts index 0f106f6..c4f5807 100644 --- a/packages/utils/src/types/getTypeDocumentation.test.ts +++ b/packages/utils/src/types/getTypeDocumentation.test.ts @@ -1054,4 +1054,106 @@ describe('getTypeDocumentation', () => { } `) }) + + test('function types', () => { + const sourceFile = project.createSourceFile( + 'test.ts', + dedent` + import * as React from 'react'; + + export function getExportedTypes() { + return [ + { + /** The name of the component. */ + name: 'Button', + + /** The description of the component. */ + description: 'A button component' + } + ] + } + + type BaseExportedTypesProps = { + /** Controls how types are rendered. */ + children?: ( + exportedTypes: ReturnType + ) => React.ReactNode + } + + type ExportedTypesProps = + | ({ source: string } & BaseExportedTypesProps) + | ({ filename: string; value: string } & BaseExportedTypesProps) + + function ExportedTypes({ children }: ExportedTypesProps) {} + `, + { overwrite: true } + ) + const types = getTypeDocumentation( + sourceFile.getFunctionOrThrow('ExportedTypes') + ) + + expect(types).toMatchInlineSnapshot(` + { + "name": "ExportedTypes", + "parameters": [ + { + "defaultValue": undefined, + "description": undefined, + "name": undefined, + "properties": [ + { + "defaultValue": undefined, + "description": "Controls how types are rendered.", + "name": "children", + "parameters": [ + { + "defaultValue": undefined, + "description": undefined, + "name": "exportedTypes", + "required": true, + "type": "{ name: string; description: string; }[]", + }, + ], + "required": false, + "returnType": "React.ReactNode", + "tags": undefined, + "type": "(exportedTypes: ReturnType) => React.ReactNode", + }, + ], + "required": true, + "type": "ExportedTypesProps", + "unionProperties": [ + [ + { + "defaultValue": undefined, + "description": undefined, + "name": "source", + "required": true, + "type": "string", + }, + ], + [ + { + "defaultValue": undefined, + "description": undefined, + "name": "filename", + "required": true, + "type": "string", + }, + { + "defaultValue": undefined, + "description": undefined, + "name": "value", + "required": true, + "type": "string", + }, + ], + ], + }, + ], + "returnType": "void", + "type": "({ children }: ExportedTypesProps) => void", + } + `) + }) }) diff --git a/packages/utils/src/types/getTypeDocumentation.ts b/packages/utils/src/types/getTypeDocumentation.ts index c739b0a..f6ce6f5 100644 --- a/packages/utils/src/types/getTypeDocumentation.ts +++ b/packages/utils/src/types/getTypeDocumentation.ts @@ -18,7 +18,7 @@ import type { Type, ts, } from 'ts-morph' -import { Node, SyntaxKind, TypeFormatFlags, TypeChecker } from 'ts-morph' +import { Node, SyntaxKind, TypeFormatFlags } from 'ts-morph' import { getDefaultValuesFromProperties, getJsDocMetadata, @@ -87,7 +87,7 @@ export interface FunctionMetadata { tags?: { tagName: string; text?: string }[] } -export interface PropertyMetadata { +interface BasePropertyMetadata { name?: string description?: string tags?: { tagName: string; text?: string }[] @@ -96,8 +96,24 @@ export interface PropertyMetadata { type: string properties?: PropertyMetadata[] unionProperties?: PropertyMetadata[][] + parameters?: ParameterMetadata[] + returnType?: string } +export type PropertyMetadata = + | (BasePropertyMetadata & { + properties?: PropertyMetadata[] + unionProperties?: PropertyMetadata[][] + }) + | (BasePropertyMetadata & { + parameters?: { + name?: string + description?: string + type: string + }[] + returnType?: string + }) + export interface ParameterMetadata { name?: string description?: string @@ -196,7 +212,6 @@ function processInterface( interfaceDeclaration: InterfaceDeclaration, propertyFilter?: PropertyFilter ): InterfaceMetadata { - const typeChecker = interfaceDeclaration.getProject().getTypeChecker() const interfaceType = interfaceDeclaration.getType() return { @@ -204,7 +219,6 @@ function processInterface( properties: processTypeProperties( interfaceType, interfaceDeclaration, - typeChecker, propertyFilter ), ...getJsDocMetadata(interfaceDeclaration), @@ -216,17 +230,11 @@ function processTypeAlias( typeAlias: TypeAliasDeclaration, propertyFilter?: PropertyFilter ): TypeAliasMetadata { - const typeChecker = typeAlias.getProject().getTypeChecker() const aliasType = typeAlias.getType() return { name: typeAlias.getName(), - properties: processTypeProperties( - aliasType, - typeAlias, - typeChecker, - propertyFilter - ), + properties: processTypeProperties(aliasType, typeAlias, propertyFilter), ...getJsDocMetadata(typeAlias), } } @@ -253,7 +261,7 @@ function processFunctionOrExpression( ) } - const signature = signatures[0] + const signature = signatures.at(0)! const parameters = signature.getParameters() let parameterTypes: ReturnType[] = [] @@ -287,6 +295,40 @@ function processFunctionOrExpression( } } +/** Processes a function type into a metadata object. */ +function processFunctionType( + type: Type, + propertyFilter?: PropertyFilter +): FunctionMetadata { + const signatures = type.getCallSignatures() + const signature = signatures.at(0)! + const symbol = type.getSymbol() + const declaration = getSymbolDeclaration(symbol) + const parameters = signature.getParameters() + let parameterTypes: ReturnType[] = [] + + for (const parameter of parameters) { + // TODO: function type parameter types need to be processed differently since they don't have default values, required, etc. + const parameterType = processParameterType( + parameter.getValueDeclaration() as ParameterDeclaration, + declaration, + propertyFilter + ) + parameterTypes.push(parameterType) + } + + return { + parameters: parameterTypes, + type: type.getText( + declaration, + TypeFormatFlags.UseAliasDefinedOutsideCurrentScope + ), + returnType: signature + .getReturnType() + .getText(declaration, TypeFormatFlags.UseAliasDefinedOutsideCurrentScope), + } +} + function getModifier( node: | SetAccessorDeclaration @@ -500,10 +542,9 @@ function processClassPropertyDeclaration(property: PropertyDeclaration) { /** Processes a signature parameter into a metadata object. */ function processParameterType( parameterDeclaration: ParameterDeclaration, - enclosingNode: Node, + enclosingNode?: Node, propertyFilter?: PropertyFilter ): ParameterMetadata { - const typeChecker = enclosingNode.getProject().getTypeChecker() const parameterType = parameterDeclaration.getType() const defaultValue = parameterDeclaration.getInitializer()?.getText() const isObjectBindingPattern = Node.isObjectBindingPattern( @@ -534,20 +575,25 @@ function processParameterType( ) } - const typeDeclaration = parameterType.getSymbol()?.getDeclarations()?.at(0) - const isTypeInNodeModules = parameterType - .getSymbol() + const typeSymbol = parameterType.getSymbol() + const typeDeclaration = typeSymbol?.getDeclarations()?.at(0) + const isTypeInNodeModules = typeSymbol ?.getValueDeclaration() ?.getSourceFile() .isInNodeModules() - const isLocalType = typeDeclaration - ? enclosingNode.getSourceFile().getFilePath() === - typeDeclaration.getSourceFile().getFilePath() - : true - - if (isTypeInNodeModules || !isLocalType) { - // If the type is imported from a node module or not in the same file, return - // the type name and don't process the properties any further. + // TODO: local types need to account if they are exported from the file since they will be linked to the type + const isLocalType = + enclosingNode && typeDeclaration + ? enclosingNode.getSourceFile().getFilePath() === + typeDeclaration.getSourceFile().getFilePath() + : true + + // If the type is imported from a node module or not in the same file, return + // the type name and don't process the properties any further. + if ( + !isPrimitiveType(parameterType) && + (isTypeInNodeModules || !isLocalType) + ) { const parameterTypeNode = parameterDeclaration.getTypeNodeOrThrow() metadata.type = parameterTypeNode.getText() return metadata @@ -563,7 +609,6 @@ function processParameterType( const { properties, unionProperties } = processUnionType( parameterType, enclosingNode, - typeChecker, defaultValues, propertyFilter ) @@ -573,7 +618,6 @@ function processParameterType( metadata.properties = processTypeProperties( parameterType, enclosingNode, - typeChecker, propertyFilter, defaultValues ) @@ -586,9 +630,8 @@ function processParameterType( /** Processes union types into an array of property arrays. */ function processUnionType( unionType: Type, - enclosingNode: Node, - typeChecker: TypeChecker, - defaultValues: Record, + enclosingNode?: Node, + defaultValues?: Record, propertyFilter?: PropertyFilter ) { const allUnionTypes = unionType @@ -597,7 +640,6 @@ function processUnionType( processTypeProperties( subType, enclosingNode, - typeChecker, propertyFilter, defaultValues ) @@ -616,8 +658,7 @@ function processUnionType( /** Processes the properties of a type. */ function processTypeProperties( type: Type, - enclosingNode: Node, - typeChecker: TypeChecker, + enclosingNode?: Node, propertyFilter?: PropertyFilter, defaultValues: Record = {} ): PropertyMetadata[] { @@ -629,7 +670,6 @@ function processTypeProperties( processTypeProperties( intersectType, enclosingNode, - typeChecker, propertyFilter, defaultValues ) @@ -667,7 +707,7 @@ function processTypeProperties( let properties = type.getApparentProperties() // Check declaration's type arguments if there are no immediate apparent properties - if (properties.length === 0) { + if (properties.length === 0 && enclosingNode) { const typeArguments = getTypeArgumentsIncludingIntersections( enclosingNode.getType() ) @@ -678,13 +718,7 @@ function processTypeProperties( return properties .map((property) => - processProperty( - property, - enclosingNode, - typeChecker, - defaultValues, - propertyFilter - ) + processProperty(property, enclosingNode, defaultValues, propertyFilter) ) .filter((property): property is NonNullable => Boolean(property) @@ -707,9 +741,8 @@ function defaultPropertyFilter(property: PropertySignature) { function processProperty( property: Symbol, - enclosingNode: Node, - typeChecker: TypeChecker, - defaultValues: Record, + enclosingNode?: Node, + defaultValues?: Record, propertyFilter: ( property: PropertySignature ) => boolean = defaultPropertyFilter @@ -724,7 +757,10 @@ function processProperty( return } - const propertyType = property.getTypeAtLocation(enclosingNode) + const contextNode = enclosingNode || declaration + const propertyType = contextNode + ? property.getTypeAtLocation(contextNode) + : undefined let typeText if ( @@ -734,7 +770,7 @@ function processProperty( ) { const typeNode = declaration.getTypeNodeOrThrow() typeText = typeNode.getText() - } else { + } else if (propertyType) { typeText = propertyType.getText( enclosingNode, TypeFormatFlags.UseAliasDefinedOutsideCurrentScope @@ -742,12 +778,12 @@ function processProperty( } const propertyName = property.getName() - const defaultValue = defaultValues[propertyName] + const defaultValue = defaultValues?.[propertyName] const propertyMetadata: PropertyMetadata = { defaultValue, name: propertyName, required: !property.isOptional() && defaultValue === undefined, - type: typeText, + type: typeText ?? 'any', } const jsDocMetadata = declaration ? getJsDocMetadata(declaration) : undefined @@ -759,25 +795,29 @@ function processProperty( propertyMetadata.description = getSymbolDescription(property) } - if (propertyType.isObject()) { + if (propertyType?.isObject()) { const typeDeclaration = propertyType.getSymbol()?.getDeclarations()?.at(0) - const isLocalType = typeDeclaration - ? enclosingNode.getSourceFile().getFilePath() === - typeDeclaration.getSourceFile().getFilePath() - : false + const isLocalType = + enclosingNode && typeDeclaration + ? enclosingNode.getSourceFile().getFilePath() === + typeDeclaration.getSourceFile().getFilePath() + : false if (isLocalType) { const firstChild = declaration?.getFirstChild() - propertyMetadata.properties = processTypeProperties( - propertyType, - enclosingNode, - typeChecker, - propertyFilter, - Node.isObjectBindingPattern(firstChild) - ? getDefaultValuesFromProperties(firstChild.getElements()) - : {} - ) + if (propertyType.getCallSignatures().length > 0) { + Object.assign(propertyMetadata, processFunctionType(propertyType)) + } else { + propertyMetadata.properties = processTypeProperties( + propertyType, + enclosingNode, + propertyFilter, + Node.isObjectBindingPattern(firstChild) + ? getDefaultValuesFromProperties(firstChild.getElements()) + : {} + ) + } } } @@ -785,7 +825,7 @@ function processProperty( } /** Determine if a type is external, mapped, or in node_modules. */ -function getTypeMetadata(type: Type, declaration: Node) { +function getTypeMetadata(type: Type, declaration?: Node) { const typeSymbol = type.getSymbol() if (!typeSymbol) { return { @@ -804,7 +844,7 @@ function getTypeMetadata(type: Type, declaration: Node) { } } - const sourceFile = declaration.getSourceFile() + const sourceFile = declaration?.getSourceFile() let isExternal = true let isMapped = true let isInNodeModules = true @@ -839,6 +879,7 @@ function getTypeMetadata(type: Type, declaration: Node) { /** Checks if a type is a primitive type. */ function isPrimitiveType(type: Type) { return ( + type.isArray() || type.isBoolean() || type.isBooleanLiteral() || type.isNumber() || @@ -854,6 +895,13 @@ function isPrimitiveType(type: Type) { ) } +/** Attempts to find the declaration of a symbol. */ +function getSymbolDeclaration(symbol?: Symbol) { + return symbol + ? symbol.getValueDeclaration() || symbol.getDeclarations().at(0) + : undefined +} + /** Parses duplicates from an array of arrays. */ function parseDuplicates( arrays: Item[][],