Skip to content

Add babel macro #24

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Aug 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions lib/macro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// @ts-ignore
import * as path from "path";
import { createMacro, MacroHandler } from "babel-plugin-macros";
import { NodePath, types } from "@babel/core"; // typescript types ONLY
import { ICompilerOptions } from "./index";
import { getCallPaths } from "./macro/getCallPaths";
import { RequirementRegistry } from "./macro/RequirementRegistry";
import { getGetArgValue } from "./macro/getGetArgValue";
import { compileTypeSuite, ICompilerArgs } from "./macro/compileTypeSuite";
import { macroInternalError } from "./macro/errors";

const tsInterfaceCheckerIdentifier = "t";
const onceIdentifier = "once";

/**
* This macro handler is called for each file that imports the macro module.
* `params.references` is an object where each key is the name of a variable imported from the macro module,
* and each value is an array of references to that that variable.
* Said references come in the form of Babel `NodePath`s,
* which have AST (Abstract Syntax Tree) data and methods for manipulating it.
* For more info: https://github.com/kentcdodds/babel-plugin-macros/blob/master/other/docs/author.md#function-api
*
* This macro handler needs to replace each call to `getTypeSuite` or `getCheckers`
* with the code that fulfills that function's behavior as documented in `macro.d.ts` (in root of repo).
*/
const macroHandler: MacroHandler = (params) => {
const { references, babel, state } = params;
const callPaths = getCallPaths(references);
const somePath = callPaths.getTypeSuite[0] || callPaths.getCheckers[0];
if (!somePath) {
return;
}
const programPath = somePath.findParent((path) => path.isProgram());

const registry = new RequirementRegistry();
const toReplace = [
...callPaths.getTypeSuite.map((callPath, index) => {
const compilerArgs = getCompilerArgs(callPath, "getTypeSuite", index);
const typeSuiteId = registry.requireTypeSuite(compilerArgs);
return { callPath, id: typeSuiteId };
}),
...callPaths.getCheckers.map((callPath, index) => {
const compilerArgs = getCompilerArgs(callPath, "getCheckers", index);
const checkerSuiteId = registry.requireCheckerSuite(compilerArgs);
return { callPath, id: checkerSuiteId };
}),
];

// Begin mutations

programPath.scope.rename(tsInterfaceCheckerIdentifier);
programPath.scope.rename(onceIdentifier);
toReplace.forEach(({ callPath, id }) => {
scopeRenameRecursive(callPath.scope, id);
});

const toPrepend = `
import * as ${tsInterfaceCheckerIdentifier} from "ts-interface-checker";
function ${onceIdentifier}(fn) {
var result;
return function () {
return result || (result = fn());
};
}
${registry.typeSuites
.map(
({ compilerArgs, id }) => `
var ${id} = ${onceIdentifier}(function(){
return ${compileTypeSuite(compilerArgs)};
});
`
)
.join("")}
${registry.checkerSuites
.map(
({ typeSuiteId, id }) => `
var ${id} = ${onceIdentifier}(function(){
return ${tsInterfaceCheckerIdentifier}.createCheckers(${typeSuiteId}());
});
`
)
.join("")}
`;
parseStatements(toPrepend).reverse().forEach(prependProgramStatement);

const { identifier, callExpression } = babel.types;
toReplace.forEach(({ callPath, id }) => {
callPath.replaceWith(callExpression(identifier(id), []));
});

// Done mutations (only helper functions below)

function getCompilerArgs(
callPath: NodePath<types.CallExpression>,
functionName: string,
callIndex: number
): ICompilerArgs {
const callDescription = `${functionName} call ${callIndex + 1}`;
const getArgValue = getGetArgValue(callPath, callDescription);

const basename = getArgValue(0) || path.basename(state.filename);
const file = path.resolve(state.filename, "..", basename);

// Get the user config passed to us by babel-plugin-macros, for use as default options
// Note: `config` property is missing in `babelPluginMacros.MacroParams` type definition
const defaultOptions = (params as any).config;
const options = {
...(defaultOptions || {}),
...(getArgValue(1) || {}),
format: "js:cjs",
} as ICompilerOptions;

return [file, options];
}

function scopeRenameRecursive(scope: NodePath["scope"], oldName: string) {
scope.rename(oldName);
if (scope.parent) {
scopeRenameRecursive(scope.parent, oldName);
}
}

function parseStatements(code: string) {
const parsed = babel.parse(code, { configFile: false });
if (!parsed || parsed.type !== "File") throw macroInternalError();
return parsed.program.body;
}

function prependProgramStatement(statement: types.Statement) {
(programPath.get("body.0") as NodePath).insertBefore(statement);
}
};

