From 38a4c4e2b0c996cea68654c8dd4ba9dcf0e8c78b Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Mon, 1 Jan 2024 23:21:26 -0800 Subject: [PATCH] Refactor runtime type definition generator using AST modifications (#73) --- .changeset/wise-masks-sing.md | 5 + package.json | 2 +- packages/runtime/build.mjs | 275 +++++++++++++++++++++++----------- update-apps.mjs | 6 +- 4 files changed, 197 insertions(+), 91 deletions(-) create mode 100644 .changeset/wise-masks-sing.md diff --git a/.changeset/wise-masks-sing.md b/.changeset/wise-masks-sing.md new file mode 100644 index 00000000..29a16cd6 --- /dev/null +++ b/.changeset/wise-masks-sing.md @@ -0,0 +1,5 @@ +--- +'nxjs-runtime': patch +--- + +Refactor runtime type definition generator using AST modifications diff --git a/package.json b/package.json index 2d17ce11..5451d504 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "scripts": { "build": "turbo run build", "bundle": "turbo run bundle", - "format": "prettier --write \"**/*.{ts,js}\"", + "format": "prettier --write \"**/*.{ts,js,mjs}\"", "ci:version": "changeset version && node .github/scripts/cleanup-examples.mjs && pnpm install --no-frozen-lockfile", "ci:publish": "pnpm publish -r && node .github/scripts/create-git-tag.mjs" }, diff --git a/packages/runtime/build.mjs b/packages/runtime/build.mjs index 186589b3..9be59328 100644 --- a/packages/runtime/build.mjs +++ b/packages/runtime/build.mjs @@ -1,113 +1,214 @@ import fs from 'fs'; +import ts from 'typescript'; import { generateDtsBundle } from 'dts-bundle-generator'; const distDir = new URL('dist/', import.meta.url); fs.mkdirSync(distDir, { recursive: true }); -let [output] = generateDtsBundle( +let [globalTypes, switchTypes, wasmTypes] = generateDtsBundle( [ { filePath: './src/index.ts', }, + { + filePath: './src/switch.ts', + }, + { + filePath: './src/wasm.ts', + }, ], { - noBanner: true, + preferredConfigPath: 'tsconfig.json', } ); -// We need to do some post-processing on the `dts-bundle-generator` -// output to make the result suitable as a runtime `.d.ts` file. - -// 1) Add references to core JavaScript interfaces -// that are compatible with QuickJS. -output = ` -/// - -/// +const globalNames = new Set(); -${output}`; - -// 2) Remove all `export` declarations. -output = output.replace(/^export /gm, ''); - -function splitOnComma(str) { - let result = []; - let depth = 0; - let start = 0; - - for (let i = 0; i < str.length; i++) { - switch (str[i]) { - case '<': - depth++; - break; - case '>': - depth--; - break; - case ',': - if (depth === 0) { - result.push(str.slice(start, i).trim()); - start = i + 1; - } - break; - } - } - - result.push(str.slice(start).trim()); // Add the last segment - - return result; -} +function transform(name, input, opts = {}) { + // Remove banner + input = input.split('\n').slice(2).join('\n'); -// 3) Remove all `implements globalThis.` statements. -output = output.replace(/\bimplements (.*){/g, (_, matches) => { - const filtered = splitOnComma(matches).filter( - (i) => !i.includes('globalThis.') + const sourceFile = ts.createSourceFile( + 'file.d.ts', + input, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS ); - if (filtered.length > 0) { - return `implements ${filtered.join(', ')} {`; + + function filterImplements(type) { + return ( + !type.expression.getText(sourceFile).startsWith(`${name}.`) && + !type.typeArguments?.some((typeArgument) => + typeArgument.getText(sourceFile).startsWith(`${name}.`) + ) + ); } - return '{'; -}); - -// 4) `ctx.canvas` is marked as `HTMLCanvasElement` to make TypeScript -// happy, but the class name in nx.js is `Canvas`. So let's fix that. -output = output.replace(/\bHTMLCanvasElement\b/g, 'Canvas'); - -// 5) At this point, the final line contains a `{};` which doesn't -// hurt, but also isn't necessary. Clean that up. -output = output.trim().split('\n').slice(0, -1).join('\n'); - -function generateNamespace(name, filePath) { - let [output] = generateDtsBundle( - [ - { - filePath, - }, - ], - { - noBanner: true, + + function visit(node, context) { + // For class/interface/type, remove if part of a namespace + // where the type has already been defined in the global scope + if ( + ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) + ) { + const name = node.name?.getText(); + if (name) { + if (opts.removeTypes) { + if (opts.removeTypes.has(name)) return undefined; + } else { + globalNames.add(name); + } + } } - ); - output = output.replace(/^export (declare )?/gm, ''); - output = output.replace(/\bimplements (.*){/g, (_, matches) => { - const filtered = matches - .split(',') - .map((i) => i.trim()) - .filter((i) => !i.startsWith(`${name}.`)); - if (filtered.length > 0) { - return `implements ${filtered.join(', ')} {`; + + // Check for and remove stray 'export {}' + if (ts.isExportDeclaration(node)) { + if ( + !node.exportClause || + (ts.isNamedExports(node.exportClause) && + node.exportClause.elements.length === 0) + ) { + return undefined; // Remove this node + } + } + + if (ts.isClassDeclaration(node)) { + // Filter or remove 'implements ${name}.*' from heritageClauses + const newHeritageClauses = node.heritageClauses + ?.filter((clause) => { + if (clause.token === ts.SyntaxKind.ImplementsKeyword) { + return clause.types.filter(filterImplements).length > 0; // Keep the clause only if there are types left after filtering + } + return true; // Keep 'extends' and other clauses + }) + .map((clause) => { + // Update the clause with the filtered types + if (clause.token === ts.SyntaxKind.ImplementsKeyword) { + return ts.factory.updateHeritageClause( + clause, + clause.types.filter(filterImplements) + ); + } + return clause; + }); + + node = ts.factory.updateClassDeclaration( + node, + node.modifiers, + node.name, + node.typeParameters, + newHeritageClauses?.length > 0 ? newHeritageClauses : undefined, + node.members + ); } - return '{'; - }); - output = output.trim().split('\n').slice(2, -2).map(l => ` ${l}`).join('\n'); - //console.log({ output }); - return `declare namespace ${name} {\n${output}\n}\n`; + + // Process class, interface, type alias, and function declarations + if ( + (ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isFunctionDeclaration(node) || + ts.isVariableStatement(node)) && + node.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword + ) + ) { + // Remove the 'export' modifier + const newModifiers = node.modifiers.filter( + (modifier) => modifier.kind !== ts.SyntaxKind.ExportKeyword + ); + + if (ts.isClassDeclaration(node)) { + return ts.factory.updateClassDeclaration( + node, + newModifiers, + node.name, + node.typeParameters, + node.heritageClauses, + node.members + ); + } else if (ts.isInterfaceDeclaration(node)) { + return ts.factory.updateInterfaceDeclaration( + node, + newModifiers, + node.name, + node.typeParameters, + node.heritageClauses, + node.members + ); + } else if (ts.isTypeAliasDeclaration(node)) { + return ts.factory.updateTypeAliasDeclaration( + node, + newModifiers, + node.name, + node.typeParameters, + node.type + ); + } else if (ts.isFunctionDeclaration(node)) { + return ts.factory.updateFunctionDeclaration( + node, + newModifiers, + node.asteriskToken, + node.name, + node.typeParameters, + node.parameters, + node.type, + node.body + ); + } else if (ts.isVariableStatement(node)) { + return ts.factory.updateVariableStatement( + node, + newModifiers, + node.declarationList + ); + } + } + + return ts.visitEachChild( + node, + (child) => visit(child, context), + context + ); + } + + const result = ts.transform(sourceFile, [ + (context) => (rootNode) => + ts.visitNode(rootNode, (node) => visit(node, context)), + ]); + return ts.createPrinter().printFile(result.transformed[0]).trim(); +} + +function namespace(name, input) { + return `declare namespace ${name} { +${transform(name, input, { removeTypes: globalNames }) + .split('\n') + .map((l) => `\t${l}`) + .join('\n')} +}`; } -// `dts-bundle-generator` does not currently support namespaces, -// so we need to add those in manually: -// See: https://github.com/timocov/dts-bundle-generator/issues/134 -output += generateNamespace('WebAssembly', './src/wasm.ts'); -output += generateNamespace('Switch', './src/switch.ts'); +const output = `/// +/// + +${transform('globalThis', globalTypes)} + +/** + * The \`Switch\` global object contains native interfaces to interact with the Switch hardware. + */ +${namespace('Switch', switchTypes)} + +/** + * The \`WebAssembly\` JavaScript object acts as the namespace for all + * {@link https://developer.mozilla.org/docs/WebAssembly | WebAssembly}-related functionality. + * + * Unlike most other global objects, \`WebAssembly\` is not a constructor (it is not a function object). + * + * @see https://developer.mozilla.org/docs/WebAssembly + */ +${namespace('WebAssembly', wasmTypes)} +`; fs.writeFileSync(new URL('index.d.ts', distDir), output); diff --git a/update-apps.mjs b/update-apps.mjs index 5a2e6e8a..b3063a8c 100644 --- a/update-apps.mjs +++ b/update-apps.mjs @@ -3,7 +3,7 @@ import fs from 'fs'; const appsDir = new URL('apps/', import.meta.url); for (const app of fs.readdirSync(appsDir)) { - if (app === '.DS_Store') continue; - const appUrl = new URL(`${app}/`, appsDir); - // … do your thing… + if (app === '.DS_Store') continue; + const appUrl = new URL(`${app}/`, appsDir); + // … do your thing… }