From 2dd2a484e52d66689554af3e3bad3a3dbc1b3da9 Mon Sep 17 00:00:00 2001 From: Miorel-Lucian Palii Date: Wed, 10 Jul 2024 16:52:24 -0700 Subject: [PATCH] Merge TypeScript interface declarations when merging code Fixes #151. --- .../Array.prototype.slidingWindows/index.ts | 6 +- .../__snapshots__/equip-test.ts.snap | 76 +++++++------------ .../__snapshots__/render-test.ts.snap | 14 ++-- tools/adventure-pack/src/app/goodyToText.tsx | 11 +-- tools/adventure-pack/src/app/mergeCode.ts | 42 +++++++--- .../src/app/parsers/typeScriptGoodyParser.ts | 6 +- ...ypeScriptModuleAndInterfaceDeclarations.ts | 22 ++++++ ...tringifyTypeScriptInterfaceDeclarations.ts | 13 ++++ .../stringifyTypeScriptModuleDeclarations.ts | 14 ++++ .../typescript/extractModuleDeclarations.ts | 47 ++++++++++++ .../typescript/readBaseGoody.ts | 28 +------ .../package-goodies/typescript/readGoodies.ts | 2 +- 12 files changed, 177 insertions(+), 104 deletions(-) create mode 100644 tools/adventure-pack/src/app/sortTypeScriptModuleAndInterfaceDeclarations.ts create mode 100644 tools/adventure-pack/src/app/stringifyTypeScriptInterfaceDeclarations.ts create mode 100644 tools/adventure-pack/src/app/stringifyTypeScriptModuleDeclarations.ts create mode 100644 tools/adventure-pack/src/scripts/package-goodies/typescript/extractModuleDeclarations.ts diff --git a/tools/adventure-pack/goodies/typescript/Array.prototype.slidingWindows/index.ts b/tools/adventure-pack/goodies/typescript/Array.prototype.slidingWindows/index.ts index 8366a319..be2d1614 100644 --- a/tools/adventure-pack/goodies/typescript/Array.prototype.slidingWindows/index.ts +++ b/tools/adventure-pack/goodies/typescript/Array.prototype.slidingWindows/index.ts @@ -22,13 +22,11 @@ class ArraySlice { return this.array[this.start + adjustedIndex]; } - [Symbol.iterator] = function* ( - this: ArraySlice, - ): Generator { + *[Symbol.iterator](this: ArraySlice): Generator { for (let i = this.start; i <= this.end; ++i) { yield this.array[i]; } - }; + } slide(delta: number = 1): IndexableArraySlice { return ArraySlice.get(this.array, this.start + delta, this.end + delta); diff --git a/tools/adventure-pack/src/app/__tests__/__snapshots__/equip-test.ts.snap b/tools/adventure-pack/src/app/__tests__/__snapshots__/equip-test.ts.snap index c62a6456..296ef7b8 100644 --- a/tools/adventure-pack/src/app/__tests__/__snapshots__/equip-test.ts.snap +++ b/tools/adventure-pack/src/app/__tests__/__snapshots__/equip-test.ts.snap @@ -119,11 +119,11 @@ class ArraySlice { return this.array[this.start + adjustedIndex]; } - [Symbol.iterator] = function* () { + *[Symbol.iterator]() { for (let i = this.start; i <= this.end; ++i) { yield this.array[i]; } - }; + } slide(delta = 1) { return ArraySlice.get(this.array, this.start + delta, this.end + delta); @@ -907,14 +907,14 @@ exports[`App can equip single goody: TypeScript Array.prototype.slidingWindows 1 // Running at: https://example.com/ declare global { - interface ReadonlyArray { + interface Array { slidingWindows( this: ReadonlyArray, windowSize: number, ): Generator, void, void>; } - interface Array { + interface ReadonlyArray { slidingWindows( this: ReadonlyArray, windowSize: number, @@ -946,13 +946,11 @@ class ArraySlice { return this.array[this.start + adjustedIndex]; } - [Symbol.iterator] = function* ( - this: ArraySlice, - ): Generator { + *[Symbol.iterator](this: ArraySlice): Generator { for (let i = this.start; i <= this.end; ++i) { yield this.array[i]; } - }; + } slide(delta: number = 1): IndexableArraySlice { return ArraySlice.get(this.array, this.start + delta, this.end + delta); @@ -1180,15 +1178,13 @@ declare global { returnThis(this: T): T; } - interface Iterator { - toIterable(this: Iterator): IterableIterator; - } - interface Iterator { every( this: Iterator, callbackfn: (value: T, index: number) => unknown, ): boolean; + + toIterable(this: Iterator): IterableIterator; } } @@ -1235,15 +1231,13 @@ declare global { returnThis(this: T): T; } - interface Iterator { - toIterable(this: Iterator): IterableIterator; - } - interface Iterator { filter( this: Iterator, callbackfn: (value: T, index: number) => unknown, ): Generator; + + toIterable(this: Iterator): IterableIterator; } } @@ -1289,15 +1283,13 @@ declare global { returnThis(this: T): T; } - interface Iterator { - toIterable(this: Iterator): IterableIterator; - } - interface Iterator { find( this: Iterator, callbackFn: (element: T, index: number) => boolean, ): T | undefined; + + toIterable(this: Iterator): IterableIterator; } } @@ -1344,15 +1336,13 @@ declare global { returnThis(this: T): T; } - interface Iterator { - toIterable(this: Iterator): IterableIterator; - } - interface Iterator { forEach( this: Iterator, callbackFn: (element: T, index: number) => void, ): void; + + toIterable(this: Iterator): IterableIterator; } } @@ -1396,15 +1386,13 @@ declare global { returnThis(this: T): T; } - interface Iterator { - toIterable(this: Iterator): IterableIterator; - } - interface Iterator { map( this: Iterator, callbackFn: (element: T, index: number) => TOut, ): Generator; + + toIterable(this: Iterator): IterableIterator; } } @@ -1448,16 +1436,14 @@ declare global { returnThis(this: T): T; } - interface Iterator { - toIterable(this: Iterator): IterableIterator; - } - interface Iterator { max( this: Iterator, compareFn?: (a: T, b: T) => number, options?: { nanBehavior?: "avoid" | "compare" }, ): T | undefined; + + toIterable(this: Iterator): IterableIterator; } } @@ -1535,16 +1521,14 @@ declare global { returnThis(this: T): T; } - interface Iterator { - toIterable(this: Iterator): IterableIterator; - } - interface Iterator { min( this: Iterator, compareFn?: (a: T, b: T) => number, options?: { nanBehavior?: "avoid" | "compare" }, ): T | undefined; + + toIterable(this: Iterator): IterableIterator; } } @@ -1622,15 +1606,13 @@ declare global { returnThis(this: T): T; } - interface Iterator { - toIterable(this: Iterator): IterableIterator; - } - interface Iterator { some( this: Iterator, callbackfn: (value: T, index: number) => unknown, ): boolean; + + toIterable(this: Iterator): IterableIterator; } } @@ -1677,12 +1659,10 @@ declare global { returnThis(this: T): T; } - interface Iterator { - toIterable(this: Iterator): IterableIterator; - } - interface Iterator { toArray(this: Iterator): T[]; + + toIterable(this: Iterator): IterableIterator; } } @@ -1814,14 +1794,14 @@ exports[`App can equip single goody: TypeScript Number.prototype.digits 1`] = ` // Running at: https://example.com/ declare global { - interface NumberConstructor { - isIntegerOrIntegerObject(num: unknown): boolean; - } - interface Number { digits(this: Number): Generator; digits(this: Number, radix: number): Generator; } + + interface NumberConstructor { + isIntegerOrIntegerObject(num: unknown): boolean; + } } Number.isIntegerOrIntegerObject = function (num: unknown): boolean { diff --git a/tools/adventure-pack/src/app/__tests__/__snapshots__/render-test.ts.snap b/tools/adventure-pack/src/app/__tests__/__snapshots__/render-test.ts.snap index 7b5b59a6..c56f5f26 100644 --- a/tools/adventure-pack/src/app/__tests__/__snapshots__/render-test.ts.snap +++ b/tools/adventure-pack/src/app/__tests__/__snapshots__/render-test.ts.snap @@ -89,11 +89,11 @@ exports[`App can render goody: JavaScript Array.prototype.slidingWindows 1`] = ` return this.array[this.start + adjustedIndex]; } - [Symbol.iterator] = function* () { + *[Symbol.iterator]() { for (let i = this.start; i <= this.end; ++i) { yield this.array[i]; } - }; + } slide(delta = 1) { return ArraySlice.get(this.array, this.start + delta, this.end + delta); @@ -560,14 +560,14 @@ del set_up_adventure_pack" exports[`App can render goody: TypeScript Array.prototype.slidingWindows 1`] = ` "declare global { - interface ReadonlyArray { + interface Array { slidingWindows( this: ReadonlyArray, windowSize: number, ): Generator, void, void>; } - interface Array { + interface ReadonlyArray { slidingWindows( this: ReadonlyArray, windowSize: number, @@ -599,13 +599,11 @@ class ArraySlice { return this.array[this.start + adjustedIndex]; } - [Symbol.iterator] = function* ( - this: ArraySlice, - ): Generator { + *[Symbol.iterator](this: ArraySlice): Generator { for (let i = this.start; i <= this.end; ++i) { yield this.array[i]; } - }; + } slide(delta: number = 1): IndexableArraySlice { return ArraySlice.get(this.array, this.start + delta, this.end + delta); diff --git a/tools/adventure-pack/src/app/goodyToText.tsx b/tools/adventure-pack/src/app/goodyToText.tsx index cd6ec019..2e4c2815 100644 --- a/tools/adventure-pack/src/app/goodyToText.tsx +++ b/tools/adventure-pack/src/app/goodyToText.tsx @@ -1,5 +1,6 @@ import type { Goody } from "./Goody"; import { mergeJavaCode } from "./mergeJavaCode"; +import { stringifyTypeScriptModuleDeclarations } from "./stringifyTypeScriptModuleDeclarations"; export function goodyToText(goody: Goody): string { switch (goody.language) { @@ -28,14 +29,14 @@ export function goodyToText(goody: Goody): string { return goody.code.trim(); } case "typescript": { + const moduleDeclarations = stringifyTypeScriptModuleDeclarations( + goody.moduleDeclarations, + ); + return ( goody.imports.map((im) => `import ${JSON.stringify(im)};\n`).join("") + "\n" + - (goody.globalModuleDeclarations.length > 0 - ? `declare global {\n ${goody.globalModuleDeclarations - .join("\n\n") - .trim()}\n}\n\n` - : "") + + (moduleDeclarations.length > 0 ? moduleDeclarations + "\n\n" : "") + goody.code ).trim(); } diff --git a/tools/adventure-pack/src/app/mergeCode.ts b/tools/adventure-pack/src/app/mergeCode.ts index 7d422d37..0df8558c 100644 --- a/tools/adventure-pack/src/app/mergeCode.ts +++ b/tools/adventure-pack/src/app/mergeCode.ts @@ -10,6 +10,8 @@ import type { Goody } from "./Goody"; import type { Language } from "./Language"; import { mergeJavaCode } from "./mergeJavaCode"; import type { JavaGoody } from "./parsers/javaGoodyParser"; +import { sortTypeScriptModuleAndInterfaceDeclarations } from "./sortTypeScriptModuleAndInterfaceDeclarations"; +import { stringifyTypeScriptModuleDeclarations } from "./stringifyTypeScriptModuleDeclarations"; function topo({ goodies, @@ -110,19 +112,37 @@ export function mergeCode({ ); } - const globalModuleDeclarations = - language === "typescript" - ? orderedGoodies.flatMap((goody) => - goody.language === "typescript" - ? goody.globalModuleDeclarations - : [], - ) - : []; + const moduleDeclarations = (() => { + if (language !== "typescript") { + return ""; + } + + const mergedDeclarations: Record> = {}; + for (const goody of orderedGoodies) { + invariant( + goody.language === "typescript", + "Goody language must match language!", + ); + + for (const [moduleName, interfaceDeclarations] of Object.entries( + goody.moduleDeclarations, + )) { + for (const [interfaceName, codeGroups] of Object.entries( + interfaceDeclarations, + )) { + ((mergedDeclarations[moduleName] ??= {})[interfaceName] ??= + []).push(...codeGroups); + } + } + } + + return stringifyTypeScriptModuleDeclarations( + sortTypeScriptModuleAndInterfaceDeclarations(mergedDeclarations), + ); + })(); return [ - globalModuleDeclarations.length > 0 - ? `declare global {\n${globalModuleDeclarations.join("\n\n")}\n}` - : "", + moduleDeclarations, ...orderedGoodies.map((goody) => { invariant( diff --git a/tools/adventure-pack/src/app/parsers/typeScriptGoodyParser.ts b/tools/adventure-pack/src/app/parsers/typeScriptGoodyParser.ts index 26c33cbb..6e3a98fb 100644 --- a/tools/adventure-pack/src/app/parsers/typeScriptGoodyParser.ts +++ b/tools/adventure-pack/src/app/parsers/typeScriptGoodyParser.ts @@ -6,8 +6,10 @@ import { nonBlankStringParser } from "./nonBlankStringParser"; export const typeScriptGoodyParser = goodyBaseParser.extend({ code: nonBlankStringParser, - // TODO: generalize to simply declarations - globalModuleDeclarations: z.array(nonBlankStringParser), + moduleDeclarations: z.record( + z.string(), + z.record(z.string(), z.array(nonBlankStringParser)), + ), language: z.literal("typescript"), }); diff --git a/tools/adventure-pack/src/app/sortTypeScriptModuleAndInterfaceDeclarations.ts b/tools/adventure-pack/src/app/sortTypeScriptModuleAndInterfaceDeclarations.ts new file mode 100644 index 00000000..8a2f27eb --- /dev/null +++ b/tools/adventure-pack/src/app/sortTypeScriptModuleAndInterfaceDeclarations.ts @@ -0,0 +1,22 @@ +import type { ReadonlyDeep } from "type-fest"; + +// TODO: split util by type of util so importing the main package doesn't pull in node:fs +import { compareStringsCaseInsensitive } from "@code-chronicles/util/src/compareStringsCaseInsensitive"; +// TODO: split util by type of util so importing the main package doesn't pull in node:fs +import { mapObjectValues } from "@code-chronicles/util/src/mapObjectValues"; +// TODO: split util by type of util so importing the main package doesn't pull in node:fs +import { sortObjectKeysRecursive } from "@code-chronicles/util/src/sortObjectKeysRecursive"; + +export function sortTypeScriptModuleAndInterfaceDeclarations( + moduleDeclarations: ReadonlyDeep>>, +): Record> { + return sortObjectKeysRecursive( + mapObjectValues(moduleDeclarations, (interfaceDeclarations) => + mapObjectValues(interfaceDeclarations, (codeGroups) => + [...codeGroups].sort(compareStringsCaseInsensitive), + ), + ), + compareStringsCaseInsensitive, + 2, + ); +} diff --git a/tools/adventure-pack/src/app/stringifyTypeScriptInterfaceDeclarations.ts b/tools/adventure-pack/src/app/stringifyTypeScriptInterfaceDeclarations.ts new file mode 100644 index 00000000..e2325a94 --- /dev/null +++ b/tools/adventure-pack/src/app/stringifyTypeScriptInterfaceDeclarations.ts @@ -0,0 +1,13 @@ +import type { ReadonlyDeep } from "type-fest"; + +export function stringifyTypeScriptInterfaceDeclarations( + interfaceDeclarations: ReadonlyDeep>, + indent: string = "", +): string { + return Object.entries(interfaceDeclarations) + .map( + ([interfaceName, codeGroups]) => + `${indent}interface ${interfaceName} {\n${codeGroups.join("\n\n")}\n${indent}}`, + ) + .join("\n\n"); +} diff --git a/tools/adventure-pack/src/app/stringifyTypeScriptModuleDeclarations.ts b/tools/adventure-pack/src/app/stringifyTypeScriptModuleDeclarations.ts new file mode 100644 index 00000000..477c34ef --- /dev/null +++ b/tools/adventure-pack/src/app/stringifyTypeScriptModuleDeclarations.ts @@ -0,0 +1,14 @@ +import type { ReadonlyDeep } from "type-fest"; + +import { stringifyTypeScriptInterfaceDeclarations } from "./stringifyTypeScriptInterfaceDeclarations"; + +export function stringifyTypeScriptModuleDeclarations( + moduleDeclarations: ReadonlyDeep>>, +): string { + return Object.entries(moduleDeclarations) + .map( + ([moduleName, interfaceDeclarations]) => + `declare ${moduleName} {\n${stringifyTypeScriptInterfaceDeclarations(interfaceDeclarations, " ")}\n}`, + ) + .join("\n\n"); +} diff --git a/tools/adventure-pack/src/scripts/package-goodies/typescript/extractModuleDeclarations.ts b/tools/adventure-pack/src/scripts/package-goodies/typescript/extractModuleDeclarations.ts new file mode 100644 index 00000000..b9d4b787 --- /dev/null +++ b/tools/adventure-pack/src/scripts/package-goodies/typescript/extractModuleDeclarations.ts @@ -0,0 +1,47 @@ +import { SourceFile as TSSourceFile, SyntaxKind } from "ts-morph"; + +import { sortTypeScriptModuleAndInterfaceDeclarations } from "../../../app/sortTypeScriptModuleAndInterfaceDeclarations"; +import { removeNode } from "./removeNode"; + +export function extractModuleDeclarations( + sourceFile: TSSourceFile, +): Record> { + const res: Record> = {}; + + sourceFile.getModules().forEach((mod) => { + mod + .getBodyOrThrow() + .getChildSyntaxListOrThrow() + .getChildren() + .forEach((decl) => { + const interfaceDecl = decl.asKindOrThrow( + SyntaxKind.InterfaceDeclaration, + "Only interface declarations are currently supported in module declarations, got: " + + decl.getKindName(), + ); + + const interfaceName = interfaceDecl.getName(); + const typeParameters = interfaceDecl + .getFirstChildByKind(SyntaxKind.TypeParameter) + ?.getParentSyntaxListOrThrow() + .getFullText(); + + const interfaceKey = + typeParameters == null + ? interfaceName + : `${interfaceName}<${typeParameters}>`; + + ((res[mod.getName()] ??= {})[interfaceKey] ??= []).push( + decl + .getChildSyntaxListOrThrow() + .getFullText() + .replace(/^\n+/, "") + .replace(/\n+$/, ""), + ); + }); + + removeNode(mod); + }); + + return sortTypeScriptModuleAndInterfaceDeclarations(res); +} diff --git a/tools/adventure-pack/src/scripts/package-goodies/typescript/readBaseGoody.ts b/tools/adventure-pack/src/scripts/package-goodies/typescript/readBaseGoody.ts index aaed22ac..1cd8ec54 100644 --- a/tools/adventure-pack/src/scripts/package-goodies/typescript/readBaseGoody.ts +++ b/tools/adventure-pack/src/scripts/package-goodies/typescript/readBaseGoody.ts @@ -1,6 +1,5 @@ import fsPromises from "node:fs/promises"; import path from "node:path"; -import { SourceFile as TSSourceFile, SyntaxKind } from "ts-morph"; import { WritableDeep } from "type-fest"; import { stripPrefixOrThrow } from "@code-chronicles/util"; @@ -8,33 +7,12 @@ import { stripPrefixOrThrow } from "@code-chronicles/util"; import type { TypeScriptGoody } from "../../../app/parsers/typeScriptGoodyParser"; import { createSourceFile } from "./createSourceFile"; import { extractImports } from "./extractImports"; +import { extractModuleDeclarations } from "./extractModuleDeclarations"; import { formatCode } from "./formatCode"; import { removeNode } from "./removeNode"; export const GOODIES_DIRECTORY = path.join("goodies", "typescript"); -function extractGlobalModuleDeclarations(sourceFile: TSSourceFile): string[] { - const res: string[] = []; - - sourceFile - .getDescendantsOfKind(SyntaxKind.ModuleDeclaration) - .forEach((decl) => { - if (decl.getName() === "global") { - res.push( - decl - .getBodyOrThrow() - .getChildSyntaxListOrThrow() - .getFullText() - .replace(/^\n+/, "") - .replace(/\n+$/, ""), - ); - removeNode(decl); - } - }); - - return res; -} - export type TypeScriptGoodyBase = Omit< WritableDeep, "importedBy" @@ -50,7 +28,7 @@ export async function readBaseGoody( const sourceFile = createSourceFile(code); const imports = extractImports(sourceFile); - const globalModuleDeclarations = extractGlobalModuleDeclarations(sourceFile); + const moduleDeclarations = extractModuleDeclarations(sourceFile); sourceFile.getExportDeclarations().forEach((decl) => { if (decl.getNamedExports().length === 0) { @@ -62,9 +40,9 @@ export async function readBaseGoody( return { code: updatedCode, - globalModuleDeclarations, imports: Array.from(imports).map((im) => stripPrefixOrThrow(im, "../")), language: "typescript", + moduleDeclarations, name, }; } diff --git a/tools/adventure-pack/src/scripts/package-goodies/typescript/readGoodies.ts b/tools/adventure-pack/src/scripts/package-goodies/typescript/readGoodies.ts index 347f68a4..10a25dda 100644 --- a/tools/adventure-pack/src/scripts/package-goodies/typescript/readGoodies.ts +++ b/tools/adventure-pack/src/scripts/package-goodies/typescript/readGoodies.ts @@ -43,7 +43,7 @@ export async function readGoodies(): Promise<{ javascript: await mapObjectValuesAsync( goodiesByName, async (goody: TypeScriptGoody): Promise => { - const { globalModuleDeclarations: _, ...rest } = goody; + const { moduleDeclarations: _, ...rest } = goody; return { ...rest,