Skip to content

Commit

Permalink
[Chore] add initial version of ts-smoosh (#1437)
Browse files Browse the repository at this point in the history
Co-authored-by: Brian Ford <brian@sequence.studio>
  • Loading branch information
btford and sequence-studio committed Mar 16, 2021
1 parent 6b39c43 commit d4698bb
Show file tree
Hide file tree
Showing 13 changed files with 316 additions and 0 deletions.
32 changes: 32 additions & 0 deletions 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)
13 changes: 13 additions & 0 deletions 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);
182 changes: 182 additions & 0 deletions 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,
};
27 changes: 27 additions & 0 deletions 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");
6 changes: 6 additions & 0 deletions 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, B>(a: A, b: B): B;
15 changes: 15 additions & 0 deletions 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;
}
13 changes: 13 additions & 0 deletions 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, B>(a: A, b: B): B {
return a + b;
}
2 changes: 2 additions & 0 deletions scripts/ts-smoosh/tests/02-imports/b.d.ts
@@ -0,0 +1,2 @@

export type MyFn = (a: number, b: number) => number
9 changes: 9 additions & 0 deletions 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;
}
8 changes: 8 additions & 0 deletions 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;
}
4 changes: 4 additions & 0 deletions 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;
3 changes: 3 additions & 0 deletions scripts/ts-smoosh/tests/03-const/e.js
@@ -0,0 +1,3 @@
export const x = 3;

export const add = (a, b) => a + b;
2 changes: 2 additions & 0 deletions 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;

0 comments on commit d4698bb

Please sign in to comment.