diff --git a/src/CompilerOptions.ts b/src/CompilerOptions.ts index f7c5999c5..11b51ded5 100644 --- a/src/CompilerOptions.ts +++ b/src/CompilerOptions.ts @@ -20,6 +20,7 @@ export interface TransformerImport { export type CompilerOptions = OmitIndexSignature & { noImplicitSelf?: boolean; noHeader?: boolean; + luaEntry?: string; luaTarget?: LuaTarget; luaLibImport?: LuaLibImportKind; noHoisting?: boolean; diff --git a/src/LuaLib.ts b/src/LuaLib.ts index c877dd9cc..f72bff62b 100644 --- a/src/LuaLib.ts +++ b/src/LuaLib.ts @@ -35,6 +35,7 @@ export enum LuaLibFeature { InstanceOf = "InstanceOf", InstanceOfObject = "InstanceOfObject", Iterator = "Iterator", + LuaRequire = "LuaRequire", Map = "Map", NewIndex = "NewIndex", Number = "Number", diff --git a/src/LuaTransformer.ts b/src/LuaTransformer.ts index e460993b7..7c6e2a95f 100644 --- a/src/LuaTransformer.ts +++ b/src/LuaTransformer.ts @@ -92,8 +92,6 @@ export class LuaTransformer { this.genVarCounter = 0; this.luaLibFeatureSet = new Set(); - this.visitedExportEquals = false; - this.scopeStack = []; this.classStack = []; @@ -104,24 +102,63 @@ export class LuaTransformer { protected currentSourceFile!: ts.SourceFile; protected isModule!: boolean; + protected isWithinBundle!: boolean; protected resolver!: EmitResolver; /** @internal */ - public transform(sourceFile: ts.SourceFile): [tstl.Block, Set] { + public transform(node: ts.Bundle | ts.SourceFile): [tstl.Block, Set] { this.setupState(); - this.currentSourceFile = sourceFile; - this.isModule = tsHelper.isFileModule(sourceFile); + if (ts.isSourceFile(node)) { + return [this.transformSourceFileWithState(node), this.luaLibFeatureSet]; + } else { + return [this.transformBundle(node), this.luaLibFeatureSet]; + } + } + + private transformBundle(bundle: ts.Bundle): tstl.Block { + this.isWithinBundle = true; + const combinedStatements = bundle.sourceFiles.reduce( + (statements: tstl.Statement[], sourceFile: ts.SourceFile) => { + const transformResult = this.transformSourceFileWithState(sourceFile); + return [...statements, ...transformResult.statements]; + }, + [] + ); + + if (this.options.luaEntry) { + const basedir = this.options.configFilePath + ? path.dirname(this.options.configFilePath as string) + : undefined; + const luaEntryPath = basedir ? path.resolve(basedir, this.options.luaEntry) : this.options.luaEntry; + const sourceFile = this.program.getSourceFile(luaEntryPath); + if (sourceFile) { + const formattedEntryName = tsHelper.getExportPath(sourceFile.fileName, this.options); + const requireCall = this.createModuleRequire(ts.createStringLiteral(formattedEntryName), false); + const returnStatement = tstl.createReturnStatement([requireCall]); + combinedStatements.push(returnStatement); + } else { + throw TSTLErrors.LuaEntryNotFound(this.options.luaEntry); + } + } + + return tstl.createBlock(combinedStatements, bundle); + } + + private transformSourceFileWithState(node: ts.SourceFile): tstl.Block { + this.currentSourceFile = node; + this.isModule = tsHelper.isFileModule(node); // Use `getParseTreeNode` to get original SourceFile node, before it was substituted by custom transformers. // It's required because otherwise `getEmitResolver` won't use cached diagnostics, produced in `emitWorker` // and would try to re-analyze the file, which would fail because of replaced nodes. - const originalSourceFile = ts.getParseTreeNode(sourceFile, ts.isSourceFile) || sourceFile; + const originalSourceFile = ts.getParseTreeNode(node, ts.isSourceFile) || node; this.resolver = this.checker.getEmitResolver(originalSourceFile); - return [this.transformSourceFile(sourceFile), this.luaLibFeatureSet]; + return this.transformSourceFile(node); } public transformSourceFile(sourceFile: ts.SourceFile): tstl.Block { + this.visitedExportEquals = false; let statements: tstl.Statement[] = []; if (sourceFile.flags & ts.NodeFlags.JsonFile) { const statement = sourceFile.statements[0]; @@ -136,15 +173,17 @@ export class LuaTransformer { this.popScope(); if (this.isModule) { - // If export equals was not used. Create the exports table. - // local exports = {} - if (!this.visitedExportEquals) { - statements.unshift( - tstl.createVariableDeclarationStatement( - this.createExportsIdentifier(), - tstl.createTableExpression() - ) - ); + if (!this.isWithinBundle) { + // If export equals was not used. Create the exports table. + // local exports = {} + if (!this.visitedExportEquals) { + statements.unshift( + tstl.createVariableDeclarationStatement( + this.createExportsIdentifier(), + tstl.createTableExpression() + ) + ); + } } // return exports @@ -152,6 +191,17 @@ export class LuaTransformer { } } + if (this.isWithinBundle) { + const moduleTableIdentifier = tstl.createIdentifier("____modules"); + const exportPath = tsHelper.getExportPath(sourceFile.fileName, this.options); + const moduleParameters = this.visitedExportEquals ? undefined : [this.createExportsIdentifier()]; + const moduleDeclaration = tstl.createAssignmentStatement( + tstl.createTableIndexExpression(moduleTableIdentifier, tstl.createStringLiteral(exportPath)), + tstl.createFunctionExpression(tstl.createBlock(statements, sourceFile), moduleParameters) + ); + return tstl.createBlock([moduleDeclaration], sourceFile); + } + return tstl.createBlock(statements, sourceFile); } @@ -511,7 +561,12 @@ export class LuaTransformer { ) : moduleSpecifier.text; const modulePath = tstl.createStringLiteral(modulePathString); - return tstl.createCallExpression(tstl.createIdentifier("require"), [modulePath], moduleSpecifier); + const requireFunctionName = this.options.outFile ? "__TS__LuaRequire" : "require"; + if (this.options.outFile) { + this.importLuaLibFeature(LuaLibFeature.LuaRequire); + } + + return tstl.createCallExpression(tstl.createIdentifier(requireFunctionName), [modulePath], moduleSpecifier); } protected validateClassElement(element: ts.ClassElement): void { diff --git a/src/TSTLErrors.ts b/src/TSTLErrors.ts index c32318f45..75b77a26c 100644 --- a/src/TSTLErrors.ts +++ b/src/TSTLErrors.ts @@ -57,6 +57,9 @@ export const InvalidElementCall = (node: ts.Node) => export const ForbiddenStaticClassPropertyName = (node: ts.Node, name: string) => new TranspileError(`Cannot use "${name}" as a static class property or method name.`, node); +export const LuaEntryNotFound = (entryName: string) => + new TranspileError(`Could not find luaEntry file '${entryName}'.`); + export const MissingClassName = (node: ts.Node) => new TranspileError(`Class declarations must have a name.`, node); export const MissingForOfVariables = (node: ts.Node) => diff --git a/src/TSTransformers.ts b/src/TSTransformers.ts index 710e3ac0e..6dd34c893 100644 --- a/src/TSTransformers.ts +++ b/src/TSTransformers.ts @@ -10,12 +10,18 @@ export function getCustomTransformers( program: ts.Program, diagnostics: ts.Diagnostic[], customTransformers: ts.CustomTransformers, - onSourceFile: (sourceFile: ts.SourceFile) => void + onRootNode: (node: ts.Bundle | ts.SourceFile) => void ): ts.CustomTransformers { - const luaTransformer: ts.TransformerFactory = () => sourceFile => { - onSourceFile(sourceFile); - return ts.createSourceFile(sourceFile.fileName, "", ts.ScriptTarget.ESNext); - }; + const luaTransformer: ts.CustomTransformerFactory = () => ({ + transformBundle: node => { + onRootNode(node); + return ts.createBundle([]); + }, + transformSourceFile: node => { + onRootNode(node); + return ts.createSourceFile(node.fileName, "", ts.ScriptTarget.ESNext); + }, + }); const transformersFromOptions = loadTransformersFromOptions(program, diagnostics); diff --git a/src/Transpile.ts b/src/Transpile.ts index 28a6282a9..4ee6e390d 100644 --- a/src/Transpile.ts +++ b/src/Transpile.ts @@ -78,26 +78,32 @@ export function transpile({ } } - const processSourceFile = (sourceFile: ts.SourceFile) => { + const processRootNode = (node: ts.Bundle | ts.SourceFile) => { + if (ts.isBundle(node) && !options.outFile) { + throw new Error("The option outFile must be specified when transforming a bundle."); + } + + const fileName = ts.isBundle(node) ? options.outFile! : node.fileName; + try { - const [luaAst, lualibFeatureSet] = transformer.transform(sourceFile); + const [luaAst, lualibFeatureSet] = transformer.transform(node); if (!options.noEmit && !options.emitDeclarationOnly) { - const [lua, sourceMap] = printer.print(luaAst, lualibFeatureSet, sourceFile.fileName); - updateTranspiledFile(sourceFile.fileName, { luaAst, lua, sourceMap }); + const [lua, sourceMap] = printer.print(luaAst, lualibFeatureSet, fileName); + updateTranspiledFile(fileName, { luaAst, lua, sourceMap }); } } catch (err) { if (!(err instanceof TranspileError)) throw err; diagnostics.push(diagnosticFactories.transpileError(err)); - updateTranspiledFile(sourceFile.fileName, { + updateTranspiledFile(fileName, { lua: `error(${JSON.stringify(err.message)})\n`, sourceMap: "", }); } }; - const transformers = getCustomTransformers(program, diagnostics, customTransformers, processSourceFile); + const transformers = getCustomTransformers(program, diagnostics, customTransformers, processRootNode); const writeFile: ts.WriteFileCallback = (fileName, data, _bom, _onError, sourceFiles = []) => { for (const sourceFile of sourceFiles) { @@ -123,7 +129,7 @@ export function transpile({ if (targetSourceFiles) { for (const file of targetSourceFiles) { if (isEmittableJsonFile(file)) { - processSourceFile(file); + processRootNode(file); } else { diagnostics.push(...program.emit(file, writeFile, undefined, false, transformers).diagnostics); } @@ -135,7 +141,7 @@ export function transpile({ program .getSourceFiles() .filter(isEmittableJsonFile) - .forEach(processSourceFile); + .forEach(processRootNode); } options.noEmit = oldNoEmit; diff --git a/src/TranspileError.ts b/src/TranspileError.ts index baeded940..d0610b16f 100644 --- a/src/TranspileError.ts +++ b/src/TranspileError.ts @@ -2,7 +2,7 @@ import * as ts from "typescript"; export class TranspileError extends Error { public name = "TranspileError"; - constructor(message: string, public node: ts.Node) { + constructor(message: string, public node?: ts.Node) { super(message); } } diff --git a/src/cli/parse.ts b/src/cli/parse.ts index 8bbaad42b..3891dcaf0 100644 --- a/src/cli/parse.ts +++ b/src/cli/parse.ts @@ -17,13 +17,18 @@ interface CommandLineOptionOfEnum extends CommandLineOptionBase { choices: string[]; } -interface CommandLineOptionOfBoolean extends CommandLineOptionBase { - type: "boolean"; +interface CommandLineOptionOfPrimitiveType extends CommandLineOptionBase { + type: "string" | "boolean"; } -type CommandLineOption = CommandLineOptionOfEnum | CommandLineOptionOfBoolean; +type CommandLineOption = CommandLineOptionOfEnum | CommandLineOptionOfPrimitiveType; export const optionDeclarations: CommandLineOption[] = [ + { + name: "luaEntry", + description: "Specifies an entry point that will be executed in the resulting output file.", + type: "string", + }, { name: "luaLibImport", description: "Specifies how js standard features missing in lua are imported.", @@ -164,11 +169,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..b566a98c1 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -2,9 +2,9 @@ import * as ts from "typescript"; import { TranspileError } from "./TranspileError"; export const transpileError = (error: TranspileError): ts.Diagnostic => ({ - file: error.node.getSourceFile(), - start: error.node.getStart(), - length: error.node.getWidth(), + file: error.node ? error.node.getSourceFile() : undefined, + start: error.node ? error.node.getStart() : undefined, + length: error.node ? error.node.getWidth() : undefined, category: ts.DiagnosticCategory.Error, code: 0, source: "typescript-to-lua", diff --git a/src/lualib/LuaRequire.ts b/src/lualib/LuaRequire.ts new file mode 100644 index 000000000..086e41d90 --- /dev/null +++ b/src/lualib/LuaRequire.ts @@ -0,0 +1,18 @@ +// tslint:disable: variable-name +const ____modules: Record any> = {}; +const ____moduleCache: Record = {}; + +function __TS__LuaRequire(this: void, moduleName: string): any { + if (____moduleCache[moduleName]) { + return ____moduleCache[moduleName]; + } + const loadScript = ____modules[moduleName]; + if (!loadScript) { + // tslint:disable-next-line: no-string-throw + throw `module '${moduleName}' not found`; + } + const moduleExports = {}; + ____moduleCache[moduleName] = moduleExports; + ____moduleCache[moduleName] = loadScript(moduleExports); + return ____moduleCache[moduleName]; +} diff --git a/test/unit/outFile.spec.ts b/test/unit/outFile.spec.ts new file mode 100644 index 000000000..e21d36299 --- /dev/null +++ b/test/unit/outFile.spec.ts @@ -0,0 +1,102 @@ +import * as util from "../util"; +import * as TSTLErrors from "../../src/TSTLErrors"; + +test("import module -> main", () => { + util.testBundle` + export { value } from "./module"; + ` + .addExtraFile("module.ts", "export const value = true") + .expectToEqual({ value: true }); +}); + +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") + .setOptions({ luaEntry: "main/main.ts" }) + .expectToEqual({ value: true }); +}); + +test("LuaLibs", () => { + util.testBundle` + export const result = [1, 2]; + result.push(3); + `.expectToEqual({ result: [1, 2, 3] }); +}); + +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({ a: true, lazyValueResult: true }); +}); + +test("luaEntry doesn't exist", () => { + util.testBundle`` + .setOptions({ luaEntry: "entry.ts" }) + .expectToHaveDiagnosticOfError(TSTLErrors.LuaEntryNotFound("entry.ts")); +}); + +test("luaEntry resolved from path specified in tsconfig", () => { + util.testBundle`` + .addExtraFile("src/main.ts", "") + .addExtraFile("src/module.ts", "") + .setOptions({ rootDir: "src" }) + .expectToHaveNoDiagnostics(); +}); + +test("export equals", () => { + util.testBundle`export = "result"`.expectToEqual("result"); +}); diff --git a/test/util.ts b/test/util.ts index 33e48b16f..5670d5563 100644 --- a/test/util.ts +++ b/test/util.ts @@ -219,7 +219,8 @@ export abstract class TestBuilder { @memoize public getMainLuaFileResult(): ExecutableTranspiledFile { const { transpiledFiles } = this.getLuaResult(); - const mainFile = transpiledFiles.find(x => x.fileName === this.mainFileName); + const mainFileName = this.options.outFile ? this.options.outFile : this.mainFileName; + const mainFile = transpiledFiles.find(x => x.fileName === mainFileName); expect(mainFile).toMatchObject({ lua: expect.any(String), sourceMap: expect.any(String) }); return mainFile as ExecutableTranspiledFile; } @@ -414,6 +415,22 @@ class AccessorTestBuilder extends TestBuilder { } } +class BundleTestBuilder extends AccessorTestBuilder { + protected mainFileName = "main.ts"; + + public constructor(_tsCode: string) { + super(_tsCode); + this.setOptions({ outFile: "main.lua", module: ts.ModuleKind.AMD, luaEntry: this.mainFileName }); + } + + public getLuaCodeWithWrapper(): string { + const noRequire = "_G.require = nil"; + const noPackage = "_G.package = nil"; + const luaCode = super.getLuaCodeWithWrapper(); + return `${noRequire}\n${noPackage}\n${luaCode}`; + } +} + class ModuleTestBuilder extends AccessorTestBuilder { public setReturnExport(name: string): this { expect(this.hasProgram).toBe(false); @@ -458,6 +475,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);