diff --git a/build/logic/ast/getStatementDeclName.ts b/build/logic/ast/getStatementDeclName.ts new file mode 100644 index 0000000..9923e4d --- /dev/null +++ b/build/logic/ast/getStatementDeclName.ts @@ -0,0 +1,23 @@ +import ts from "typescript"; + +export function getStatementDeclName( + statement: ts.Statement, +): string | undefined { + if (ts.isVariableStatement(statement)) { + for (const dec of statement.declarationList.declarations) { + if (ts.isIdentifier(dec.name)) { + return dec.name.text; + } + } + } else if ( + ts.isFunctionDeclaration(statement) || + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) || + ts.isModuleDeclaration(statement) + ) { + return statement.name?.text; + } else if (ts.isInterfaceDeclaration(statement)) { + return statement.name.text; + } + return undefined; +} diff --git a/build/logic/generate.ts b/build/logic/generate.ts index 7a2227f..b98d71c 100644 --- a/build/logic/generate.ts +++ b/build/logic/generate.ts @@ -1,10 +1,15 @@ import path from "path"; import ts from "typescript"; -import { alias } from "../util/alias"; +import { mergeArrayMap } from "../util/mergeArrayMap"; import { upsert } from "../util/upsert"; -import { projectDir } from "./projectDir"; - -const betterLibDir = path.join(projectDir, "lib"); +import { getStatementDeclName } from "./ast/getStatementDeclName"; +import { + declareGlobalSymbol, + ReplacementMap, + ReplacementName, + ReplacementTarget, + scanBetterFile, +} from "./scanBetterFile"; type GenerateOptions = { emitOriginalAsComment?: boolean; @@ -41,9 +46,53 @@ export function generate( return result + originalFile.text; } - const consumedReplacements = new Set(); + return ( + result + + generateStatements( + printer, + originalFile, + originalFile.statements, + replacementTargets, + emitOriginalAsComment, + ) + ); +} + +function generateStatements( + printer: ts.Printer, + originalFile: ts.SourceFile, + statements: readonly ts.Statement[], + replacementTargets: ReplacementMap, + emitOriginalAsComment: boolean, +): string { + let result = ""; + const consumedReplacements = new Set(); + for (const statement of statements) { + if ( + ts.isModuleDeclaration(statement) && + ts.isIdentifier(statement.name) && + statement.name.text === "global" + ) { + // declare global { ... } + consumedReplacements.add(declareGlobalSymbol); + + const declareGlobalReplacement = + replacementTargets.get(declareGlobalSymbol); + if (declareGlobalReplacement === undefined) { + result += statement.getFullText(originalFile); + continue; + } + + result += generateDeclareGlobalReplacement( + printer, + originalFile, + statement, + declareGlobalReplacement, + emitOriginalAsComment, + ); + continue; + } - for (const statement of originalFile.statements) { const name = getStatementDeclName(statement); if (name === undefined) { result += statement.getFullText(originalFile); @@ -57,8 +106,9 @@ export function generate( consumedReplacements.add(name); - if (!ts.isInterfaceDeclaration(statement)) { - result += generateFullReplacement( + if (ts.isInterfaceDeclaration(statement)) { + result += generateInterface( + printer, originalFile, statement, replacementTarget, @@ -67,8 +117,7 @@ export function generate( continue; } - result += generateInterface( - printer, + result += generateFullReplacement( originalFile, statement, replacementTarget, @@ -122,6 +171,42 @@ function generateFullReplacement( return result; } +function generateDeclareGlobalReplacement( + printer: ts.Printer, + originalFile: ts.SourceFile, + statement: ts.ModuleDeclaration, + replacementTarget: readonly ReplacementTarget[], + emitOriginalAsComment: boolean, +) { + if (!replacementTarget.every((target) => target.type === "declare-global")) { + throw new Error("Invalid replacement target"); + } + if (!statement.body || !ts.isModuleBlock(statement.body)) { + return statement.getFullText(originalFile); + } + + const nestedStatements = statement.body.statements; + + let result = ""; + + result += "declare global {\n"; + + const nestedReplacementTarget = mergeArrayMap( + replacementTarget.map((t) => t.statements), + ); + + result += generateStatements( + printer, + originalFile, + nestedStatements, + nestedReplacementTarget, + emitOriginalAsComment, + ); + + result += "}\n"; + return result; +} + function generateInterface( printer: ts.Printer, originalFile: ts.SourceFile, @@ -208,101 +293,6 @@ function generateInterface( return result; } -type ReplacementTarget = ( - | { - type: "interface"; - originalStatement: ts.InterfaceDeclaration; - members: Map< - string, - { - member: ts.TypeElement; - text: string; - }[] - >; - } - | { - type: "non-interface"; - statement: ts.Statement; - } -) & { - sourceFile: ts.SourceFile; -}; - -/** - * Scan better lib file to determine which statements need to be replaced. - */ -function scanBetterFile( - printer: ts.Printer, - targetFile: string, -): Map { - const replacementTargets = new Map(); - { - const betterLibFile = path.join(betterLibDir, targetFile); - const betterProgram = ts.createProgram([betterLibFile], {}); - const betterFile = betterProgram.getSourceFile(betterLibFile); - if (betterFile) { - // Scan better file to determine which statements need to be replaced. - for (const statement of betterFile.statements) { - const name = getStatementDeclName(statement) ?? ""; - const aliasesMap = - alias.get(name) ?? new Map([[name, new Map()]]); - for (const [targetName, typeMap] of aliasesMap) { - const transformedStatement = replaceAliases(statement, typeMap); - if (ts.isInterfaceDeclaration(transformedStatement)) { - const members = new Map< - string, - { - member: ts.TypeElement; - text: string; - }[] - >(); - for (const member of transformedStatement.members) { - const memberName = member.name?.getText(betterFile) ?? ""; - upsert(members, memberName, (members = []) => { - const leadingSpacesMatch = /^\s*/.exec( - member.getFullText(betterFile), - ); - const leadingSpaces = - leadingSpacesMatch !== null ? leadingSpacesMatch[0] : ""; - members.push({ - member, - text: - leadingSpaces + - printer.printNode( - ts.EmitHint.Unspecified, - member, - betterFile, - ), - }); - return members; - }); - } - upsert(replacementTargets, targetName, (targets = []) => { - targets.push({ - type: "interface", - members, - originalStatement: transformedStatement, - sourceFile: betterFile, - }); - return targets; - }); - } else { - upsert(replacementTargets, targetName, (statements = []) => { - statements.push({ - type: "non-interface", - statement: transformedStatement, - sourceFile: betterFile, - }); - return statements; - }); - } - } - } - } - } - return replacementTargets; -} - /** * Determines whether interface can be partially replaced. */ @@ -410,54 +400,8 @@ function printInterface( return result; } -function getStatementDeclName(statement: ts.Statement): string | undefined { - if (ts.isVariableStatement(statement)) { - for (const dec of statement.declarationList.declarations) { - if (ts.isIdentifier(dec.name)) { - return dec.name.text; - } - } - } else if ( - ts.isFunctionDeclaration(statement) || - ts.isInterfaceDeclaration(statement) || - ts.isTypeAliasDeclaration(statement) || - ts.isModuleDeclaration(statement) - ) { - return statement.name?.text; - } else if (ts.isInterfaceDeclaration(statement)) { - return statement.name.text; - } - return undefined; -} - function commentOut(code: string): string { const lines = code.split("\n").filter((line) => line.trim().length > 0); const result = lines.map((line) => `// ${line}`); return result.join("\n") + "\n"; } - -function replaceAliases( - statement: ts.Statement, - typeMap: Map, -): ts.Statement { - if (typeMap.size === 0) return statement; - return ts.transform(statement, [ - (context) => (sourceStatement) => { - const visitor = (node: ts.Node): ts.Node => { - if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { - const replacementType = typeMap.get(node.typeName.text); - if (replacementType === undefined) { - return node; - } - return ts.factory.updateTypeReferenceNode( - node, - ts.factory.createIdentifier(replacementType), - node.typeArguments, - ); - } - return ts.visitEachChild(node, visitor, context); - }; - return ts.visitNode(sourceStatement, visitor, ts.isStatement); - }, - ]).transformed[0]; -} diff --git a/build/logic/scanBetterFile.ts b/build/logic/scanBetterFile.ts new file mode 100644 index 0000000..45908ae --- /dev/null +++ b/build/logic/scanBetterFile.ts @@ -0,0 +1,165 @@ +import path from "path"; +import ts from "typescript"; +import { alias } from "../util/alias"; +import { upsert } from "../util/upsert"; +import { getStatementDeclName } from "./ast/getStatementDeclName"; +import { projectDir } from "./projectDir"; + +const betterLibDir = path.join(projectDir, "lib"); + +export type ReplacementTarget = ( + | { + type: "interface"; + originalStatement: ts.InterfaceDeclaration; + members: Map< + string, + { + member: ts.TypeElement; + text: string; + }[] + >; + } + | { + type: "declare-global"; + originalStatement: ts.ModuleDeclaration; + statements: ReplacementMap; + } + | { + type: "non-interface"; + statement: ts.Statement; + } +) & { + sourceFile: ts.SourceFile; +}; + +export type ReplacementMap = Map; + +export const declareGlobalSymbol = Symbol("declare global"); +export type ReplacementName = string | typeof declareGlobalSymbol; + +/** + * Scan better lib file to determine which statements need to be replaced. + */ +export function scanBetterFile( + printer: ts.Printer, + targetFile: string, +): ReplacementMap { + const betterLibFile = path.join(betterLibDir, targetFile); + const betterProgram = ts.createProgram([betterLibFile], {}); + const betterFile = betterProgram.getSourceFile(betterLibFile); + if (!betterFile) { + // This happens when the better file of that name does not exist. + return new Map(); + } + return scanStatements(printer, betterFile.statements, betterFile); +} + +function scanStatements( + printer: ts.Printer, + statements: ts.NodeArray, + sourceFile: ts.SourceFile, +): ReplacementMap { + const replacementTargets = new Map(); + for (const statement of statements) { + const name = getStatementDeclName(statement) ?? ""; + const aliasesMap = + alias.get(name) ?? new Map([[name, new Map()]]); + for (const [targetName, typeMap] of aliasesMap) { + const transformedStatement = replaceAliases(statement, typeMap); + if (ts.isInterfaceDeclaration(transformedStatement)) { + const members = new Map< + string, + { + member: ts.TypeElement; + text: string; + }[] + >(); + for (const member of transformedStatement.members) { + const memberName = member.name?.getText(sourceFile) ?? ""; + upsert(members, memberName, (members = []) => { + const leadingSpacesMatch = /^\s*/.exec( + member.getFullText(sourceFile), + ); + const leadingSpaces = + leadingSpacesMatch !== null ? leadingSpacesMatch[0] : ""; + members.push({ + member, + text: + leadingSpaces + + printer.printNode(ts.EmitHint.Unspecified, member, sourceFile), + }); + return members; + }); + } + upsert(replacementTargets, targetName, (targets = []) => { + targets.push({ + type: "interface", + members, + originalStatement: transformedStatement, + sourceFile: sourceFile, + }); + return targets; + }); + } else if ( + ts.isModuleDeclaration(transformedStatement) && + ts.isIdentifier(transformedStatement.name) && + transformedStatement.name.text === "global" + ) { + // declare global + upsert(replacementTargets, declareGlobalSymbol, (targets = []) => { + targets.push({ + type: "declare-global", + originalStatement: transformedStatement, + statements: + transformedStatement.body && + ts.isModuleBlock(transformedStatement.body) + ? scanStatements( + printer, + transformedStatement.body.statements, + sourceFile, + ) + : new Map(), + sourceFile: sourceFile, + }); + return targets; + }); + } else { + upsert(replacementTargets, targetName, (statements = []) => { + statements.push({ + type: "non-interface", + statement: transformedStatement, + sourceFile: sourceFile, + }); + return statements; + }); + } + } + } + return replacementTargets; +} + +function replaceAliases( + statement: ts.Statement, + typeMap: Map, +): ts.Statement { + if (typeMap.size === 0) return statement; + return ts.transform(statement, [ + (context) => (sourceStatement) => { + const visitor = (node: ts.Node): ts.Node => { + if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { + const replacementType = typeMap.get(node.typeName.text); + if (replacementType === undefined) { + return node; + } + return ts.factory.updateTypeReferenceNode( + node, + ts.factory.createIdentifier(replacementType), + node.typeArguments, + ); + } + return ts.visitEachChild(node, visitor, context); + }; + return ts.visitNode(sourceStatement, visitor, ts.isStatement); + }, + ]).transformed[0]; +} diff --git a/build/util/mergeArrayMap.ts b/build/util/mergeArrayMap.ts new file mode 100644 index 0000000..94effd7 --- /dev/null +++ b/build/util/mergeArrayMap.ts @@ -0,0 +1,15 @@ +import { upsert } from "./upsert"; + +export function mergeArrayMap( + arrayMaps: readonly Map[], +): Map { + const result = new Map(); + for (const arrayMap of arrayMaps) { + for (const [key, value] of arrayMap) { + upsert(result, key, (array = []) => { + return array.concat(value); + }); + } + } + return result; +}