diff --git a/scripts/ts-smoosh/README.md b/scripts/ts-smoosh/README.md new file mode 100644 index 0000000000..e1c25b1d85 --- /dev/null +++ b/scripts/ts-smoosh/README.md @@ -0,0 +1,32 @@ +## TS Smoosh + +Combine type decls with related source files. + + +# Use + +Given a JavaScript file (or list of files) like so: + +``` +$ node ts-smoosh/bin ./src/some-file.js +``` + +Will produce `.jsx` files using nearby `.d.ts` files. + + +# Running Tests + +``` +$ node test +``` + +Test cases live in various directories in `./tests`. Tests run by comparing the output against golden master `.tsx` files. To update the masters, run `node ts-smoosh/bin ./test/0X-dir/somefile.js`. + +# Notes + +Here are articles and docs I that were helpful when writing this script. + +- [Using the Compiler API](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API) +- [Blog post about using TS's parser directly](https://medium.com/allenhwkim/how-to-parse-typescript-from-source-643387971f4e) +- [TypeScript compiler APIs revisited](https://blog.scottlogic.com/2017/05/02/typescript-compiler-api-revisited.html) +- [TypeScript API Playground on Glitch](https://typescript-api-playground.glitch.me/#example=Transformation%203) diff --git a/scripts/ts-smoosh/bin.js b/scripts/ts-smoosh/bin.js new file mode 100644 index 0000000000..680d97252c --- /dev/null +++ b/scripts/ts-smoosh/bin.js @@ -0,0 +1,13 @@ +const { smoosh } = require("./smoosh"); + +// Given an array of `.js` files, smooshes their .d.ts declarations and +// produces a .tsx file. + +const without = (ending) => (fileName) => + fileName.endsWith(ending) + ? fileName.substr(0, fileName.length - ending.length) + : fileName; + +const files = process.argv.slice(2); + +files.map(without(".js")).map(smoosh); diff --git a/scripts/ts-smoosh/smoosh.js b/scripts/ts-smoosh/smoosh.js new file mode 100644 index 0000000000..a06d7507dc --- /dev/null +++ b/scripts/ts-smoosh/smoosh.js @@ -0,0 +1,182 @@ +const ts = require("typescript"); +const fs = require("fs"); +const path = require("path"); + +/** + * Writes the result of smooshing to a file + */ +function smoosh(base) { + const smooshedSrc = returnSmooshed(base); + const outputFile = `./${base}.tsx`; + fs.writeFileSync(outputFile, smooshedSrc); +} + +const declDoesntExist = { typeAliases: [], declarations: [], imports: [] }; + +function returnSmooshed(base) { + const dtsFile = `${base}.d.ts`; + // TODO(btford): log a warning here? + const decls = fs.existsSync(dtsFile) ? parseDts(dtsFile) : declDoesntExist; + + const jsFile = `${base}.js`; + const enrichedJsNode = enrichJs(jsFile, decls); + + const outputFile = `${base}.tsx`; + + const resultFile = ts.createSourceFile( + outputFile, + "", + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TSX + ); + const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + + const smooshedSrc = printer.printNode( + ts.EmitHint.Unspecified, + enrichedJsNode, + resultFile + ); + + return smooshedSrc; +} + +function parseDts(dtsFile) { + const parsed = ts.createSourceFile( + dtsFile, + fs.readFileSync(dtsFile, "utf8"), + ts.ScriptTarget.Latest + ); + + // these are going on top + const typeAliases = []; + const declarations = {}; + const imports = []; + + const aggregateDecl = (statement) => { + // console.log(statement); + const kind = ts.SyntaxKind[statement.kind]; + + if (kind === "TypeAliasDeclaration") { + declarations[getIdentifierName(statement)] = statement.type; + typeAliases.push(statement); + return; + } + if (kind === "ImportDeclaration") { + return console.log("import..."); + } + + if (kind === "FirstStatement") { + return statement.declarationList.declarations.map(aggregateDecl); + } + + if (!kind.endsWith("Declaration")) { + const message = `Unexpected statement kind "${kind}" in type definition file "${dtsFile}"`; + return console.warn(message); + } else { + declarations[getIdentifierName(statement)] = statement; + } + }; + + parsed.statements.forEach(aggregateDecl); + + return { typeAliases, declarations, imports }; +} + +function enrichJs(jsFile, dts) { + const parsed = ts.createSourceFile( + jsFile, + fs.readFileSync(jsFile, "utf8"), + ts.ScriptTarget.Latest + ); + + const findSource = (node) => { + + let typeSource = null; + + // First, search for a jsdoc tag with the type, like: + // @type {typeof import('./b').Noop} + if (node.jsDoc) { + const typeTag = node.jsDoc[0].tags.find( + (tag) => tag.tagName.escapedText === "type" + ); + if (typeTag) { + const fileName = + typeTag.typeExpression.type.argument.literal.text; + const identifier = + typeTag.typeExpression.type.qualifier.escapedText; + const dir = path.dirname(jsFile); + const fullPath = path.resolve(dir, fileName + ".d.ts"); + const importedDts = parseDts(fullPath); + const importedType = importedDts.declarations[identifier]; + if (!importedType) { + console.warn( + `Could not find ${identifier} in ${fullPath} while trying to smoosh ${jsFile}` + ); + return node; + } + typeSource = importedType; + } + } + + // Second, use the d.ts file with the same name as this file. + if (!typeSource) { + typeSource = dts.declarations[getIdentifierName(node)]; + } + + return typeSource; + } + + const transformer = (context) => (rootNode) => { + function visit(node) { + const kind = ts.SyntaxKind[node.kind]; + if (kind.endsWith("Declaration")) { + if (kind === "FunctionDeclaration") { + const typeSource = findSource(node); + if (typeSource) { + return ts.factory.updateFunctionDeclaration( + node, + node.decorators, + node.modifiers, + node.asteriskToken, + node.name, + typeSource.typeParameters, + typeSource.parameters, + typeSource.type, + node.body + ); + } + return node; + } else if (kind === 'VariableDeclaration') { + const typeSource = findSource(node); + if (typeSource) { + return ts.factory.updateVariableDeclaration( + node, + node.name, + node.exclamationToken, + typeSource.type, + node.initializer + ); + } + return node; + } + + return node; + } + + return ts.visitEachChild(node, visit, context); + } + return ts.visitNode(rootNode, visit); + }; + + return ts.transform(parsed, [transformer]).transformed[0]; +} + +function getIdentifierName(node) { + return node.name.escapedText; +} + +module.exports = { + smoosh, + returnSmooshed, +}; diff --git a/scripts/ts-smoosh/test.js b/scripts/ts-smoosh/test.js new file mode 100644 index 0000000000..72789a9f05 --- /dev/null +++ b/scripts/ts-smoosh/test.js @@ -0,0 +1,27 @@ +const fs = require("fs"); +const assert = require("assert").strict; +const path = require("path"); + +const { returnSmooshed } = require("./smoosh"); + +const args = process.argv.slice(2); + +const dirs = args.length > 0 ? args : fs.readdirSync("./tests").map(f => `./tests/${f}`); + +dirs.forEach((dir) => { + const files = fs.readdirSync(dir); + + files + .filter((file) => file.endsWith(".js")) + .map((file) => file.substr(0, file.length - 3)) + .forEach((file) => { + const fullFile = path.resolve(dir, file); + console.log(`Testing ${fullFile}.js`); + const smooshed = returnSmooshed(fullFile); + + const target = fs.readFileSync(fullFile + ".tsx", "utf8"); + assert.equal(smooshed, target); + }); +}); + +console.log("Done"); diff --git a/scripts/ts-smoosh/tests/01-functions/a.d.ts b/scripts/ts-smoosh/tests/01-functions/a.d.ts new file mode 100644 index 0000000000..4cc6c20337 --- /dev/null +++ b/scripts/ts-smoosh/tests/01-functions/a.d.ts @@ -0,0 +1,6 @@ +export function foo( + bar: string, + baz: number +): {boo: number}; + +export function typeParams(a: A, b: B): B; diff --git a/scripts/ts-smoosh/tests/01-functions/a.js b/scripts/ts-smoosh/tests/01-functions/a.js new file mode 100644 index 0000000000..65cccbb1d6 --- /dev/null +++ b/scripts/ts-smoosh/tests/01-functions/a.js @@ -0,0 +1,15 @@ +/** + * A function with a JSDoc type import that matches its name + * @type {typeof import('./a').foo} + */ + export function foo( + bar, + baz +) { + return {boo: baz} +} + +// A function with no type import +export function typeParams(a, b) { + return a + b; +} diff --git a/scripts/ts-smoosh/tests/01-functions/a.tsx b/scripts/ts-smoosh/tests/01-functions/a.tsx new file mode 100644 index 0000000000..168cf84717 --- /dev/null +++ b/scripts/ts-smoosh/tests/01-functions/a.tsx @@ -0,0 +1,13 @@ +/** + * A function with a JSDoc type import that matches its name + * @type {typeof import('./a').foo} + */ +export function foo(bar: string, baz: number): { + boo: number; +} { + return { boo: baz }; +} +// A function with no type import +export function typeParams(a: A, b: B): B { + return a + b; +} diff --git a/scripts/ts-smoosh/tests/02-imports/b.d.ts b/scripts/ts-smoosh/tests/02-imports/b.d.ts new file mode 100644 index 0000000000..38ca8b3eaa --- /dev/null +++ b/scripts/ts-smoosh/tests/02-imports/b.d.ts @@ -0,0 +1,2 @@ + +export type MyFn = (a: number, b: number) => number diff --git a/scripts/ts-smoosh/tests/02-imports/c.js b/scripts/ts-smoosh/tests/02-imports/c.js new file mode 100644 index 0000000000..396b6c2008 --- /dev/null +++ b/scripts/ts-smoosh/tests/02-imports/c.js @@ -0,0 +1,9 @@ + +/** + * A function with a JSDoc type import that is different from its name + * @type {typeof import('./b').MyFn} + */ +function c(a, b) { + console.log('haha hi') + return a + b; +} diff --git a/scripts/ts-smoosh/tests/02-imports/c.tsx b/scripts/ts-smoosh/tests/02-imports/c.tsx new file mode 100644 index 0000000000..674be451ae --- /dev/null +++ b/scripts/ts-smoosh/tests/02-imports/c.tsx @@ -0,0 +1,8 @@ +/** + * A function with a JSDoc type import that is different from its name + * @type {typeof import('./b').MyFn} + */ +function c(a: number, b: number): number { + console.log("haha hi"); + return a + b; +} diff --git a/scripts/ts-smoosh/tests/03-const/e.d.ts b/scripts/ts-smoosh/tests/03-const/e.d.ts new file mode 100644 index 0000000000..f46e533529 --- /dev/null +++ b/scripts/ts-smoosh/tests/03-const/e.d.ts @@ -0,0 +1,4 @@ +// hi +export const x: number; + +export const add: (a: number, b: number) => number; diff --git a/scripts/ts-smoosh/tests/03-const/e.js b/scripts/ts-smoosh/tests/03-const/e.js new file mode 100644 index 0000000000..d0ab5ccc16 --- /dev/null +++ b/scripts/ts-smoosh/tests/03-const/e.js @@ -0,0 +1,3 @@ +export const x = 3; + +export const add = (a, b) => a + b; diff --git a/scripts/ts-smoosh/tests/03-const/e.tsx b/scripts/ts-smoosh/tests/03-const/e.tsx new file mode 100644 index 0000000000..8886106ea2 --- /dev/null +++ b/scripts/ts-smoosh/tests/03-const/e.tsx @@ -0,0 +1,2 @@ +export const x: number = 3; +export const add: (a: number, b: number) => number = (a, b) => a + b;