Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions src/transpilation/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class ResolutionContext {

public diagnostics: ts.Diagnostic[] = [];
public resolvedFiles = new Map<string, ProcessedFile>();
private realPathMap = new Map<string, string>();

constructor(
public readonly program: ts.Program,
Expand Down Expand Up @@ -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) {
Expand Down
45 changes: 45 additions & 0 deletions test/transpile/module-resolution.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** @noSelfInFile */
export declare function greet(name: string): string;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
return {
greet = function(name)
return "Hello, " .. name
end
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"strict": true,
"moduleResolution": "Node",
"target": "esnext",
"lib": ["esnext"],
"types": [],
"rootDir": "."
}
}