Compare commits

...

2 Commits

Author SHA1 Message Date
Claude Bot
3aa7964f9a fix: merge all inheritable fields in tsconfig references extends chain
The extends merge loop for referenced configs was only copying
base_url and paths. Now also merges JSX settings, emit_decorator_metadata,
and preserve_imports_not_used_as_values, matching the root extends merge.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 09:55:22 +00:00
Claude Bot
e99608620c fix(resolver): support tsconfig project references for path mapping (#20172)
When a root tsconfig.json uses "references" to point to sub-configs
(e.g. tsconfig.app.json, tsconfig.node.json), Bun now loads those
referenced configs and merges their compilerOptions.paths into the
root config. This is a common pattern in Vue/Vite projects.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 09:36:07 +00:00
3 changed files with 247 additions and 0 deletions

View File

@@ -4244,6 +4244,132 @@ pub const Resolver = struct {
// todo deinit these parent configs somehow?
}
info.tsconfig_json = merged_config;
// Handle "references" - load each referenced tsconfig and merge
// their paths into this config. This supports the common pattern
// where a root tsconfig.json uses "references" to delegate to
// sub-configs (e.g. tsconfig.app.json, tsconfig.node.json).
if (merged_config.references.len > 0) {
const ts_dir_name = Dirname.dirname(merged_config.abs_path);
for (merged_config.references) |ref_path| {
// Per the TypeScript spec, if "path" points to a directory,
// look for tsconfig.json inside it. If it points to a file,
// use it directly.
const abs_ref_path = brk2: {
if (strings.endsWithComptime(ref_path, ".json")) {
break :brk2 ResolvePath.joinAbsStringBuf(
ts_dir_name,
bufs(.tsconfig_path_abs),
&[_]string{ ts_dir_name, ref_path },
.auto,
);
} else {
break :brk2 ResolvePath.joinAbsStringBuf(
ts_dir_name,
bufs(.tsconfig_path_abs),
&[_]string{ ts_dir_name, ref_path, "tsconfig.json" },
.auto,
);
}
};
const ref_config_maybe = r.parseTSConfig(abs_ref_path, bun.invalid_fd) catch |err| brk2: {
r.log.addDebugFmt(null, logger.Loc.Empty, r.allocator, "{s} loading tsconfig.json reference {f}", .{
@errorName(err),
bun.fmt.QuotedFormatter{
.text = abs_ref_path,
},
}) catch {};
break :brk2 null;
};
if (ref_config_maybe) |ref_config| {
// Also resolve extends chains for referenced configs
var ref_parent_configs = try bun.BoundedArray(*TSConfigJSON, 64).init(0);
try ref_parent_configs.append(ref_config);
var ref_current = ref_config;
while (ref_current.extends.len > 0) {
const ref_ts_dir = Dirname.dirname(ref_current.abs_path);
const ref_extends_abs = ResolvePath.joinAbsStringBuf(ref_ts_dir, bufs(.tsconfig_path_abs), &[_]string{ ref_ts_dir, ref_current.extends }, .auto);
const ref_parent_maybe = r.parseTSConfig(ref_extends_abs, bun.invalid_fd) catch break;
if (ref_parent_maybe) |ref_parent| {
try ref_parent_configs.append(ref_parent);
ref_current = ref_parent;
} else {
break;
}
}
// Merge the referenced config's extends chain
// (same fields as the root extends merge)
var ref_merged = ref_parent_configs.pop().?;
while (ref_parent_configs.pop()) |ref_parent| {
ref_merged.emit_decorator_metadata = ref_merged.emit_decorator_metadata or ref_parent.emit_decorator_metadata;
if (ref_parent.base_url.len > 0) {
ref_merged.base_url = ref_parent.base_url;
ref_merged.base_url_for_paths = ref_parent.base_url_for_paths;
}
ref_merged.jsx = ref_parent.mergeJSX(ref_merged.jsx);
ref_merged.jsx_flags.setUnion(ref_parent.jsx_flags);
if (ref_parent.preserve_imports_not_used_as_values) |value| {
ref_merged.preserve_imports_not_used_as_values = value;
}
var ref_iter = ref_parent.paths.iterator();
while (ref_iter.next()) |c| {
ref_merged.paths.put(c.key_ptr.*, c.value_ptr.*) catch unreachable;
}
}
// Merge referenced config's paths into the root config.
// Path values need to be made absolute using the referenced
// config's base_url_for_paths, since the root config may
// have a different (or no) base URL.
const ref_base = if (ref_merged.hasBaseURL()) ref_merged.base_url else ref_merged.base_url_for_paths;
var ref_iter = ref_merged.paths.iterator();
while (ref_iter.next()) |c| {
const original_values = c.value_ptr.*;
if (ref_base.len > 0 and (merged_config.base_url_for_paths.len == 0 or
!strings.eql(ref_base, merged_config.base_url_for_paths)))
{
// Resolve each path value to absolute so it works
// regardless of the root config's baseUrl
var abs_values = bun.default_allocator.alloc(string, original_values.len) catch unreachable;
for (original_values, 0..) |orig_path, i| {
if (!std.fs.path.isAbsolute(orig_path)) {
const join_parts = [_]string{ ref_base, orig_path };
abs_values[i] = r.fs.dirname_store.append(
string,
r.fs.absBuf(&join_parts, bufs(.tsconfig_base_url)),
) catch unreachable;
} else {
abs_values[i] = orig_path;
}
}
merged_config.paths.put(c.key_ptr.*, abs_values) catch unreachable;
} else {
merged_config.paths.put(c.key_ptr.*, original_values) catch unreachable;
}
}
// If the root config has no base_url_for_paths but the referenced
// config has paths, we need to ensure base_url_for_paths is set
if (merged_config.base_url_for_paths.len == 0 and ref_merged.paths.count() > 0) {
merged_config.base_url_for_paths = ref_merged.base_url_for_paths;
}
// Merge other settings from referenced configs
merged_config.jsx = ref_merged.mergeJSX(merged_config.jsx);
merged_config.jsx_flags.setUnion(ref_merged.jsx_flags);
merged_config.emit_decorator_metadata = merged_config.emit_decorator_metadata or ref_merged.emit_decorator_metadata;
if (ref_merged.preserve_imports_not_used_as_values) |value| {
if (merged_config.preserve_imports_not_used_as_values == null) {
merged_config.preserve_imports_not_used_as_values = value;
}
}
}
}
}
}
info.enclosing_tsconfig_json = info.tsconfig_json;
}

