diff --git a/package-lock.json b/package-lock.json index a69f58e8f..a33e2537d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2512,9 +2512,9 @@ "dev": true }, "handlebars": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.4.2.tgz", - "integrity": "sha512-cIv17+GhL8pHHnRJzGu2wwcthL5sb8uDKBHvZ2Dtu5s1YNt0ljbzKbamnc+gr69y7bzwQiBdr5+hOpRd5pnOdg==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.5.2.tgz", + "integrity": "sha512-29Zxv/cynYB7mkT1rVWQnV7mGX6v7H/miQ6dbEpYTKq5eJBN7PsRB+ViYJlcT6JINTSu4dVB9kOqEun78h6Exg==", "dev": true, "requires": { "neo-async": "^2.6.0", @@ -5227,16 +5227,23 @@ "integrity": "sha512-lmQ4L+J6mnu3xweP8+rOrUwzmN+MRAj7TgtJtDaXE5PMyX2kCrklhg3rvOsOIfNeAWMQWO2F1GPc1kMD2vLAfw==" }, "uglify-js": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.0.tgz", - "integrity": "sha512-W+jrUHJr3DXKhrsS7NUVxn3zqMOFn0hL/Ei6v0anCIMoKC93TjcflTagwIHLW7SfMFfiQuktQyFVCFHGUE0+yg==", + "version": "3.6.9", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.9.tgz", + "integrity": "sha512-pcnnhaoG6RtrvHJ1dFncAe8Od6Nuy30oaJ82ts6//sGSXOP5UjBMEthiProjXmMNHOfd93sqlkztifFMcb+4yw==", "dev": true, "optional": true, "requires": { - "commander": "~2.20.0", + "commander": "~2.20.3", "source-map": "~0.6.1" }, "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "optional": true + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/src/CompilerOptions.ts b/src/CompilerOptions.ts index f7c5999c5..be3aa09b4 100644 --- a/src/CompilerOptions.ts +++ b/src/CompilerOptions.ts @@ -20,6 +20,8 @@ export interface TransformerImport { export type CompilerOptions = OmitIndexSignature & { noImplicitSelf?: boolean; noHeader?: boolean; + luaBundle?: string; + luaBundleEntry?: string; luaTarget?: LuaTarget; luaLibImport?: LuaLibImportKind; noHoisting?: boolean; @@ -41,3 +43,42 @@ export enum LuaTarget { Lua53 = "5.3", LuaJIT = "JIT", } + +export function validateOptions(options: CompilerOptions): ts.Diagnostic[] { + const diagnostics: ts.Diagnostic[] = []; + + if (options.luaBundle && !options.luaBundleEntry) { + diagnostics.push(configErrorDiagnostic(`'luaBundleEntry' is required when 'luaBundle' is enabled.`)); + } + + if (options.luaBundle && options.luaLibImport === LuaLibImportKind.Inline) { + diagnostics.push( + configWarningDiagnostic( + `Using 'luaBundle' with 'luaLibImport: "inline"' might generate duplicate code. ` + + `It is recommended to use 'luaLibImport: "require"'` + ) + ); + } + + return diagnostics; +} + +const configErrorDiagnostic = (message: string): ts.Diagnostic => ({ + file: undefined, + start: undefined, + length: undefined, + category: ts.DiagnosticCategory.Error, + code: 0, + source: "typescript-to-lua", + messageText: message, +}); + +const configWarningDiagnostic = (message: string): ts.Diagnostic => ({ + file: undefined, + start: undefined, + length: undefined, + category: ts.DiagnosticCategory.Warning, + code: 0, + source: "typescript-to-lua", + messageText: message, +}); diff --git a/src/Emit.ts b/src/Emit.ts index ecb1453f9..dd3208eb9 100644 --- a/src/Emit.ts +++ b/src/Emit.ts @@ -1,10 +1,8 @@ import * as path from "path"; import * as ts from "typescript"; -import { CompilerOptions, LuaLibImportKind } from "./CompilerOptions"; +import { LuaLibImportKind } from "./CompilerOptions"; import { EmitHost, TranspiledFile } from "./Transpile"; -import { normalizeSlashes } from "./utils"; - -const trimExt = (filePath: string) => filePath.slice(0, -path.extname(filePath).length); +import { normalizeSlashes, trimExtension } from "./utils"; export interface OutputFile { name: string; @@ -13,18 +11,15 @@ export interface OutputFile { let lualibContent: string; export function emitTranspiledFiles( - options: CompilerOptions, + program: ts.Program, transpiledFiles: TranspiledFile[], emitHost: EmitHost = ts.sys ): OutputFile[] { - let { rootDir, outDir, outFile, luaLibImport } = options; - - const configFileName = options.configFilePath as string | undefined; - // TODO: Use getCommonSourceDirectory - const baseDir = configFileName ? path.dirname(configFileName) : process.cwd(); + const options = program.getCompilerOptions(); + let { outDir, luaLibImport, luaBundle } = options; - rootDir = rootDir || baseDir; - outDir = outDir ? path.resolve(baseDir, outDir) : rootDir; + const rootDir = program.getCommonSourceDirectory(); + outDir = outDir || rootDir; const files: OutputFile[] = []; for (const { fileName, lua, sourceMap, declaration, declarationMap } of transpiledFiles) { @@ -33,14 +28,8 @@ export function emitTranspiledFiles( outPath = path.resolve(outDir, path.relative(rootDir, fileName)); } - // change extension or rename to outFile - if (outFile) { - outPath = path.isAbsolute(outFile) ? outFile : path.resolve(baseDir, outFile); - } else { - outPath = trimExt(outPath) + ".lua"; - } - - outPath = normalizeSlashes(outPath); + // change extension + outPath = normalizeSlashes(trimExtension(outPath) + ".lua"); if (lua !== undefined) { files.push({ name: outPath, text: lua }); @@ -51,30 +40,38 @@ export function emitTranspiledFiles( } if (declaration !== undefined) { - files.push({ name: trimExt(outPath) + ".d.ts", text: declaration }); + files.push({ name: trimExtension(outPath) + ".d.ts", text: declaration }); } if (declarationMap !== undefined) { - files.push({ name: trimExt(outPath) + ".d.ts.map", text: declarationMap }); + files.push({ name: trimExtension(outPath) + ".d.ts.map", text: declarationMap }); } } - if (luaLibImport === LuaLibImportKind.Require || luaLibImport === LuaLibImportKind.Always) { - if (lualibContent === undefined) { - const lualibBundle = emitHost.readFile(path.resolve(__dirname, "../dist/lualib/lualib_bundle.lua")); - if (lualibBundle !== undefined) { - lualibContent = lualibBundle; - } else { - throw new Error("Could not load lualib bundle from ./dist/lualib/lualib_bundle.lua"); + if ( + !luaBundle && + (luaLibImport === undefined || + luaLibImport === LuaLibImportKind.Require || + luaLibImport === LuaLibImportKind.Always) + ) { + const lualibRequired = files.some(f => f.text && f.text.includes(`require("lualib_bundle")`)); + if (lualibRequired) { + if (lualibContent === undefined) { + const lualibBundle = emitHost.readFile(path.resolve(__dirname, "../dist/lualib/lualib_bundle.lua")); + if (lualibBundle !== undefined) { + lualibContent = lualibBundle; + } else { + throw new Error("Could not load lualib bundle from ./dist/lualib/lualib_bundle.lua"); + } } - } - let outPath = path.resolve(rootDir, "lualib_bundle.lua"); - if (outDir !== rootDir) { - outPath = path.join(outDir, path.relative(rootDir, outPath)); - } + let outPath = path.resolve(rootDir, "lualib_bundle.lua"); + if (outDir !== rootDir) { + outPath = path.join(outDir, path.relative(rootDir, outPath)); + } - files.push({ name: normalizeSlashes(outPath), text: lualibContent }); + files.push({ name: normalizeSlashes(outPath), text: lualibContent }); + } } return files; diff --git a/src/LuaPrinter.ts b/src/LuaPrinter.ts index 48ea0e752..4fec7e595 100644 --- a/src/LuaPrinter.ts +++ b/src/LuaPrinter.ts @@ -43,7 +43,11 @@ export class LuaPrinter { public constructor(private options: CompilerOptions, private emitHost: EmitHost) {} - public print(block: tstl.Block, luaLibFeatures?: Set, sourceFile = ""): [string, string] { + public print( + block: tstl.Block, + luaLibFeatures?: Set, + sourceFile = "" + ): [string, string, SourceNode] { // Add traceback lualib if sourcemap traceback option is enabled if (this.options.sourceMapTraceback) { if (luaLibFeatures === undefined) { @@ -71,7 +75,7 @@ export class LuaPrinter { codeResult = codeResult.replace("{#SourceMapTraceback}", stackTraceOverride); } - return [codeResult, sourceMap.toString()]; + return [codeResult, sourceMap.toString(), rootSourceNode]; } private printInlineSourceMap(sourceMap: SourceMapGenerator): string { @@ -113,7 +117,7 @@ export class LuaPrinter { } if (luaLibFeatures) { - const luaLibImport = this.options.luaLibImport || LuaLibImportKind.Inline; + const luaLibImport = this.options.luaLibImport || LuaLibImportKind.Require; // Require lualib bundle if ( (luaLibImport === LuaLibImportKind.Require && luaLibFeatures.size > 0) || diff --git a/src/TSHelper.ts b/src/TSHelper.ts index c237ae188..4c0735310 100644 --- a/src/TSHelper.ts +++ b/src/TSHelper.ts @@ -5,6 +5,7 @@ import { Decorator, DecoratorKind } from "./Decorator"; import * as tstl from "./LuaAST"; import * as TSTLErrors from "./TSTLErrors"; import { EmitResolver } from "./LuaTransformer"; +import { formatPathToLuaPath } from "./utils"; export enum ContextType { None, @@ -1060,15 +1061,6 @@ export function getExportPath(fileName: string, options: ts.CompilerOptions): st return formatPathToLuaPath(absolutePath.replace(absoluteRootDirPath, "").slice(1)); } -export function formatPathToLuaPath(filePath: string): string { - filePath = filePath.replace(/\.json$/, ""); - if (process.platform === "win32") { - // Windows can use backslashes - filePath = filePath.replace(/\.\\/g, "").replace(/\\/g, "."); - } - return filePath.replace(/\.\//g, "").replace(/\//g, "."); -} - export function isBuiltinErrorTypeName(name: string): boolean { return builtinErrorTypeNames.has(name); } diff --git a/src/Transpile.ts b/src/Transpile.ts index 28a6282a9..01d8df15a 100644 --- a/src/Transpile.ts +++ b/src/Transpile.ts @@ -1,5 +1,7 @@ +import { SourceNode } from "source-map"; import * as ts from "typescript"; -import { CompilerOptions } from "./CompilerOptions"; +import { bundleTranspiledFiles } from "./bundle"; +import { CompilerOptions, validateOptions } from "./CompilerOptions"; import * as diagnosticFactories from "./diagnostics"; import { Block } from "./LuaAST"; import { LuaPrinter } from "./LuaPrinter"; @@ -14,6 +16,8 @@ export interface TranspiledFile { sourceMap?: string; declaration?: string; declarationMap?: string; + /** @internal */ + sourceMapNode?: SourceNode; } export interface TranspileResult { @@ -31,6 +35,7 @@ export interface TranspileOptions { } export interface EmitHost { + getCurrentDirectory(): string; readFile(path: string): string | undefined; } @@ -44,7 +49,7 @@ export function transpile({ }: TranspileOptions): TranspileResult { const options = program.getCompilerOptions() as CompilerOptions; - const diagnostics: ts.Diagnostic[] = []; + const diagnostics = validateOptions(options); let transpiledFiles: TranspiledFile[] = []; const updateTranspiledFile = (fileName: string, update: Omit) => { @@ -57,7 +62,11 @@ export function transpile({ }; if (options.noEmitOnError) { - const preEmitDiagnostics = [...program.getOptionsDiagnostics(), ...program.getGlobalDiagnostics()]; + const preEmitDiagnostics = [ + ...diagnostics, + ...program.getOptionsDiagnostics(), + ...program.getGlobalDiagnostics(), + ]; if (targetSourceFiles) { for (const sourceFile of targetSourceFiles) { @@ -82,8 +91,8 @@ export function transpile({ try { const [luaAst, lualibFeatureSet] = transformer.transform(sourceFile); if (!options.noEmit && !options.emitDeclarationOnly) { - const [lua, sourceMap] = printer.print(luaAst, lualibFeatureSet, sourceFile.fileName); - updateTranspiledFile(sourceFile.fileName, { luaAst, lua, sourceMap }); + const [lua, sourceMap, sourceNode] = printer.print(luaAst, lualibFeatureSet, sourceFile.fileName); + updateTranspiledFile(sourceFile.fileName, { luaAst, lua, sourceMap, sourceMapNode: sourceNode }); } } catch (err) { if (!(err instanceof TranspileError)) throw err; @@ -144,5 +153,17 @@ export function transpile({ transpiledFiles = []; } + if (options.luaBundle && options.luaBundleEntry) { + const [bundleDiagnostics, bundle] = bundleTranspiledFiles( + options.luaBundle, + options.luaBundleEntry, + transpiledFiles, + program, + emitHost + ); + diagnostics.push(...bundleDiagnostics); + transpiledFiles = [bundle]; + } + return { diagnostics, transpiledFiles }; } diff --git a/src/bundle.ts b/src/bundle.ts new file mode 100644 index 000000000..20476b43b --- /dev/null +++ b/src/bundle.ts @@ -0,0 +1,112 @@ +import * as path from "path"; +import { SourceNode } from "source-map"; +import * as ts from "typescript"; +import { couldNotFindBundleEntryPoint } from "./diagnostics"; +import { EmitHost, TranspiledFile } from "./Transpile"; +import { formatPathToLuaPath, trimExtension, normalizeSlashes } from "./utils"; +import { escapeString } from "./TSHelper"; +import { CompilerOptions } from "./CompilerOptions"; + +const createModulePath = (baseDir: string, pathToResolve: string) => + escapeString(formatPathToLuaPath(trimExtension(path.relative(baseDir, pathToResolve)))); + +export function bundleTranspiledFiles( + bundleFile: string, + entryModule: string, + transpiledFiles: TranspiledFile[], + program: ts.Program, + emitHost: EmitHost +): [ts.Diagnostic[], TranspiledFile] { + const diagnostics: ts.Diagnostic[] = []; + + const options = program.getCompilerOptions() as CompilerOptions; + + const projectRootDir = options.configFilePath + ? path.dirname(options.configFilePath) + : emitHost.getCurrentDirectory(); + + // Resolve project settings relative to project file. + const resolvedEntryModule = path.resolve(projectRootDir, entryModule); + const resolvedBundleFile = path.resolve(projectRootDir, bundleFile); + + // Resolve source files relative to common source directory. + const sourceRootDir = program.getCommonSourceDirectory(); + if (!transpiledFiles.some(f => path.resolve(sourceRootDir, f.fileName) === resolvedEntryModule)) { + return [[couldNotFindBundleEntryPoint(entryModule)], { fileName: bundleFile }]; + } + + // For each file: [""] = function() end, + const moduleTableEntries: SourceChunk[] = transpiledFiles.map(f => + moduleSourceNode(f, createModulePath(sourceRootDir, f.fileName)) + ); + + // If any of the modules contains a require for lualib_bundle, add it to the module table. + const lualibRequired = transpiledFiles.some(f => f.lua && f.lua.includes(`require("lualib_bundle")`)); + if (lualibRequired) { + const lualibBundle = emitHost.readFile(path.resolve(__dirname, "../dist/lualib/lualib_bundle.lua")); + moduleTableEntries.push(`["lualib_bundle"] = function() ${lualibBundle} end,\n`); + } + + // Create ____modules table containing all entries from moduleTableEntries + const moduleTable = createModuleTableNode(moduleTableEntries); + + // Override `require` to read from ____modules table. + const requireOverride = ` +local ____moduleCache = {} +local ____originalRequire = require +function require(file) + if ____moduleCache[file] then + return ____moduleCache[file] + end + if ____modules[file] then + ____moduleCache[file] = ____modules[file]() + return ____moduleCache[file] + else + if ____originalRequire then + return ____originalRequire(file) + else + error("module '" .. file .. "' not found") + end + end +end\n`; + + // return require("") + const entryPoint = `return require("${createModulePath(sourceRootDir, resolvedEntryModule)}")\n`; + + const bundleNode = joinSourceChunks([moduleTable, requireOverride, entryPoint]); + const { code, map } = bundleNode.toStringWithSourceMap(); + + return [ + diagnostics, + { + fileName: normalizeSlashes(resolvedBundleFile), + lua: code, + sourceMap: map.toString(), + sourceMapNode: moduleTable, + }, + ]; +} + +function moduleSourceNode(transpiledFile: TranspiledFile, modulePath: string): SourceNode { + const tableEntryHead = `["${modulePath}"] = function() `; + const tableEntryTail = `end,\n`; + + if (transpiledFile.lua && transpiledFile.sourceMapNode) { + return joinSourceChunks([tableEntryHead, transpiledFile.sourceMapNode, tableEntryTail]); + } else { + return joinSourceChunks([tableEntryHead, tableEntryTail]); + } +} + +function createModuleTableNode(fileChunks: SourceChunk[]): SourceNode { + const tableHead = `local ____modules = {\n`; + const tableEnd = `}\n`; + + return joinSourceChunks([tableHead, ...fileChunks, tableEnd]); +} + +type SourceChunk = string | SourceNode; +function joinSourceChunks(chunks: SourceChunk[]): SourceNode { + // tslint:disable-next-line:no-null-keyword + return new SourceNode(null, null, null, chunks); +} diff --git a/src/cli/parse.ts b/src/cli/parse.ts index 8bbaad42b..40f564268 100644 --- a/src/cli/parse.ts +++ b/src/cli/parse.ts @@ -21,9 +21,23 @@ interface CommandLineOptionOfBoolean extends CommandLineOptionBase { type: "boolean"; } -type CommandLineOption = CommandLineOptionOfEnum | CommandLineOptionOfBoolean; +interface CommandLineOptionOfString extends CommandLineOptionBase { + type: "string"; +} + +type CommandLineOption = CommandLineOptionOfEnum | CommandLineOptionOfBoolean | CommandLineOptionOfString; export const optionDeclarations: CommandLineOption[] = [ + { + name: "luaBundle", + description: "The name of the lua file to bundle output lua to. Requires luaBundleEntry.", + type: "string", + }, + { + name: "luaBundleEntry", + description: "The entry *.ts file that will be executed when entering the luaBundle. Requires luaBundle.", + type: "string", + }, { name: "luaLibImport", description: "Specifies how js standard features missing in lua are imported.", @@ -164,11 +178,12 @@ function readValue(option: CommandLineOption, value: unknown): ReadValueResult { if (value === null) return { value }; switch (option.type) { + case "string": case "boolean": { - if (typeof value !== "boolean") { + if (typeof value !== option.type) { return { value: undefined, - error: cliDiagnostics.compilerOptionRequiresAValueOfType(option.name, "boolean"), + error: cliDiagnostics.compilerOptionRequiresAValueOfType(option.name, option.type), }; } diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 5e614a380..9382ca834 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -50,3 +50,13 @@ export const transformerShouldBeATsTransformerFactory = (transform: string): ts. source: "typescript-to-lua", messageText: `"${transform}" transformer should be a ts.TransformerFactory or an object with ts.TransformerFactory values`, }); + +export const couldNotFindBundleEntryPoint = (entryPoint: string): ts.Diagnostic => ({ + file: undefined, + start: undefined, + length: undefined, + category: ts.DiagnosticCategory.Error, + code: 0, + source: "typescript-to-lua", + messageText: `Could not find bundle entry point '${entryPoint}'. It should be a file in the project.`, +}); diff --git a/src/index.ts b/src/index.ts index f9a514fb6..fc686df20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,7 @@ export interface TranspileFilesResult { export function transpileFiles(rootNames: string[], options: CompilerOptions = {}): TranspileFilesResult { const program = ts.createProgram(rootNames, options); const { transpiledFiles, diagnostics: transpileDiagnostics } = transpile({ program }); - const emitResult = emitTranspiledFiles(program.getCompilerOptions(), transpiledFiles); + const emitResult = emitTranspiledFiles(program, transpiledFiles); const diagnostics = ts.sortAndDeduplicateDiagnostics([ ...ts.getPreEmitDiagnostics(program), diff --git a/src/tstl.ts b/src/tstl.ts index e3c416f6e..e37073442 100644 --- a/src/tstl.ts +++ b/src/tstl.ts @@ -111,7 +111,7 @@ function performCompilation( ...transpileDiagnostics, ]); - const emitResult = tstl.emitTranspiledFiles(options, transpiledFiles); + const emitResult = tstl.emitTranspiledFiles(program, transpiledFiles); emitResult.forEach(({ name, text }) => ts.sys.writeFile(name, text)); diagnostics.forEach(reportDiagnostic); @@ -182,7 +182,7 @@ function updateWatchCompilationHost( const { diagnostics: emitDiagnostics, transpiledFiles } = tstl.transpile({ program, sourceFiles }); - const emitResult = tstl.emitTranspiledFiles(options, transpiledFiles); + const emitResult = tstl.emitTranspiledFiles(program, transpiledFiles); emitResult.forEach(({ name, text }) => ts.sys.writeFile(name, text)); const diagnostics = ts.sortAndDeduplicateDiagnostics([ diff --git a/src/typescript-internal.ts b/src/typescript-internal.ts index 6f5819c38..3a8013d61 100644 --- a/src/typescript-internal.ts +++ b/src/typescript-internal.ts @@ -11,4 +11,12 @@ declare module "typescript" { interface Statement { jsDoc?: ts.JSDoc[]; } + + interface Program { + getCommonSourceDirectory(): string; + } + + interface CompilerOptions { + configFilePath?: string; + } } diff --git a/src/utils.ts b/src/utils.ts index 204a1d16c..16997a549 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,18 @@ +import * as path from "path"; + export const normalizeSlashes = (filePath: string) => filePath.replace(/\\/g, "/"); +export const trimExtension = (filePath: string) => filePath.slice(0, -path.extname(filePath).length); + +export function formatPathToLuaPath(filePath: string): string { + filePath = filePath.replace(/\.json$/, ""); + if (process.platform === "win32") { + // Windows can use backslashes + filePath = filePath.replace(/\.\\/g, "").replace(/\\/g, "."); + } + return filePath.replace(/\.\//g, "").replace(/\//g, "."); +} + export function flatMap(array: readonly T[], callback: (value: T, index: number) => U | readonly U[]): U[] { const result: U[] = []; diff --git a/test/cli/parse.spec.ts b/test/cli/parse.spec.ts index 0f4942d9c..ae95751e9 100644 --- a/test/cli/parse.spec.ts +++ b/test/cli/parse.spec.ts @@ -115,6 +115,9 @@ describe("command line", () => { ["luaTarget", "5.2", { luaTarget: tstl.LuaTarget.Lua52 }], ["luaTarget", "5.3", { luaTarget: tstl.LuaTarget.Lua53 }], ["luaTarget", "jit", { luaTarget: tstl.LuaTarget.LuaJIT }], + + ["luaBundle", "foo", { luaBundle: "foo" }], + ["luaBundleEntry", "bar", { luaBundleEntry: "bar" }], ])("--%s %s", (optionName, value, expected) => { const result = tstl.parseCommandLine([`--${optionName}`, value]); @@ -201,4 +204,33 @@ describe("tsconfig", () => { expect(result.options.noHeader).toBeUndefined(); }); }); + + describe("integration", () => { + test.each<[string, any, tstl.CompilerOptions]>([ + ["noHeader", false, { noHeader: false }], + ["noHeader", true, { noHeader: true }], + ["noHoisting", false, { noHoisting: false }], + ["noHoisting", true, { noHoisting: true }], + ["sourceMapTraceback", false, { sourceMapTraceback: false }], + ["sourceMapTraceback", true, { sourceMapTraceback: true }], + + ["luaLibImport", "none", { luaLibImport: tstl.LuaLibImportKind.None }], + ["luaLibImport", "always", { luaLibImport: tstl.LuaLibImportKind.Always }], + ["luaLibImport", "inline", { luaLibImport: tstl.LuaLibImportKind.Inline }], + ["luaLibImport", "require", { luaLibImport: tstl.LuaLibImportKind.Require }], + + ["luaTarget", "5.1", { luaTarget: tstl.LuaTarget.Lua51 }], + ["luaTarget", "5.2", { luaTarget: tstl.LuaTarget.Lua52 }], + ["luaTarget", "5.3", { luaTarget: tstl.LuaTarget.Lua53 }], + ["luaTarget", "jit", { luaTarget: tstl.LuaTarget.LuaJIT }], + + ["luaBundle", "foo", { luaBundle: "foo" }], + ["luaBundleEntry", "bar", { luaBundleEntry: "bar" }], + ])("{ %p: %p }", (optionName, value, expected) => { + const result = parseConfigFileContent({ tstl: { [optionName]: value } }); + + expect(result.errors).not.toHaveDiagnostics(); + expect(result.options).toEqual(expected); + }); + }); }); diff --git a/test/transpile/__snapshots__/directories.spec.ts.snap b/test/transpile/__snapshots__/directories.spec.ts.snap index 952fd709b..901d885f4 100644 --- a/test/transpile/__snapshots__/directories.spec.ts.snap +++ b/test/transpile/__snapshots__/directories.spec.ts.snap @@ -10,17 +10,17 @@ Array [ exports[`should be able to resolve ({"name": "basic", "options": [Object]}) 1`] = ` Array [ - "directories/basic/lualib_bundle.lua", "directories/basic/src/lib/file.lua", + "directories/basic/src/lualib_bundle.lua", "directories/basic/src/main.lua", ] `; exports[`should be able to resolve ({"name": "basic", "options": [Object]}) 2`] = ` Array [ + "directories/basic/out/lib/file.lua", "directories/basic/out/lualib_bundle.lua", - "directories/basic/out/src/lib/file.lua", - "directories/basic/out/src/main.lua", + "directories/basic/out/main.lua", ] `; diff --git a/test/transpile/__snapshots__/project.spec.ts.snap b/test/transpile/__snapshots__/project.spec.ts.snap new file mode 100644 index 000000000..498b36d47 --- /dev/null +++ b/test/transpile/__snapshots__/project.spec.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should transpile 1`] = ` +Array [ + Object { + "name": "otherFile.lua", + "text": "--[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] +local ____exports = {} +function ____exports.getNumber(self) + return GetAPIValue() +end +return ____exports +", + }, + Object { + "name": "index.lua", + "text": "--[[ Generated with https://github.com/TypeScriptToLua/TypeScriptToLua ]] +local ____otherFile = require(\\"otherFile\\") +local getNumber = ____otherFile.getNumber +local myNumber = getNumber(nil) +SetAPIValue(myNumber * 5) +", + }, +] +`; diff --git a/test/transpile/bundle.spec.ts b/test/transpile/bundle.spec.ts new file mode 100644 index 000000000..a5afd90b0 --- /dev/null +++ b/test/transpile/bundle.spec.ts @@ -0,0 +1,19 @@ +import * as path from "path"; +import * as util from "../util"; +import { transpileProject } from "../../src"; + +const projectDir = path.join(__dirname, "bundle"); +const inputProject = path.join(projectDir, "tsconfig.json"); + +test("should transpile into one file", () => { + const transpileResult = transpileProject(inputProject); + + expect(transpileResult.diagnostics).not.toHaveDiagnostics(); + expect(transpileResult.emitResult.length).toBe(1); + + const { name, text } = transpileResult.emitResult[0]; + // Verify the name is as specified in tsconfig + expect(name).toBe(path.join(projectDir, "bundle.lua").replace(/\\/g, "/")); + // Verify exported module by executing + expect(util.executeLuaModule(text)).toEqual({ myNumber: 3 }); +}); diff --git a/test/transpile/bundle/index.ts b/test/transpile/bundle/index.ts new file mode 100644 index 000000000..f189ac088 --- /dev/null +++ b/test/transpile/bundle/index.ts @@ -0,0 +1,3 @@ +import { getNumber } from "./otherFile"; + +export const myNumber = getNumber(); diff --git a/test/transpile/bundle/otherFile.ts b/test/transpile/bundle/otherFile.ts new file mode 100644 index 000000000..ad9eaceb7 --- /dev/null +++ b/test/transpile/bundle/otherFile.ts @@ -0,0 +1,3 @@ +export function getNumber(): number { + return 3; +} diff --git a/test/transpile/bundle/tsconfig.json b/test/transpile/bundle/tsconfig.json new file mode 100644 index 000000000..71060927d --- /dev/null +++ b/test/transpile/bundle/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "target": "esnext", + "lib": ["esnext"], + "types": [], + "rootDir": "." + }, + "tstl": { + "luaBundle": "bundle.lua", + "luaBundleEntry": "index.ts" + }, + "include": ["."] +} diff --git a/test/transpile/outFile.spec.ts b/test/transpile/outFile.spec.ts deleted file mode 100644 index fa073490e..000000000 --- a/test/transpile/outFile.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as path from "path"; -import { buildVirtualProject } from "./run"; - -const inputFilePath = path.join(__dirname, "outFile/index.ts"); -test("should support absolute outFile", () => { - const { diagnostics, emittedFiles } = buildVirtualProject([inputFilePath], { - outFile: path.join(__dirname, "output.script"), - }); - - expect(diagnostics).not.toHaveDiagnostics(); - expect(emittedFiles).toEqual(["output.script"]); -}); - -test("should support relative outFile", () => { - jest.spyOn(process, "cwd").mockReturnValue(__dirname); - const { diagnostics, emittedFiles } = buildVirtualProject([inputFilePath], { - outFile: "output.script", - }); - - expect(diagnostics).not.toHaveDiagnostics(); - expect(emittedFiles).toEqual(["output.script"]); -}); - -test("should support outFile with declaration", () => { - const { diagnostics, emittedFiles } = buildVirtualProject([inputFilePath], { - outFile: path.join(__dirname, "output.script"), - declaration: true, - }); - - expect(diagnostics).not.toHaveDiagnostics(); - expect(emittedFiles).toEqual(["output.d.ts", "output.script"]); -}); - -test("should resolve outFile relative to base directory", () => { - jest.spyOn(process, "cwd").mockReturnValue(__dirname); - const { diagnostics, emittedFiles } = buildVirtualProject([inputFilePath], { - outFile: "output.script", - outDir: "out", - declaration: true, - }); - - expect(diagnostics).not.toHaveDiagnostics(); - expect(emittedFiles).toEqual(["output.d.ts", "output.script"]); -}); diff --git a/test/transpile/outFile/index.ts b/test/transpile/outFile/index.ts deleted file mode 100644 index ef22a69f1..000000000 --- a/test/transpile/outFile/index.ts +++ /dev/null @@ -1 +0,0 @@ -class Test {} diff --git a/test/transpile/project.spec.ts b/test/transpile/project.spec.ts new file mode 100644 index 000000000..a8afc4229 --- /dev/null +++ b/test/transpile/project.spec.ts @@ -0,0 +1,18 @@ +import * as path from "path"; +import { transpileProject } from "../../src"; + +const projectDir = path.join(__dirname, "project"); +const inputProject = path.join(projectDir, "tsconfig.json"); + +test("should transpile", () => { + const transpileResult = transpileProject(inputProject); + + expect(transpileResult.diagnostics).not.toHaveDiagnostics(); + + // Check output paths relative to projectDir + const relativeResult = transpileResult.emitResult.map(({ name, text }) => ({ + name: path.relative(projectDir, name), + text, + })); + expect(relativeResult).toMatchSnapshot(); +}); diff --git a/test/transpile/project/api.d.ts b/test/transpile/project/api.d.ts new file mode 100644 index 000000000..dcac56a84 --- /dev/null +++ b/test/transpile/project/api.d.ts @@ -0,0 +1,3 @@ +/** @noSelfInFile */ +declare function GetAPIValue(): number; +declare function SetAPIValue(n: number): void; diff --git a/test/transpile/project/index.ts b/test/transpile/project/index.ts new file mode 100644 index 000000000..bd11cd347 --- /dev/null +++ b/test/transpile/project/index.ts @@ -0,0 +1,4 @@ +import { getNumber } from "./otherFile"; + +const myNumber = getNumber(); +SetAPIValue(myNumber * 5); diff --git a/test/transpile/project/otherFile.ts b/test/transpile/project/otherFile.ts new file mode 100644 index 000000000..ca4d2aa23 --- /dev/null +++ b/test/transpile/project/otherFile.ts @@ -0,0 +1,3 @@ +export function getNumber(): number { + return GetAPIValue(); +} diff --git a/test/transpile/project/tsconfig.json b/test/transpile/project/tsconfig.json new file mode 100644 index 000000000..8fb9d9e90 --- /dev/null +++ b/test/transpile/project/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "target": "esnext", + "lib": ["esnext"], + "types": [], + "rootDir": "." + } +} diff --git a/test/unit/bundle.spec.ts b/test/unit/bundle.spec.ts new file mode 100644 index 000000000..7bca27ae5 --- /dev/null +++ b/test/unit/bundle.spec.ts @@ -0,0 +1,141 @@ +import * as path from "path"; +import * as ts from "typescript"; +import { DiagnosticCategory } from "typescript"; +import { LuaLibImportKind } from "../../src"; +import { couldNotFindBundleEntryPoint } from "../../src/diagnostics"; +import * as util from "../util"; + +test("no entry point", () => { + util.testBundle`` + .setOptions({ luaBundleEntry: undefined }) + .expectToHaveDiagnostic( + d => + d.messageText === `'luaBundleEntry' is required when 'luaBundle' is enabled.` && + d.category === DiagnosticCategory.Error + ); +}); + +test("import module -> main", () => { + util.testBundle` + export { value } from "./module"; + ` + .addExtraFile("module.ts", "export const value = true") + .expectToEqual({ value: true }); +}); + +test("bundle file name", () => { + const { diagnostics, transpiledFiles } = util.testModule` + export { value } from "./module"; +` + .addExtraFile("module.ts", "export const value = true") + .setOptions({ luaBundle: "mybundle.lua", luaBundleEntry: "main.ts" }) + .getLuaResult(); + + expect(diagnostics.length).toBe(0); + expect(transpiledFiles.length).toBe(1); + expect(transpiledFiles[0].fileName).toBe( + path.join(ts.sys.getCurrentDirectory(), "mybundle.lua").replace(/\\/g, "/") + ); +}); + +test("import chain export -> reexport -> main", () => { + util.testBundle` + export { value } from "./reexport"; + ` + .addExtraFile("reexport.ts", "export { value } from './export'") + .addExtraFile("export.ts", "export const value = true") + .expectToEqual({ value: true }); +}); + +test("diamond imports/exports -> reexport1 & reexport2 -> main", () => { + util.testBundle` + export { value as a } from "./reexport1"; + export { value as b } from "./reexport2"; + ` + .addExtraFile("reexport1.ts", "export { value } from './export'") + .addExtraFile("reexport2.ts", "export { value } from './export'") + .addExtraFile("export.ts", "export const value = true") + .expectToEqual({ a: true, b: true }); +}); + +test("module in directory", () => { + util.testBundle` + export { value } from "./module/module"; + ` + .addExtraFile("module/module.ts", "export const value = true") + .expectToEqual({ value: true }); +}); + +test("modules aren't ordered by name", () => { + util.testBundle` + export { value } from "./a"; + ` + .addExtraFile("a.ts", "export const value = true") + .expectToEqual({ value: true }); +}); + +test("entry point in directory", () => { + util.testBundle`` + .addExtraFile( + "main/main.ts", + ` + export { value } from "../module"; + ` + ) + .addExtraFile("module.ts", "export const value = true") + .setEntryPoint("main/main.ts") + .expectToEqual({ value: true }); +}); + +test.each([LuaLibImportKind.Inline, LuaLibImportKind.Require])("LuaLib %p", lualibOption => { + const testBundle = util.testBundle` + export const result = [1, 2]; + result.push(3); + `.setOptions({ luaLibImport: lualibOption }); + + if (lualibOption === LuaLibImportKind.Inline) { + testBundle.expectToHaveDiagnostic(d => d.category === DiagnosticCategory.Warning); + } else { + expect(testBundle.getLuaResult().diagnostics).toEqual([]); + } + expect(testBundle.getLuaExecutionResult()).toEqual({ result: [1, 2, 3] }); +}); + +test("LuaBundle and LuaLibImport.Inline generate warning", () => { + const testBundle = util.testBundle` + export const result = [1, 2]; + result.push(3); + ` + .setOptions({ luaLibImport: LuaLibImportKind.Inline }) + .expectToHaveDiagnostic( + d => + d.category === DiagnosticCategory.Warning && + d.messageText === + `Using 'luaBundle' with 'luaLibImport: "inline"' might generate duplicate code. ` + + `It is recommended to use 'luaLibImport: "require"'` + ); + + expect(testBundle.getLuaExecutionResult()).toEqual({ result: [1, 2, 3] }); // Result should still be the same +}); + +test("cyclic imports", () => { + util.testBundle` + import * as b from "./b"; + export const a = true; + export const valueResult = b.value; + export const lazyValueResult = b.lazyValue(); + ` + .addExtraFile( + "b.ts", + ` + import * as a from "./main"; + export const value = a.a; + export const lazyValue = () => a.a; + ` + ) + .expectToEqual(new util.ExecutionError("stack overflow")); +}); + +test("luaEntry doesn't exist", () => { + util.testBundle``.setEntryPoint("entry.ts").expectToHaveExactDiagnostic(couldNotFindBundleEntryPoint("entry.ts")); +}); diff --git a/test/util.ts b/test/util.ts index 33e48b16f..b1dc96934 100644 --- a/test/util.ts +++ b/test/util.ts @@ -1,11 +1,11 @@ import { lauxlib, lua, lualib, to_jsstring, to_luastring } from "fengari"; 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 { stringify } from "javascript-stringify"; export * from "./legacy-utils"; @@ -107,6 +107,31 @@ function transpileJs(program: ts.Program): TranspileJsResult { return { transpiledFiles, diagnostics: [...diagnostics] }; } +function executeLua(code: string): any { + const L = lauxlib.luaL_newstate(); + lualib.luaL_openlibs(L); + const status = lauxlib.luaL_dostring(L, to_luastring(code)); + + if (status === lua.LUA_OK) { + if (lua.lua_isstring(L, -1)) { + const result = eval(`(${lua.lua_tojsstring(L, -1)})`); + return result === null ? undefined : result; + } else { + const returnType = to_jsstring(lua.lua_typename(L, lua.lua_type(L, -1))); + throw new Error(`Unsupported Lua return type: ${returnType}`); + } + } else { + // Filter out control characters appearing on some systems + const luaStackString = lua.lua_tostring(L, -1).filter(c => c >= 20); + const message = to_jsstring(luaStackString).replace(/^\[string "--\.\.\."\]:\d+: /, ""); + return new ExecutionError(message); + } +} + +export function executeLuaModule(code: string): any { + return executeLua(`${minimalTestLib}return JSONStringify((function()\n${code}\nend)())`); +} + const memoize: MethodDecorator = (_target, _propertyKey, descriptor) => { const originalFunction = descriptor.value as any; const memoized = new WeakMap(); @@ -129,6 +154,7 @@ export class ExecutionError extends Error { export type ExecutableTranspiledFile = tstl.TranspiledFile & { lua: string; sourceMap: string }; export type TapCallback = (builder: TestBuilder) => void; +export type DiagnosticMatcher = (diagnostic: ts.Diagnostic) => boolean; export abstract class TestBuilder { constructor(protected _tsCode: string) {} @@ -219,7 +245,9 @@ export abstract class TestBuilder { @memoize public getMainLuaFileResult(): ExecutableTranspiledFile { const { transpiledFiles } = this.getLuaResult(); - const mainFile = transpiledFiles.find(x => x.fileName === this.mainFileName); + const mainFile = this.options.luaBundle + ? transpiledFiles[0] + : transpiledFiles.find(x => x.fileName === this.mainFileName); expect(mainFile).toMatchObject({ lua: expect.any(String), sourceMap: expect.any(String) }); return mainFile as ExecutableTranspiledFile; } @@ -234,25 +262,7 @@ export abstract class TestBuilder { @memoize public getLuaExecutionResult(): any { - const code = this.getLuaCodeWithWrapper(); - const L = lauxlib.luaL_newstate(); - lualib.luaL_openlibs(L); - const status = lauxlib.luaL_dostring(L, to_luastring(code)); - - if (status === lua.LUA_OK) { - if (lua.lua_isstring(L, -1)) { - const result = eval(`(${lua.lua_tojsstring(L, -1)})`); - return result === null ? undefined : result; - } else { - const returnType = to_jsstring(lua.lua_typename(L, lua.lua_type(L, -1))); - throw new Error(`Unsupported Lua return type: ${returnType}`); - } - } else { - // Filter out control characters appearing on some systems - const luaStackString = lua.lua_tostring(L, -1).filter(c => c >= 20); - const message = to_jsstring(luaStackString).replace(/^\[string "--\.\.\."\]:\d+: /, ""); - return new ExecutionError(message); - } + return executeLua(this.getLuaCodeWithWrapper()); } @memoize @@ -330,12 +340,22 @@ export abstract class TestBuilder { return this; } + public expectToHaveDiagnostic(matcher: DiagnosticMatcher): this { + expect(this.getLuaDiagnostics().find(matcher)).toBeDefined(); + return this; + } + + public expectToHaveExactDiagnostic(diagnostic: ts.Diagnostic): this { + expect(this.getLuaDiagnostics()).toContainEqual(diagnostic); + return this; + } + public expectToHaveDiagnostics(): this { expect(this.getLuaDiagnostics()).toHaveDiagnostics(); return this; } - public expectToHaveDiagnosticOfError(error: tstl.TranspileError): this { + public expectToHaveDiagnosticOfError(error: Error): this { this.expectToHaveDiagnostics(); expect(this.getLuaDiagnostics()).toHaveLength(1); const firstDiagnostic = this.getLuaDiagnostics()[0]; @@ -414,6 +434,17 @@ class AccessorTestBuilder extends TestBuilder { } } +class BundleTestBuilder extends AccessorTestBuilder { + public 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(name: string): this { expect(this.hasProgram).toBe(false); @@ -458,6 +489,7 @@ const createTestBuilderFactory = ( 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);