Skip to content
Closed
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
25 changes: 18 additions & 7 deletions src/resolver/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -636,24 +636,35 @@ impl DirEntry {
})
}

/// Looks up a cached entry by name. Takes a `&'static [u8]` that is
/// already lowercase, so no per-call lowercasing buffer is needed.
/// A hit stored under another case (`Package.json`) counts only if the probed spelling exists.
pub(crate) fn get_comptime_query<'a>(
&'a self,
query_lower: &'static [u8],
) -> Option<EntryLookup<'a>> {
Self::debug_assert_entries_mutex_held();
let &result_ptr = self.data.get(query_lower)?;
Some(EntryLookup {
let lookup = EntryLookup {
entry: result_ptr,
_marker: core::marker::PhantomData,
})
};
if lookup.entry().base() != query_lower && !self.spelling_exists(query_lower) {
return None;
}
Some(lookup)
}

/// True if a cached entry exists for the given already-lowercase name.
/// See [`get_comptime_query`](Self::get_comptime_query).
pub fn has_comptime_query(&self, query_lower: &'static [u8]) -> bool {
Self::debug_assert_entries_mutex_held();
self.data.contains_key(query_lower)
self.get_comptime_query(query_lower).is_some()
}

fn spelling_exists(&self, name: &[u8]) -> bool {
use bun_paths::resolve_path::{join_string_buf_z, platform};
let mut buf = bun_paths::path_buffer_pool::get();
bun_sys::exists_z(join_string_buf_z::<platform::Auto>(
&mut buf[..],
&[self.dir, name],
))
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/resolver/package_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ impl PackageJSON {
bun_ast::Loc::EMPTY,
format_args!(
"Cannot read file \"{}\": {}",
bstr::BStr::new(input_path),
bstr::BStr::new(package_json_path),
bstr::BStr::new(err.name())
),
);
Expand Down
22 changes: 22 additions & 0 deletions test/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1459,6 +1459,28 @@ export function tmpdirSync(pattern: string = "bun.test."): string {
return fs.mkdtempSync(join(fs.realpathSync.native(os.tmpdir()), pattern));
}

let caseSensitiveFileSystem: boolean | undefined;

/**
* Whether the filesystem holding the temporary directories (`tempDir`,
* `tempDirWithFiles`, the bundler test fixtures) treats names that differ only
* in case as different files. Usually true on Linux and false on macOS and
* Windows, but it depends on the mounted filesystem, so it is probed once on
* first use.
*/
export function isCaseSensitiveFileSystem(): boolean {
if (caseSensitiveFileSystem === undefined) {
const dir = tmpdirSync("bun.case-probe.");
try {
fs.writeFileSync(join(dir, "probe"), "");
caseSensitiveFileSystem = !fs.existsSync(join(dir, "PROBE"));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
return caseSensitiveFileSystem;
}

export async function runBunInstall(
env: NodeJS.Dict<string>,
cwd: string,
Expand Down
125 changes: 124 additions & 1 deletion test/js/bun/resolve/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { pathToFileURL } from "bun";
import { describe, expect, it, test } from "bun:test";
import { chmodSync, chownSync, mkdirSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "fs";
import { bunEnv, bunExe, bunRun, isLinux, isMacOS, isWindows, joinP, tempDir, tempDirWithFiles } from "harness";
import {
bunEnv,
bunExe,
bunRun,
isCaseSensitiveFileSystem,
isLinux,
isMacOS,
isWindows,
joinP,
tempDir,
tempDirWithFiles,
} from "harness";
import { join, resolve, sep } from "path";

const fixture = (...segs: string[]) => resolve(import.meta.dir, "fixtures", ...segs);
Expand Down Expand Up @@ -1689,3 +1700,115 @@ describe.concurrent("dot specifiers resolve to the directory index, not a siblin
expect(exitCode).toBe(0);
});
});

// The resolver looks every directory up for package.json / tsconfig.json /
// jsconfig.json in a listing keyed by lowercased name, then opens the
// lowercase spelling. A differently-cased file on disk used to satisfy the
// lookup and fail the open.
describe.concurrent("differently-cased package.json / tsconfig.json in a directory", () => {
const aliasFixture = {
"src/x.ts": `export const x = "via-alias";`,
"decoy/x.ts": `export const x = "via-decoy";`,
"entry.ts": `import { x } from "@alias/x"; console.log(x);`,
};
const aliasConfig = (target: string) =>
JSON.stringify({ compilerOptions: { baseUrl: ".", paths: { "@alias/*": [`./${target}/*`] } } });

async function bunBuild(dir: string, entry: string) {
await using proc = Bun.spawn({
cmd: [bunExe(), "build", entry],
env: bunEnv,
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}

describe.skipIf(!isCaseSensitiveFileSystem())("on a case-sensitive filesystem it is an unrelated file", () => {
// Before: `error: Cannot read file ".../": ENOENT` (Package.json) or
// `error: Cannot find tsconfig file ".../tsconfig.json"` on every run.
it.each(["Package.json", "Tsconfig.json", "Jsconfig.json"])("%s next to the entry point is ignored", async name => {
using dir = tempDir("resolve-cased-dir-scan", {
[name]: "{}",
"entry.js": `console.log("ran");`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("ran\n");
expect(exitCode).toBe(0);
});

it("bun build succeeds next to a Package.json", async () => {
using dir = tempDir("resolve-cased-dir-scan-build", {
"Package.json": JSON.stringify({ name: "not-a-manifest-here" }),
"entry.js": `console.log("ran");`,
});
const { stdout, stderr, exitCode } = await bunBuild(String(dir), "entry.js");
expect(stderr).toBe("");
expect(stdout).toContain("ran");
expect(exitCode).toBe(0);
});

it.each(["Tsconfig.json", "Jsconfig.json"])("%s does not configure the directory", async name => {
using dir = tempDir("resolve-cased-config-ignored", { ...aliasFixture, [name]: aliasConfig("src") });
const { stdout, stderr, exitCode } = await bunBuild(String(dir), "entry.ts");
expect(stderr).toContain('Could not resolve: "@alias/x"');
expect(stderr).not.toContain("Cannot find tsconfig file");
expect(stdout).toBe("");
expect(exitCode).toBe(1);
});

// Whichever of the two files the lowercased listing ends up holding, the
// lowercase one is the configuration that applies.
it.each([
["tsconfig.json", "Tsconfig.json"],
["jsconfig.json", "Jsconfig.json"],
])("%s is used when a %s with other contents sits next to it", async (lower, other) => {
using dir = tempDir("resolve-cased-config-both", {
...aliasFixture,
[other]: aliasConfig("decoy"),
[lower]: aliasConfig("src"),
});
const { stdout, stderr, exitCode } = await bunBuild(String(dir), "entry.ts");
expect(stderr).toBe("");
expect(stdout).toContain("via-alias");
expect(stdout).not.toContain("via-decoy");
expect(exitCode).toBe(0);
});
});

describe.skipIf(isCaseSensitiveFileSystem())("on a case-insensitive filesystem it is the configuration file", () => {
it.each(["Tsconfig.json", "Jsconfig.json"])("%s configures the directory", async name => {
using dir = tempDir("resolve-cased-config-folded", { ...aliasFixture, [name]: aliasConfig("src") });
const { stdout, stderr, exitCode } = await bunBuild(String(dir), "entry.ts");
expect(stderr).toBe("");
expect(stdout).toContain("via-alias");
expect(exitCode).toBe(0);
});
});

it.skipIf(isWindows)("a package.json that cannot be read is reported under its own path", async () => {
using dir = tempDir("resolve-unreadable-package-json", { "entry.js": `console.log("ran");` });
symlinkSync("does-not-exist.json", join(String(dir), "package.json"));
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain(`Cannot read file "${join(String(dir), "package.json")}": ENOENT`);
expect(stdout).toBe("ran\n");
expect(exitCode).toBe(0);
});
});
Loading