mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 22:01:47 +00:00
fix(resolver): support tsconfig project references path mappings
Parse and resolve path mappings from tsconfig.json `references` array. This allows monorepo-style setups where path aliases are defined in referenced configs to work correctly. - Add `references` field parsing in tsconfig_json.zig - Load and merge paths from referenced configs in resolver.zig - Referenced config paths have lower priority than main config paths - Support both file paths and directory paths (with tsconfig.json) Fixes #3617 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,7 @@ const bufs = struct {
|
||||
pub threadlocal var node_modules_check: bun.PathBuffer = undefined;
|
||||
pub threadlocal var field_abs_path: bun.PathBuffer = undefined;
|
||||
pub threadlocal var tsconfig_path_abs: bun.PathBuffer = undefined;
|
||||
pub threadlocal var tsconfig_path_abs2: bun.PathBuffer = undefined;
|
||||
pub threadlocal var check_browser_map: bun.PathBuffer = undefined;
|
||||
pub threadlocal var remap_path: bun.PathBuffer = undefined;
|
||||
pub threadlocal var load_as_file: bun.PathBuffer = undefined;
|
||||
@@ -4241,6 +4242,55 @@ pub const Resolver = struct {
|
||||
}
|
||||
// todo deinit these parent configs somehow?
|
||||
}
|
||||
|
||||
// Process "references" array - load configs and merge their paths
|
||||
// References are lower priority than extends, so we only add paths that don't already exist
|
||||
// https://www.typescriptlang.org/docs/handbook/project-references.html
|
||||
for (tsconfig_json.references) |ref_path| {
|
||||
const ts_dir_name = Dirname.dirname(tsconfig_json.abs_path);
|
||||
// Reference paths can point to directories or tsconfig files
|
||||
// If it's a directory, append tsconfig.json
|
||||
const resolved_ref_path = ResolvePath.joinAbsStringBuf(ts_dir_name, bufs(.tsconfig_path_abs), &[_]string{ ts_dir_name, ref_path }, .auto);
|
||||
|
||||
// Try to load the referenced config (either as file or directory/tsconfig.json)
|
||||
const ref_config_maybe = r.parseTSConfig(resolved_ref_path, bun.invalid_fd) catch |err| blk: {
|
||||
// If path doesn't have .json extension, try as directory with tsconfig.json
|
||||
if (!strings.endsWith(ref_path, ".json")) {
|
||||
const dir_config_path = ResolvePath.joinAbsStringBuf(resolved_ref_path, bufs(.tsconfig_path_abs2), &[_]string{ resolved_ref_path, "tsconfig.json" }, .auto);
|
||||
break :blk r.parseTSConfig(dir_config_path, bun.invalid_fd) catch {
|
||||
r.log.addDebugFmt(null, logger.Loc.Empty, r.allocator, "{s} loading tsconfig.json reference {f}", .{
|
||||
@errorName(err),
|
||||
bun.fmt.QuotedFormatter{ .text = ref_path },
|
||||
}) catch {};
|
||||
break :blk null;
|
||||
};
|
||||
}
|
||||
r.log.addDebugFmt(null, logger.Loc.Empty, r.allocator, "{s} loading tsconfig.json reference {f}", .{
|
||||
@errorName(err),
|
||||
bun.fmt.QuotedFormatter{ .text = ref_path },
|
||||
}) catch {};
|
||||
break :blk null;
|
||||
};
|
||||
|
||||
if (ref_config_maybe) |ref_config| {
|
||||
// Merge paths from referenced config (lower priority - don't overwrite existing paths)
|
||||
var ref_iter = ref_config.paths.iterator();
|
||||
while (ref_iter.next()) |c| {
|
||||
// Only add if this path pattern doesn't already exist in the merged config
|
||||
if (!merged_config.paths.contains(c.key_ptr.*)) {
|
||||
merged_config.paths.put(c.key_ptr.*, c.value_ptr.*) catch unreachable;
|
||||
}
|
||||
}
|
||||
|
||||
// If merged config doesn't have baseUrl but ref does, we can use it
|
||||
// (lower priority, so only if not already set)
|
||||
if (merged_config.base_url.len == 0 and ref_config.base_url.len > 0) {
|
||||
merged_config.base_url = ref_config.base_url;
|
||||
merged_config.base_url_for_paths = ref_config.base_url_for_paths;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info.tsconfig_json = merged_config;
|
||||
}
|
||||
info.enclosing_tsconfig_json = info.tsconfig_json;
|
||||
|
||||
@@ -24,6 +24,9 @@ pub const TSConfigJSON = struct {
|
||||
base_url_for_paths: string = "",
|
||||
|
||||
extends: string = "",
|
||||
// Array of referenced tsconfig paths for project references
|
||||
// https://www.typescriptlang.org/docs/handbook/project-references.html
|
||||
references: []const string = &.{},
|
||||
// The verbatim values of "compilerOptions.paths". The keys are patterns to
|
||||
// match and the values are arrays of fallback paths to search. Each key and
|
||||
// each fallback path can optionally have a single "*" wildcard character.
|
||||
@@ -157,6 +160,26 @@ pub const TSConfigJSON = struct {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse "references" array for project references
|
||||
// https://www.typescriptlang.org/docs/handbook/project-references.html
|
||||
if (json.asProperty("references")) |references_value| {
|
||||
if (!source.path.isNodeModule()) parse_references: {
|
||||
var array = references_value.expr.asArray() orelse break :parse_references;
|
||||
var refs = std.ArrayListUnmanaged(string){};
|
||||
errdefer refs.deinit(allocator);
|
||||
while (array.next()) |element| {
|
||||
// Each reference is an object with a "path" property
|
||||
if (element.asProperty("path")) |path_prop| {
|
||||
if (path_prop.expr.asString(allocator)) |path_str| {
|
||||
refs.append(allocator, path_str) catch continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.references = refs.toOwnedSlice(allocator) catch &.{};
|
||||
}
|
||||
}
|
||||
|
||||
var has_base_url = false;
|
||||
|
||||
// Parse "compilerOptions"
|
||||
|
||||
240
test/regression/issue/3617.test.ts
Normal file
240
test/regression/issue/3617.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
|
||||
import path from "node:path";
|
||||
|
||||
describe("issue #3617: tsconfig references path mappings", () => {
|
||||
test("should resolve paths from referenced tsconfig", async () => {
|
||||
// Create test structure matching the issue's reproduction case
|
||||
const dir = tempDirWithFiles("tsconfig-references", {
|
||||
// Main tsconfig with references pointing to app config
|
||||
"tsconfig.json": JSON.stringify({
|
||||
files: [],
|
||||
references: [{ path: "./tsconfig.app.json" }],
|
||||
}),
|
||||
// App tsconfig with path mappings
|
||||
"tsconfig.app.json": JSON.stringify({
|
||||
compilerOptions: {
|
||||
baseUrl: ".",
|
||||
paths: {
|
||||
"~/*": ["./src/*"],
|
||||
},
|
||||
},
|
||||
include: ["src/**/*"],
|
||||
}),
|
||||
// Source file
|
||||
"src/number.ts": `
|
||||
export function displayMax(a: number, b: number): number {
|
||||
return Math.max(a, b);
|
||||
}
|
||||
`,
|
||||
// Main file using the path alias
|
||||
"index.ts": `
|
||||
import { displayMax } from "~/number";
|
||||
console.log("Result:", displayMax(1, 2));
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "run", path.join(dir, "index.ts")],
|
||||
env: bunEnv,
|
||||
cwd: dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
expect(stderr).not.toContain("Cannot find module");
|
||||
expect(stdout).toContain("Result: 2");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("should resolve paths from referenced tsconfig directory (without .json extension)", async () => {
|
||||
// Reference can point to a directory containing tsconfig.json
|
||||
const dir = tempDirWithFiles("tsconfig-references-dir", {
|
||||
"tsconfig.json": JSON.stringify({
|
||||
files: [],
|
||||
references: [{ path: "./packages/core" }],
|
||||
}),
|
||||
"packages/core/tsconfig.json": JSON.stringify({
|
||||
compilerOptions: {
|
||||
baseUrl: "../..",
|
||||
paths: {
|
||||
"@core/*": ["./packages/core/src/*"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
"packages/core/src/utils.ts": `
|
||||
export function greet(name: string): string {
|
||||
return \`Hello, \${name}!\`;
|
||||
}
|
||||
`,
|
||||
"app/index.ts": `
|
||||
import { greet } from "@core/utils";
|
||||
console.log(greet("World"));
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "run", path.join(dir, "app/index.ts")],
|
||||
env: bunEnv,
|
||||
cwd: dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
expect(stderr).not.toContain("Cannot find module");
|
||||
expect(stdout).toContain("Hello, World!");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("should prefer main config paths over referenced config paths", async () => {
|
||||
// Main config paths should have higher priority than referenced config paths
|
||||
const dir = tempDirWithFiles("tsconfig-references-priority", {
|
||||
"tsconfig.json": JSON.stringify({
|
||||
compilerOptions: {
|
||||
baseUrl: ".",
|
||||
paths: {
|
||||
"@lib/*": ["./lib-override/*"],
|
||||
},
|
||||
},
|
||||
references: [{ path: "./tsconfig.base.json" }],
|
||||
}),
|
||||
"tsconfig.base.json": JSON.stringify({
|
||||
compilerOptions: {
|
||||
baseUrl: ".",
|
||||
paths: {
|
||||
"@lib/*": ["./lib-base/*"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
"lib-override/helper.ts": `
|
||||
export const value = "from-override";
|
||||
`,
|
||||
"lib-base/helper.ts": `
|
||||
export const value = "from-base";
|
||||
`,
|
||||
"index.ts": `
|
||||
import { value } from "@lib/helper";
|
||||
console.log(value);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "run", path.join(dir, "index.ts")],
|
||||
env: bunEnv,
|
||||
cwd: dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
expect(stderr).not.toContain("Cannot find module");
|
||||
expect(stdout).toContain("from-override");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("should merge non-conflicting paths from referenced config", async () => {
|
||||
// Referenced config should add additional paths that don't conflict with main
|
||||
const dir = tempDirWithFiles("tsconfig-references-merge", {
|
||||
"tsconfig.json": JSON.stringify({
|
||||
compilerOptions: {
|
||||
baseUrl: ".",
|
||||
paths: {
|
||||
"@app/*": ["./app/*"],
|
||||
},
|
||||
},
|
||||
references: [{ path: "./tsconfig.lib.json" }],
|
||||
}),
|
||||
"tsconfig.lib.json": JSON.stringify({
|
||||
compilerOptions: {
|
||||
baseUrl: ".",
|
||||
paths: {
|
||||
"@lib/*": ["./lib/*"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
"app/main.ts": `
|
||||
export const appValue = "app";
|
||||
`,
|
||||
"lib/utils.ts": `
|
||||
export const libValue = "lib";
|
||||
`,
|
||||
"index.ts": `
|
||||
import { appValue } from "@app/main";
|
||||
import { libValue } from "@lib/utils";
|
||||
console.log(appValue, libValue);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "run", path.join(dir, "index.ts")],
|
||||
env: bunEnv,
|
||||
cwd: dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
expect(stderr).not.toContain("Cannot find module");
|
||||
expect(stdout).toContain("app");
|
||||
expect(stdout).toContain("lib");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("should handle multiple references", async () => {
|
||||
// Multiple references should all be processed
|
||||
const dir = tempDirWithFiles("tsconfig-references-multiple", {
|
||||
"tsconfig.json": JSON.stringify({
|
||||
files: [],
|
||||
references: [{ path: "./packages/a/tsconfig.json" }, { path: "./packages/b/tsconfig.json" }],
|
||||
}),
|
||||
"packages/a/tsconfig.json": JSON.stringify({
|
||||
compilerOptions: {
|
||||
baseUrl: "../..",
|
||||
paths: {
|
||||
"@a/*": ["./packages/a/src/*"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
"packages/b/tsconfig.json": JSON.stringify({
|
||||
compilerOptions: {
|
||||
baseUrl: "../..",
|
||||
paths: {
|
||||
"@b/*": ["./packages/b/src/*"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
"packages/a/src/a.ts": `
|
||||
export const a = "from-a";
|
||||
`,
|
||||
"packages/b/src/b.ts": `
|
||||
export const b = "from-b";
|
||||
`,
|
||||
"index.ts": `
|
||||
import { a } from "@a/a";
|
||||
import { b } from "@b/b";
|
||||
console.log(a, b);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "run", path.join(dir, "index.ts")],
|
||||
env: bunEnv,
|
||||
cwd: dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
expect(stderr).not.toContain("Cannot find module");
|
||||
expect(stdout).toContain("from-a");
|
||||
expect(stdout).toContain("from-b");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user