const macroParams = { configName: "ts-interface-builder" };

export const macro = () => createMacro(macroHandler, macroParams);
47 changes: 47 additions & 0 deletions lib/macro/RequirementRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// @ts-ignore
import { isDeepStrictEqual } from "util";
import { ICompilerArgs } from "./compileTypeSuite";

export interface IRequiredTypeSuite {
compilerArgs: ICompilerArgs;
id: string;
}

export interface IRequiredCheckerSuite {
typeSuiteId: string;
id: string;
}

export class RequirementRegistry {
public typeSuites: IRequiredTypeSuite[] = [];
public checkerSuites: IRequiredCheckerSuite[] = [];

public requireTypeSuite(compilerArgs: ICompilerArgs): string {
let index = this.typeSuites.findIndex((typeSuite) =>
isDeepStrictEqual(typeSuite.compilerArgs, compilerArgs)
);
if (index === -1) {
index = this.typeSuites.length;
this.typeSuites.push({
compilerArgs,
id: `typeSuite${index}`,
});
}
return this.typeSuites[index].id;
}

public requireCheckerSuite(compilerArgs: ICompilerArgs): string {
const typeSuiteId = this.requireTypeSuite(compilerArgs);
let index = this.checkerSuites.findIndex(
(checkerSuite) => checkerSuite.typeSuiteId === typeSuiteId
);
if (index === -1) {
index = this.checkerSuites.length;
this.checkerSuites.push({
typeSuiteId,
id: `checkerSuite${index}`,
});
}
return this.checkerSuites[index].id;
}
}
35 changes: 35 additions & 0 deletions lib/macro/compileTypeSuite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Compiler, ICompilerOptions } from "../index";
import { macroError, macroInternalError } from "./errors";

export type ICompilerArgs = [string, ICompilerOptions];

export function compileTypeSuite(args: ICompilerArgs): string {
let compiled: string | undefined;
const [file, options] = args;
const optionsString = JSON.stringify(options);
const context = `compiling file ${file} with options ${optionsString}`;
try {
compiled = Compiler.compile(file, options);
} catch (error) {
throw macroError(`Error ${context}: ${error.name}: ${error.message}`);
}
/*
Here we have `compiled` in "js:cjs" format.
From this string we need to extract the type suite expression that is exported.
The format is expected to have only two statements:
1. a cjs-style import statement which defines `t`, e.g. `const t = require("ts-interface-checker")`
2. beginning on 3rd line, a cjs-style export statement that starts with `module.exports = ` and ends with `;\n`
*/
const exportStatement = compiled.split("\n").slice(2).join("\n");
const prefix = "module.exports = ";
const postfix = ";\n";
if (
!exportStatement.startsWith(prefix) ||
!exportStatement.endsWith(postfix)
) {
throw macroInternalError(
`Unexpected output format from Compiler (${context})`
);
}
return exportStatement.slice(prefix.length, -postfix.length);
}
9 changes: 9 additions & 0 deletions lib/macro/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {MacroError} from "babel-plugin-macros";

export function macroError(message: string): MacroError {
return new MacroError(`ts-interface-builder/macro: ${message}`);
}

export function macroInternalError(message?: string): MacroError {
return macroError(`Internal Error: ${message || "Check stack trace"}`);
}
37 changes: 37 additions & 0 deletions lib/macro/getCallPaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { References } from "babel-plugin-macros";
import { NodePath, types } from "@babel/core"; // typescript types ONLY
import { macroError } from "./errors";

