Skip to content

Commit

Permalink
Refactor runtime type definition generator using AST modifications (#73)
Browse files Browse the repository at this point in the history
  • Loading branch information
TooTallNate authored Jan 2, 2024
1 parent 53d33de commit 38a4c4e
Show file tree
Hide file tree
Showing 4 changed files with 197 additions and 91 deletions.
5 changes: 5 additions & 0 deletions .changeset/wise-masks-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'nxjs-runtime': patch
---

Refactor runtime type definition generator using AST modifications
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
275 changes: 188 additions & 87 deletions packages/runtime/build.mjs
Original file line number Diff line number Diff line change
@@ -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 = `
/// <reference no-default-lib="true"/>
/// <reference lib="es2022" />
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 = `/// <reference no-default-lib="true"/>
/// <reference lib="es2022" />
${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);
6 changes: 3 additions & 3 deletions update-apps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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…
}

0 comments on commit 38a4c4e

Please sign in to comment.