From 1b27258a91659f1db92d5b1aa2ab4ed0c3e48987 Mon Sep 17 00:00:00 2001 From: Arthur Fiorette <47537704+arthurfiorette@users.noreply.github.com> Date: Thu, 23 May 2024 10:39:50 -0300 Subject: [PATCH] feat: support `export *` statements (#1962) --- src/SchemaGenerator.ts | 196 ++++++++++++++++-------- src/Utils/symbolAtNode.ts | 7 +- test/sourceless-nodes/index.test.ts | 2 +- test/valid-data-type.test.ts | 2 + test/valid-data/export-star/literal.ts | 5 + test/valid-data/export-star/main.ts | 6 + test/valid-data/export-star/object.ts | 11 ++ test/valid-data/export-star/schema.json | 43 ++++++ 8 files changed, 199 insertions(+), 73 deletions(-) create mode 100644 test/valid-data/export-star/literal.ts create mode 100644 test/valid-data/export-star/main.ts create mode 100644 test/valid-data/export-star/object.ts create mode 100644 test/valid-data/export-star/schema.json diff --git a/src/SchemaGenerator.ts b/src/SchemaGenerator.ts index 723aaa7c9..2d3fbfcaf 100644 --- a/src/SchemaGenerator.ts +++ b/src/SchemaGenerator.ts @@ -1,16 +1,15 @@ import ts from "typescript"; +import type { Config } from "./Config.js"; import { NoRootTypeError } from "./Error/NoRootTypeError.js"; -import { Context, NodeParser } from "./NodeParser.js"; -import { Definition } from "./Schema/Definition.js"; -import { Schema } from "./Schema/Schema.js"; -import { BaseType } from "./Type/BaseType.js"; +import { Context, type NodeParser } from "./NodeParser.js"; +import type { Definition } from "./Schema/Definition.js"; +import type { Schema } from "./Schema/Schema.js"; +import type { BaseType } from "./Type/BaseType.js"; import { DefinitionType } from "./Type/DefinitionType.js"; -import { TypeFormatter } from "./TypeFormatter.js"; -import { StringMap } from "./Utils/StringMap.js"; -import { localSymbolAtNode, symbolAtNode } from "./Utils/symbolAtNode.js"; -import { removeUnreachable } from "./Utils/removeUnreachable.js"; -import { Config } from "./Config.js"; +import type { TypeFormatter } from "./TypeFormatter.js"; +import type { StringMap } from "./Utils/StringMap.js"; import { hasJsDocTag } from "./Utils/hasJsDocTag.js"; +import { removeUnreachable } from "./Utils/removeUnreachable.js"; export class SchemaGenerator { public constructor( @@ -32,7 +31,10 @@ export class SchemaGenerator { const rootTypeDefinition = rootTypes.length === 1 ? this.getRootTypeDefinition(rootTypes[0]) : undefined; const definitions: StringMap = {}; - rootTypes.forEach((rootType) => this.appendRootChildDefinitions(rootType, definitions)); + + for (const rootType of rootTypes) { + this.appendRootChildDefinitions(rootType, definitions); + } const reachableDefinitions = removeUnreachable(rootTypeDefinition, definitions); @@ -47,15 +49,15 @@ export class SchemaGenerator { protected getRootNodes(fullName: string | undefined): ts.Node[] { if (fullName && fullName !== "*") { return [this.findNamedNode(fullName)]; - } else { - const rootFileNames = this.program.getRootFileNames(); - const rootSourceFiles = this.program - .getSourceFiles() - .filter((sourceFile) => rootFileNames.includes(sourceFile.fileName)); - const rootNodes = new Map(); - this.appendTypes(rootSourceFiles, this.program.getTypeChecker(), rootNodes); - return [...rootNodes.values()]; } + + const rootFileNames = this.program.getRootFileNames(); + const rootSourceFiles = this.program + .getSourceFiles() + .filter((sourceFile) => rootFileNames.includes(sourceFile.fileName)); + const rootNodes = new Map(); + this.appendTypes(rootSourceFiles, this.program.getTypeChecker(), rootNodes); + return [...rootNodes.values()]; } protected findNamedNode(fullName: string): ts.Node { const typeChecker = this.program.getTypeChecker(); @@ -129,6 +131,7 @@ export class SchemaGenerator { return { projectFiles, externalFiles }; } + protected appendTypes( sourceFiles: readonly ts.SourceFile[], typeChecker: ts.TypeChecker, @@ -138,72 +141,131 @@ export class SchemaGenerator { this.inspectNode(sourceFile, typeChecker, types); } } + protected inspectNode(node: ts.Node, typeChecker: ts.TypeChecker, allTypes: Map): void { - switch (node.kind) { - case ts.SyntaxKind.VariableDeclaration: { - const variableDeclarationNode = node as ts.VariableDeclaration; - if ( - variableDeclarationNode.initializer?.kind === ts.SyntaxKind.ArrowFunction || - variableDeclarationNode.initializer?.kind === ts.SyntaxKind.FunctionExpression - ) { - this.inspectNode(variableDeclarationNode.initializer, typeChecker, allTypes); - } - return; + if (ts.isVariableDeclaration(node)) { + if ( + node.initializer?.kind === ts.SyntaxKind.ArrowFunction || + node.initializer?.kind === ts.SyntaxKind.FunctionExpression + ) { + this.inspectNode(node.initializer, typeChecker, allTypes); } - case ts.SyntaxKind.InterfaceDeclaration: - case ts.SyntaxKind.ClassDeclaration: - case ts.SyntaxKind.EnumDeclaration: - case ts.SyntaxKind.TypeAliasDeclaration: - if ( - this.config?.expose === "all" || - (this.isExportType(node) && !this.isGenericType(node as ts.TypeAliasDeclaration)) - ) { - allTypes.set(this.getFullName(node, typeChecker), node); - return; - } - return; - case ts.SyntaxKind.ConstructorType: - case ts.SyntaxKind.FunctionDeclaration: - case ts.SyntaxKind.FunctionExpression: - case ts.SyntaxKind.ArrowFunction: + + return; + } + + if ( + ts.isInterfaceDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isEnumDeclaration(node) || + ts.isTypeAliasDeclaration(node) + ) { + if ( + this.config?.expose === "all" || + (this.isExportType(node) && !this.isGenericType(node as ts.TypeAliasDeclaration)) + ) { allTypes.set(this.getFullName(node, typeChecker), node); return; - case ts.SyntaxKind.ExportSpecifier: { - const exportSpecifierNode = node as ts.ExportSpecifier; - const symbol = typeChecker.getExportSpecifierLocalTargetSymbol(exportSpecifierNode); - if (symbol?.declarations?.length === 1) { - const declaration = symbol.declarations[0]; - if (declaration.kind === ts.SyntaxKind.ImportSpecifier) { - // Handling the `Foo` in `import { Foo } from "./lib"; export { Foo };` - const importSpecifierNode = declaration as ts.ImportSpecifier; - const type = typeChecker.getTypeAtLocation(importSpecifierNode); - if (type.symbol?.declarations?.length === 1) { - this.inspectNode(type.symbol.declarations[0], typeChecker, allTypes); - } - } else { - // Handling the `Bar` in `export { Bar } from './lib';` - this.inspectNode(declaration, typeChecker, allTypes); + } + return; + } + + if ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) || + ts.isConstructorTypeNode(node) + ) { + allTypes.set(this.getFullName(node, typeChecker), node); + return; + } + + if (ts.isExportSpecifier(node)) { + const symbol = typeChecker.getExportSpecifierLocalTargetSymbol(node); + + if (symbol?.declarations?.length === 1) { + const declaration = symbol.declarations[0]; + + if (ts.isImportSpecifier(declaration)) { + // Handling the `Foo` in `import { Foo } from "./lib"; export { Foo };` + const type = typeChecker.getTypeAtLocation(declaration); + + if (type.symbol?.declarations?.length === 1) { + this.inspectNode(type.symbol.declarations[0], typeChecker, allTypes); } + } else { + // Handling the `Bar` in `export { Bar } from './lib';` + this.inspectNode(declaration, typeChecker, allTypes); } + } + + return; + } + + if (ts.isExportDeclaration(node)) { + if (!ts.isExportDeclaration(node)) { return; } - default: - ts.forEachChild(node, (subnode) => this.inspectNode(subnode, typeChecker, allTypes)); + + // export { variable } clauses + if (!node.moduleSpecifier) { return; + } + + const symbol = typeChecker.getSymbolAtLocation(node.moduleSpecifier); + + // should never hit this (maybe type error in user's code) + if (!symbol || !symbol.declarations) { + return; + } + + // module augmentation can result in more than one source file + for (const source of symbol.declarations) { + const sourceSymbol = typeChecker.getSymbolAtLocation(source); + + if (!sourceSymbol) { + return; + } + + const moduleExports = typeChecker.getExportsOfModule(sourceSymbol); + + for (const moduleExport of moduleExports) { + const nodes = + moduleExport.declarations || + (!!moduleExport.valueDeclaration && [moduleExport.valueDeclaration]); + + // should never hit this (maybe type error in user's code) + if (!nodes) { + return; + } + + for (const subnodes of nodes) { + this.inspectNode(subnodes, typeChecker, allTypes); + } + } + } + + return; } + + ts.forEachChild(node, (subnode) => this.inspectNode(subnode, typeChecker, allTypes)); } - protected isExportType(node: ts.Node): boolean { + + protected isExportType( + node: ts.InterfaceDeclaration | ts.ClassDeclaration | ts.EnumDeclaration | ts.TypeAliasDeclaration, + ): boolean { if (this.config?.jsDoc !== "none" && hasJsDocTag(node, "internal")) { return false; } - const localSymbol = localSymbolAtNode(node); - return localSymbol ? "exportSymbol" in localSymbol : false; + + return !!node.localSymbol?.exportSymbol; } + protected isGenericType(node: ts.TypeAliasDeclaration): boolean { return !!(node.typeParameters && node.typeParameters.length > 0); } - protected getFullName(node: ts.Node, typeChecker: ts.TypeChecker): string { - const symbol = symbolAtNode(node)!; - return typeChecker.getFullyQualifiedName(symbol).replace(/".*"\./, ""); + + protected getFullName(node: ts.Declaration, typeChecker: ts.TypeChecker): string { + return typeChecker.getFullyQualifiedName(node.symbol).replace(/".*"\./, ""); } } diff --git a/src/Utils/symbolAtNode.ts b/src/Utils/symbolAtNode.ts index ab0335e15..1a6988d2b 100644 --- a/src/Utils/symbolAtNode.ts +++ b/src/Utils/symbolAtNode.ts @@ -1,8 +1,5 @@ -import ts from "typescript"; +import type ts from "typescript"; export function symbolAtNode(node: ts.Node): ts.Symbol | undefined { - return (node as any).symbol; -} -export function localSymbolAtNode(node: ts.Node): ts.Symbol | undefined { - return (node as any).localSymbol; + return (node as ts.Declaration).symbol; } diff --git a/test/sourceless-nodes/index.test.ts b/test/sourceless-nodes/index.test.ts index cb73297f5..90c2e6369 100644 --- a/test/sourceless-nodes/index.test.ts +++ b/test/sourceless-nodes/index.test.ts @@ -41,7 +41,7 @@ describe("sourceless-nodes", () => { }); }); -// From github.com/arthurfiorette/kita/blob/main/packages/generator/src/util/type-resolver.ts +// From https://github.com/kitajs/kitajs/blob/ebf23297de07887c78becff52120f941e69386ec/packages/parser/src/util/nodes.ts#L64 function getReturnType(node: ts.SignatureDeclaration, typeChecker: ts.TypeChecker) { const signature = typeChecker.getSignatureFromDeclaration(node); const implicitType = typeChecker.getReturnTypeOfSignature(signature!); diff --git a/test/valid-data-type.test.ts b/test/valid-data-type.test.ts index c1699a58e..39e626b47 100644 --- a/test/valid-data-type.test.ts +++ b/test/valid-data-type.test.ts @@ -145,4 +145,6 @@ describe("valid-data-type", () => { it("lowercase", assertValidSchema("lowercase", "MyType")); it("promise-extensions", assertValidSchema("promise-extensions", "*")); + + it("export-star", assertValidSchema("export-star", "*", undefined, { mainTsOnly: true })); }); diff --git a/test/valid-data/export-star/literal.ts b/test/valid-data/export-star/literal.ts new file mode 100644 index 000000000..e7a9fe6eb --- /dev/null +++ b/test/valid-data/export-star/literal.ts @@ -0,0 +1,5 @@ +export type A = 1; + +export type B = "string"; + +type C = "internal"; diff --git a/test/valid-data/export-star/main.ts b/test/valid-data/export-star/main.ts new file mode 100644 index 000000000..524c70e91 --- /dev/null +++ b/test/valid-data/export-star/main.ts @@ -0,0 +1,6 @@ +export * from "./literal"; +export * from "./object"; + +export type External = 1; + +type Internal = 2; diff --git a/test/valid-data/export-star/object.ts b/test/valid-data/export-star/object.ts new file mode 100644 index 000000000..e9ebcacea --- /dev/null +++ b/test/valid-data/export-star/object.ts @@ -0,0 +1,11 @@ +export interface D { + a: 1; +} + +export class E { + b: 2; +} + +interface F { + internal: true; +} diff --git a/test/valid-data/export-star/schema.json b/test/valid-data/export-star/schema.json new file mode 100644 index 000000000..0c34121c2 --- /dev/null +++ b/test/valid-data/export-star/schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "A": { + "const": 1, + "type": "number" + }, + "B": { + "const": "string", + "type": "string" + }, + "D": { + "additionalProperties": false, + "properties": { + "a": { + "const": 1, + "type": "number" + } + }, + "required": [ + "a" + ], + "type": "object" + }, + "E": { + "additionalProperties": false, + "properties": { + "b": { + "const": 2, + "type": "number" + } + }, + "required": [ + "b" + ], + "type": "object" + }, + "External": { + "const": 1, + "type": "number" + } + } +}