diff --git a/src/transpilation/resolve.ts b/src/transpilation/resolve.ts index bd16773e9..a64a24b14 100644 --- a/src/transpilation/resolve.ts +++ b/src/transpilation/resolve.ts @@ -31,6 +31,7 @@ class ResolutionContext { public diagnostics: ts.Diagnostic[] = []; public resolvedFiles = new Map(); + private realPathMap = new Map(); constructor( public readonly program: ts.Program, @@ -88,19 +89,42 @@ class ResolutionContext { if (!dependencyPath) return this.couldNotResolveImport(required, file); + // Canonicalize symlink paths to avoid bundling the same physical file multiple times + const canonicalPath = this.canonicalizeDependencyPath(dependencyPath); + if (this.options.tstlVerbose) { - console.log(`Resolved ${required.requirePath} to ${normalizeSlashes(dependencyPath)}`); + console.log(`Resolved ${required.requirePath} to ${normalizeSlashes(canonicalPath)}`); } - this.processDependency(dependencyPath); + this.processDependency(canonicalPath); // Figure out resolved require path and dependency output path - if (shouldRewriteRequires(dependencyPath, this.program)) { - const resolvedRequire = getEmitPathRelativeToOutDir(dependencyPath, this.program); + if (shouldRewriteRequires(canonicalPath, this.program)) { + const resolvedRequire = getEmitPathRelativeToOutDir(canonicalPath, this.program); replaceRequireInCode(file, required, resolvedRequire, this.options.extension); replaceRequireInSourceMap(file, required, resolvedRequire, this.options.extension); } } + /** + * Canonicalize dependency path by resolving symlinks. + * If the same physical file was already seen under a different path, + * return the first-seen path to ensure consistent module keys in bundles. + */ + private canonicalizeDependencyPath(dependencyPath: string): string { + let realPath: string; + try { + realPath = fs.realpathSync(dependencyPath); + } catch { + return dependencyPath; + } + + const existing = this.realPathMap.get(realPath); + if (existing !== undefined) return existing; + + this.realPathMap.set(realPath, dependencyPath); + return dependencyPath; + } + private resolveDependencyPathsWithPlugins(requiringFile: ProcessedFile, dependency: string) { const requiredFromLuaFile = requiringFile.fileName.endsWith(".lua"); for (const plugin of this.plugins) { diff --git a/test/transpile/module-resolution.spec.ts b/test/transpile/module-resolution.spec.ts index ff29ac9b5..4b9090674 100644 --- a/test/transpile/module-resolution.spec.ts +++ b/test/transpile/module-resolution.spec.ts @@ -1,4 +1,5 @@ import * as path from "path"; +import * as fs from "fs"; import * as tstl from "../../src"; import * as util from "../util"; import * as ts from "typescript"; @@ -383,6 +384,50 @@ describe("dependency with complicated inner structure", () => { }); }); +describe("symlinked dependency deduplication", () => { + const projectPath = path.resolve(__dirname, "module-resolution", "project-with-symlinked-dependency"); + const tsConfigPath = path.join(projectPath, "tsconfig.json"); + const mainFilePath = path.join(projectPath, "main.ts"); + const symlinkPath = path.join(projectPath, "node_modules", "shared-lib"); + + beforeAll(() => { + // Dynamically create symlink to avoid git symlink portability issues + const symlinkDir = path.dirname(symlinkPath); + if (!fs.existsSync(symlinkDir)) { + fs.mkdirSync(symlinkDir, { recursive: true }); + } + if (!fs.existsSync(symlinkPath)) { + fs.symlinkSync(path.join(projectPath, "shared-lib"), symlinkPath, "junction"); + } + }); + + afterAll(() => { + if (fs.existsSync(symlinkPath) && fs.lstatSync(symlinkPath).isSymbolicLink()) { + fs.unlinkSync(symlinkPath); + } + }); + + test("bundle should not contain duplicate files from symlinked dependencies", () => { + const mainFile = path.join(projectPath, "main.ts"); + const { transpiledFiles } = util + .testProject(tsConfigPath) + .setMainFileName(mainFilePath) + .setOptions({ luaBundle: "bundle.lua", luaBundleEntry: mainFile }) + .expectToEqual({ + directResult: "Hello, World", + indirectResult: "Hello, World, welcome!", + }) + .getLuaResult(); + + expect(transpiledFiles).toHaveLength(1); + const lua = transpiledFiles[0].lua!; + // shared-lib is used by both main.ts (directly) and consumer (indirectly via symlink), + // but should only appear once in the bundle + const moduleEntries = (lua.match(/\["[^"]*shared-lib[^"]*"\]\s*=\s*function/g) ?? []).length; + expect(moduleEntries).toBe(1); + }); +}); + test("module resolution should not try to resolve @noResolution annotation", () => { util.testModule` import * as json from "json"; diff --git a/test/transpile/module-resolution/project-with-symlinked-dependency/main.ts b/test/transpile/module-resolution/project-with-symlinked-dependency/main.ts new file mode 100644 index 000000000..917ceda3f --- /dev/null +++ b/test/transpile/module-resolution/project-with-symlinked-dependency/main.ts @@ -0,0 +1,5 @@ +import * as sharedLib from "shared-lib"; +import * as consumer from "consumer"; + +export const directResult = sharedLib.greet("World"); +export const indirectResult = consumer.welcome("World"); diff --git a/test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.d.ts b/test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.d.ts new file mode 100644 index 000000000..e32864ade --- /dev/null +++ b/test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.d.ts @@ -0,0 +1,2 @@ +/** @noSelfInFile */ +export declare function greet(name: string): string; diff --git a/test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.lua b/test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.lua new file mode 100644 index 000000000..8769ba351 --- /dev/null +++ b/test/transpile/module-resolution/project-with-symlinked-dependency/shared-lib/index.lua @@ -0,0 +1,5 @@ +return { + greet = function(name) + return "Hello, " .. name + end +} diff --git a/test/transpile/module-resolution/project-with-symlinked-dependency/tsconfig.json b/test/transpile/module-resolution/project-with-symlinked-dependency/tsconfig.json new file mode 100644 index 000000000..b76533290 --- /dev/null +++ b/test/transpile/module-resolution/project-with-symlinked-dependency/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "strict": true, + "moduleResolution": "Node", + "target": "esnext", + "lib": ["esnext"], + "types": [], + "rootDir": "." + } +}