@blainehansen/macro-ts
Version:
An ergonomic typescript compiler that enables typesafe syntactic macros.
495 lines (493 loc) • 23.7 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.main = exports.register = exports.compile = exports.produceTransformer = exports.defaultMacrosEntry = exports.configLocation = exports.reportDiagnostics = void 0;
const fs = require("fs");
const arg = require("arg");
const nodepath = require("path");
const ts = require("typescript");
const toml = require("@iarna/toml");
const monads_1 = require("@ts-std/monads");
const fast_glob_1 = require("fast-glob");
const sourceMapSupport = require("source-map-support");
const utils_1 = require("./utils");
const message_1 = require("./message");
const transformer_1 = require("../lib/transformer");
const config_1 = require("../lib/config");
const utils_2 = require("../lib/utils");
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
function glob(patterns, ignore) {
return (0, fast_glob_1.sync)(patterns, { dot: true, ignore });
}
function undefReadFile(path) {
try {
return fs.readFileSync(path, "utf8");
}
catch {
return undefined;
}
}
function isNodeExported(node) {
return ((ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) !== 0
|| (!!node.parent && node.parent.kind === ts.SyntaxKind.SourceFile));
}
function createInterceptingHost(transformer, compilerOptions) {
const defaultCompilerHost = ts.createCompilerHost(compilerOptions);
return {
...defaultCompilerHost,
getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile) {
const transformedSource = transformer.get(fileName);
return transformedSource !== undefined
? ts.createSourceFile(fileName, transformedSource, languageVersion)
: defaultCompilerHost.getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile);
},
fileExists(fileName) {
return transformer.has(fileName) || defaultCompilerHost.fileExists(fileName);
}
};
}
const alwaysOptions = {
strict: true,
moduleResolution: ts.ModuleResolutionKind.NodeJs,
allowSyntheticDefaultImports: false,
esModuleInterop: false,
resolveJsonModule: false
};
const nonEmitOptions = {
...alwaysOptions,
noEmit: true,
declaration: false,
sourceMap: false
};
const alwaysEmitOptions = {
noEmitOnError: true,
declaration: true,
sourceMap: true,
rootDir: "."
};
function makeDevModeOptions(devMode) {
const releaseMode = !devMode;
return { noUnusedParameters: releaseMode, noUnusedLocals: releaseMode, preserveConstEnums: !releaseMode, removeComments: releaseMode };
}
const chalk = require("chalk");
const filesColor = chalk.cyan;
const commandsColor = chalk.bold.magenta;
const environmentColor = chalk.bold.blue;
const progressColor = chalk.green;
const successColor = chalk.bold.green;
const errorColor = chalk.bold.red;
const warnColor = chalk.yellow;
function formatStringList(list) {
return list.map(s => filesColor(s)).join(", ");
}
function reportDiagnostics(workingDir, diagnostics) {
const diagnosticText = ts.formatDiagnosticsWithColorAndContext(diagnostics, {
getNewLine: () => ts.sys.newLine,
getCurrentDirectory: () => workingDir,
getCanonicalFileName: ts.sys.useCaseSensitiveFileNames ? x => x : x => x.toLowerCase()
});
return (0, utils_1.fatal)("\n" + diagnosticText);
}
exports.reportDiagnostics = reportDiagnostics;
exports.configLocation = "./.macro-ts.toml";
exports.defaultMacrosEntry = "./.macros.ts";
const defaultEnvironment = { platform: "anywhere", target: ts.ScriptTarget.Latest };
function attemptGetConfig(workingDir) {
const configPath = nodepath.join(workingDir, exports.configLocation);
const configText = undefReadFile(configPath);
if (configText === undefined)
return undefined;
const configResult = monads_1.Result.attempt(() => toml.parse(configText))
.change_err(e => e.message)
.try_change(obj => config_1.MacroTsConfig.decode(obj));
if (configResult.is_err())
(0, utils_1.fatal)(`${errorColor(`Invalid config in`)} ${filesColor(exports.configLocation)}\n${configResult.error}`);
return configResult.value;
}
function getRunConfig(entryFile, workingDir) {
const config = attemptGetConfig(workingDir);
if (config === undefined)
return { macrosEntry: exports.defaultMacrosEntry, configDevMode: false, target: ts.ScriptTarget.Latest };
const { environment: { target } = defaultEnvironment, dev = false, } = entryFile ? config_1.MacroTsConfig.selectPackageForPath(entryFile, config) || {} : {};
return {
macrosEntry: (config.macros || exports.defaultMacrosEntry),
configDevMode: dev,
target: target
};
}
function getCompileConfig(entryGlob, workingDir) {
const config = attemptGetConfig(workingDir);
if (entryGlob === undefined) {
if (config === undefined)
(0, utils_1.fatal)(errorColor(`if an entryGlob isn't provided, a `) + filesColor(".macro-ts.toml") + errorColor(` config file must be present`));
const outputs = {};
for (const { location, entry, exclude, environment, dev } of Object.values(config.packages)) {
const excludes = exclude ? utils_2.NonEmpty.flattenInto(exclude) : [];
const entries = glob(utils_2.NonEmpty.flattenInto(entry).map(e => nodepath.join(location, e)), excludes);
const environments = utils_2.NonEmpty.flattenInto(environment);
for (const environment of environments) {
const outDir = config_1.CompilationEnvironment.key(environment);
const currentOutput = outputs[outDir];
if (currentOutput) {
currentOutput.includedLocations.push(location);
currentOutput.entryFiles = (0, utils_2.setExtend)(currentOutput.entryFiles, entries);
}
else
outputs[outDir] = {
includedLocations: [location],
entryFiles: new Set(entries),
outDir,
dev,
environment,
...config_1.CompilationEnvironment.options(environment)
};
}
}
return {
macrosEntry: config.macros || exports.defaultMacrosEntry,
configDevMode: false,
compileArgsList: Object.values(outputs)
};
}
const { location = undefined, environment = defaultEnvironment, dev = false, } = config ? config_1.MacroTsConfig.selectPackageForPath(entryGlob, config) || {} : {};
const macrosEntry = config ? config.macros || exports.defaultMacrosEntry : exports.defaultMacrosEntry;
const compileArgsList = [{
includedLocations: location ? [location] : [],
entryFiles: new Set(glob([entryGlob], [])),
outDir: ".",
dev,
environment,
...config_1.CompilationEnvironment.options(environment)
}];
return { macrosEntry, configDevMode: dev, compileArgsList };
}
function produceTransformer(macrosEntry, workingDir) {
const macrosContents = undefReadFile(macrosEntry);
const macrosEntryFilePortion = filesColor(macrosEntry);
if (macrosContents === undefined) {
console.log(warnColor(`unable to read file at ${macrosEntryFilePortion}, proceeding without macros`));
return undefined;
}
console.log(progressColor(`found macros file at ${macrosEntryFilePortion}`));
const macrosOptions = {
...alwaysOptions,
noEmit: false,
noEmitOnError: true,
declaration: false,
sourceMap: false,
target: ts.ScriptTarget.Latest,
module: ts.ModuleKind.CommonJS,
outDir: "./.macro-ts/macros"
};
const macrosSourceFile = ts.createSourceFile(macrosEntry, macrosContents, ts.ScriptTarget.Latest);
let foundMacros = false;
const { transformed: [newMacrosSourceFile] } = ts.transform(macrosSourceFile, [() => sourceFile => {
const finalStatements = [ts.createImportDeclaration(undefined, undefined, ts.createImportClause(undefined, ts.createNamedImports([
ts.createImportSpecifier(ts.createIdentifier("Dict"), ts.createIdentifier("___Dict")),
ts.createImportSpecifier(ts.createIdentifier("Macro"), ts.createIdentifier("___Macro")),
])), ts.createStringLiteral("@blainehansen/macro-ts"))];
for (const statement of sourceFile.statements) {
if (foundMacros || !(ts.isVariableStatement(statement)
&& isNodeExported(statement)
&& statement.declarationList.declarations.length === 1
&& ts.isIdentifier(statement.declarationList.declarations[0].name)
&& statement.declarationList.declarations[0].name.text === "macros")) {
finalStatements.push(statement);
continue;
}
foundMacros = true;
finalStatements.push(ts.updateVariableStatement(statement, statement.modifiers, ts.updateVariableDeclarationList(statement.declarationList, [ts.updateVariableDeclaration(statement.declarationList.declarations[0], statement.declarationList.declarations[0].name, ts.createTypeReferenceNode(ts.createIdentifier("___Dict"), [
ts.createTypeReferenceNode(ts.createIdentifier("___Macro"), undefined),
]), statement.declarationList.declarations[0].initializer)])));
}
return ts.updateSourceFileNode(sourceFile, finalStatements);
}]);
if (!foundMacros)
(0, utils_1.fatal)(errorColor(`your macros file at ${macrosEntryFilePortion} didn't export a "macros" identifier`));
function dirMaker(sourceFileName) {
const currentDir = nodepath.relative(workingDir, nodepath.dirname(sourceFileName));
const currentFile = nodepath.basename(sourceFileName);
return { currentDir, currentFile };
}
const dummyTransformer = new transformer_1.Transformer(undefined, workingDir, () => { }, undefReadFile, nodepath.join, dirMaker);
dummyTransformer.transformSourceFile(newMacrosSourceFile);
const macrosCompilerHost = createInterceptingHost(dummyTransformer, macrosOptions);
const macrosProgram = ts.createProgram([macrosEntry], macrosOptions, macrosCompilerHost);
fs.rmSync(macrosOptions.outDir, { recursive: true, force: true });
const emitResult = macrosProgram.emit();
const wereErrors = emitResult.emitSkipped;
if (wereErrors) {
console.error(errorColor(`while compiling macros, type errors were encountered`));
reportDiagnostics(workingDir, emitResult.diagnostics);
}
console.log(progressColor(`macros compiled successfully`));
const macrosPath = nodepath.join(workingDir, "./.macro-ts/macros/", nodepath.basename(macrosEntry, ".ts"));
const macros = require(macrosPath).macros;
if (Object.keys(macros).length === 0)
return undefined;
return new transformer_1.Transformer(macros, workingDir, () => { }, undefReadFile, nodepath.join, dirMaker);
}
exports.produceTransformer = produceTransformer;
function compile(entryGlob, devMode, shouldEmit) {
const workingDir = process.cwd();
const { macrosEntry, configDevMode, compileArgsList } = getCompileConfig(entryGlob, workingDir);
const transformer = produceTransformer(macrosEntry, workingDir);
const emitDirectory = "./.macro-ts/dist";
if (shouldEmit)
fs.rmSync(emitDirectory, { recursive: true, force: true });
if (entryGlob !== undefined)
console.log("entry glob is: " + filesColor(entryGlob));
if (devMode)
console.log(warnColor("dev mode enabled"));
console.log("");
for (const { includedLocations, entryFiles, outDir, environment: { platform, target }, dev, module, lib, types } of compileArgsList) {
const entries = [...entryFiles];
const actualOutDir = nodepath.join(emitDirectory, outDir);
const commandPortion = commandsColor(shouldEmit ? "building" : "checking");
const environmentPortion = `platform ${environmentColor(platform)} and target ${environmentColor(ts.ScriptTarget[target])}`;
const emitPortion = shouldEmit ? ` into ${filesColor(actualOutDir)}` : "";
const locationsPortion = includedLocations.length !== 0 ? formatStringList(includedLocations) : warnColor("no locations");
if (entryFiles.size === 0) {
console.warn(`${warnColor(`no files were matched`)} while ${commandPortion} ${locationsPortion} with ${environmentPortion}${emitPortion}`);
continue;
}
console.log(`${commandPortion} ${locationsPortion} with ${environmentPortion}${emitPortion}`);
console.log(`includes these files: \n${formatStringList(entries)}`);
const compileOptions = {
...alwaysOptions,
...makeDevModeOptions(dev || devMode || configDevMode),
target,
module,
lib,
types,
...(shouldEmit
? { ...alwaysEmitOptions, outDir: actualOutDir }
: nonEmitOptions)
};
const finalProgram = transformer
? (0, utils_2.exec)(() => {
transformer.reset();
const initialProgram = ts.createProgram(entries, nonEmitOptions);
for (const sourceFile of initialProgram.getSourceFiles()) {
if (sourceFile.isDeclarationFile)
continue;
transformer.transformSourceFile(sourceFile);
}
const capturingCompilerHost = createInterceptingHost(transformer, compileOptions);
const program = ts.createProgram(entries, compileOptions, capturingCompilerHost);
(0, message_1.assertSuccess)(transformer);
return program;
})
: ts.createProgram(entries, compileOptions);
const diagnostics = shouldEmit
? finalProgram.emit().diagnostics
: ts.getPreEmitDiagnostics(finalProgram);
if (diagnostics.length)
reportDiagnostics(workingDir, diagnostics);
else
console.log(successColor("no type errors\n"));
}
}
exports.compile = compile;
function check(entryGlob, devMode) {
compile(entryGlob, devMode, false);
}
function build(devMode) {
compile(undefined, devMode, true);
}
const Module = require("module");
function run(entryFile, runArgs, devMode) {
const workingDir = process.cwd();
register(entryFile, workingDir, devMode);
process.argv = ["node", nodepath.join(workingDir, entryFile), ...runArgs];
console.log(`${commandsColor("running")} ${filesColor(`${entryFile} ${runArgs.join(" ")}`)}`);
Module.runMain();
}
function register(entryFile, workingDir, devMode) {
const { macrosEntry, configDevMode, target } = getRunConfig(entryFile, workingDir);
const transformer = produceTransformer(macrosEntry, workingDir);
const runOptions = {
...alwaysOptions,
...makeDevModeOptions(devMode || configDevMode),
noEmit: false,
noEmitOnError: true,
sourceMap: true,
inlineSources: true,
target,
module: ts.ModuleKind.CommonJS
};
const fileVersions = new Map(entryFile ? [[entryFile, 0]] : []);
const fileContents = new Map();
function updateFiles(contents, fileName) {
const previousVersion = fileVersions.get(fileName) || 0;
const previousContents = fileContents.get(fileName);
if (contents === previousContents)
return;
fileVersions.set(fileName, previousVersion + 1);
fileContents.set(fileName, contents);
projectVersion++;
}
let projectVersion = 1;
const scriptSnapshotCache = new Map();
const cachedFileExists = (0, utils_2.cachedLookup)(ts.sys.fileExists);
const registry = ts.createDocumentRegistry(ts.sys.useCaseSensitiveFileNames, workingDir);
const service = ts.createLanguageService({
getProjectVersion: () => String(projectVersion),
getScriptFileNames: () => Array.from(fileVersions.keys()),
getScriptVersion(fileName) {
const version = fileVersions.get(fileName);
return version ? version.toString() : "";
},
readFile: (0, utils_2.cachedLookup)(ts.sys.readFile),
fileExists(fileName) {
return (transformer ? transformer.has(fileName) : undefined) || cachedFileExists(fileName);
},
getScriptSnapshot(fileName) {
const cachedScriptSnapshot = scriptSnapshotCache.get(fileName);
if (cachedScriptSnapshot !== undefined)
return cachedScriptSnapshot;
const fileContents = (transformer ? transformer.get(fileName) : undefined) || undefReadFile(fileName);
if (fileContents === undefined)
return undefined;
const finalContents = transformer && !fileName.includes("node_modules")
? (0, utils_2.exec)(() => {
const source = transformer.transformSource(fileName, fileContents);
(0, message_1.assertSuccess)(transformer);
return source;
})
: fileContents;
const snapshot = ts.ScriptSnapshot.fromString(finalContents);
scriptSnapshotCache.set(fileName, snapshot);
return snapshot;
},
getDirectories: (0, utils_2.cachedLookup)(ts.sys.getDirectories),
directoryExists: (0, utils_2.cachedLookup)(ts.sys.directoryExists),
getNewLine: () => ts.sys.newLine,
useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
getCurrentDirectory: () => workingDir,
getCompilationSettings: () => runOptions,
getDefaultLibFileName: () => ts.getDefaultLibFilePath(runOptions)
}, registry);
const emitCache = new Map();
sourceMapSupport.install({
environment: "node",
retrieveFile(path) {
return emitCache.get(path) || "";
}
});
function compileWithService(code, fileName) {
updateFiles(code, fileName);
const output = service.getEmitOutput(fileName);
const diagnostics = service.getSemanticDiagnostics(fileName).concat(service.getSyntacticDiagnostics(fileName));
const filesPortion = filesColor(nodepath.relative(workingDir, fileName));
if (diagnostics.length) {
const program = service.getProgram();
let fileText = "";
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile || sourceFile.fileName !== fileName)
continue;
fileText = printer.printFile(sourceFile);
break;
}
console.error(errorColor(`type errors encountered in `) + filesPortion);
if (transformer)
console.error(`${chalk.italic.gray("this file has potentially been transformed by macros, so here is the transformed text:")}\n${fileText}`);
reportDiagnostics(workingDir, diagnostics);
}
if (output.emitSkipped)
(0, utils_1.fatal)(`${errorColor("emit skipped")} for ${filesPortion}`);
if (output.outputFiles.length === 0)
(0, utils_1.fatal)(`${errorColor("Unable to require file:")} ${filesPortion}`);
const compiledCode = output.outputFiles[1].text;
const sourceMap = JSON.parse(output.outputFiles[0].text);
sourceMap.file = fileName;
sourceMap.sources = [fileName];
delete sourceMap.sourceRoot;
const sourceMapText = JSON.stringify(sourceMap);
const base64Map = Buffer.from(sourceMapText, "utf8").toString("base64");
const sourceMapContent = `data:application/json;charset=utf-8;base64,${base64Map}`;
const sourceMapLength = `${nodepath.basename(fileName)}.map`.length + (".js".length - nodepath.extname(fileName).length);
const runnableCode = compiledCode.slice(0, -sourceMapLength) + sourceMapContent;
emitCache.set(fileName, runnableCode);
return runnableCode;
}
const jsHandler = require.extensions[".js"];
let jsCompile = undefined;
require.extensions[".ts"] = function (mod, fileName) {
if (/(?:^|\/)node_modules\//.test(fileName))
return jsHandler(mod, fileName);
const originalModuleCompile = jsCompile = mod._compile;
mod._compile = function (code, fileName) {
return originalModuleCompile.call(this, compileWithService(code, fileName), fileName);
};
return jsHandler(mod, fileName);
};
if (transformer && transformer.macros)
for (const [extension, macro] of Object.entries(transformer.macros)) {
if (macro.type !== "import")
continue;
require.extensions[`.${extension}`] = function (mod, fileName) {
mod._compile = function (code, fileName) {
if (!jsCompile)
throw new Error();
return jsCompile.call(this, compileWithService(code, fileName + ".ts"), fileName);
};
return jsHandler(mod, fileName);
};
}
return service;
}
exports.register = register;
const helpText = `\
Usage: macro-ts [options] <command>
Commands:
run <filename>.ts Run the specified file.
check [entryGlob] Perform typechecking without running or emitting.
Checks all configured packages if no entryGlob is provided.
build Typecheck and emit javascript for all configured packages,
emitting into .macro-ts/dist.
Options:
-h, --help Print this message.
-v, --version Print version.
-d, --dev Set noUnusedParameters, noUnusedLocals, preserveConstEnums, and removeComments
to more lenient dev quality values.
`;
const versionText = "0.1.0";
function main(argv) {
const { "--help": help = false, "--version": version = false, "--dev": devMode = false, _: [command = undefined, ...args] = [], } = arg({
"--help": Boolean,
"-h": "--help",
"--version": Boolean,
"-v": "--version",
"--dev": Boolean,
"-d": "--dev"
}, { argv, stopAtPositional: true });
if (help)
(0, utils_1.exit)(helpText);
if (version)
(0, utils_1.exit)(versionText);
switch (command) {
case "run":
const entryFile = args[0];
if (entryFile === undefined)
(0, utils_1.fatal)(commandsColor("run") + errorColor(` expects a filename\n\n`) + helpText);
run(entryFile, args.slice(1), devMode);
break;
case "build":
if (args.length)
(0, utils_1.fatal)(commandsColor("build") + errorColor(` accepts no positional arguments\n\n`) + helpText);
build(devMode);
break;
case "check":
if (args.length > 1)
(0, utils_1.fatal)(commandsColor("check") + errorColor(` can only accept one positional argument\n\n`) + helpText);
check(args[0], devMode);
break;
default:
(0, utils_1.fatal)(`${errorColor("invalid command:")} ${command}\n\n` + helpText);
}
}
exports.main = main;
if (require.main === module)
main(process.argv.slice(2));
//# sourceMappingURL=cli.js.map