From b85b5dee94a3b5a84ff7da4b9d576b98c9a849ae Mon Sep 17 00:00:00 2001 From: Cold Fry Date: Mon, 30 Mar 2026 14:28:19 +0100 Subject: [PATCH 1/3] fix dots in file/directory names breaking Lua require resolution (#1445) Lua's require() uses dots as path separators, so a file at Foo.Bar/index.lua is unreachable via require("Foo.Bar.index") since Lua looks for Foo/Bar/index.lua. Expand dotted path segments into nested directories in the emit output, and emit a diagnostic when this expansion causes output path collisions. --- src/transpilation/diagnostics.ts | 6 +++++ src/transpilation/transpiler.ts | 28 +++++++++++++++++----- test/unit/modules/resolution.spec.ts | 35 +++++++++++++++++++++++++++- test/util.ts | 3 ++- 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/transpilation/diagnostics.ts b/src/transpilation/diagnostics.ts index 348542ac5..53b3e0f01 100644 --- a/src/transpilation/diagnostics.ts +++ b/src/transpilation/diagnostics.ts @@ -59,3 +59,9 @@ export const unsupportedJsxEmit = createDiagnosticFactory(() => 'JSX is only sup export const pathsWithoutBaseUrl = createDiagnosticFactory( () => "When configuring 'paths' in tsconfig.json, the option 'baseUrl' must also be provided." ); + +export const emitPathCollision = createDiagnosticFactory( + (outputPath: string, file1: string, file2: string) => + `Output path '${outputPath}' is used by both '${file1}' and '${file2}'. ` + + `Dots in file/directory names are expanded to nested directories for Lua module resolution.` +); diff --git a/src/transpilation/transpiler.ts b/src/transpilation/transpiler.ts index 9bac1f5bf..05bd1f082 100644 --- a/src/transpilation/transpiler.ts +++ b/src/transpilation/transpiler.ts @@ -4,6 +4,7 @@ import { CompilerOptions, isBundleEnabled, LuaLibImportKind, LuaTarget } from ". import { buildMinimalLualibBundle, findUsedLualibFeatures, getLuaLibBundle } from "../LuaLib"; import { normalizeSlashes, trimExtension } from "../utils"; import { getBundleResult } from "./bundle"; +import { emitPathCollision } from "./diagnostics"; import { getPlugins, Plugin } from "./plugins"; import { resolveDependencies } from "./resolve"; import { getProgramTranspileResult, TranspileOptions } from "./transpile"; @@ -143,10 +144,18 @@ export class Transpiler { diagnostics.push(...bundleDiagnostics); emitPlan = [bundleFile]; } else { - emitPlan = resolutionResult.resolvedFiles.map(file => ({ - ...file, - outputPath: getEmitPath(file.fileName, program), - })); + // Check for output path collisions caused by dot expansion + const outputPathMap = new Map(); + emitPlan = resolutionResult.resolvedFiles.map(file => { + const outputPath = getEmitPath(file.fileName, program); + const existing = outputPathMap.get(outputPath); + if (existing) { + diagnostics.push(emitPathCollision(outputPath, existing, file.fileName)); + } else { + outputPathMap.set(outputPath, file.fileName); + } + return { ...file, outputPath }; + }); } performance.endSection("getEmitPlan"); @@ -189,11 +198,18 @@ export function getEmitPathRelativeToOutDir(fileName: string, program: ts.Progra emitPathSplits[0] = "lua_modules"; } + // Expand dots in path segments into nested directories so that Lua's require() + // resolves correctly (e.g. "Foo.Bar/index.ts" -> "Foo/Bar/index.lua"). + // Dots are path separators in Lua's module system, so a file at "Foo.Bar/index.lua" + // would be unreachable via require("Foo.Bar.index") since Lua looks for "Foo/Bar/index.lua". + // Strip the source extension first, split all dots, then re-add the output extension. + emitPathSplits[emitPathSplits.length - 1] = trimExtension(emitPathSplits[emitPathSplits.length - 1]); + emitPathSplits = emitPathSplits.flatMap(segment => segment.split(".")); + // Set extension const extension = ((program.getCompilerOptions() as CompilerOptions).extension ?? "lua").trim(); const trimmedExtension = extension.startsWith(".") ? extension.substring(1) : extension; - emitPathSplits[emitPathSplits.length - 1] = - trimExtension(emitPathSplits[emitPathSplits.length - 1]) + "." + trimmedExtension; + emitPathSplits[emitPathSplits.length - 1] += "." + trimmedExtension; return path.join(...emitPathSplits); } diff --git a/test/unit/modules/resolution.spec.ts b/test/unit/modules/resolution.spec.ts index 26c147c68..d6b46fa87 100644 --- a/test/unit/modules/resolution.spec.ts +++ b/test/unit/modules/resolution.spec.ts @@ -1,5 +1,5 @@ import * as ts from "typescript"; -import { couldNotResolveRequire } from "../../../src/transpilation/diagnostics"; +import { couldNotResolveRequire, emitPathCollision } from "../../../src/transpilation/diagnostics"; import * as util from "../../util"; const requireRegex = /require\("(.*?)"\)/; @@ -166,6 +166,39 @@ test.each([ .tap(expectToRequire(expectedPath)); }); +// https://github.com/TypeScriptToLua/TypeScriptToLua/issues/1445 +// Can't test this via execution because the test harness uses package.preload +// instead of real filesystem resolution, so require() always finds the module +// regardless of output path. We check the output path directly instead. +// TODO: test via actual Lua execution once the harness supports filesystem resolution. +test("dots in directory names emit to nested directories", () => { + const { transpiledFiles } = util.testModule` + import { answer } from "./Foo.Bar"; + export const result = answer; + ` + .addExtraFile("Foo.Bar/index.ts", "export const answer = 42;") + .setOptions({ rootDir: "." }) + .getLuaResult(); + + // Foo.Bar/index.ts should emit to Foo/Bar/index.lua, not Foo.Bar/index.lua + const dottedFile = transpiledFiles.find(f => f.lua?.includes("answer = 42")); + expect(dottedFile).toBeDefined(); + expect(dottedFile!.outPath).toContain("Foo/Bar/index.lua"); + expect(dottedFile!.outPath).not.toContain("Foo.Bar"); +}); + +test("dots in paths that collide with existing paths produce a diagnostic", () => { + util.testModule` + import { a } from "./Foo.Bar"; + import { b } from "./Foo/Bar"; + export const result = a + b; + ` + .addExtraFile("Foo.Bar/index.ts", "export const a = 1;") + .addExtraFile("Foo/Bar/index.ts", "export const b = 2;") + .setOptions({ rootDir: "." }) + .expectToHaveDiagnostics([emitPathCollision.code]); +}); + test("import = require", () => { util.testModule` import foo = require("./foo/bar"); diff --git a/test/util.ts b/test/util.ts index 2871f6e5a..8c5e69f8f 100644 --- a/test/util.ts +++ b/test/util.ts @@ -534,8 +534,9 @@ end)());`; const moduleExports = {}; globalContext.exports = moduleExports; globalContext.module = { exports: moduleExports }; + const baseName = fileName.replace("./", ""); const transpiledExtraFile = transpiledFiles.find(({ sourceFiles }) => - sourceFiles.some(f => f.fileName === fileName.replace("./", "") + ".ts") + sourceFiles.some(f => f.fileName === baseName + ".ts" || f.fileName === baseName + "/index.ts") ); if (transpiledExtraFile?.js) { From 3f30817a57f1122107ffd54241338840e727b290 Mon Sep 17 00:00:00 2001 From: Cold Fry Date: Fri, 3 Apr 2026 02:45:56 +0100 Subject: [PATCH 2/3] replace dots with underscores in output paths instead of expanding to nested directories --- src/transpilation/diagnostics.ts | 2 +- src/transpilation/transpiler.ts | 12 +++++------- test/unit/modules/resolution.spec.ts | 11 +++++------ 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/transpilation/diagnostics.ts b/src/transpilation/diagnostics.ts index 53b3e0f01..b7282f0f2 100644 --- a/src/transpilation/diagnostics.ts +++ b/src/transpilation/diagnostics.ts @@ -63,5 +63,5 @@ export const pathsWithoutBaseUrl = createDiagnosticFactory( export const emitPathCollision = createDiagnosticFactory( (outputPath: string, file1: string, file2: string) => `Output path '${outputPath}' is used by both '${file1}' and '${file2}'. ` + - `Dots in file/directory names are expanded to nested directories for Lua module resolution.` + `Dots in file/directory names are replaced with underscores for Lua module resolution.` ); diff --git a/src/transpilation/transpiler.ts b/src/transpilation/transpiler.ts index 05bd1f082..7f79af35d 100644 --- a/src/transpilation/transpiler.ts +++ b/src/transpilation/transpiler.ts @@ -144,7 +144,6 @@ export class Transpiler { diagnostics.push(...bundleDiagnostics); emitPlan = [bundleFile]; } else { - // Check for output path collisions caused by dot expansion const outputPathMap = new Map(); emitPlan = resolutionResult.resolvedFiles.map(file => { const outputPath = getEmitPath(file.fileName, program); @@ -198,13 +197,12 @@ export function getEmitPathRelativeToOutDir(fileName: string, program: ts.Progra emitPathSplits[0] = "lua_modules"; } - // Expand dots in path segments into nested directories so that Lua's require() - // resolves correctly (e.g. "Foo.Bar/index.ts" -> "Foo/Bar/index.lua"). - // Dots are path separators in Lua's module system, so a file at "Foo.Bar/index.lua" - // would be unreachable via require("Foo.Bar.index") since Lua looks for "Foo/Bar/index.lua". - // Strip the source extension first, split all dots, then re-add the output extension. + // Replace dots with underscores in path segments so that Lua's require() + // resolves correctly. Dots are path separators in Lua's module system, so + // "Foo.Bar/index.lua" would be unreachable via require("Foo.Bar.index") + // since Lua interprets it as "Foo/Bar/index.lua". emitPathSplits[emitPathSplits.length - 1] = trimExtension(emitPathSplits[emitPathSplits.length - 1]); - emitPathSplits = emitPathSplits.flatMap(segment => segment.split(".")); + emitPathSplits = emitPathSplits.map(segment => segment.replace(/\./g, "_")); // Set extension const extension = ((program.getCompilerOptions() as CompilerOptions).extension ?? "lua").trim(); diff --git a/test/unit/modules/resolution.spec.ts b/test/unit/modules/resolution.spec.ts index d6b46fa87..218f95aee 100644 --- a/test/unit/modules/resolution.spec.ts +++ b/test/unit/modules/resolution.spec.ts @@ -1,3 +1,4 @@ +import * as path from "path"; import * as ts from "typescript"; import { couldNotResolveRequire, emitPathCollision } from "../../../src/transpilation/diagnostics"; import * as util from "../../util"; @@ -170,8 +171,7 @@ test.each([ // Can't test this via execution because the test harness uses package.preload // instead of real filesystem resolution, so require() always finds the module // regardless of output path. We check the output path directly instead. -// TODO: test via actual Lua execution once the harness supports filesystem resolution. -test("dots in directory names emit to nested directories", () => { +test("dots in directory names are replaced with underscores in output", () => { const { transpiledFiles } = util.testModule` import { answer } from "./Foo.Bar"; export const result = answer; @@ -180,21 +180,20 @@ test("dots in directory names emit to nested directories", () => { .setOptions({ rootDir: "." }) .getLuaResult(); - // Foo.Bar/index.ts should emit to Foo/Bar/index.lua, not Foo.Bar/index.lua const dottedFile = transpiledFiles.find(f => f.lua?.includes("answer = 42")); expect(dottedFile).toBeDefined(); - expect(dottedFile!.outPath).toContain("Foo/Bar/index.lua"); + expect(dottedFile!.outPath).toContain(path.join("Foo_Bar", "index.lua")); expect(dottedFile!.outPath).not.toContain("Foo.Bar"); }); test("dots in paths that collide with existing paths produce a diagnostic", () => { util.testModule` import { a } from "./Foo.Bar"; - import { b } from "./Foo/Bar"; + import { b } from "./Foo_Bar"; export const result = a + b; ` .addExtraFile("Foo.Bar/index.ts", "export const a = 1;") - .addExtraFile("Foo/Bar/index.ts", "export const b = 2;") + .addExtraFile("Foo_Bar/index.ts", "export const b = 2;") .setOptions({ rootDir: "." }) .expectToHaveDiagnostics([emitPathCollision.code]); }); From c43850140f254180771e702e5c664cece68eba91 Mon Sep 17 00:00:00 2001 From: Cold Fry Date: Tue, 14 Apr 2026 07:49:53 +0000 Subject: [PATCH 3/3] more tests --- test/unit/modules/resolution.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/unit/modules/resolution.spec.ts b/test/unit/modules/resolution.spec.ts index 218f95aee..c6f97d5f9 100644 --- a/test/unit/modules/resolution.spec.ts +++ b/test/unit/modules/resolution.spec.ts @@ -186,6 +186,21 @@ test("dots in directory names are replaced with underscores in output", () => { expect(dottedFile!.outPath).not.toContain("Foo.Bar"); }); +test("dots in file names are replaced with underscores in output", () => { + const { transpiledFiles } = util.testModule` + import { answer } from "./foo.test"; + export const result = answer; + ` + .addExtraFile("foo.test.ts", "export const answer = 42;") + .setOptions({ rootDir: "." }) + .getLuaResult(); + + const dottedFile = transpiledFiles.find(f => f.lua?.includes("answer = 42")); + expect(dottedFile).toBeDefined(); + expect(dottedFile!.outPath).toContain("foo_test.lua"); + expect(dottedFile!.outPath).not.toContain("foo.test"); +}); + test("dots in paths that collide with existing paths produce a diagnostic", () => { util.testModule` import { a } from "./Foo.Bar";