Skip to content

Commit b85b5de

Browse files
committed
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.
1 parent ec26bdf commit b85b5de

File tree

4 files changed

+64
-8
lines changed

4 files changed

+64
-8
lines changed

src/transpilation/diagnostics.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,9 @@ export const unsupportedJsxEmit = createDiagnosticFactory(() => 'JSX is only sup
5959
export const pathsWithoutBaseUrl = createDiagnosticFactory(
6060
() => "When configuring 'paths' in tsconfig.json, the option 'baseUrl' must also be provided."
6161
);
62+
63+
export const emitPathCollision = createDiagnosticFactory(
64+
(outputPath: string, file1: string, file2: string) =>
65+
`Output path '${outputPath}' is used by both '${file1}' and '${file2}'. ` +
66+
`Dots in file/directory names are expanded to nested directories for Lua module resolution.`
67+
);

src/transpilation/transpiler.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { CompilerOptions, isBundleEnabled, LuaLibImportKind, LuaTarget } from ".
44
import { buildMinimalLualibBundle, findUsedLualibFeatures, getLuaLibBundle } from "../LuaLib";
55
import { normalizeSlashes, trimExtension } from "../utils";
66
import { getBundleResult } from "./bundle";
7+
import { emitPathCollision } from "./diagnostics";
78
import { getPlugins, Plugin } from "./plugins";
89
import { resolveDependencies } from "./resolve";
910
import { getProgramTranspileResult, TranspileOptions } from "./transpile";
@@ -143,10 +144,18 @@ export class Transpiler {
143144
diagnostics.push(...bundleDiagnostics);
144145
emitPlan = [bundleFile];
145146
} else {
146-
emitPlan = resolutionResult.resolvedFiles.map(file => ({
147-
...file,
148-
outputPath: getEmitPath(file.fileName, program),
149-
}));
147+
// Check for output path collisions caused by dot expansion
148+
const outputPathMap = new Map<string, string>();
149+
emitPlan = resolutionResult.resolvedFiles.map(file => {
150+
const outputPath = getEmitPath(file.fileName, program);
151+
const existing = outputPathMap.get(outputPath);
152+
if (existing) {
153+
diagnostics.push(emitPathCollision(outputPath, existing, file.fileName));
154+
} else {
155+
outputPathMap.set(outputPath, file.fileName);
156+
}
157+
return { ...file, outputPath };
158+
});
150159
}
151160

152161
performance.endSection("getEmitPlan");
@@ -189,11 +198,18 @@ export function getEmitPathRelativeToOutDir(fileName: string, program: ts.Progra
189198
emitPathSplits[0] = "lua_modules";
190199
}
191200

201+
// Expand dots in path segments into nested directories so that Lua's require()
202+
// resolves correctly (e.g. "Foo.Bar/index.ts" -> "Foo/Bar/index.lua").
203+
// Dots are path separators in Lua's module system, so a file at "Foo.Bar/index.lua"
204+
// would be unreachable via require("Foo.Bar.index") since Lua looks for "Foo/Bar/index.lua".
205+
// Strip the source extension first, split all dots, then re-add the output extension.
206+
emitPathSplits[emitPathSplits.length - 1] = trimExtension(emitPathSplits[emitPathSplits.length - 1]);
207+
emitPathSplits = emitPathSplits.flatMap(segment => segment.split("."));
208+
192209
// Set extension
193210
const extension = ((program.getCompilerOptions() as CompilerOptions).extension ?? "lua").trim();
194211
const trimmedExtension = extension.startsWith(".") ? extension.substring(1) : extension;
195-
emitPathSplits[emitPathSplits.length - 1] =
196-
trimExtension(emitPathSplits[emitPathSplits.length - 1]) + "." + trimmedExtension;
212+
emitPathSplits[emitPathSplits.length - 1] += "." + trimmedExtension;
197213

198214
return path.join(...emitPathSplits);
199215
}

test/unit/modules/resolution.spec.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as ts from "typescript";
2-
import { couldNotResolveRequire } from "../../../src/transpilation/diagnostics";
2+
import { couldNotResolveRequire, emitPathCollision } from "../../../src/transpilation/diagnostics";
33
import * as util from "../../util";
44

55
const requireRegex = /require\("(.*?)"\)/;
@@ -166,6 +166,39 @@ test.each([
166166
.tap(expectToRequire(expectedPath));
167167
});
168168

169+
// https://github.com/TypeScriptToLua/TypeScriptToLua/issues/1445
170+
// Can't test this via execution because the test harness uses package.preload
171+
// instead of real filesystem resolution, so require() always finds the module
172+
// regardless of output path. We check the output path directly instead.
173+
// TODO: test via actual Lua execution once the harness supports filesystem resolution.
174+
test("dots in directory names emit to nested directories", () => {
175+
const { transpiledFiles } = util.testModule`
176+
import { answer } from "./Foo.Bar";
177+
export const result = answer;
178+
`
179+
.addExtraFile("Foo.Bar/index.ts", "export const answer = 42;")
180+
.setOptions({ rootDir: "." })
181+
.getLuaResult();
182+
183+
// Foo.Bar/index.ts should emit to Foo/Bar/index.lua, not Foo.Bar/index.lua
184+
const dottedFile = transpiledFiles.find(f => f.lua?.includes("answer = 42"));
185+
expect(dottedFile).toBeDefined();
186+
expect(dottedFile!.outPath).toContain("Foo/Bar/index.lua");
187+
expect(dottedFile!.outPath).not.toContain("Foo.Bar");
188+
});
189+
190+
test("dots in paths that collide with existing paths produce a diagnostic", () => {
191+
util.testModule`
192+
import { a } from "./Foo.Bar";
193+
import { b } from "./Foo/Bar";
194+
export const result = a + b;
195+
`
196+
.addExtraFile("Foo.Bar/index.ts", "export const a = 1;")
197+
.addExtraFile("Foo/Bar/index.ts", "export const b = 2;")
198+
.setOptions({ rootDir: "." })
199+
.expectToHaveDiagnostics([emitPathCollision.code]);
200+
});
201+
169202
test("import = require", () => {
170203
util.testModule`
171204
import foo = require("./foo/bar");

test/util.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,8 +534,9 @@ end)());`;
534534
const moduleExports = {};
535535
globalContext.exports = moduleExports;
536536
globalContext.module = { exports: moduleExports };
537+
const baseName = fileName.replace("./", "");
537538
const transpiledExtraFile = transpiledFiles.find(({ sourceFiles }) =>
538-
sourceFiles.some(f => f.fileName === fileName.replace("./", "") + ".ts")
539+
sourceFiles.some(f => f.fileName === baseName + ".ts" || f.fileName === baseName + "/index.ts")
539540
);
540541

541542
if (transpiledExtraFile?.js) {

0 commit comments

Comments
 (0)