View File

@@ -43,6 +43,12 @@ pub const TSConfigJSON = struct {
emit_decorator_metadata: bool = false,
experimental_decorators: bool = false,
// TypeScript project references. Each entry is the "path" value from the
// "references" array in tsconfig.json. These are relative paths to other
// tsconfig files (or directories containing tsconfig.json).
// See: https://www.typescriptlang.org/docs/handbook/project-references.html
references: []const string = &.{},
pub fn hasBaseURL(tsconfig: *const TSConfigJSON) bool {
return tsconfig.base_url.len > 0;
}
@@ -158,6 +164,26 @@ pub const TSConfigJSON = struct {
}
}
}
// Parse "references"
if (json.asProperty("references")) |references_value| {
if (!source.path.isNodeModule()) {
if (references_value.expr.asArray()) |ref_array_iter| {
var ref_array = ref_array_iter;
var refs = std.array_list.Managed(string).init(allocator);
while (ref_array.next()) |element| {
if (element.asProperty("path")) |path_prop| {
if (path_prop.expr.asString(allocator)) |ref_path| {
refs.append(ref_path) catch unreachable;
}
}
}
if (refs.items.len > 0) {
result.references = refs.toOwnedSlice() catch unreachable;
}
}
}
}
var has_base_url = false;
// Parse "compilerOptions"

View File

@@ -0,0 +1,95 @@
import { expect, test } from "bun:test";
import { bunRun, tempDirWithFiles } from "harness";
import { join } from "path";
test("tsconfig references resolves paths from referenced configs", () => {
const dir = tempDirWithFiles("tsconfig-refs", {
"tsconfig.json": JSON.stringify({
files: [],
references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }],
}),
"tsconfig.app.json": JSON.stringify({
compilerOptions: {
baseUrl: ".",
paths: {
"@/*": ["./src/*"],
},
},
include: ["src/**/*"],
}),
"tsconfig.node.json": JSON.stringify({
compilerOptions: {
baseUrl: ".",
paths: {
"@server/*": ["./server/*"],
},
},
include: ["server/**/*"],
}),
"server/index.ts": `import { foo } from '@server/lib/foo';
console.log(foo);`,
"server/lib/foo.ts": `export const foo = 123;`,
"src/main.ts": `import { bar } from '@/lib/bar';
console.log(bar);`,
"src/lib/bar.ts": `export const bar = 456;`,
});
// Test @server/* paths from tsconfig.node.json
const serverResult = bunRun(join(dir, "server/index.ts"));
expect(serverResult.stdout).toBe("123");
// Test @/* paths from tsconfig.app.json
const appResult = bunRun(join(dir, "src/main.ts"));
expect(appResult.stdout).toBe("456");
});
test("tsconfig references resolves directory references", () => {
const dir = tempDirWithFiles("tsconfig-dir-refs", {
"tsconfig.json": JSON.stringify({
files: [],
references: [{ path: "./app" }],
}),
"app/tsconfig.json": JSON.stringify({
compilerOptions: {
baseUrl: "..",
paths: {
"#utils/*": ["./src/utils/*"],
},
},
}),
"src/index.ts": `import { helper } from '#utils/helper';
console.log(helper);`,
"src/utils/helper.ts": `export const helper = "works";`,
});
const result = bunRun(join(dir, "src/index.ts"));
expect(result.stdout).toBe("works");
});
test("tsconfig references with extends in referenced config", () => {
const dir = tempDirWithFiles("tsconfig-refs-extends", {
"tsconfig.json": JSON.stringify({
files: [],
references: [{ path: "./tsconfig.app.json" }],
}),
"tsconfig.app.json": JSON.stringify({
extends: "./tsconfig.base.json",
compilerOptions: {
paths: {
"@app/*": ["./src/*"],
},
},
}),
"tsconfig.base.json": JSON.stringify({
compilerOptions: {
baseUrl: ".",
},
}),
"src/index.ts": `import { val } from '@app/lib/val';
console.log(val);`,
"src/lib/val.ts": `export const val = "extended";`,
});
const result = bunRun(join(dir, "src/index.ts"));
expect(result.stdout).toBe("extended");
});