Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
8c2626b0ee Add support for TypeScript project references
Fixes #4774

This PR adds support for the `references` field in tsconfig.json,
enabling proper path resolution and configuration merging for
TypeScript project references.

## Changes

- Parse the `references` array from tsconfig.json files
- Load and merge settings from all referenced tsconfig files
- Referenced configs have lower priority than the main config
- Support both direct file paths and directory paths (which look for tsconfig.json)
- Merge `paths`, `baseUrl`, and `jsx` settings from references

## Testing

Added comprehensive tests in test/regression/issue/4774.test.ts:
-  Path aliases from referenced configs
-  Multiple references
-  Override behavior (main config takes precedence)
-  Directory-based references
- ⏭️ JSX settings from references (skipped - needs additional work)

The core functionality requested in #4774 (path resolution from
referenced configs) is now working. JSX settings will need additional
investigation as they're applied at a different stage of transpilation.

🤖 Generated with Claude Code
2025-11-11 12:22:47 +00:00
3 changed files with 342 additions and 0 deletions

View File

@@ -4235,6 +4235,95 @@ pub const Resolver = struct {
}
// todo deinit these parent configs somehow?
}
// Handle project references
// Load all referenced tsconfig files and merge their paths
if (merged_config.references.len > 0) {
const ts_dir_name = Dirname.dirname(merged_config.abs_path);
for (merged_config.references) |reference_path| {
// Resolve the reference path relative to the tsconfig directory
var reference_abs_path = ResolvePath.joinAbsStringBuf(
ts_dir_name,
bufs(.tsconfig_path_abs),
&[_]string{ ts_dir_name, reference_path },
.auto,
);
// Try the path as-is first, then try with /tsconfig.json appended if it fails
const referenced_config = r.parseTSConfig(reference_abs_path, bun.invalid_fd) catch |err| try_with_dir: {
// If the first attempt failed, try appending /tsconfig.json
if (err == error.EISDIR or err == error.IsDir) {
reference_abs_path = ResolvePath.joinAbsStringBuf(
reference_abs_path,
bufs(.tsconfig_path_abs),
&[_]string{ reference_abs_path, "tsconfig.json" },
.auto,
);
break :try_with_dir r.parseTSConfig(reference_abs_path, bun.invalid_fd) catch |inner_err| {
r.log.addDebugFmt(null, logger.Loc.Empty, r.allocator, "{s} loading tsconfig.json reference {}", .{
@errorName(inner_err),
bun.fmt.QuotedFormatter{
.text = reference_abs_path,
},
}) catch {};
continue;
};
}
r.log.addDebugFmt(null, logger.Loc.Empty, r.allocator, "{s} loading tsconfig.json reference {}", .{
@errorName(err),
bun.fmt.QuotedFormatter{
.text = reference_abs_path,
},
}) catch {};
continue;
};
if (referenced_config) |ref_config| {
// Merge paths from the referenced config
// The referenced config's paths take lower priority than the main config
var iter = ref_config.paths.iterator();
while (iter.next()) |entry| {
// Only add if the key doesn't already exist
const gop_result = merged_config.paths.getOrPut(entry.key_ptr.*) catch unreachable;
if (!gop_result.found_existing) {
gop_result.value_ptr.* = entry.value_ptr.*;
}
}
// Also merge other settings from referenced configs
// Referenced configs have lower priority than the main config
if (ref_config.base_url.len > 0 and merged_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;
}
// Merge JSX settings from referenced config
// Only apply ref_config settings if not already set in merged_config
if (!merged_config.jsx_flags.contains(.factory) and ref_config.jsx_flags.contains(.factory)) {
merged_config.jsx.factory = ref_config.jsx.factory;
merged_config.jsx_flags.insert(.factory);
}
if (!merged_config.jsx_flags.contains(.fragment) and ref_config.jsx_flags.contains(.fragment)) {
merged_config.jsx.fragment = ref_config.jsx.fragment;
merged_config.jsx_flags.insert(.fragment);
}
if (!merged_config.jsx_flags.contains(.import_source) and ref_config.jsx_flags.contains(.import_source)) {
merged_config.jsx.import_source = ref_config.jsx.import_source;
merged_config.jsx.package_name = ref_config.jsx.package_name;
merged_config.jsx_flags.insert(.import_source);
}
if (!merged_config.jsx_flags.contains(.runtime) and ref_config.jsx_flags.contains(.runtime)) {
merged_config.jsx.runtime = ref_config.jsx.runtime;
merged_config.jsx_flags.insert(.runtime);
}
if (!merged_config.jsx_flags.contains(.development) and ref_config.jsx_flags.contains(.development)) {
merged_config.jsx.development = ref_config.jsx.development;
merged_config.jsx_flags.insert(.development);
}
}
}
}
info.tsconfig_json = merged_config;
}
info.enclosing_tsconfig_json = info.tsconfig_json;

View File

