Files
bun.sh/src/install/dependency.zig
taylor.fish 437e15bae5 Replace catch bun.outOfMemory() with safer alternatives (#22141)
Replace `catch bun.outOfMemory()`, which can accidentally catch
non-OOM-related errors, with either `bun.handleOom` or a manual `catch
|err| switch (err)`.

(For internal tracking: fixes STAB-1070)

---------

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
2025-08-26 12:50:25 -07:00

1462 lines
52 KiB
Zig

const Dependency = @This();
const URI = union(Tag) {
local: String,
remote: String,
pub fn eql(lhs: URI, rhs: URI, lhs_buf: []const u8, rhs_buf: []const u8) bool {
if (@as(Tag, lhs) != @as(Tag, rhs)) {
return false;
}
if (@as(Tag, lhs) == .local) {
return strings.eqlLong(lhs.local.slice(lhs_buf), rhs.local.slice(rhs_buf), true);
} else {
return strings.eqlLong(lhs.remote.slice(lhs_buf), rhs.remote.slice(rhs_buf), true);
}
}
pub const Tag = enum {
local,
remote,
};
};
name_hash: PackageNameHash = 0,
name: String = .{},
version: Dependency.Version = .{},
/// This is how the dependency is specified in the package.json file.
/// This allows us to track whether a package originated in any permutation of:
/// - `dependencies`
/// - `devDependencies`
/// - `optionalDependencies`
/// - `peerDependencies`
/// Technically, having the same package name specified under multiple fields is invalid
/// But we don't want to allocate extra arrays for them. So we use a bitfield instead.
behavior: Behavior = .{},
/// Sorting order for dependencies is:
/// 1. [ `peerDependencies`, `optionalDependencies`, `devDependencies`, `dependencies` ]
/// 2. name ASC
/// "name" must be ASC so that later, when we rebuild the lockfile
/// we insert it back in reverse order without an extra sorting pass
pub fn isLessThan(string_buf: []const u8, lhs: Dependency, rhs: Dependency) bool {
const behavior = lhs.behavior.cmp(rhs.behavior);
if (behavior != .eq) {
return behavior == .lt;
}
const lhs_name = lhs.name.slice(string_buf);
const rhs_name = rhs.name.slice(string_buf);
return strings.cmpStringsAsc({}, lhs_name, rhs_name);
}
pub fn countWithDifferentBuffers(this: *const Dependency, name_buf: []const u8, version_buf: []const u8, comptime StringBuilder: type, builder: StringBuilder) void {
builder.count(this.name.slice(name_buf));
builder.count(this.version.literal.slice(version_buf));
}
pub fn count(this: *const Dependency, buf: []const u8, comptime StringBuilder: type, builder: StringBuilder) void {
this.countWithDifferentBuffers(buf, buf, StringBuilder, builder);
}
pub fn clone(this: *const Dependency, package_manager: *PackageManager, buf: []const u8, comptime StringBuilder: type, builder: StringBuilder) !Dependency {
return this.cloneWithDifferentBuffers(package_manager, buf, buf, StringBuilder, builder);
}
pub fn cloneWithDifferentBuffers(this: *const Dependency, package_manager: *PackageManager, name_buf: []const u8, version_buf: []const u8, comptime StringBuilder: type, builder: StringBuilder) !Dependency {
const out_slice = builder.lockfile.buffers.string_bytes.items;
const new_literal = builder.append(String, this.version.literal.slice(version_buf));
const sliced = new_literal.sliced(out_slice);
const new_name = builder.append(String, this.name.slice(name_buf));
return Dependency{
.name_hash = this.name_hash,
.name = new_name,
.version = Dependency.parseWithTag(
builder.lockfile.allocator,
new_name,
String.Builder.stringHash(new_name.slice(out_slice)),
new_literal.slice(out_slice),
this.version.tag,
&sliced,
null,
package_manager,
) orelse Dependency.Version{},
.behavior = this.behavior,
};
}
pub const External = [size]u8;
const size = @sizeOf(Dependency.Version.External) +
@sizeOf(PackageNameHash) +
@sizeOf(Dependency.Behavior) +
@sizeOf(String);
pub const Context = struct {
allocator: std.mem.Allocator,
log: *logger.Log,
buffer: []const u8,
package_manager: ?*PackageManager,
};
/// Get the name of the package as it should appear in a remote registry.
pub inline fn realname(this: *const Dependency) String {
return switch (this.version.tag) {
.dist_tag => this.version.value.dist_tag.name,
.git => this.version.value.git.package_name,
.github => this.version.value.github.package_name,
.npm => this.version.value.npm.name,
.tarball => this.version.value.tarball.package_name,
else => this.name,
};
}
pub inline fn isAliased(this: *const Dependency, buf: []const u8) bool {
return switch (this.version.tag) {
.npm => !this.version.value.npm.name.eql(this.name, buf, buf),
.dist_tag => !this.version.value.dist_tag.name.eql(this.name, buf, buf),
.git => !this.version.value.git.package_name.eql(this.name, buf, buf),
.github => !this.version.value.github.package_name.eql(this.name, buf, buf),
.tarball => !this.version.value.tarball.package_name.eql(this.name, buf, buf),
else => false,
};
}
pub fn toDependency(
this: External,
ctx: Context,
) Dependency {
const name = String{
.bytes = this[0..8].*,
};
const name_hash: u64 = @bitCast(this[8..16].*);
return Dependency{
.name = name,
.name_hash = name_hash,
.behavior = @bitCast(this[16]),
.version = Dependency.Version.toVersion(name, name_hash, this[17..this.len].*, ctx),
};
}
pub fn toExternal(this: Dependency) External {
var bytes: External = undefined;
bytes[0..this.name.bytes.len].* = this.name.bytes;
bytes[8..16].* = @as([8]u8, @bitCast(this.name_hash));
bytes[16] = @bitCast(this.behavior);
bytes[17..bytes.len].* = this.version.toExternal();
return bytes;
}
pub inline fn isSCPLikePath(dependency: string) bool {
// Shortest valid expression: h:p
if (dependency.len < 3) return false;
var at_index: ?usize = null;
for (dependency, 0..) |c, i| {
switch (c) {
'@' => {
if (at_index == null) at_index = i;
},
':' => {
if (strings.hasPrefixComptime(dependency[i..], "://")) return false;
return i > if (at_index) |index| index + 1 else 0;
},
'/' => return if (at_index) |index| i > index + 1 else false,
else => {},
}
}
return false;
}
/// `isGitHubShorthand` from npm
/// https://github.com/npm/cli/blob/22731831e22011e32fa0ca12178e242c2ee2b33d/node_modules/hosted-git-info/lib/from-url.js#L6
pub inline fn isGitHubRepoPath(dependency: string) bool {
// Shortest valid expression: u/r
if (dependency.len < 3) return false;
var hash_index: usize = 0;
// the branch could have slashes
// - oven-sh/bun#brach/name
var first_slash_index: usize = 0;
for (dependency, 0..) |c, i| {
switch (c) {
'/' => {
if (i == 0) return false;
if (first_slash_index == 0) {
first_slash_index = i;
}
},
'#' => {
if (i == 0) return false;
if (hash_index > 0) return false;
if (first_slash_index == 0) return false;
hash_index = i;
},
// Not allowed in username
'.', '_' => {
if (first_slash_index == 0) return false;
},
// Must be alphanumeric
'-', 'a'...'z', 'A'...'Z', '0'...'9' => {},
else => return false,
}
}
return hash_index != dependency.len - 1 and first_slash_index > 0 and first_slash_index != dependency.len - 1;
}
/// Github allows for the following format of URL:
/// https://github.com/<org>/<repo>/tarball/<ref>
/// This is a legacy (but still supported) method of retrieving a tarball of an
/// entire source tree at some git reference. (ref = branch, tag, etc. Note: branch
/// can have arbitrary number of slashes)
///
/// This also checks for a github url that ends with ".tar.gz"
pub inline fn isGitHubTarballPath(dependency: string) bool {
if (isTarball(dependency)) return true;
var parts = strings.split(dependency, "/");
var n_parts: usize = 0;
while (parts.next()) |part| {
n_parts += 1;
if (n_parts == 3) {
return strings.eqlComptime(part, "tarball");
}
}
return false;
}
// This won't work for query string params, but I'll let someone file an issue
// before I add that.
pub inline fn isTarball(dependency: string) bool {
return strings.endsWithComptime(dependency, ".tgz") or strings.endsWithComptime(dependency, ".tar.gz");
}
/// the input is assumed to be either a remote or local tarball
pub inline fn isRemoteTarball(dependency: string) bool {
return strings.hasPrefixComptime(dependency, "https://") or strings.hasPrefixComptime(dependency, "http://");
}
/// Turns `foo@1.1.1` into `foo`, `1.1.1`, or `@foo/bar@1.1.1` into `@foo/bar`, `1.1.1`, or `foo` into `foo`, `null`.
pub fn splitNameAndMaybeVersion(str: string) struct { string, ?string } {
if (strings.indexOfChar(str, '@')) |at_index| {
if (at_index != 0) {
return .{ str[0..at_index], if (at_index + 1 < str.len) str[at_index + 1 ..] else null };
}
const second_at_index = (strings.indexOfChar(str[1..], '@') orelse return .{ str, null }) + 1;
return .{ str[0..second_at_index], if (second_at_index + 1 < str.len) str[second_at_index + 1 ..] else null };
}
return .{ str, null };
}
pub fn splitNameAndVersionOrLatest(str: string) struct { string, string } {
const name, const version = splitNameAndMaybeVersion(str);
return .{
name,
version orelse "latest",
};
}
pub fn splitNameAndVersion(str: string) error{MissingVersion}!struct { string, string } {
const name, const version = splitNameAndMaybeVersion(str);
return .{
name,
version orelse return error.MissingVersion,
};
}
pub fn unscopedPackageName(name: []const u8) []const u8 {
if (name[0] != '@') return name;
var name_ = name;
name_ = name[1..];
return name_[(strings.indexOfChar(name_, '/') orelse return name) + 1 ..];
}
pub fn isScopedPackageName(name: string) error{InvalidPackageName}!bool {
if (name.len == 0) return error.InvalidPackageName;
if (name[0] != '@') return false;
if (strings.indexOfChar(name, '/')) |slash| {
if (slash != 1 and slash != name.len - 1) {
return true;
}
}
return error.InvalidPackageName;
}
/// assumes version is valid
pub fn withoutBuildTag(version: string) string {
if (strings.indexOfChar(version, '+')) |plus| return version[0..plus] else return version;
}
pub const Version = struct {
tag: Tag = .uninitialized,
literal: String = .{},
value: Value = .{ .uninitialized = {} },
pub fn toJS(dep: *const Version, buf: []const u8, globalThis: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue {
const object = jsc.JSValue.createEmptyObject(globalThis, 2);
object.put(globalThis, "type", bun.String.static(@tagName(dep.tag)).toJS(globalThis));
switch (dep.tag) {
.dist_tag => {
object.put(globalThis, "name", try dep.value.dist_tag.name.toJS(buf, globalThis));
object.put(globalThis, "tag", try dep.value.dist_tag.tag.toJS(buf, globalThis));
},
.folder => {
object.put(globalThis, "folder", try dep.value.folder.toJS(buf, globalThis));
},
.git => {
object.put(globalThis, "owner", try dep.value.git.owner.toJS(buf, globalThis));
object.put(globalThis, "repo", try dep.value.git.repo.toJS(buf, globalThis));
object.put(globalThis, "ref", try dep.value.git.committish.toJS(buf, globalThis));
},
.github => {
object.put(globalThis, "owner", try dep.value.github.owner.toJS(buf, globalThis));
object.put(globalThis, "repo", try dep.value.github.repo.toJS(buf, globalThis));
object.put(globalThis, "ref", try dep.value.github.committish.toJS(buf, globalThis));
},
.npm => {
object.put(globalThis, "name", try dep.value.npm.name.toJS(buf, globalThis));
var version_str = try bun.String.createFormat("{}", .{dep.value.npm.version.fmt(buf)});
object.put(globalThis, "version", version_str.transferToJS(globalThis));
object.put(globalThis, "alias", jsc.JSValue.jsBoolean(dep.value.npm.is_alias));
},
.symlink => {
object.put(globalThis, "path", try dep.value.symlink.toJS(buf, globalThis));
},
.workspace => {
object.put(globalThis, "name", try dep.value.workspace.toJS(buf, globalThis));
},
.tarball => {
object.put(globalThis, "name", try dep.value.tarball.package_name.toJS(buf, globalThis));
switch (dep.value.tarball.uri) {
.local => |*local| {
object.put(globalThis, "path", try local.toJS(buf, globalThis));
},
.remote => |*remote| {
object.put(globalThis, "url", try remote.toJS(buf, globalThis));
},
}
},
else => {
return globalThis.throwTODO("Unsupported dependency type");
},
}
return object;
}
pub inline fn npm(this: *const Version) ?NpmInfo {
return if (this.tag == .npm) this.value.npm else null;
}
pub fn deinit(this: *Version) void {
switch (this.tag) {
.npm => {
this.value.npm.version.deinit();
},
else => {},
}
}
pub const zeroed = Version{};
pub fn clone(
this: *const Version,
buf: []const u8,
comptime StringBuilder: type,
builder: StringBuilder,
) !Version {
return Version{
.tag = this.tag,
.literal = builder.append(String, this.literal.slice(buf)),
.value = try this.value.clone(buf, builder),
};
}
pub fn isLessThan(string_buf: []const u8, lhs: Dependency.Version, rhs: Dependency.Version) bool {
if (comptime Environment.allow_assert) bun.assert(lhs.tag == rhs.tag);
return strings.cmpStringsAsc({}, lhs.literal.slice(string_buf), rhs.literal.slice(string_buf));
}
pub fn isLessThanWithTag(string_buf: []const u8, lhs: Dependency.Version, rhs: Dependency.Version) bool {
const tag_order = lhs.tag.cmp(rhs.tag);
if (tag_order != .eq)
return tag_order == .lt;
return strings.cmpStringsAsc({}, lhs.literal.slice(string_buf), rhs.literal.slice(string_buf));
}
pub const External = [9]u8;
pub fn toVersion(
alias: String,
alias_hash: PackageNameHash,
bytes: Version.External,
ctx: Dependency.Context,
) Dependency.Version {
const slice = String{ .bytes = bytes[1..9].* };
const tag = @as(Dependency.Version.Tag, @enumFromInt(bytes[0]));
const sliced = &slice.sliced(ctx.buffer);
return Dependency.parseWithTag(
ctx.allocator,
alias,
alias_hash,
sliced.slice,
tag,
sliced,
ctx.log,
ctx.package_manager,
) orelse Dependency.Version.zeroed;
}
pub inline fn toExternal(this: Version) Version.External {
var bytes: Version.External = undefined;
bytes[0] = @intFromEnum(this.tag);
bytes[1..9].* = this.literal.bytes;
return bytes;
}
pub inline fn eql(
lhs: *const Version,
rhs: *const Version,
lhs_buf: []const u8,
rhs_buf: []const u8,
) bool {
if (lhs.tag != rhs.tag) {
return false;
}
return switch (lhs.tag) {
// if the two versions are identical as strings, it should often be faster to compare that than the actual semver version
// semver ranges involve a ton of pointer chasing
.npm => strings.eqlLong(lhs.literal.slice(lhs_buf), rhs.literal.slice(rhs_buf), true) or
lhs.value.npm.eql(rhs.value.npm, lhs_buf, rhs_buf),
.folder, .dist_tag => lhs.literal.eql(rhs.literal, lhs_buf, rhs_buf),
.git => lhs.value.git.eql(&rhs.value.git, lhs_buf, rhs_buf),
.github => lhs.value.github.eql(&rhs.value.github, lhs_buf, rhs_buf),
.tarball => lhs.value.tarball.eql(rhs.value.tarball, lhs_buf, rhs_buf),
.symlink => lhs.value.symlink.eql(rhs.value.symlink, lhs_buf, rhs_buf),
.workspace => lhs.value.workspace.eql(rhs.value.workspace, lhs_buf, rhs_buf),
else => true,
};
}
pub const Tag = enum(u8) {
uninitialized = 0,
/// Semver range
npm = 1,
/// NPM dist tag, e.g. "latest"
dist_tag = 2,
/// URI to a .tgz or .tar.gz
tarball = 3,
/// Local folder
folder = 4,
/// link:path
/// https://docs.npmjs.com/cli/v8/commands/npm-link#synopsis
/// https://stackoverflow.com/questions/51954956/whats-the-difference-between-yarn-link-and-npm-link
symlink = 5,
/// Local path specified under `workspaces`
workspace = 6,
/// Git Repository (via `git` CLI)
git = 7,
/// GitHub Repository (via REST API)
github = 8,
catalog = 9,
pub const map = bun.ComptimeStringMap(Tag, .{
.{ "npm", .npm },
.{ "dist_tag", .dist_tag },
.{ "tarball", .tarball },
.{ "folder", .folder },
.{ "symlink", .symlink },
.{ "workspace", .workspace },
.{ "git", .git },
.{ "github", .github },
.{ "catalog", .catalog },
});
pub const fromJS = map.fromJS;
pub fn cmp(this: Tag, other: Tag) std.math.Order {
// TODO: align with yarn
return std.math.order(@intFromEnum(this), @intFromEnum(other));
}
pub inline fn isNPM(this: Tag) bool {
return @intFromEnum(this) < 3;
}
pub fn infer(dependency: string) Tag {
// empty string means `latest`
if (dependency.len == 0) return .dist_tag;
if (strings.startsWithWindowsDriveLetter(dependency) and (std.fs.path.isSep(dependency[2]))) {
if (isTarball(dependency)) return .tarball;
return .folder;
}
switch (dependency[0]) {
// =1
// >1.2
// >=1.2.3
// <1
// <=1.2
// ^1.2.3
// *
// || 1.x
'=', '>', '<', '^', '*', '|' => return .npm,
// ./foo.tgz
// ./path/to/foo
// ../path/to/bar
'.' => {
if (isTarball(dependency)) return .tarball;
return .folder;
},
// ~1.2.3
// ~/foo.tgz
// ~/path/to/foo
'~' => {
// https://docs.npmjs.com/cli/v8/configuring-npm/package-json#local-paths
if (dependency.len > 1 and dependency[1] == '/') {
if (isTarball(dependency)) return .tarball;
return .folder;
}
return .npm;
},
// /path/to/foo
// /path/to/foo.tgz
'/' => {
if (isTarball(dependency)) return .tarball;
return .folder;
},
// 1.2.3
// 123.tar.gz
'0'...'9' => {
if (isTarball(dependency)) return .tarball;
return .npm;
},
// foo.tgz
// foo/repo
// file:path/to/foo
// file:path/to/foo.tar.gz
'f' => {
if (strings.hasPrefixComptime(dependency, "file:")) {
if (isTarball(dependency)) return .tarball;
return .folder;
}
},
'c' => {
if (strings.hasPrefixComptime(dependency, "catalog:")) {
return .catalog;
}
},
// git_user/repo
// git_tarball.tgz
// github:user/repo
// git@example.com/repo.git
// git://user@example.com/repo.git
'g' => {
if (strings.hasPrefixComptime(dependency, "git")) {
var url = dependency["git".len..];
if (url.len > 2) {
switch (url[0]) {
':' => {
if (strings.hasPrefixComptime(url, "://")) {
url = url["://".len..];
if (strings.hasPrefixComptime(url, "github.com/")) {
if (isGitHubRepoPath(url["github.com/".len..])) return .github;
}
return .git;
}
},
'+' => {
if (strings.hasPrefixComptime(url, "+ssh:") or
strings.hasPrefixComptime(url, "+file:"))
{
return .git;
}
if (strings.hasPrefixComptime(url, "+http")) {
url = url["+http".len..];
if (url.len > 2 and switch (url[0]) {
':' => brk: {
if (strings.hasPrefixComptime(url, "://")) {
url = url["://".len..];
break :brk true;
}
break :brk false;
},
's' => brk: {
if (strings.hasPrefixComptime(url, "s://")) {
url = url["s://".len..];
break :brk true;
}
break :brk false;
},
else => false,
}) {
if (strings.hasPrefixComptime(url, "github.com/")) {
if (isGitHubRepoPath(url["github.com/".len..])) return .github;
}
return .git;
}
}
},
'h' => {
if (strings.hasPrefixComptime(url, "hub:")) {
if (isGitHubRepoPath(url["hub:".len..])) return .github;
}
},
else => {},
}
}
}
},
// hello/world
// hello.tar.gz
// https://github.com/user/repo
'h' => {
if (strings.hasPrefixComptime(dependency, "http")) {
var url = dependency["http".len..];
if (url.len > 2) {
switch (url[0]) {
':' => {
if (strings.hasPrefixComptime(url, "://")) {
url = url["://".len..];
}
},
's' => {
if (strings.hasPrefixComptime(url, "s://")) {
url = url["s://".len..];
}
},
else => {},
}
if (strings.hasPrefixComptime(url, "github.com/")) {
const path = url["github.com/".len..];
if (isGitHubTarballPath(path)) return .tarball;
if (isGitHubRepoPath(path)) return .github;
}
if (strings.indexOfChar(url, '.')) |dot| {
if (Repository.Hosts.has(url[0..dot])) return .git;
}
return .tarball;
}
}
},
's' => {
if (strings.hasPrefixComptime(dependency, "ssh")) {
var url = dependency["ssh".len..];
if (url.len > 2) {
if (url[0] == ':') {
if (strings.hasPrefixComptime(url, "://")) {
url = url["://".len..];
}
}
if (url.len > 4 and strings.eqlComptime(url[0.."git@".len], "git@")) {
url = url["git@".len..];
}
if (strings.indexOfChar(url, '.')) |dot| {
if (Repository.Hosts.has(url[0..dot])) return .git;
}
}
}
},
// lisp.tgz
// lisp/repo
// link:path/to/foo
'l' => {
if (strings.hasPrefixComptime(dependency, "link:")) return .symlink;
},
// newspeak.tgz
// newspeak/repo
// npm:package@1.2.3
'n' => {
if (strings.hasPrefixComptime(dependency, "npm:") and dependency.len > "npm:".len) {
const remain = dependency["npm:".len + @intFromBool(dependency["npm:".len] == '@') ..];
for (remain, 0..) |c, i| {
if (c == '@') {
return infer(remain[i + 1 ..]);
}
}
return .npm;
}
},
// v1.2.3
// verilog
// verilog.tar.gz
// verilog/repo
// virt@example.com:repo.git
'v' => {
if (isTarball(dependency)) return .tarball;
if (isGitHubRepoPath(dependency)) return .github;
if (isSCPLikePath(dependency)) return .git;
if (dependency.len == 1) return .dist_tag;
return switch (dependency[1]) {
'0'...'9' => .npm,
else => .dist_tag,
};
},
// workspace:*
// w00t
// w00t.tar.gz
// w00t/repo
'w' => {
if (strings.hasPrefixComptime(dependency, "workspace:")) return .workspace;
},
// x
// xyz.tar.gz
// xyz/repo#main
'x', 'X' => {
if (dependency.len == 1) return .npm;
if (dependency[1] == '.') return .npm;
},
'p' => {
// TODO(dylan-conway): apply .patch files on packages. In the future this could
// return `Tag.git` or `Tag.npm`.
if (strings.hasPrefixComptime(dependency, "patch:")) return .npm;
},
else => {},
}
// foo.tgz
// bar.tar.gz
if (isTarball(dependency)) return .tarball;
// user/repo
// user/repo#main
if (isGitHubRepoPath(dependency)) return .github;
// git@example.com:path/to/repo.git
if (isSCPLikePath(dependency)) return .git;
// beta
if (!strings.containsChar(dependency, '|')) {
return .dist_tag;
}
return .npm;
}
pub fn inferFromJS(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments_old(1).slice();
if (arguments.len == 0 or !arguments[0].isString()) {
return .js_undefined;
}
const tag = try Tag.fromJS(globalObject, arguments[0]) orelse return .js_undefined;
var str = bun.String.init(@tagName(tag));
return str.transferToJS(globalObject);
}
};
pub const NpmInfo = struct {
name: String,
version: Semver.Query.Group,
is_alias: bool = false,
fn eql(this: NpmInfo, that: NpmInfo, this_buf: []const u8, that_buf: []const u8) bool {
return this.name.eql(that.name, this_buf, that_buf) and this.version.eql(that.version);
}
};
pub const TagInfo = struct {
name: String,
tag: String,
fn eql(this: TagInfo, that: TagInfo, this_buf: []const u8, that_buf: []const u8) bool {
return this.name.eql(that.name, this_buf, that_buf) and this.tag.eql(that.tag, this_buf, that_buf);
}
};
pub const TarballInfo = struct {
uri: URI,
package_name: String = .{},
fn eql(this: TarballInfo, that: TarballInfo, this_buf: []const u8, that_buf: []const u8) bool {
return this.uri.eql(that.uri, this_buf, that_buf);
}
};
pub const Value = union {
uninitialized: void,
npm: NpmInfo,
dist_tag: TagInfo,
tarball: TarballInfo,
folder: String,
/// Equivalent to npm link
symlink: String,
workspace: String,
git: Repository,
github: Repository,
// dep version without 'catalog:' protocol
// empty string == default catalog
catalog: String,
};
};
pub fn eql(
a: *const Dependency,
b: *const Dependency,
lhs_buf: []const u8,
rhs_buf: []const u8,
) bool {
return a.name_hash == b.name_hash and a.name.len() == b.name.len() and a.version.eql(&b.version, lhs_buf, rhs_buf);
}
pub fn isWindowsAbsPathWithLeadingSlashes(dep: string) ?string {
var i: usize = 0;
if (dep.len > 2 and dep[i] == '/') {
while (dep[i] == '/') {
i += 1;
// not possible to have windows drive letter and colon
if (i > dep.len - 3) return null;
}
if (strings.startsWithWindowsDriveLetter(dep[i..])) {
return dep[i..];
}
}
return null;
}
pub inline fn parse(
allocator: std.mem.Allocator,
alias: String,
alias_hash: ?PackageNameHash,
dependency: string,
sliced: *const SlicedString,
log: ?*logger.Log,
manager: ?*PackageManager,
) ?Version {
const dep = std.mem.trimLeft(u8, dependency, " \t\n\r");
return parseWithTag(allocator, alias, alias_hash, dep, Version.Tag.infer(dep), sliced, log, manager);
}
pub fn parseWithOptionalTag(
allocator: std.mem.Allocator,
alias: String,
alias_hash: ?PackageNameHash,
dependency: string,
tag: ?Dependency.Version.Tag,
sliced: *const SlicedString,
log: ?*logger.Log,
package_manager: ?*PackageManager,
) ?Version {
const dep = std.mem.trimLeft(u8, dependency, " \t\n\r");
return parseWithTag(
allocator,
alias,
alias_hash,
dep,
tag orelse Version.Tag.infer(dep),
sliced,
log,
package_manager,
);
}
pub fn parseWithTag(
allocator: std.mem.Allocator,
alias: String,
alias_hash: ?PackageNameHash,
dependency: string,
tag: Dependency.Version.Tag,
sliced: *const SlicedString,
log_: ?*logger.Log,
package_manager: ?*PackageManager,
) ?Version {
switch (tag) {
.npm => {
var input = dependency;
var is_alias = false;
const name = brk: {
if (strings.hasPrefixComptime(input, "npm:")) {
is_alias = true;
var str = input["npm:".len..];
var i: usize = @intFromBool(str.len > 0 and str[0] == '@');
while (i < str.len) : (i += 1) {
if (str[i] == '@') {
input = str[i + 1 ..];
break :brk sliced.sub(str[0..i]).value();
}
}
input = str[i..];
break :brk sliced.sub(str[0..i]).value();
}
break :brk alias;
};
is_alias = is_alias and alias_hash != null;
// Strip single leading v
// v1.0.0 -> 1.0.0
// note: "vx" is valid, it becomes "x". "yarn add react@vx" -> "yarn add react@x" -> "yarn add react@17.0.2"
if (input.len > 1 and input[0] == 'v') {
input = input[1..];
}
const version = Semver.Query.parse(
allocator,
input,
sliced.sub(input),
) catch |err| {
switch (err) {
error.OutOfMemory => bun.outOfMemory(),
}
};
const result = Version{
.literal = sliced.value(),
.value = .{
.npm = .{
.is_alias = is_alias,
.name = name,
.version = version,
},
},
.tag = .npm,
};
if (is_alias) {
if (package_manager) |pm| {
pm.known_npm_aliases.put(
allocator,
alias_hash.?,
result,
) catch unreachable;
}
}
return result;
},
.dist_tag => {
var tag_to_use = sliced.value();
const actual = if (strings.hasPrefixComptime(dependency, "npm:") and dependency.len > "npm:".len)
// npm:@foo/bar@latest
sliced.sub(brk: {
var i = "npm:".len;
// npm:@foo/bar@latest
// ^
i += @intFromBool(dependency[i] == '@');
while (i < dependency.len) : (i += 1) {
// npm:@foo/bar@latest
// ^
if (dependency[i] == '@') {
break;
}
}
tag_to_use = sliced.sub(dependency[i + 1 ..]).value();
break :brk dependency["npm:".len..i];
}).value()
else
alias;
// name should never be empty
if (comptime Environment.allow_assert) bun.assert(!actual.isEmpty());
return .{
.literal = sliced.value(),
.value = .{
.dist_tag = .{
.name = actual,
.tag = if (tag_to_use.isEmpty()) String.from("latest") else tag_to_use,
},
},
.tag = .dist_tag,
};
},
.git => {
var input = dependency;
if (strings.hasPrefixComptime(input, "git+")) {
input = input["git+".len..];
}
const hash_index = strings.lastIndexOfChar(input, '#');
return .{
.literal = sliced.value(),
.value = .{
.git = .{
.owner = String.from(""),
.repo = sliced.sub(if (hash_index) |index| input[0..index] else input).value(),
.committish = if (hash_index) |index| sliced.sub(input[index + 1 ..]).value() else String.from(""),
},
},
.tag = .git,
};
},
.github => {
var from_url = false;
var input = dependency;
if (strings.hasPrefixComptime(input, "github:")) {
input = input["github:".len..];
} else if (strings.hasPrefixComptime(input, "git://github.com/")) {
input = input["git://github.com/".len..];
from_url = true;
} else {
if (strings.hasPrefixComptime(input, "git+")) {
input = input["git+".len..];
}
if (strings.hasPrefixComptime(input, "http")) {
var url = input["http".len..];
if (url.len > 2) {
switch (url[0]) {
':' => {
if (strings.hasPrefixComptime(url, "://")) {
url = url["://".len..];
}
},
's' => {
if (strings.hasPrefixComptime(url, "s://")) {
url = url["s://".len..];
}
},
else => {},
}
if (strings.hasPrefixComptime(url, "github.com/")) {
input = url["github.com/".len..];
from_url = true;
}
}
}
}
if (comptime Environment.allow_assert) bun.assert(isGitHubRepoPath(input));
var hash_index: usize = 0;
var slash_index: usize = 0;
for (input, 0..) |c, i| {
switch (c) {
'/' => {
slash_index = i;
},
'#' => {
hash_index = i;
break;
},
else => {},
}
}
var repo = if (hash_index == 0) input[slash_index + 1 ..] else input[slash_index + 1 .. hash_index];
if (from_url and strings.endsWithComptime(repo, ".git")) {
repo = repo[0 .. repo.len - ".git".len];
}
return .{
.literal = sliced.value(),
.value = .{
.github = .{
.owner = sliced.sub(input[0..slash_index]).value(),
.repo = sliced.sub(repo).value(),
.committish = if (hash_index == 0) String.from("") else sliced.sub(input[hash_index + 1 ..]).value(),
},
},
.tag = .github,
};
},
.tarball => {
if (isRemoteTarball(dependency)) {
return .{
.tag = .tarball,
.literal = sliced.value(),
.value = .{ .tarball = .{ .uri = .{ .remote = sliced.sub(dependency).value() } } },
};
} else if (strings.hasPrefixComptime(dependency, "file://")) {
return .{
.tag = .tarball,
.literal = sliced.value(),
.value = .{ .tarball = .{ .uri = .{ .local = sliced.sub(dependency[7..]).value() } } },
};
} else if (strings.hasPrefixComptime(dependency, "file:")) {
return .{
.tag = .tarball,
.literal = sliced.value(),
.value = .{ .tarball = .{ .uri = .{ .local = sliced.sub(dependency[5..]).value() } } },
};
} else if (strings.contains(dependency, "://")) {
if (log_) |log| log.addErrorFmt(null, logger.Loc.Empty, allocator, "invalid or unsupported dependency \"{s}\"", .{dependency}) catch unreachable;
return null;
}
return .{
.tag = .tarball,
.literal = sliced.value(),
.value = .{ .tarball = .{ .uri = .{ .local = sliced.value() } } },
};
},
.folder => {
if (strings.indexOfChar(dependency, ':')) |protocol| {
if (strings.eqlComptime(dependency[0..protocol], "file")) {
const folder = folder: {
// from npm:
//
// turn file://../foo into file:../foo
// https://github.com/npm/cli/blob/fc6e291e9c2154c2e76636cb7ebf0a17be307585/node_modules/npm-package-arg/lib/npa.js#L269
//
// something like this won't behave the same
// file://bar/../../foo
const maybe_dot_dot = maybe_dot_dot: {
if (dependency.len > protocol + 1 and dependency[protocol + 1] == '/') {
if (dependency.len > protocol + 2 and dependency[protocol + 2] == '/') {
if (dependency.len > protocol + 3 and dependency[protocol + 3] == '/') {
break :maybe_dot_dot dependency[protocol + 4 ..];
}
break :maybe_dot_dot dependency[protocol + 3 ..];
}
break :maybe_dot_dot dependency[protocol + 2 ..];
}
break :folder dependency[protocol + 1 ..];
};
if (maybe_dot_dot.len > 1 and maybe_dot_dot[0] == '.' and maybe_dot_dot[1] == '.') {
return .{
.literal = sliced.value(),
.value = .{ .folder = sliced.sub(maybe_dot_dot).value() },
.tag = .folder,
};
}
break :folder dependency[protocol + 1 ..];
};
// from npm:
//
// turn /C:/blah info just C:/blah on windows
// https://github.com/npm/cli/blob/fc6e291e9c2154c2e76636cb7ebf0a17be307585/node_modules/npm-package-arg/lib/npa.js#L277
if (comptime Environment.isWindows) {
if (isWindowsAbsPathWithLeadingSlashes(folder)) |dep| {
return .{
.literal = sliced.value(),
.value = .{ .folder = sliced.sub(dep).value() },
.tag = .folder,
};
}
}
return .{
.literal = sliced.value(),
.value = .{ .folder = sliced.sub(folder).value() },
.tag = .folder,
};
}
// check for absolute windows paths
if (comptime Environment.isWindows) {
if (protocol == 1 and strings.startsWithWindowsDriveLetter(dependency)) {
return .{
.literal = sliced.value(),
.value = .{ .folder = sliced.sub(dependency).value() },
.tag = .folder,
};
}
// from npm:
//
// turn /C:/blah info just C:/blah on windows
// https://github.com/npm/cli/blob/fc6e291e9c2154c2e76636cb7ebf0a17be307585/node_modules/npm-package-arg/lib/npa.js#L277
if (isWindowsAbsPathWithLeadingSlashes(dependency)) |dep| {
return .{
.literal = sliced.value(),
.value = .{ .folder = sliced.sub(dep).value() },
.tag = .folder,
};
}
}
if (log_) |log| log.addErrorFmt(null, logger.Loc.Empty, allocator, "Unsupported protocol {s}", .{dependency}) catch unreachable;
return null;
}
return .{
.value = .{ .folder = sliced.value() },
.tag = .folder,
.literal = sliced.value(),
};
},
.uninitialized => return null,
.symlink => {
if (strings.indexOfChar(dependency, ':')) |colon| {
return .{
.value = .{ .symlink = sliced.sub(dependency[colon + 1 ..]).value() },
.tag = .symlink,
.literal = sliced.value(),
};
}
return .{
.value = .{ .symlink = sliced.value() },
.tag = .symlink,
.literal = sliced.value(),
};
},
.workspace => {
var input = dependency;
if (strings.hasPrefixComptime(input, "workspace:")) {
input = input["workspace:".len..];
}
return .{
.value = .{ .workspace = sliced.sub(input).value() },
.tag = .workspace,
.literal = sliced.value(),
};
},
.catalog => {
bun.assert(strings.hasPrefixComptime(dependency, "catalog:"));
const group = dependency["catalog:".len..];
const trimmed = strings.trim(group, &strings.whitespace_chars);
return .{
.value = .{ .catalog = sliced.sub(trimmed).value() },
.tag = .catalog,
.literal = sliced.value(),
};
},
}
}
pub fn fromJS(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const arguments = callframe.arguments_old(2).slice();
if (arguments.len == 1) {
return try bun.install.PackageManager.UpdateRequest.fromJS(globalThis, arguments[0]);
}
var arena = std.heap.ArenaAllocator.init(bun.default_allocator);
defer arena.deinit();
var stack = std.heap.stackFallback(1024, arena.allocator());
const allocator = stack.get();
const alias_value: jsc.JSValue = if (arguments.len > 0) arguments[0] else .js_undefined;
if (!alias_value.isString()) {
return .js_undefined;
}
const alias_slice = try alias_value.toSlice(globalThis, allocator);
defer alias_slice.deinit();
if (alias_slice.len == 0) {
return .js_undefined;
}
const name_value: jsc.JSValue = if (arguments.len > 1) arguments[1] else .js_undefined;
const name_slice = try name_value.toSlice(globalThis, allocator);
defer name_slice.deinit();
var name = alias_slice.slice();
var alias = alias_slice.slice();
var buf = alias;
if (name_value.isString()) {
var builder = bun.handleOom(bun.StringBuilder.initCapacity(allocator, name_slice.len + alias_slice.len));
name = builder.append(name_slice.slice());
alias = builder.append(alias_slice.slice());
buf = builder.allocatedSlice();
}
var log = logger.Log.init(allocator);
const sliced = SlicedString.init(buf, name);
const dep: Version = Dependency.parse(allocator, SlicedString.init(buf, alias).value(), null, buf, &sliced, &log, null) orelse {
if (log.msgs.items.len > 0) {
return globalThis.throwValue(try log.toJS(globalThis, bun.default_allocator, "Failed to parse dependency"));
}
return .js_undefined;
};
if (log.msgs.items.len > 0) {
return globalThis.throwValue(try log.toJS(globalThis, bun.default_allocator, "Failed to parse dependency"));
}
log.deinit();
return dep.toJS(buf, globalThis);
}
pub const Behavior = packed struct(u8) {
_unused_1: u1 = 0,
prod: bool = false,
optional: bool = false,
dev: bool = false,
peer: bool = false,
workspace: bool = false,
/// Is not set for transitive bundled dependencies
bundled: bool = false,
_unused_2: u1 = 0,
pub inline fn isProd(this: Behavior) bool {
return this.prod;
}
pub inline fn isOptional(this: Behavior) bool {
return this.optional and !this.peer;
}
pub inline fn isOptionalPeer(this: Behavior) bool {
return this.optional and this.peer;
}
pub inline fn isDev(this: Behavior) bool {
return this.dev;
}
pub inline fn isPeer(this: Behavior) bool {
return this.peer;
}
pub inline fn isWorkspace(this: Behavior) bool {
return this.workspace;
}
pub inline fn isBundled(this: Behavior) bool {
return this.bundled;
}
pub inline fn eq(lhs: Behavior, rhs: Behavior) bool {
return @as(u8, @bitCast(lhs)) == @as(u8, @bitCast(rhs));
}
pub inline fn includes(lhs: Behavior, rhs: Behavior) bool {
return @as(u8, @bitCast(lhs)) & @as(u8, @bitCast(rhs)) != 0;
}
pub inline fn add(this: Behavior, kind: @Type(.enum_literal)) Behavior {
var new = this;
@field(new, @tagName(kind)) = true;
return new;
}
pub inline fn set(this: Behavior, kind: @Type(.enum_literal), value: bool) Behavior {
var new = this;
@field(new, @tagName(kind)) = value;
return new;
}
pub inline fn cmp(lhs: Behavior, rhs: Behavior) std.math.Order {
if (eq(lhs, rhs)) {
return .eq;
}
if (lhs.isWorkspace() != rhs.isWorkspace()) {
// ensure workspaces are placed at the beginning
return if (lhs.isWorkspace())
.lt
else
.gt;
}
if (lhs.isDev() != rhs.isDev()) {
return if (lhs.isDev())
.lt
else
.gt;
}
if (lhs.isOptional() != rhs.isOptional()) {
return if (lhs.isOptional())
.lt
else
.gt;
}
if (lhs.isProd() != rhs.isProd()) {
return if (lhs.isProd())
.lt
else
.gt;
}
if (lhs.isPeer() != rhs.isPeer()) {
return if (lhs.isPeer())
.lt
else
.gt;
}
return .eq;
}
pub inline fn isRequired(this: Behavior) bool {
return !isOptional(this);
}
pub fn isEnabled(this: Behavior, features: Features) bool {
return this.isProd() or
(features.optional_dependencies and this.isOptional()) or
(features.dev_dependencies and this.isDev()) or
(features.peer_dependencies and this.isPeer()) or
(features.workspaces and this.isWorkspace());
}
comptime {
bun.assert(@as(u8, @bitCast(Behavior{ .prod = true })) == (1 << 1));
bun.assert(@as(u8, @bitCast(Behavior{ .optional = true })) == (1 << 2));
bun.assert(@as(u8, @bitCast(Behavior{ .dev = true })) == (1 << 3));
bun.assert(@as(u8, @bitCast(Behavior{ .peer = true })) == (1 << 4));
bun.assert(@as(u8, @bitCast(Behavior{ .workspace = true })) == (1 << 5));
}
};
const string = []const u8;
const Environment = @import("../env.zig");
const std = @import("std");
const Repository = @import("./repository.zig").Repository;
const Install = @import("./install.zig");
const Features = Install.Features;
const PackageManager = Install.PackageManager;
const PackageNameHash = Install.PackageNameHash;
const bun = @import("bun");
const jsc = bun.jsc;
const logger = bun.logger;
const strings = bun.strings;
const Semver = bun.Semver;
const SlicedString = Semver.SlicedString;
const String = Semver.String;