export function getCallPaths({
getTypeSuite = [],
getCheckers = [],
...rest
}: References) {
const restKeys = Object.keys(rest);
if (restKeys.length) {
throw macroError(
`Reference(s) to unknown export(s): ${restKeys.join(", ")}`
);
}
const callPaths = {
getTypeSuite: [] as NodePath<types.CallExpression>[],
getCheckers: [] as NodePath<types.CallExpression>[],
};
getTypeSuite.forEach((path, index) => {
if (!path.parentPath.isCallExpression()) {
throw macroError(
`Reference ${index + 1} to getTypeSuite not used for a call expression`
);
}
callPaths.getTypeSuite.push(path.parentPath);
});
getCheckers.forEach((path, index) => {
if (!path.parentPath.isCallExpression()) {
throw macroError(
`Reference ${index + 1} to getCheckers not used for a call expression`
);
}
callPaths.getCheckers.push(path.parentPath);
});
return callPaths;
}
31 changes: 31 additions & 0 deletions lib/macro/getGetArgValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NodePath, types } from "@babel/core"; // typescript types ONLY
import { macroError, macroInternalError } from "./errors";

export function getGetArgValue(
callPath: NodePath<types.CallExpression>,
callDescription: string
) {
const argPaths = callPath.get("arguments");
if (!Array.isArray(argPaths)) throw macroInternalError();
return (argIndex: number): any => {
const argPath = argPaths[argIndex];
if (!argPath) {
return null;
}
const { confident, value } = argPath.evaluate();
if (!confident) {
/**
* TODO: Could not get following line to work:
* const lineSuffix = argPath.node.loc ? ` on line ${argPath.node.loc.start.line}` : ""
* Line number displayed is for the intermediary js produced by typescript.
* Even with `inputSourceMap: true`, Babel doesn't seem to parse inline sourcemaps in input.
* Maybe babel-plugin-macros doesn't support "input -> TS -> babel -> output" pipeline?
* Or maybe I'm doing that pipeline wrong?
*/
throw macroError(
`Unable to evaluate argument ${argIndex + 1} of ${callDescription}`
);
}
return value;
};
}
16 changes: 16 additions & 0 deletions macro.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ICompilerOptions } from "."
import { ICheckerSuite, ITypeSuite } from "ts-interface-checker"

/**
* Returns a type suite compiled from the given module with the given compiler options
* @param modulePath - Relative path to the target module (defaults to the module in which the function is called)
* @param options - Compiler options
*/
export declare function getTypeSuite (modulePath?: string, options?: ICompilerOptions): ITypeSuite

/**
* Returns a checker suite created from a type suite compiled from the given module with the given compiler options
* @param modulePath - Relative path to the target module (defaults to the module in which the function is called)
* @param options - Compiler options
*/
export declare function getCheckers (modulePath?: string, options?: ICompilerOptions): ICheckerSuite
1 change: 1 addition & 0 deletions macro.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require("./dist/macro.js").macro()
17 changes: 14 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"test": "tsc && mocha 'test/*.ts'",
"test-only": "mocha 'test/*.ts'",
"test": "npm run build && npm run test-only",
"prepack": "npm run test"
},
"keywords": [
Expand All @@ -20,9 +21,13 @@
"type",
"validate",
"validator",
"check"
"check",
"babel-plugin-macros"
],
"author": "Dmitry S, Grist Labs",
"contributors": [
"Matthew Francis Brunetti <[email protected]> (https://github.com/zenflow)"
],
"license": "Apache-2.0",
"repository": {
"type": "git",
Expand All @@ -33,19 +38,25 @@
},
"files": [
"dist",
"bin"
"bin",
"macro.js",
"macro.d.ts"
],
"dependencies": {
"commander": "^2.12.2",
"fs-extra": "^4.0.3",
"typescript": "^3.0.0"
},
"devDependencies": {
"@babel/core": "^7.10.5",
"@types/babel-plugin-macros": "^2.8.2",
"@types/fs-extra": "^4.0.5",
"@types/mocha": "^5.2.7",
"@types/node": "^8.0.57",
"babel-plugin-macros": "^2.8.0",
"fs-extra": "^4.0.3",
"mocha": "^6.2.0",
"ts-interface-checker": "^0.1.12",
"ts-node": "^4.0.1"
}
}
3 changes: 3 additions & 0 deletions test/fixtures/macro-error-compiling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { getCheckers } from "../../macro";

getCheckers("./ignore-index-signature.ts");
4 changes: 4 additions & 0 deletions test/fixtures/macro-error-evaluating-arguments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { join } from "path";
import { getCheckers } from "../../macro";

getCheckers(join(__dirname, "foo.ts"));
5 changes: 5 additions & 0 deletions test/fixtures/macro-error-reference-not-called.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { getCheckers } from "../../macro";

const foo = getCheckers;

foo("foo.ts");
Loading