diff --git a/lib/macro.ts b/lib/macro.ts new file mode 100644 index 0000000..0a44660 --- /dev/null +++ b/lib/macro.ts @@ -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, + 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); diff --git a/lib/macro/RequirementRegistry.ts b/lib/macro/RequirementRegistry.ts new file mode 100644 index 0000000..a9e8bc2 --- /dev/null +++ b/lib/macro/RequirementRegistry.ts @@ -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; + } +} diff --git a/lib/macro/compileTypeSuite.ts b/lib/macro/compileTypeSuite.ts new file mode 100644 index 0000000..1b52c83 --- /dev/null +++ b/lib/macro/compileTypeSuite.ts @@ -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); +} diff --git a/lib/macro/errors.ts b/lib/macro/errors.ts new file mode 100644 index 0000000..0fd370f --- /dev/null +++ b/lib/macro/errors.ts @@ -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"}`); +} diff --git a/lib/macro/getCallPaths.ts b/lib/macro/getCallPaths.ts new file mode 100644 index 0000000..c3e9e0d --- /dev/null +++ b/lib/macro/getCallPaths.ts @@ -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[], + getCheckers: [] as NodePath[], + }; + 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; +} diff --git a/lib/macro/getGetArgValue.ts b/lib/macro/getGetArgValue.ts new file mode 100644 index 0000000..c614a57 --- /dev/null +++ b/lib/macro/getGetArgValue.ts @@ -0,0 +1,31 @@ +import { NodePath, types } from "@babel/core"; // typescript types ONLY +import { macroError, macroInternalError } from "./errors"; + +export function getGetArgValue( + callPath: NodePath, + 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; + }; +} diff --git a/macro.d.ts b/macro.d.ts new file mode 100644 index 0000000..64e330b --- /dev/null +++ b/macro.d.ts @@ -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 diff --git a/macro.js b/macro.js new file mode 100644 index 0000000..5a8393d --- /dev/null +++ b/macro.js @@ -0,0 +1 @@ +module.exports = require("./dist/macro.js").macro() diff --git a/package.json b/package.json index acfa114..c500e0d 100644 --- a/package.json +++ b/package.json @@ -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": [ @@ -20,9 +21,13 @@ "type", "validate", "validator", - "check" + "check", + "babel-plugin-macros" ], "author": "Dmitry S, Grist Labs", + "contributors": [ + "Matthew Francis Brunetti (https://github.com/zenflow)" + ], "license": "Apache-2.0", "repository": { "type": "git", @@ -33,7 +38,9 @@ }, "files": [ "dist", - "bin" + "bin", + "macro.js", + "macro.d.ts" ], "dependencies": { "commander": "^2.12.2", @@ -41,11 +48,15 @@ "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" } } diff --git a/test/fixtures/macro-error-compiling.ts b/test/fixtures/macro-error-compiling.ts new file mode 100644 index 0000000..1651794 --- /dev/null +++ b/test/fixtures/macro-error-compiling.ts @@ -0,0 +1,3 @@ +import { getCheckers } from "../../macro"; + +getCheckers("./ignore-index-signature.ts"); diff --git a/test/fixtures/macro-error-evaluating-arguments.ts b/test/fixtures/macro-error-evaluating-arguments.ts new file mode 100644 index 0000000..62a679b --- /dev/null +++ b/test/fixtures/macro-error-evaluating-arguments.ts @@ -0,0 +1,4 @@ +import { join } from "path"; +import { getCheckers } from "../../macro"; + +getCheckers(join(__dirname, "foo.ts")); diff --git a/test/fixtures/macro-error-reference-not-called.ts b/test/fixtures/macro-error-reference-not-called.ts new file mode 100644 index 0000000..d0c1559 --- /dev/null +++ b/test/fixtures/macro-error-reference-not-called.ts @@ -0,0 +1,5 @@ +import { getCheckers } from "../../macro"; + +const foo = getCheckers; + +foo("foo.ts"); diff --git a/test/fixtures/macro-locals.js b/test/fixtures/macro-locals.js new file mode 100644 index 0000000..b3f17ad --- /dev/null +++ b/test/fixtures/macro-locals.js @@ -0,0 +1,41 @@ +import * as t from "ts-interface-checker"; + +function once(fn) { + var result; + return function () { + return result || (result = fn()); + }; +} + +var typeSuite0 = once(function () { + return { + LocalInterface: t.iface([], { + "foo": "number" + }) + }; +}); +var checkerSuite0 = once(function () { + return t.createCheckers(typeSuite0()); +}); +var _t = null; +export { _t as t }; +var _once = null; +export { _once as once }; + +var _typeSuite = typeSuite0(); + +export { _typeSuite as typeSuite0 }; +export function getTypeSuite0() { + var _typeSuite2 = typeSuite0(); + + return _typeSuite2; +} + +var _checkerSuite = checkerSuite0(); + +export { _checkerSuite as checkerSuite0 }; +export function getCheckerSuite0() { + var _checkerSuite2 = checkerSuite0(); + + return _checkerSuite2; +} \ No newline at end of file diff --git a/test/fixtures/macro-locals.ts b/test/fixtures/macro-locals.ts new file mode 100644 index 0000000..45b3bc5 --- /dev/null +++ b/test/fixtures/macro-locals.ts @@ -0,0 +1,24 @@ +import { getTypeSuite, getCheckers } from "../../macro"; + +export const t = null; +export const once = null; + +// @ts-ignore-rule +interface LocalInterface { + // does not need to be exported + foo: number; +} + +export const typeSuite0 = getTypeSuite(undefined, { inlineImports: false }); + +export function getTypeSuite0() { + const typeSuite0 = getTypeSuite(undefined, { inlineImports: false }); + return typeSuite0; +} + +export const checkerSuite0 = getCheckers(undefined, { inlineImports: false }); + +export function getCheckerSuite0() { + const checkerSuite0 = getCheckers(undefined, { inlineImports: false }); + return checkerSuite0; +} diff --git a/test/fixtures/macro-options.js b/test/fixtures/macro-options.js new file mode 100644 index 0000000..77fb5cc --- /dev/null +++ b/test/fixtures/macro-options.js @@ -0,0 +1,44 @@ +import * as t from "ts-interface-checker"; + +function once(fn) { + var result; + return function () { + return result || (result = fn()); + }; +} + +var typeSuite0 = once(function () { + return { + TypeA: t.iface([], {}), + TypeB: t.iface([], {}), + TypeC: t.iface([], {}), + TypeD: t.iface([], {}), + TypeAll: t.iface([], { + "a": "TypeA", + "b": "TypeB", + "c": "TypeC", + "d": "TypeD" + }) + }; +}); +var typeSuite1 = once(function () { + return { + TypeAll: t.iface([], { + "a": "TypeA", + "b": "TypeB", + "c": "TypeC", + "d": "TypeD" + }) + }; +}); +var checkerSuite0 = once(function () { + return t.createCheckers(typeSuite0()); +}); +var checkerSuite1 = once(function () { + return t.createCheckers(typeSuite1()); +}); +// Note: default options defined in babel plugin options in ../test_macro.ts +var dir = "."; +var file = dir + "/imports-parent.ts"; +export var checkersUsingDefaultOptions = checkerSuite0(); +export var checkersWithInlineOptions = checkerSuite1(); \ No newline at end of file diff --git a/test/fixtures/macro-options.ts b/test/fixtures/macro-options.ts new file mode 100644 index 0000000..9f99615 --- /dev/null +++ b/test/fixtures/macro-options.ts @@ -0,0 +1,11 @@ +import { getCheckers } from "../../macro"; +// Note: default options defined in babel plugin options in ../test_macro.ts + +const dir = "."; +const file = `${dir}/imports-parent.ts`; + +export const checkersUsingDefaultOptions = getCheckers(file); + +export const checkersWithInlineOptions = getCheckers(file, { + inlineImports: false, +}); diff --git a/test/test_macro.ts b/test/test_macro.ts new file mode 100644 index 0000000..84ff414 --- /dev/null +++ b/test/test_macro.ts @@ -0,0 +1,96 @@ +const UPDATE_SNAPSHOTS = false; + +import * as assert from "assert"; +import { readFile, writeFile } from "fs-extra"; +import { join } from "path"; +import * as ts from "typescript"; +import * as babel from "@babel/core"; +import * as macroPlugin from "babel-plugin-macros"; + +const fixtures = join(__dirname, "fixtures"); + +async function snapshot(output: string, snapshotFile: string) { + if (UPDATE_SNAPSHOTS) { + await writeFile(join(fixtures, snapshotFile), output); + } else { + const snapshot = ( + await readFile(join(fixtures, snapshotFile), { encoding: "utf8" }) + ).trim(); + assert.equal(output, snapshot); + } +} + +describe("ts-interface-builder/macro", () => { + it("locals", async function () { + this.timeout(5000); + const file = join(fixtures, "macro-locals.ts"); + const output = babelCompile( + tsCompile(await readFile(file, { encoding: "utf8" })), + file + ); + await snapshot(output, "macro-locals.js"); + }); + it("options", async function () { + this.timeout(5000); + const file = join(fixtures, "macro-options.ts"); + const output = babelCompile( + tsCompile(await readFile(file, { encoding: "utf8" })), + file + ); + await snapshot(output, "macro-options.js"); + }); + it("error reference not called", async function () { + const file = join(fixtures, "macro-error-reference-not-called.ts"); + const tsOutput = tsCompile(await readFile(file, { encoding: "utf8" })); + assert.throws(() => babelCompile(tsOutput, file), { + name: "MacroError", + message: `${file}: ts-interface-builder/macro: Reference 1 to getCheckers not used for a call expression`, + } as any); + }); + it("error evaluating arguments", async function () { + const file = join(fixtures, "macro-error-evaluating-arguments.ts"); + const tsOutput = tsCompile(await readFile(file, { encoding: "utf8" })); + assert.throws(() => babelCompile(tsOutput, file), { + name: "MacroError", + message: `${file}: ts-interface-builder/macro: Unable to evaluate argument 1 of getCheckers call 1`, + } as any); + }); + it("error compiling", async function () { + this.timeout(5000); + const file = join(fixtures, "macro-error-compiling.ts"); + const tsOutput = tsCompile(await readFile(file, { encoding: "utf8" })); + const errorCompilingFile = join(fixtures, "ignore-index-signature.ts"); + assert.throws(() => babelCompile(tsOutput, file), { + name: "MacroError", + message: `${file}: ts-interface-builder/macro: Error compiling file ${errorCompilingFile} with options {"inlineImports":true,"format":"js:cjs"}: Error: Node IndexSignature not supported by ts-interface-builder: [extra: string]: any;`, + } as any); + }); +}); + +function tsCompile(code: string): string { + const compilerOptions: ts.CompilerOptions = { + module: ts.ModuleKind.ES2015, + inlineSourceMap: true, + }; + const { outputText, diagnostics } = ts.transpileModule(code, { + compilerOptions, + }); + if (diagnostics && diagnostics.length) { + throw new Error( + "Got diagnostic errors: " + JSON.stringify(diagnostics, null, 2) + ); + } + return outputText; +} + +function babelCompile(code: string, filename: string): string { + return babel.transform(code, { + babelrc: false, + plugins: [ + [macroPlugin, { "ts-interface-builder": { inlineImports: true } }], + ], + filename, + // Note: Type definitions for @babel/core's TransformOptions.inputSourceMap is wrong; see https://babeljs.io/docs/en/options#source-map-options + inputSourceMap: true as any, + })!.code!; +} diff --git a/tsconfig.json b/tsconfig.json index 20c7ffa..ed53cb7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "declaration": true }, "files": [ - "lib/index.ts" + "lib/index.ts", + "lib/macro.ts" ] }