UNPKG

@kubb/parser-ts

Version:

TypeScript parsing and manipulation utilities for Kubb, enabling code generation with proper TypeScript syntax and formatting.

579 lines (501 loc) 17.7 kB
import { isNumber } from 'remeda' import ts from 'typescript' const { SyntaxKind, factory } = ts // https://ts-ast-viewer.com/ export const modifiers = { async: factory.createModifier(ts.SyntaxKind.AsyncKeyword), export: factory.createModifier(ts.SyntaxKind.ExportKeyword), const: factory.createModifier(ts.SyntaxKind.ConstKeyword), static: factory.createModifier(ts.SyntaxKind.StaticKeyword), } as const export const syntaxKind = { union: SyntaxKind.UnionType as 192, } as const function isValidIdentifier(str: string): boolean { if (!str.length || str.trim() !== str) { return false } const node = ts.parseIsolatedEntityName(str, ts.ScriptTarget.Latest) return !!node && node.kind === ts.SyntaxKind.Identifier && ts.identifierToKeywordKind(node.kind as unknown as ts.Identifier) === undefined } function propertyName(name: string | ts.PropertyName): ts.PropertyName { if (typeof name === 'string') { return isValidIdentifier(name) ? factory.createIdentifier(name) : factory.createStringLiteral(name) } return name } const questionToken = factory.createToken(ts.SyntaxKind.QuestionToken) export function createQuestionToken(token?: boolean | ts.QuestionToken) { if (!token) { return undefined } if (token === true) { return questionToken } return token } export function createIntersectionDeclaration({ nodes, withParentheses }: { nodes: Array<ts.TypeNode>; withParentheses?: boolean }): ts.TypeNode | null { if (!nodes.length) { return null } if (nodes.length === 1) { return nodes[0] || null } const node = factory.createIntersectionTypeNode(nodes) if (withParentheses) { return factory.createParenthesizedType(node) } return node } /** * Minimum nodes length of 2 * @example `string & number` */ export function createTupleDeclaration({ nodes, withParentheses }: { nodes: Array<ts.TypeNode>; withParentheses?: boolean }): ts.TypeNode | null { if (!nodes.length) { return null } if (nodes.length === 1) { return nodes[0] || null } const node = factory.createTupleTypeNode(nodes) if (withParentheses) { return factory.createParenthesizedType(node) } return node } export function createArrayDeclaration({ nodes }: { nodes: Array<ts.TypeNode> }): ts.TypeNode | null { if (!nodes.length) { return factory.createTupleTypeNode([]) } if (nodes.length === 1) { return factory.createArrayTypeNode(nodes.at(0)!) } return factory.createExpressionWithTypeArguments(factory.createIdentifier('Array'), [factory.createUnionTypeNode(nodes)]) } /** * Minimum nodes length of 2 * @example `string | number` */ export function createUnionDeclaration({ nodes, withParentheses }: { nodes: Array<ts.TypeNode>; withParentheses?: boolean }): ts.TypeNode { if (!nodes.length) { return keywordTypeNodes.any } if (nodes.length === 1) { return nodes[0] as ts.TypeNode } const node = factory.createUnionTypeNode(nodes) if (withParentheses) { return factory.createParenthesizedType(node) } return node } export function createPropertySignature({ readOnly, modifiers = [], name, questionToken, type, }: { readOnly?: boolean modifiers?: Array<ts.Modifier> name: ts.PropertyName | string questionToken?: ts.QuestionToken | boolean type?: ts.TypeNode }) { return factory.createPropertySignature( [...modifiers, readOnly ? factory.createToken(ts.SyntaxKind.ReadonlyKeyword) : undefined].filter(Boolean), propertyName(name), createQuestionToken(questionToken), type, ) } export function createParameterSignature( name: string | ts.BindingName, { modifiers, dotDotDotToken, questionToken, type, initializer, }: { decorators?: Array<ts.Decorator> modifiers?: Array<ts.Modifier> dotDotDotToken?: ts.DotDotDotToken questionToken?: ts.QuestionToken | boolean type?: ts.TypeNode initializer?: ts.Expression }, ): ts.ParameterDeclaration { return factory.createParameterDeclaration(modifiers, dotDotDotToken, name, createQuestionToken(questionToken), type, initializer) } export function createJSDoc({ comments }: { comments: string[] }) { if (!comments.length) { return null } return factory.createJSDocComment( factory.createNodeArray( comments.map((comment, i) => { if (i === comments.length - 1) { return factory.createJSDocText(comment) } return factory.createJSDocText(`${comment}\n`) }), ), ) } /** * @link https://github.com/microsoft/TypeScript/issues/44151 */ export function appendJSDocToNode<TNode extends ts.Node>({ node, comments }: { node: TNode; comments: Array<string | undefined> }) { const filteredComments = comments.filter(Boolean) if (!filteredComments.length) { return node } const text = filteredComments.reduce((acc = '', comment = '') => { return `${acc}\n * ${comment.replaceAll('*/', '*\\/')}` }, '*') // node: {...node}, with that ts.addSyntheticLeadingComment is appending return ts.addSyntheticLeadingComment({ ...node }, ts.SyntaxKind.MultiLineCommentTrivia, `${text || '*'}\n`, true) } export function createIndexSignature( type: ts.TypeNode, { modifiers, indexName = 'key', indexType = factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), }: { indexName?: string indexType?: ts.TypeNode decorators?: Array<ts.Decorator> modifiers?: Array<ts.Modifier> } = {}, ) { return factory.createIndexSignature(modifiers, [createParameterSignature(indexName, { type: indexType })], type) } export function createTypeAliasDeclaration({ modifiers, name, typeParameters, type, }: { modifiers?: Array<ts.Modifier> name: string | ts.Identifier typeParameters?: Array<ts.TypeParameterDeclaration> type: ts.TypeNode }) { return factory.createTypeAliasDeclaration(modifiers, name, typeParameters, type) } export function createInterfaceDeclaration({ modifiers, name, typeParameters, members, }: { modifiers?: Array<ts.Modifier> name: string | ts.Identifier typeParameters?: Array<ts.TypeParameterDeclaration> members: Array<ts.TypeElement> }) { return factory.createInterfaceDeclaration(modifiers, name, typeParameters, undefined, members) } export function createTypeDeclaration({ syntax, isExportable, comments, name, type, }: { syntax: 'type' | 'interface' comments: Array<string | undefined> isExportable?: boolean name: string | ts.Identifier type: ts.TypeNode }) { if (syntax === 'interface' && 'members' in type) { const node = createInterfaceDeclaration({ members: type.members as Array<ts.TypeElement>, modifiers: isExportable ? [modifiers.export] : [], name, typeParameters: undefined, }) return appendJSDocToNode({ node, comments, }) } const node = createTypeAliasDeclaration({ type, modifiers: isExportable ? [modifiers.export] : [], name, typeParameters: undefined, }) return appendJSDocToNode({ node, comments, }) } export function createNamespaceDeclaration({ statements, name }: { name: string; statements: ts.Statement[] }) { return factory.createModuleDeclaration( [factory.createToken(ts.SyntaxKind.ExportKeyword)], factory.createIdentifier(name), factory.createModuleBlock(statements), ts.NodeFlags.Namespace, ) } /** * In { propertyName: string; name?: string } is `name` being used to make the type more unique when multiple same names are used. * @example `import { Pet as Cat } from './Pet'` */ export function createImportDeclaration({ name, path, isTypeOnly = false, isNameSpace = false, }: { name: string | Array<string | { propertyName: string; name?: string }> path: string isTypeOnly?: boolean isNameSpace?: boolean }) { if (!Array.isArray(name)) { let importPropertyName: ts.Identifier | undefined = factory.createIdentifier(name) let importName: ts.NamedImportBindings | undefined if (isNameSpace) { importPropertyName = undefined importName = factory.createNamespaceImport(factory.createIdentifier(name)) } return factory.createImportDeclaration( undefined, factory.createImportClause(isTypeOnly, importPropertyName, importName), factory.createStringLiteral(path), undefined, ) } return factory.createImportDeclaration( undefined, factory.createImportClause( isTypeOnly, undefined, factory.createNamedImports( name.map((item) => { if (typeof item === 'object') { const obj = item as { propertyName: string; name?: string } if (obj.name) { return factory.createImportSpecifier(false, factory.createIdentifier(obj.propertyName), factory.createIdentifier(obj.name)) } return factory.createImportSpecifier(false, undefined, factory.createIdentifier(obj.propertyName)) } return factory.createImportSpecifier(false, undefined, factory.createIdentifier(item)) }), ), ), factory.createStringLiteral(path), undefined, ) } export function createExportDeclaration({ path, asAlias, isTypeOnly = false, name, }: { path: string asAlias?: boolean isTypeOnly?: boolean name?: string | Array<ts.Identifier | string> }) { if (name && !Array.isArray(name) && !asAlias) { console.warn(`When using name as string, asAlias should be true ${name}`) } if (!Array.isArray(name)) { const parsedName = name?.match(/^\d/) ? `_${name?.slice(1)}` : name return factory.createExportDeclaration( undefined, isTypeOnly, asAlias && parsedName ? factory.createNamespaceExport(factory.createIdentifier(parsedName)) : undefined, factory.createStringLiteral(path), undefined, ) } return factory.createExportDeclaration( undefined, isTypeOnly, factory.createNamedExports( name.map((propertyName) => { return factory.createExportSpecifier(false, undefined, typeof propertyName === 'string' ? factory.createIdentifier(propertyName) : propertyName) }), ), factory.createStringLiteral(path), undefined, ) } export function createEnumDeclaration({ type = 'enum', name, typeName, enums, }: { /** * @default `'enum'` */ type?: 'enum' | 'asConst' | 'asPascalConst' | 'constEnum' | 'literal' /** * Enum name in camelCase. */ name: string /** * Enum name in PascalCase. */ typeName: string enums: [key: string | number, value: string | number | boolean][] }): [name: ts.Node | undefined, type: ts.Node] { if (type === 'literal') { return [ undefined, factory.createTypeAliasDeclaration( [factory.createToken(ts.SyntaxKind.ExportKeyword)], factory.createIdentifier(typeName), undefined, factory.createUnionTypeNode( enums .map(([_key, value]) => { if (isNumber(value)) { return factory.createLiteralTypeNode(factory.createNumericLiteral(value?.toString())) } if (typeof value === 'boolean') { return factory.createLiteralTypeNode(value ? factory.createTrue() : factory.createFalse()) } if (value) { return factory.createLiteralTypeNode(factory.createStringLiteral(value.toString())) } return undefined }) .filter(Boolean), ), ), ] } if (type === 'enum' || type === 'constEnum') { return [ undefined, factory.createEnumDeclaration( [factory.createToken(ts.SyntaxKind.ExportKeyword), type === 'constEnum' ? factory.createToken(ts.SyntaxKind.ConstKeyword) : undefined].filter(Boolean), factory.createIdentifier(typeName), enums .map(([key, value]) => { let initializer: ts.Expression = factory.createStringLiteral(value?.toString()) const isExactNumber = Number.parseInt(value.toString()) === value if (isExactNumber && isNumber(Number.parseInt(value.toString()))) { initializer = factory.createNumericLiteral(value as number) } if (typeof value === 'boolean') { initializer = value ? factory.createTrue() : factory.createFalse() } if (isNumber(Number.parseInt(key.toString()))) { return factory.createEnumMember(factory.createStringLiteral(`${typeName}_${key}`), initializer) } if (key) { return factory.createEnumMember(factory.createStringLiteral(`${key}`), initializer) } return undefined }) .filter(Boolean), ), ] } // used when using `as const` instead of an TypeScript enum. const identifierName = type === 'asPascalConst' ? typeName : name return [ factory.createVariableStatement( [factory.createToken(ts.SyntaxKind.ExportKeyword)], factory.createVariableDeclarationList( [ factory.createVariableDeclaration( factory.createIdentifier(identifierName), undefined, undefined, factory.createAsExpression( factory.createObjectLiteralExpression( enums .map(([key, value]) => { let initializer: ts.Expression = factory.createStringLiteral(value?.toString()) if (isNumber(value)) { // Error: Negative numbers should be created in combination with createPrefixUnaryExpression factory. // The method createNumericLiteral only accepts positive numbers // or those combined with createPrefixUnaryExpression. // Therefore, we need to ensure that the number is not negative. if (value < 0) { initializer = factory.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, factory.createNumericLiteral(Math.abs(value))) } else { initializer = factory.createNumericLiteral(value) } } if (typeof value === 'boolean') { initializer = value ? factory.createTrue() : factory.createFalse() } if (key) { return factory.createPropertyAssignment(factory.createStringLiteral(`${key}`), initializer) } return undefined }) .filter(Boolean), true, ), factory.createTypeReferenceNode(factory.createIdentifier('const'), undefined), ), ), ], ts.NodeFlags.Const, ), ), factory.createTypeAliasDeclaration( type === 'asPascalConst' ? [] : [factory.createToken(ts.SyntaxKind.ExportKeyword)], factory.createIdentifier(typeName), undefined, factory.createIndexedAccessTypeNode( factory.createParenthesizedType(factory.createTypeQueryNode(factory.createIdentifier(identifierName), undefined)), factory.createTypeOperatorNode(ts.SyntaxKind.KeyOfKeyword, factory.createTypeQueryNode(factory.createIdentifier(identifierName), undefined)), ), ), ] } export function createOmitDeclaration({ keys, type, nonNullable }: { keys: Array<string> | string; type: ts.TypeNode; nonNullable?: boolean }) { const node = nonNullable ? factory.createTypeReferenceNode(factory.createIdentifier('NonNullable'), [type]) : type if (Array.isArray(keys)) { return factory.createTypeReferenceNode(factory.createIdentifier('Omit'), [ node, factory.createUnionTypeNode( keys.map((key) => { return factory.createLiteralTypeNode(factory.createStringLiteral(key)) }), ), ]) } return factory.createTypeReferenceNode(factory.createIdentifier('Omit'), [node, factory.createLiteralTypeNode(factory.createStringLiteral(keys))]) } export const keywordTypeNodes = { any: factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), unknown: factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), void: factory.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword), number: factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), integer: factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), object: factory.createKeywordTypeNode(ts.SyntaxKind.ObjectKeyword), string: factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), boolean: factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword), undefined: factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword), null: factory.createLiteralTypeNode(factory.createToken(ts.SyntaxKind.NullKeyword)), } as const export const createTypeLiteralNode = factory.createTypeLiteralNode export const createTypeReferenceNode = factory.createTypeReferenceNode export const createNumericLiteral = factory.createNumericLiteral export const createStringLiteral = factory.createStringLiteral export const createArrayTypeNode = factory.createArrayTypeNode export const createLiteralTypeNode = factory.createLiteralTypeNode export const createNull = factory.createNull export const createIdentifier = factory.createIdentifier export const createOptionalTypeNode = factory.createOptionalTypeNode export const createTupleTypeNode = factory.createTupleTypeNode export const createRestTypeNode = factory.createRestTypeNode export const createTrue = factory.createTrue export const createFalse = factory.createFalse