@@ -24,6 +24,11 @@ pub const TSConfigJSON = struct {
base_url_for_paths: string = "",
extends: string = "",
// Project references - array of paths to other tsconfig.json files
// https://www.typescriptlang.org/docs/handbook/project-references.html
references: []string = &[_]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 +162,31 @@ pub const TSConfigJSON = struct {
}
}
}
// Parse "references" field
if (json.asProperty("references")) |references_prop| {
if (!source.path.isNodeModule()) handle_references: {
var array = references_prop.expr.asArray() orelse break :handle_references;
var references_list = std.ArrayList(string).init(allocator);
errdefer references_list.deinit();
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| {
references_list.append(path_str) catch break :handle_references;
}
}
}
if (references_list.items.len > 0) {
result.references = references_list.toOwnedSlice() catch &[_]string{};
} else {
references_list.deinit();
}
}
}
var has_base_url = false;
// Parse "compilerOptions"

View File

@@ -0,0 +1,223 @@
// https://github.com/oven-sh/bun/issues/4774
// TypeScript project references should be supported
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
test("tsconfig.json references should work with paths", async () => {
using dir = tempDir("tsconfig-references", {
"tsconfig.json": JSON.stringify({
files: [],
references: [{ path: "./tsconfig.app.json" }],
}),
"tsconfig.app.json": JSON.stringify({
compilerOptions: {
baseUrl: ".",
paths: {
"@/*": ["./src/*"],
},
},
}),
"src/foo.ts": `export const foo = "hello from foo";`,
"index.ts": `
import { foo } from "@/foo";
console.log(foo);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.ts"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
stdout: "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).toBe("hello from foo\n");
expect(exitCode).toBe(0);
});
// TODO: JSX settings from references don't work yet during transpilation
// This is a separate issue from path resolution which is the main focus of #4774
test.skip("tsconfig.json references should work with jsxImportSource", async () => {
using dir = tempDir("tsconfig-jsx-references", {
"package.json": JSON.stringify({ name: "test", type: "module" }),
"tsconfig.json": JSON.stringify({
files: [],
references: [{ path: "./tsconfig.app.json" }],
}),
"tsconfig.app.json": JSON.stringify({
compilerOptions: {
jsx: "react-jsx",
jsxImportSource: "solid-js",
baseUrl: ".",
paths: {
"@/*": ["./src/*"],
},
},
}),
"node_modules/solid-js/jsx-runtime/package.json": JSON.stringify({
name: "solid-js",
version: "1.0.0",
}),
"node_modules/solid-js/jsx-runtime/index.js": `
export function jsx(type, props) {
return { type, props, framework: 'solid-js' };
}
`,
"node_modules/solid-js/package.json": JSON.stringify({
name: "solid-js",
version: "1.0.0",
exports: {
"./jsx-runtime": "./jsx-runtime/index.js",
},
}),
"src/foo.ts": `export const foo = "test";`,
"index.tsx": `
import { foo } from "@/foo";
const element = <div>Hello {foo}</div>;
console.log(JSON.stringify(element));
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.tsx"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).not.toContain("Cannot find");
expect(stderr).not.toContain("React is not defined");
expect(stdout).toContain("solid-js");
expect(exitCode).toBe(0);
});
test("tsconfig.json references should support multiple references", async () => {
using dir = tempDir("tsconfig-multi-references", {
"tsconfig.json": JSON.stringify({
files: [],
references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }],
}),
"tsconfig.app.json": JSON.stringify({
compilerOptions: {
baseUrl: ".",
paths: {
"@app/*": ["./app/*"],
},
},
}),
"tsconfig.node.json": JSON.stringify({
compilerOptions: {
baseUrl: ".",
paths: {
"@server/*": ["./server/*"],
},
},
}),
"app/component.ts": `export const component = "app component";`,
"server/handler.ts": `export const handler = "server handler";`,
"index.ts": `
import { component } from "@app/component";
import { handler } from "@server/handler";
console.log(component, handler);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.ts"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
stdout: "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).toBe("app component server handler\n");
expect(exitCode).toBe(0);
});
test("tsconfig.json main config paths should override referenced config paths", async () => {
using dir = tempDir("tsconfig-override-references", {
"tsconfig.json": JSON.stringify({
compilerOptions: {
baseUrl: ".",
paths: {
"@/*": ["./override/*"],
},
},
references: [{ path: "./tsconfig.app.json" }],
}),
"tsconfig.app.json": JSON.stringify({
compilerOptions: {
baseUrl: ".",
paths: {
"@/*": ["./src/*"],
},
},
}),
"override/foo.ts": `export const foo = "from override";`,
"src/foo.ts": `export const foo = "from src";`,
"index.ts": `
import { foo } from "@/foo";
console.log(foo);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.ts"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
stdout: "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).toBe("from override\n");
expect(exitCode).toBe(0);
});
test("tsconfig.json references can be a directory", async () => {
using dir = tempDir("tsconfig-dir-references", {
"tsconfig.json": JSON.stringify({
files: [],
references: [{ path: "./app" }],
}),
"app/tsconfig.json": JSON.stringify({
compilerOptions: {
baseUrl: ".",
paths: {
"@/*": ["./src/*"],
},
},
}),
"app/src/foo.ts": `export const foo = "hello from app";`,
"index.ts": `
import { foo } from "@/foo";
console.log(foo);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.ts"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
stdout: "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).toBe("hello from app\n");
expect(exitCode).toBe(0);
});