Skip to content

Commit 561c7d9

Browse files
committed
Lots of improvements
1 parent ed8633d commit 561c7d9

15 files changed

+453
-228
lines changed

lib/macro.ts

Lines changed: 113 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,141 +1,132 @@
1+
// @ts-ignore
12
import * as path from "path";
2-
import { MacroHandler, MacroError, createMacro } from "babel-plugin-macros";
3-
import { Compiler, ICompilerOptions } from "./index";
4-
3+
import { createMacro, MacroHandler } from "babel-plugin-macros";
4+
import { NodePath, types } from "@babel/core"; // typescript types ONLY
5+
import { ICompilerOptions } from "./index";
6+
import { getCallPaths } from "./macro/getCallPaths";
7+
import { RequirementRegistry } from "./macro/RequirementRegistry";
8+
import { getGetArgValue } from "./macro/getGetArgValue";
9+
import { compileTypeSuite, ICompilerArgs } from "./macro/compileTypeSuite";
10+
import { macroInternalError } from "./macro/errors";
11+
12+
const tsInterfaceCheckerIdentifier = "t";
13+
const onceIdentifier = "once";
14+
15+
/**
16+
* This function is called for each file that imports the macro module.
17+
* `params.references` is an object where each key is the name of a variable imported from the macro module,
18+
* and each value is an array of references to that that variable.
19+
* Said references come in the form of Babel `NodePath`s,
20+
* which have AST (Abstract Syntax Tree) data and methods for manipulating it.
21+
* For more info: https://github.com/kentcdodds/babel-plugin-macros/blob/master/other/docs/author.md#function-api
22+
*/
523
const macroHandler: MacroHandler = (params) => {
6-
const callPaths = params.references["makeCheckers"];
7-
8-
// Bail out if no calls in this file
9-
if (!callPaths || !callPaths.length) {
24+
const { references, babel, state } = params;
25+
const callPaths = getCallPaths(references);
26+
const somePath = callPaths.getTypeSuite[0] || callPaths.getCheckers[0];
27+
if (!somePath) {
1028
return;
1129
}
30+
const programPath = somePath.findParent((path) => path.isProgram());
31+
32+
const registry = new RequirementRegistry();
33+
const toReplace = [
34+
...callPaths.getTypeSuite.map((callPath, index) => {
35+
const compilerArgs = getCompilerArgs(callPath, "getTypeSuite", index);
36+
const typeSuiteId = registry.requireTypeSuite(compilerArgs);
37+
return { callPath, id: typeSuiteId };
38+
}),
39+
...callPaths.getCheckers.map((callPath, index) => {
40+
const compilerArgs = getCompilerArgs(callPath, "getCheckers", index);
41+
const checkerSuiteId = registry.requireCheckerSuite(compilerArgs);
42+
return { callPath, id: checkerSuiteId };
43+
}),
44+
];
45+
46+
// Begin mutations
47+
48+
programPath.scope.rename(tsInterfaceCheckerIdentifier);
49+
programPath.scope.rename(onceIdentifier);
50+
toReplace.forEach(({ callPath, id }) => {
51+
scopeRenameRecursive(callPath.scope, id);
52+
});
1253

13-
const {
14-
babel,
15-
state: { filename },
16-
} = params;
17-
18-
// Rename any bindings to `t` in any parent scope of any call
19-
for (const callPath of callPaths) {
20-
let scope = callPath.scope;
21-
while (true) {
22-
if (scope.hasBinding("t")) {
23-
scope.rename("t");
24-
}
25-
if (!scope.parent || scope.parent === scope) {
26-
break;
27-
}
28-
scope = scope.parent;
54+
const toPrepend = `
55+
import * as ${tsInterfaceCheckerIdentifier} from "ts-interface-checker";
56+
function ${onceIdentifier}(fn) {
57+
var result;
58+
return function () {
59+
return result || (result = fn());
60+
};
2961
}
30-
}
62+
${registry.typeSuites
63+
.map(
64+
({ compilerArgs, id }) => `
65+
var ${id} = ${onceIdentifier}(function(){
66+
return ${compileTypeSuite(compilerArgs)}
67+
});
68+
`
69+
)
70+
.join("")}
71+
${registry.checkerSuites
72+
.map(
73+
({ typeSuiteId, id }) => `
74+
var ${id} = ${onceIdentifier}(function(){
75+
return ${tsInterfaceCheckerIdentifier}.createCheckers(${typeSuiteId}());
76+
});
77+
`
78+
)
79+
.join("")}
80+
`;
81+
parseStatements(toPrepend).reverse().forEach(prependProgramStatement);
82+
83+
const { identifier, callExpression } = babel.types;
84+
toReplace.forEach(({ callPath, id }) => {
85+
callPath.replaceWith(callExpression(identifier(id), []));
86+
});
87+
88+
// Done mutations (only helper functions below)
89+
90+
function getCompilerArgs(
91+
callPath: NodePath<types.CallExpression>,
92+
functionName: string,
93+
callIndex: number
94+
): ICompilerArgs {
95+
const callDescription = `${functionName} call ${callIndex + 1}`;
96+
const getArgValue = getGetArgValue(callPath, callDescription);
97+
98+
const basename = getArgValue(0) || path.basename(state.filename);
99+
const file = path.resolve(state.filename, "..", basename);
31100

32-
// Add `import * as t from 'ts-interface-checker'` statement
33-
const firstStatementPath = callPaths[0]
34-
.findParent((path) => path.isProgram())
35-
.get("body.0") as babel.NodePath;
36-
firstStatementPath.insertBefore(
37-
babel.types.importDeclaration(
38-
[babel.types.importNamespaceSpecifier(babel.types.identifier("t"))],
39-
babel.types.stringLiteral("ts-interface-checker")
40-
)
41-
);
42-
43-
// Get the user config passed to us by babel-plugin-macros, for use as default options
44-
// Note: `config` property is missing in `babelPluginMacros.MacroParams` type definition
45-
const defaultOptions = ((params as any).config || {}) as ICompilerOptions;
46-
47-
callPaths.forEach(({ parentPath }, callIndex) => {
48-
// Determine compiler parameters
49-
const getArgValue = getGetArgValue(callIndex, parentPath);
50-
const file = path.resolve(
51-
filename,
52-
"..",
53-
getArgValue(0) || path.basename(filename)
54-
);
101+
// Get the user config passed to us by babel-plugin-macros, for use as default options
102+
// Note: `config` property is missing in `babelPluginMacros.MacroParams` type definition
103+
const defaultOptions = (params as any).config;
55104
const options = {
56-
...defaultOptions,
105+
...(defaultOptions || {}),
57106
...(getArgValue(1) || {}),
58107
format: "js:cjs",
59-
};
60-
61-
// Compile
62-
let compiled: string | undefined;
63-
try {
64-
compiled = Compiler.compile(file, options);
65-
} catch (error) {
66-
throw macroError(callIndex, `${error.name}: ${error.message}`);
67-
}
108+
} as ICompilerOptions;
68109

69-
// Get the compiled type suite as AST node
70-
const parsed = parse(compiled)!;
71-
if (parsed.type !== "File") throw macroInternalError();
72-
if (parsed.program.body[1].type !== "ExpressionStatement")
73-
throw macroInternalError();
74-
if (parsed.program.body[1].expression.type !== "AssignmentExpression")
75-
throw macroInternalError();
76-
const typeSuiteNode = parsed.program.body[1].expression.right;
77-
78-
// Build checker suite expression using type suite
79-
const checkerSuiteNode = babel.types.callExpression(
80-
babel.types.memberExpression(
81-
babel.types.identifier("t"),
82-
babel.types.identifier("createCheckers")
83-
),
84-
[typeSuiteNode]
85-
);
86-
87-
// Replace call with checker suite expression
88-
parentPath.replaceWith(checkerSuiteNode);
89-
});
110+
return [file, options];
111+
}
90112

91-
function parse(code: string) {
92-
return babel.parse(code, { configFile: false });
113+
function scopeRenameRecursive(scope: NodePath["scope"], oldName: string) {
114+
scope.rename(oldName);
115+
if (scope.parent) {
116+
scopeRenameRecursive(scope.parent, oldName);
117+
}
93118
}
94119

95-
function getGetArgValue(
96-
callIndex: number,
97-
callExpressionPath: babel.NodePath
98-
) {
99-
const argPaths = callExpressionPath.get("arguments");
100-
if (!Array.isArray(argPaths)) throw macroInternalError();
101-
return (argIndex: number): any => {
102-
const argPath = argPaths[argIndex];
103-
if (!argPath) {
104-
return null;
105-
}
106-
const { confident, value } = argPath.evaluate();
107-
if (!confident) {
108-
/**
109-
* TODO: Could not get following line to work:
110-
* const lineSuffix = argPath.node.loc ? ` on line ${argPath.node.loc.start.line}` : ""
111-
* Line number displayed is for the intermediary js produced by typescript.
112-
* Even with `inputSourceMap: true`, Babel doesn't seem to parse inline sourcemaps in input.
113-
* Maybe babel-plugin-macros doesn't support "input -> TS -> babel -> output" pipeline?
114-
* Or maybe I'm doing that pipeline wrong?
115-
*/
116-
throw macroError(
117-
callIndex,
118-
`Unable to evaluate argument ${argIndex + 1}`
119-
);
120-
}
121-
return value;
122-
};
120+
function parseStatements(code: string) {
121+
const parsed = babel.parse(code, { configFile: false });
122+
if (!parsed || parsed.type !== "File") throw macroInternalError();
123+
return parsed.program.body;
123124
}
124-
};
125125

126-
function macroError(callIndex: number, message: string): MacroError {
127-
return new MacroError(
128-
`ts-interface-builder/macro: makeCheckers call ${callIndex + 1}: ${message}`
129-
);
130-
}
131-
132-
function macroInternalError(message?: string): MacroError {
133-
return new MacroError(
134-
`ts-interface-builder/macro: Internal Error: ${
135-
message || "Check stack trace"
136-
}`
137-
);
138-
}
126+
function prependProgramStatement(statement: types.Statement) {
127+
(programPath.get("body.0") as NodePath).insertBefore(statement);
128+
}
129+
};
139130

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

lib/macro/RequirementRegistry.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// @ts-ignore
2+
import { isDeepStrictEqual } from "util";
3+
import { ICompilerArgs } from "./compileTypeSuite";
4+
5+
export interface IRequiredTypeSuite {
6+
compilerArgs: ICompilerArgs;
7+
id: string;
8+
}
9+
10+
export interface IRequiredCheckerSuite {
11+
typeSuiteId: string;
12+
id: string;
13+
}
14+
15+
export class RequirementRegistry {
16+
public typeSuites: IRequiredTypeSuite[] = [];
17+
public checkerSuites: IRequiredCheckerSuite[] = [];
18+
19+
public requireTypeSuite(compilerArgs: ICompilerArgs): string {
20+
let index = this.typeSuites.findIndex((typeSuite) =>
21+
isDeepStrictEqual(typeSuite.compilerArgs, compilerArgs)
22+
);
23+
if (index === -1) {
24+
index = this.typeSuites.length;
25+
this.typeSuites.push({
26+
compilerArgs,
27+
id: `typeSuite${index}`,
28+
});
29+
}
30+
return this.typeSuites[index].id;
31+
}
32+
33+
public requireCheckerSuite(compilerArgs: ICompilerArgs): string {
34+
const typeSuiteId = this.requireTypeSuite(compilerArgs);
35+
let index = this.checkerSuites.findIndex(
36+
(checkerSuite) => checkerSuite.typeSuiteId === typeSuiteId
37+
);
38+
if (index === -1) {
39+
index = this.checkerSuites.length;
40+
this.checkerSuites.push({
41+
typeSuiteId,
42+
id: `checkerSuite${index}`,
43+
});
44+
}
45+
return this.checkerSuites[index].id;
46+
}
47+
}

lib/macro/compileTypeSuite.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Compiler, ICompilerOptions } from "../index";
2+
import { macroError, macroInternalError } from "./errors";
3+
4+
export type ICompilerArgs = [string, ICompilerOptions];
5+
6+
export function compileTypeSuite(args: ICompilerArgs): string {
7+
let compiled: string | undefined;
8+
const [file, options] = args;
9+
const optionsString = JSON.stringify(options);
10+
const context = `compiling file ${file} with options ${optionsString}`;
11+
try {
12+
compiled = Compiler.compile(file, options);
13+
} catch (error) {
14+
throw macroError(`Error ${context}: ${error.name}: ${error.message}`);
15+
}
16+
const exportStatement = compiled.split("\n").slice(2).join("\n");
17+
const prefix = "module.exports = ";
18+
if (exportStatement.substr(0, prefix.length) !== prefix) {
19+
throw macroInternalError(
20+
`Unexpected output format from Compiler (${context})`
21+
);
22+
}
23+
return exportStatement.substr(prefix.length);
24+
}

lib/macro/errors.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {MacroError} from "babel-plugin-macros";
2+
3+
export function macroError(message: string): MacroError {
4+
return new MacroError(`ts-interface-builder/macro: ${message}`);
5+
}
6+
7+
export function macroInternalError(message?: string): MacroError {
8+
return macroError(`Internal Error: ${message || "Check stack trace"}`);
9+
}

lib/macro/getCallPaths.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { References } from "babel-plugin-macros";
2+
import { NodePath, types } from "@babel/core"; // typescript types ONLY
3+
import { macroError } from "./errors";
4+
5+
export function getCallPaths({
6+
getTypeSuite = [],
7+
getCheckers = [],
8+
...rest
9+
}: References) {
10+
const restKeys = Object.keys(rest);
11+
if (restKeys.length) {
12+
throw macroError(
13+
`Reference(s) to unknown export(s): ${restKeys.join(", ")}`
14+
);
15+
}
16+
const callPaths = {
17+
getTypeSuite: [] as NodePath<types.CallExpression>[],
18+
getCheckers: [] as NodePath<types.CallExpression>[],
19+
};
20+
getTypeSuite.forEach((path, index) => {
21+
if (!path.parentPath.isCallExpression()) {
22+
throw macroError(
23+
`Reference ${index + 1} to getTypeSuite not used for a call expression`
24+
);
25+
}
26+
callPaths.getTypeSuite.push(path.parentPath);
27+
});
28+
getCheckers.forEach((path, index) => {
29+
if (!path.parentPath.isCallExpression()) {
30+
throw macroError(
31+
`Reference ${index + 1} to getCheckers not used for a call expression`
32+
);
33+
}
34+
callPaths.getCheckers.push(path.parentPath);
35+
});
36+
return callPaths;
37+
}

0 commit comments

Comments
 (0)