Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
133 changes: 133 additions & 0 deletions lib/macro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// @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 function 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
*/
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;
}
}
24 changes: 24 additions & 0 deletions lib/macro/compileTypeSuite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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}`);
}
const exportStatement = compiled.split("\n").slice(2).join("\n");
const prefix = "module.exports = ";
if (exportStatement.substr(0, prefix.length) !== prefix) {
throw macroInternalError(
`Unexpected output format from Compiler (${context})`
);
}
return exportStatement.substr(prefix.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;
};
}
4 changes: 4 additions & 0 deletions macro.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { ICompilerOptions } from "."
import { ICheckerSuite, ITypeSuite } from "ts-interface-checker"
export declare function getCheckers (modulePath?: string, options?: ICompilerOptions): ICheckerSuite
export declare function getTypeSuite (modulePath?: string, options?: ICompilerOptions): ITypeSuite
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