Skip to content

Commit 2fc2e99

Browse files
authored
fix dots in file/directory names breaking Lua require resolution (#1445) (#1702)
* 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. * replace dots with underscores in output paths instead of expanding to nested directories * more tests
1 parent 5753d6c commit 2fc2e99

4 files changed

Lines changed: 76 additions & 8 deletions

File tree

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 replaced with underscores for Lua module resolution.`
67+
);

src/transpilation/transpiler.ts

Lines changed: 20 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,17 @@ 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+
const outputPathMap = new Map<string, string>();
148+
emitPlan = resolutionResult.resolvedFiles.map(file => {
149+
const outputPath = getEmitPath(file.fileName, program);
150+
const existing = outputPathMap.get(outputPath);
151+
if (existing) {
152+
diagnostics.push(emitPathCollision(outputPath, existing, file.fileName));
153+
} else {
154+
outputPathMap.set(outputPath, file.fileName);
155+
}
156+
return { ...file, outputPath };
157+
});
150158
}
151159

152160
performance.endSection("getEmitPlan");
@@ -189,11 +197,17 @@ export function getEmitPathRelativeToOutDir(fileName: string, program: ts.Progra
189197
emitPathSplits[0] = "lua_modules";
190198
}
191199

200+
// Replace dots with underscores in path segments so that Lua's require()
201+
// resolves correctly. Dots are path separators in Lua's module system, so
202+
// "Foo.Bar/index.lua" would be unreachable via require("Foo.Bar.index")
203+
// since Lua interprets it as "Foo/Bar/index.lua".
204+
emitPathSplits[emitPathSplits.length - 1] = trimExtension(emitPathSplits[emitPathSplits.length - 1]);
205+
emitPathSplits = emitPathSplits.map(segment => segment.replace(/\./g, "_"));
206+
192207
// Set extension
193208
const extension = ((program.getCompilerOptions() as CompilerOptions).extension ?? "lua").trim();
194209
const trimmedExtension = extension.startsWith(".") ? extension.substring(1) : extension;
195-
emitPathSplits[emitPathSplits.length - 1] =
196-
trimExtension(emitPathSplits[emitPathSplits.length - 1]) + "." + trimmedExtension;
210+
emitPathSplits[emitPathSplits.length - 1] += "." + trimmedExtension;
197211

198212
return path.join(...emitPathSplits);
199213
}

test/unit/modules/resolution.spec.ts

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

56
const requireRegex = /require\("(.*?)"\)/;
@@ -166,6 +167,52 @@ test.each([
166167
.tap(expectToRequire(expectedPath));
167168
});
168169

170+
// https://github.com/TypeScriptToLua/TypeScriptToLua/issues/1445
171+
// Can't test this via execution because the test harness uses package.preload
172+
// instead of real filesystem resolution, so require() always finds the module
173+
// regardless of output path. We check the output path directly instead.
174+
test("dots in directory names are replaced with underscores in output", () => {
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+
const dottedFile = transpiledFiles.find(f => f.lua?.includes("answer = 42"));
184+
expect(dottedFile).toBeDefined();
185+
expect(dottedFile!.outPath).toContain(path.join("Foo_Bar", "index.lua"));
186+
expect(dottedFile!.outPath).not.toContain("Foo.Bar");
187+
});
188+
189+
test("dots in file names are replaced with underscores in output", () => {
190+
const { transpiledFiles } = util.testModule`
191+
import { answer } from "./foo.test";
192+
export const result = answer;
193+
`
194+
.addExtraFile("foo.test.ts", "export const answer = 42;")
195+
.setOptions({ rootDir: "." })
196+
.getLuaResult();
197+
198+
const dottedFile = transpiledFiles.find(f => f.lua?.includes("answer = 42"));
199+
expect(dottedFile).toBeDefined();
200+
expect(dottedFile!.outPath).toContain("foo_test.lua");
201+
expect(dottedFile!.outPath).not.toContain("foo.test");
202+
});
203+
204+
test("dots in paths that collide with existing paths produce a diagnostic", () => {
205+
util.testModule`
206+
import { a } from "./Foo.Bar";
207+
import { b } from "./Foo_Bar";
208+
export const result = a + b;
209+
`
210+
.addExtraFile("Foo.Bar/index.ts", "export const a = 1;")
211+
.addExtraFile("Foo_Bar/index.ts", "export const b = 2;")
212+
.setOptions({ rootDir: "." })
213+
.expectToHaveDiagnostics([emitPathCollision.code]);
214+
});
215+
169216
test("import = require", () => {
170217
util.testModule`
171218
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)