Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support export * statements #1962

Merged
merged 7 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const config = {
coverageDirectory: "./coverage/",
collectCoverage: false,
testEnvironment: "node",
maxWorkers: '50%', // speeds up tests
arthurfiorette marked this conversation as resolved.
Show resolved Hide resolved
transform: {
".*": "babel-jest",
},
Expand Down
196 changes: 129 additions & 67 deletions src/SchemaGenerator.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -32,7 +31,10 @@

const rootTypeDefinition = rootTypes.length === 1 ? this.getRootTypeDefinition(rootTypes[0]) : undefined;
const definitions: StringMap<Definition> = {};
rootTypes.forEach((rootType) => this.appendRootChildDefinitions(rootType, definitions));

for (const rootType of rootTypes) {
this.appendRootChildDefinitions(rootType, definitions);
}

const reachableDefinitions = removeUnreachable(rootTypeDefinition, definitions);

Expand All @@ -47,15 +49,15 @@
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<string, ts.Node>();
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<string, ts.Node>();
this.appendTypes(rootSourceFiles, this.program.getTypeChecker(), rootNodes);
return [...rootNodes.values()];
}
protected findNamedNode(fullName: string): ts.Node {
const typeChecker = this.program.getTypeChecker();
Expand Down Expand Up @@ -129,6 +131,7 @@

return { projectFiles, externalFiles };
}

protected appendTypes(
sourceFiles: readonly ts.SourceFile[],
typeChecker: ts.TypeChecker,
Expand All @@ -138,72 +141,131 @@
this.inspectNode(sourceFile, typeChecker, types);
}
}

protected inspectNode(node: ts.Node, typeChecker: ts.TypeChecker, allTypes: Map<string, ts.Node>): 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);

Check warning on line 194 in src/SchemaGenerator.ts

View check run for this annotation

Codecov / codecov/patch

src/SchemaGenerator.ts#L194

Added line #L194 was not covered by tests
}
} 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;

Check warning on line 219 in src/SchemaGenerator.ts

View check run for this annotation

Codecov / codecov/patch

src/SchemaGenerator.ts#L219

Added line #L219 was not covered by tests
}

// 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]);

Check warning on line 235 in src/SchemaGenerator.ts

View check run for this annotation

Codecov / codecov/patch

src/SchemaGenerator.ts#L235

Added line #L235 was not covered by tests

// should never hit this (maybe type error in user's code)
if (!nodes) {
return;

Check warning on line 239 in src/SchemaGenerator.ts

View check run for this annotation

Codecov / codecov/patch

src/SchemaGenerator.ts#L239

Added line #L239 was not covered by tests
}

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(/".*"\./, "");
}
}
7 changes: 2 additions & 5 deletions src/Utils/symbolAtNode.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion test/sourceless-nodes/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!);
Expand Down
2 changes: 2 additions & 0 deletions test/valid-data-type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
});
5 changes: 5 additions & 0 deletions test/valid-data/export-star/literal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type A = 1;

export type B = "string";

type C = "internal";
6 changes: 6 additions & 0 deletions test/valid-data/export-star/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from "./literal";
export * from "./object";

export type External = 1;

type Internal = 2;
11 changes: 11 additions & 0 deletions test/valid-data/export-star/object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface D {
a: 1;
}

export class E {
b: 2;
}

interface F {
internal: true;
}
43 changes: 43 additions & 0 deletions test/valid-data/export-star/schema.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Loading