/* eslint-disable jest/no-standalone-expect */
import * as nativeAssert from "assert";
import { LauxLib, Lua, LuaLib, LuaState, LUA_OK } from "lua-wasm-bindings/dist/lua";
import * as fs from "fs";
import { stringify } from "javascript-stringify";
import * as path from "path";
import * as prettyFormat from "pretty-format";
import * as ts from "typescript";
import * as vm from "vm";
import * as tstl from "../src";
import { createEmitOutputCollector } from "../src/transpilation/output-collector";
import { EmitHost, getEmitOutDir, transpileProject } from "../src";
import { formatPathToLuaPath, normalizeSlashes } from "../src/utils";
import { resolveLuaLibDir } from "../src/LuaLib";
function readLuaLib(target: tstl.LuaTarget) {
return fs.readFileSync(path.join(resolveLuaLibDir(target), "lualib_bundle.lua"), "utf8");
}
function jsonLib(target: tstl.LuaTarget): string {
const fileName = target === tstl.LuaTarget.Lua50 ? "json.50.lua" : "json.lua";
return fs.readFileSync(path.join(__dirname, fileName), "utf8");
}
// Using `test` directly makes eslint-plugin-jest consider this file as a test
const defineTest = test;
function getLuaBindingsForVersion(target: tstl.LuaTarget): { lauxlib: LauxLib; lua: Lua; lualib: LuaLib } {
if (target === tstl.LuaTarget.Lua50) {
const { lauxlib, lua, lualib } = require("lua-wasm-bindings/dist/lua.50");
return { lauxlib, lua, lualib };
}
if (target === tstl.LuaTarget.Lua51) {
const { lauxlib, lua, lualib } = require("lua-wasm-bindings/dist/lua.51");
return { lauxlib, lua, lualib };
}
if (target === tstl.LuaTarget.Lua52) {
const { lauxlib, lua, lualib } = require("lua-wasm-bindings/dist/lua.52");
return { lauxlib, lua, lualib };
}
if (target === tstl.LuaTarget.Lua53) {
const { lauxlib, lua, lualib } = require("lua-wasm-bindings/dist/lua.53");
return { lauxlib, lua, lualib };
}
if (target === tstl.LuaTarget.LuaJIT) {
throw Error("Can't use executeLua() or expectToMatchJsResult() with LuaJIT as target!");
}
const { lauxlib, lua, lualib } = require("lua-wasm-bindings/dist/lua.54");
return { lauxlib, lua, lualib };
}
export function assert(value: any, message?: string | Error): asserts value {
nativeAssert(value, message);
}
export const formatCode = (...values: unknown[]) => values.map(e => stringify(e)).join(", ");
export function testEachVersion(
name: string | undefined,
common: () => T,
special?: Record void) | boolean>
): void {
for (const version of Object.values(tstl.LuaTarget) as tstl.LuaTarget[]) {
const specialBuilder = special?.[version];
if (specialBuilder === false) continue;
const testName = name === undefined ? version : `${name} [${version}]`;
defineTest(testName, () => {
const builder = common();
builder.setOptions({ luaTarget: version });
if (typeof specialBuilder === "function") {
specialBuilder(builder);
}
});
}
}
export function expectEachVersionExceptJit(
expectation: (builder: T) => void
): Record void) | boolean> {
return {
[tstl.LuaTarget.Universal]: expectation,
[tstl.LuaTarget.Lua50]: expectation,
[tstl.LuaTarget.Lua51]: expectation,
[tstl.LuaTarget.Lua52]: expectation,
[tstl.LuaTarget.Lua53]: expectation,
[tstl.LuaTarget.Lua54]: expectation,
[tstl.LuaTarget.Lua55]: expectation,
[tstl.LuaTarget.LuaJIT]: false, // Exclude JIT
[tstl.LuaTarget.Luau]: false,
};
}
const memoize: MethodDecorator = (_target, _propertyKey, descriptor) => {
const originalFunction = descriptor.value as any;
const memoized = new WeakMap();
descriptor.value = function (this: any, ...args: any[]): any {
if (!memoized.has(this)) {
memoized.set(this, originalFunction.apply(this, args));
}
return memoized.get(this);
} as any;
return descriptor;
};
export class ExecutionError extends Error {
public name = "ExecutionError";
// https://github.com/typescript-eslint/typescript-eslint/issues/1131
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor(message: string) {
super(message);
}
}
export type ExecutableTranspiledFile = tstl.TranspiledFile & { lua: string; luaSourceMap: string };
export type TapCallback = (builder: TestBuilder) => void;
export abstract class TestBuilder {
constructor(protected _tsCode: string) {}
// Options
// TODO: Use testModule in these cases?
protected tsHeader = "";
public setTsHeader(tsHeader: string): this {
this.throwIfProgramExists("setTsHeader");
this.tsHeader = tsHeader;
return this;
}
private luaHeader = "";
public setLuaHeader(luaHeader: string): this {
this.throwIfProgramExists("setLuaHeader");
this.luaHeader += luaHeader;
return this;
}
protected jsHeader = "";
public setJsHeader(jsHeader: string): this {
this.throwIfProgramExists("setJsHeader");
this.jsHeader += jsHeader;
return this;
}
protected abstract getLuaCodeWithWrapper(code: string): string;
public setLuaFactory(luaFactory: (code: string) => string): this {
this.throwIfProgramExists("setLuaFactory");
this.getLuaCodeWithWrapper = luaFactory;
return this;
}
private semanticCheck = true;
public disableSemanticCheck(): this {
this.throwIfProgramExists("disableSemanticCheck");
this.semanticCheck = false;
return this;
}
protected options: tstl.CompilerOptions = {
luaTarget: tstl.LuaTarget.Lua55,
noHeader: true,
skipLibCheck: true,
target: ts.ScriptTarget.ES2017,
lib: ["lib.esnext.d.ts"],
moduleResolution: ts.ModuleResolutionKind.Node10,
resolveJsonModule: true,
sourceMap: true,
};
public setOptions(options: tstl.CompilerOptions = {}): this {
this.throwIfProgramExists("setOptions");
Object.assign(this.options, options);
return this;
}
public withLanguageExtensions(): this {
const langExtTypes = path.resolve(__dirname, "..", "language-extensions");
this.options.types = this.options.types ? [...this.options.types, langExtTypes] : [langExtTypes];
// Polyfill lualib for JS
this.setJsHeader(`
function $multi(...args) { return args; }
`);
return this;
}
protected mainFileName = "main.ts";
public setMainFileName(mainFileName: string): this {
this.throwIfProgramExists("setMainFileName");
this.mainFileName = mainFileName;
return this;
}
protected extraFiles: Record = {};
public addExtraFile(fileName: string, code: string): this {
this.throwIfProgramExists("addExtraFile");
this.extraFiles[fileName] = normalizeSlashes(code);
return this;
}
private customTransformers?: ts.CustomTransformers;
public setCustomTransformers(customTransformers?: ts.CustomTransformers): this {
this.throwIfProgramExists("setCustomTransformers");
this.customTransformers = customTransformers;
return this;
}
private throwIfProgramExists(name: string) {
if (this.hasProgram) {
throw new Error(`${name}() should not be called after an .expect() or .debug()`);
}
}
// Transpilation and execution
public getTsCode(): string {
return `${this.tsHeader}${this._tsCode}`;
}
protected hasProgram = false;
@memoize
public getProgram(): ts.Program {
this.hasProgram = true;
// Exclude lua files from TS program, but keep them in extraFiles so module resolution can find them
const nonLuaExtraFiles = Object.fromEntries(
Object.entries(this.extraFiles).filter(([fileName]) => !fileName.endsWith(".lua"))
);
return tstl.createVirtualProgram({ ...nonLuaExtraFiles, [this.mainFileName]: this.getTsCode() }, this.options);
}
private getEmitHost(): EmitHost {
return {
fileExists: (path: string) => normalizeSlashes(path) in this.extraFiles,
directoryExists: (path: string) =>
Object.keys(this.extraFiles).some(f => f.startsWith(normalizeSlashes(path))),
getCurrentDirectory: () => ".",
readFile: (path: string) => this.extraFiles[normalizeSlashes(path)] ?? ts.sys.readFile(path),
writeFile() {},
};
}
@memoize
public getLuaResult(): tstl.TranspileVirtualProjectResult {
const program = this.getProgram();
const preEmitDiagnostics = ts.getPreEmitDiagnostics(program);
const collector = createEmitOutputCollector(this.options.extension);
const { diagnostics: transpileDiagnostics } = new tstl.Transpiler({ emitHost: this.getEmitHost() }).emit({
program,
customTransformers: this.customTransformers,
writeFile: collector.writeFile,
});
const diagnostics = ts.sortAndDeduplicateDiagnostics([...preEmitDiagnostics, ...transpileDiagnostics]);
return { diagnostics: [...diagnostics], transpiledFiles: collector.files };
}
@memoize
public getMainLuaFileResult(): ExecutableTranspiledFile {
const { transpiledFiles } = this.getLuaResult();
const mainFileName = normalizeSlashes(this.mainFileName);
const mainFile = this.options.luaBundle
? transpiledFiles[0]
: transpiledFiles.find(({ sourceFiles }) => sourceFiles.some(f => f.fileName === mainFileName));
if (mainFile === undefined) {
throw new Error(
`No source file could be found matching main file: ${mainFileName}.\nSource files in test:\n${transpiledFiles
.flatMap(f => f.sourceFiles.map(sf => sf.fileName))
.join("\n")}`
);
}
expect(mainFile).toMatchObject({ lua: expect.any(String), luaSourceMap: expect.any(String) });
return mainFile as ExecutableTranspiledFile;
}
@memoize
public getMainLuaCodeChunk(): string {
const header = this.luaHeader ? `${this.luaHeader.trimRight()}\n` : "";
return header + this.getMainLuaFileResult().lua.trimRight();
}
@memoize
public getLuaExecutionResult(): any {
return this.executeLua();
}
@memoize
public getJsResult(): tstl.TranspileVirtualProjectResult {
const program = this.getProgram();
program.getCompilerOptions().module = ts.ModuleKind.CommonJS;
const collector = createEmitOutputCollector(this.options.extension);
const { diagnostics } = program.emit(undefined, collector.writeFile);
return { transpiledFiles: collector.files, diagnostics: [...diagnostics] };
}
@memoize
public getMainJsCodeChunk(): string {
const { transpiledFiles } = this.getJsResult();
const code = transpiledFiles.find(({ sourceFiles }) =>
sourceFiles.some(f => f.fileName === this.mainFileName)
)?.js;
assert(code !== undefined);
const header = this.jsHeader ? `${this.jsHeader.trimRight()}\n` : "";
return header + code;
}
protected abstract getJsCodeWithWrapper(): string;
@memoize
public getJsExecutionResult(): any {
return this.executeJs();
}
// Utilities
private getLuaDiagnostics(): ts.Diagnostic[] {
const { diagnostics } = this.getLuaResult();
return diagnostics.filter(
d => (this.semanticCheck || d.source === "typescript-to-lua") && !this.ignoredDiagnostics.includes(d.code)
);
}
// Actions
public debug(includeLualib = false): this {
const { transpiledFiles, diagnostics } = this.getLuaResult();
const luaCode = transpiledFiles
.filter(f => includeLualib || f.outPath !== "lualib_bundle.lua")
.map(f => `[${f.outPath}]:\n${f.lua?.replace(/^/gm, " ")}`);
const value = prettyFormat.format(this.getLuaExecutionResult()).replace(/^/gm, " ");
console.log(`Lua Code:\n${luaCode.join("\n")}\n\nValue:\n${value}`);
if (diagnostics.length > 0) {
console.log(
ts.formatDiagnostics(diagnostics.map(tstl.prepareDiagnosticForFormatting), {
getCurrentDirectory: () => "",
getCanonicalFileName: fileName => fileName,
getNewLine: () => "\n",
})
);
}
return this;
}
private diagnosticsChecked = false;
private ignoredDiagnostics: number[] = [];
public ignoreDiagnostics(ignored: number[]): this {
this.ignoredDiagnostics.push(...ignored);
return this;
}
public expectToHaveDiagnostics(expected?: number[]): this {
if (this.diagnosticsChecked) return this;
this.diagnosticsChecked = true;
expect(this.getLuaDiagnostics()).toHaveDiagnostics(expected);
return this;
}
public expectToHaveNoDiagnostics(): this {
if (this.diagnosticsChecked) return this;
this.diagnosticsChecked = true;
expect(this.getLuaDiagnostics()).not.toHaveDiagnostics();
return this;
}
public expectNoTranspileException(): this {
expect(() => this.getLuaResult()).not.toThrow();
return this;
}
public expectNoExecutionError(): this {
const luaResult = this.getLuaExecutionResult();
if (luaResult instanceof ExecutionError) {
throw luaResult;
}
return this;
}
private expectNoJsExecutionError(): this {
const jsResult = this.getJsExecutionResult();
if (jsResult instanceof ExecutionError) {
throw jsResult;
}
return this;
}
public expectToMatchJsResult(allowErrors = false): this {
this.expectToHaveNoDiagnostics();
if (!allowErrors) this.expectNoExecutionError();
if (!allowErrors) this.expectNoJsExecutionError();
const luaResult = this.getLuaExecutionResult();
const jsResult = this.getJsExecutionResult();
expect(luaResult).toEqual(jsResult);
return this;
}
public expectToEqual(expected: any): this {
this.expectToHaveNoDiagnostics();
const luaResult = this.getLuaExecutionResult();
expect(luaResult).toEqual(expected);
return this;
}
public expectLuaToMatchSnapshot(): this {
this.expectToHaveNoDiagnostics();
expect(this.getMainLuaCodeChunk()).toMatchSnapshot();
return this;
}
public expectDiagnosticsToMatchSnapshot(expected?: number[], diagnosticsOnly = false): this {
this.expectToHaveDiagnostics(expected);
const diagnosticMessages = ts.formatDiagnostics(
this.getLuaDiagnostics().map(tstl.prepareDiagnosticForFormatting),
{ getCurrentDirectory: () => "", getCanonicalFileName: fileName => fileName, getNewLine: () => "\n" }
);
expect(diagnosticMessages.trim()).toMatchSnapshot("diagnostics");
if (!diagnosticsOnly) {
expect(this.getMainLuaCodeChunk()).toMatchSnapshot("code");
}
return this;
}
public tap(callback: TapCallback): this {
callback(this);
return this;
}
private executeLua(): any {
// Main file
const mainFile = this.getMainLuaCodeChunk();
const luaTarget = this.options.luaTarget ?? tstl.LuaTarget.Lua55;
const { lauxlib, lua, lualib } = getLuaBindingsForVersion(luaTarget);
const L = lauxlib.luaL_newstate();
lualib.luaL_openlibs(L);
// Load modules
// Json
this.injectLuaFile(L, lua, lauxlib, "json", jsonLib(luaTarget));
// Lua lib
if (
this.options.luaLibImport === tstl.LuaLibImportKind.Require ||
mainFile.includes('require("lualib_bundle")')
) {
this.injectLuaFile(L, lua, lauxlib, "lualib_bundle", readLuaLib(luaTarget));
}
// Load all transpiled files into Lua's package cache
const { transpiledFiles } = this.getLuaResult();
for (const transpiledFile of transpiledFiles) {
if (transpiledFile.lua) {
const filePath = path.relative(getEmitOutDir(this.getProgram()), transpiledFile.outPath);
this.injectLuaFile(L, lua, lauxlib, filePath, transpiledFile.lua);
}
}
// Execute Main
const wrappedMainCode = `
local JSON = require("json");
return JSON.stringify((function()
${this.getLuaCodeWithWrapper(mainFile)}
end)());`;
const status = lauxlib.luaL_dostring(L, wrappedMainCode);
if (status === LUA_OK) {
if (lua.lua_isstring(L, -1)) {
const result = eval(`(${lua.lua_tostring(L, -1)})`);
lua.lua_close(L);
return result === null ? undefined : result;
} else {
const returnType = lua.lua_typename(L, lua.lua_type(L, -1));
lua.lua_close(L);
throw new Error(`Unsupported Lua return type: ${returnType}`);
}
} else {
const luaStackString = lua.lua_tostring(L, -1);
const message = luaStackString.replace(/^\[string "(--)?\.\.\."\]:\d+: /, "");
lua.lua_close(L);
return new ExecutionError(message);
}
}
private injectLuaFile(state: LuaState, lua: Lua, lauxlib: LauxLib, fileName: string, fileContent: string) {
let extension = this.options.extension ?? ".lua";
if (!extension.startsWith(".")) {
extension = `.${extension}`;
}
const modName = fileName.endsWith(extension)
? formatPathToLuaPath(fileName.substring(0, fileName.length - extension.length))
: fileName;
if (this.options.luaTarget === tstl.LuaTarget.Lua50) {
// Adding source Lua to the _LOADED cache will allow require to find it
lua.lua_getglobal(state, "_LOADED");
lauxlib.luaL_dostring(state, fileContent);
lua.lua_setfield(state, -2, modName);
} else {
// Adding source Lua to the package.preload cache will allow require to find it
lua.lua_getglobal(state, "package");
lua.lua_getfield(state, -1, "preload");
lauxlib.luaL_loadstring(state, fileContent);
lua.lua_setfield(state, -2, modName);
}
}
private executeJs(): any {
const { transpiledFiles } = this.getJsResult();
// Custom require for extra files. Really basic. Global support is hacky
// TODO Should be replaced with vm.Module https://nodejs.org/api/vm.html#vm_class_vm_module
// once stable
const globalContext: any = {};
const mainExports = {};
globalContext.exports = mainExports;
globalContext.module = { exports: mainExports };
globalContext.require = (fileName: string) => {
// create clean export object for "module"
const moduleExports = {};
globalContext.exports = moduleExports;
globalContext.module = { exports: moduleExports };
const transpiledExtraFile = transpiledFiles.find(({ sourceFiles }) =>
sourceFiles.some(f => f.fileName === fileName.replace("./", "") + ".ts")
);
if (transpiledExtraFile?.js) {
vm.runInContext(transpiledExtraFile.js, globalContext);
}
// Have to return globalContext.module.exports
// becuase module.exports might no longer be equal to moduleExports (export assignment)
const result = globalContext.module.exports;
// Reset module/export
globalContext.exports = mainExports;
globalContext.module = { exports: mainExports };
return result;
};
vm.createContext(globalContext);
let result: unknown;
try {
result = vm.runInContext(this.getJsCodeWithWrapper(), globalContext);
} catch (error) {
const hasMessage = (error: any): error is { message: string } => error.message !== undefined;
assert(hasMessage(error));
return new ExecutionError(error.message);
}
function removeUndefinedFields(obj: any): any {
if (obj === null) {
return undefined;
}
if (Array.isArray(obj)) {
return obj.map(removeUndefinedFields);
}
if (typeof obj === "object") {
const copy: any = {};
for (const [key, value] of Object.entries(obj)) {
if (obj[key] !== undefined) {
copy[key] = removeUndefinedFields(value);
}
}
if (Object.keys(copy).length === 0) {
return [];
}
return copy;
}
return obj;
}
return removeUndefinedFields(result);
}
}
class AccessorTestBuilder extends TestBuilder {
protected accessor = "";
protected getLuaCodeWithWrapper(code: string) {
return `return (function(...)\n${code}\nend)()${this.accessor}`;
}
@memoize
protected getJsCodeWithWrapper(): string {
return this.getMainJsCodeChunk() + `\n;module.exports = module.exports${this.accessor}`;
}
}
class BundleTestBuilder extends AccessorTestBuilder {
constructor(_tsCode: string) {
super(_tsCode);
this.setOptions({ luaBundle: "main.lua", luaBundleEntry: this.mainFileName });
}
public setEntryPoint(fileName: string): this {
return this.setOptions({ luaBundleEntry: fileName });
}
}
class ModuleTestBuilder extends AccessorTestBuilder {
public setReturnExport(...names: string[]): this {
expect(this.hasProgram).toBe(false);
this.accessor = names.map(n => `[${tstl.escapeString(n)}]`).join("");
return this;
}
}
class FunctionTestBuilder extends AccessorTestBuilder {
protected accessor = ".__main()";
public getTsCode(): string {
return `${this.tsHeader}export function __main() {${this._tsCode}}`;
}
}
class ExpressionTestBuilder extends AccessorTestBuilder {
protected accessor = ".__result";
public getTsCode(): string {
return `${this.tsHeader}export const __result = ${this._tsCode};`;
}
}
class ProjectTestBuilder extends ModuleTestBuilder {
constructor(private tsConfig: string) {
super("");
this.setOptions({ configFilePath: this.tsConfig, ...tstl.parseConfigFileWithSystem(this.tsConfig) });
}
@memoize
public getLuaResult(): tstl.TranspileVirtualProjectResult {
// Override getLuaResult to use transpileProject with tsconfig.json instead
const collector = createEmitOutputCollector(this.options.extension);
const { diagnostics } = transpileProject(this.tsConfig, this.options, collector.writeFile);
return { diagnostics: [...diagnostics], transpiledFiles: collector.files };
}
}
const createTestBuilderFactory =
(builder: new (_tsCode: string) => T, serializeSubstitutions: boolean) =>
(...args: [string] | [TemplateStringsArray, ...any[]]): T => {
let tsCode: string;
if (typeof args[0] === "string") {
expect(serializeSubstitutions).toBe(false);
tsCode = args[0];
} else {
let [raw, ...substitutions] = args;
if (serializeSubstitutions) {
substitutions = substitutions.map(s => formatCode(s));
}
tsCode = String.raw(Object.assign([], { raw }), ...substitutions);
}
return new builder(tsCode);
};
export const testBundle = createTestBuilderFactory(BundleTestBuilder, false);
export const testModule = createTestBuilderFactory(ModuleTestBuilder, false);
export const testModuleTemplate = createTestBuilderFactory(ModuleTestBuilder, true);
export const testFunction = createTestBuilderFactory(FunctionTestBuilder, false);
export const testFunctionTemplate = createTestBuilderFactory(FunctionTestBuilder, true);
export const testExpression = createTestBuilderFactory(ExpressionTestBuilder, false);
export const testExpressionTemplate = createTestBuilderFactory(ExpressionTestBuilder, true);
export const testProject = createTestBuilderFactory(ProjectTestBuilder, false);