Skip to content
Merged
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
28 changes: 17 additions & 11 deletions src/resolver/package_json.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1456,21 +1456,27 @@ pub const ESModule = struct {
if (strings.startsWith(package.name, ".") or strings.indexAnyComptime(package.name, "\\%") != null)
return null;

// A version delimiter `@` is only valid within the package-name portion of
// the specifier. Searching the entire specifier misparses wildcard subpaths
// whose matched substring contains `@` (e.g. `test-pkg/@scope/sub/index.js`
// or `ember-source/@ember/renderer/...`) as if the package had a version.
const offset: usize = if (package.name.len == 0 or package.name[0] != '@') 0 else 1;
if (strings.indexOfChar(specifier[offset..], '@')) |at| {
package.version = parseVersion(specifier[offset..][at..]) orelse "";
if (package.version.len == 0) {
package.version = specifier[offset..][at..];
if (package.version.len > 0 and package.version[0] == '@') {
package.version = package.version[1..];
if (offset < package.name.len) {
if (strings.indexOfChar(specifier[offset..package.name.len], '@')) |at| {
package.version = parseVersion(specifier[offset..][at..]) orelse "";
if (package.version.len == 0) {
package.version = specifier[offset..][at..];
if (package.version.len > 0 and package.version[0] == '@') {
package.version = package.version[1..];
}
}
}
package.name = specifier[0 .. at + offset];
package.name = specifier[0 .. at + offset];

parseSubpath(&package.subpath, specifier[@min(package.name.len + package.version.len + 1, specifier.len)..], subpath_buf);
} else {
parseSubpath(&package.subpath, specifier[package.name.len..], subpath_buf);
parseSubpath(&package.subpath, specifier[@min(package.name.len + package.version.len + 1, specifier.len)..], subpath_buf);
return package;
}
}
parseSubpath(&package.subpath, specifier[package.name.len..], subpath_buf);

return package;
}
Expand Down
104 changes: 104 additions & 0 deletions test/js/bun/resolve/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,110 @@ it.skipIf(isWindows)("browser map resolution handles relative paths longer than
expect(exitCode).toBe(0);
});

// ESModule.Package.parse scanned the entire specifier for an `@` to split off a
// version. For wildcard `exports` maps the matched substring can contain `@`
// (e.g. `ember-source/@ember/renderer/...`, `pkg/@scope/sub`) — those `@`s
// aren't version delimiters, they're subpath content. The version split must
// be bounded to the package-name portion of the specifier.
// https://github.com/oven-sh/bun/issues/30187
describe("wildcard exports with @ in matched subpath", () => {
it.concurrent("resolves a subpath whose wildcard match starts with @", () => {
using dir = tempDir("resolver-wildcard-at-scoped", {
"package.json": JSON.stringify({ name: "host" }),
"node_modules/test-pkg/package.json": JSON.stringify({
name: "test-pkg",
version: "1.0.0",
exports: { "./*": "./dist/packages/*" },
}),
"node_modules/test-pkg/dist/packages/plain/index.js": "export default 'plain';",
"node_modules/test-pkg/dist/packages/@scope/sub/index.js": "export default 'scoped';",
});
const root = String(dir);

expect(Bun.resolveSync("test-pkg/plain/index.js", root)).toBe(
join(root, "node_modules/test-pkg/dist/packages/plain/index.js"),
);
expect(Bun.resolveSync("test-pkg/@scope/sub/index.js", root)).toBe(
join(root, "node_modules/test-pkg/dist/packages/@scope/sub/index.js"),
);
});

it.concurrent("resolves a subpath that contains `@` mid-segment", () => {
using dir = tempDir("resolver-wildcard-at-mid", {
"package.json": JSON.stringify({ name: "host" }),
"node_modules/test-pkg/package.json": JSON.stringify({
name: "test-pkg",
version: "1.0.0",
exports: { "./*": "./dist/packages/*" },
}),
"node_modules/test-pkg/dist/packages/with@sign/sub/index.js": "export default 'sign';",
});
const root = String(dir);

expect(Bun.resolveSync("test-pkg/with@sign/sub/index.js", root)).toBe(
join(root, "node_modules/test-pkg/dist/packages/with@sign/sub/index.js"),
);
});

it.concurrent("resolves an @-prefixed subpath under a scoped package", () => {
using dir = tempDir("resolver-wildcard-at-scoped-pkg", {
"package.json": JSON.stringify({ name: "host" }),
"node_modules/@my/pkg/package.json": JSON.stringify({
name: "@my/pkg",
version: "1.0.0",
exports: { "./*": "./dist/*" },
}),
"node_modules/@my/pkg/dist/@inner/bar/index.js": "export default 'inner';",
});
const root = String(dir);

expect(Bun.resolveSync("@my/pkg/@inner/bar/index.js", root)).toBe(
join(root, "node_modules/@my/pkg/dist/@inner/bar/index.js"),
);
});

// Regression guard: `@version` specifiers immediately following the package
// name must still be stripped. We don't install alternative versions; we just
// verify `pkg@1.0.0/subpath` still resolves to the same file as `pkg/subpath`.
it.concurrent("still strips a trailing @version after the package name", () => {
using dir = tempDir("resolver-wildcard-versioned", {
"package.json": JSON.stringify({ name: "host" }),
"node_modules/test-pkg/package.json": JSON.stringify({
name: "test-pkg",
version: "1.0.0",
exports: { "./*": "./dist/packages/*" },
}),
"node_modules/test-pkg/dist/packages/plain/index.js": "export default 'plain';",
});
const root = String(dir);

expect(Bun.resolveSync("test-pkg@1.0.0/plain/index.js", root)).toBe(
join(root, "node_modules/test-pkg/dist/packages/plain/index.js"),
);
});

// Regression guard for the scoped-package version split: the `@version`
// delimiter still falls inside the name span `parseName` returns (between
// the leading `@` and the second `/`), so the version branch must still
// fire for `@scope/pkg@ver/sub`.
it.concurrent("still strips @version after a scoped package name", () => {
using dir = tempDir("resolver-wildcard-scoped-versioned", {
"package.json": JSON.stringify({ name: "host" }),
"node_modules/@my/pkg/package.json": JSON.stringify({
name: "@my/pkg",
version: "1.0.0",
exports: { "./*": "./dist/*" },
}),
"node_modules/@my/pkg/dist/sub/index.js": "export default 'sub';",
});
const root = String(dir);

expect(Bun.resolveSync("@my/pkg@1.0.0/sub/index.js", root)).toBe(
join(root, "node_modules/@my/pkg/dist/sub/index.js"),
);
});
});

// dirInfoCachedMaybeLog reads the rfs.entries cache without checking the union
// tag. If readDirectory() previously failed with a non-ENOENT error (e.g.
// EACCES), a `.err` variant is stored there; re-resolving the directory after
Expand Down
Loading