Files
bun.sh/src/install/dependency.zig

678 lines
21 KiB
Zig

const ExternalStringList = @import("./install.zig").ExternalStringList;
const Semver = @import("./semver.zig");
const ExternalString = Semver.ExternalString;
const String = Semver.String;
const std = @import("std");
const SlicedString = Semver.SlicedString;
const PackageNameHash = @import("./install.zig").PackageNameHash;
const Features = @import("./install.zig").Features;
const logger = @import("../logger.zig");
const Dependency = @This();
const string = @import("../string_types.zig").string;
const strings = @import("../string_immutable.zig");
pub 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.eql(lhs.local.slice(lhs_buf), rhs.local.slice(rhs_buf));
} else {
return strings.eql(lhs.remote.slice(lhs_buf), rhs.remote.slice(rhs_buf));
}
}
pub const Tag = enum {
local,
remote,
};
};
name_hash: PackageNameHash = 0,
name: String = String{},
version: Dependency.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 = Behavior.uninitialized,
/// Sorting order for dependencies is:
/// 1. [`dependencies`, `devDependencies`, `optionalDependencies`, `peerDependencies`]
/// 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(void{}, lhs_name, rhs_name);
}
pub fn count(this: Dependency, buf: []const u8, comptime StringBuilder: type, builder: StringBuilder) void {
builder.count(this.name.slice(buf));
builder.count(this.version.literal.slice(buf));
}
pub fn clone(this: Dependency, 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(buf));
const sliced = new_literal.sliced(out_slice);
return Dependency{
.name_hash = this.name_hash,
.name = builder.append(String, this.name.slice(buf)),
.version = Dependency.parseWithTag(
builder.lockfile.allocator,
new_literal.slice(out_slice),
this.version.tag,
&sliced,
null,
) 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,
};
pub fn toDependency(
this: External,
ctx: Context,
) Dependency {
return Dependency{
.name = String{
.bytes = this[0..8].*,
},
.name_hash = @bitCast(u64, this[8..16].*),
.behavior = @intToEnum(Dependency.Behavior, this[16]),
.version = Dependency.Version.toVersion(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].* = @bitCast([8]u8, this.name_hash);
bytes[16] = @enumToInt(this.behavior);
bytes[17..bytes.len].* = this.version.toExternal();
return bytes;
}
pub const Version = struct {
tag: Dependency.Version.Tag = Dependency.Version.Tag.uninitialized,
literal: String = String{},
value: Value = Value{ .uninitialized = void{} },
pub const zeroed = Version{};
pub fn clone(
this: 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 {
std.debug.assert(lhs.tag == rhs.tag);
return strings.cmpStringsAsc(.{}, lhs.literal.slice(string_buf), rhs.literal.slice(string_buf));
}
pub const External = [9]u8;
pub fn toVersion(
bytes: Version.External,
ctx: Dependency.Context,
) Dependency.Version {
const slice = String{ .bytes = bytes[1..9].* };
const tag = @intToEnum(Dependency.Version.Tag, bytes[0]);
const sliced = &slice.sliced(ctx.buffer);
return Dependency.parseWithTag(
ctx.allocator,
sliced.slice,
tag,
sliced,
ctx.log,
) orelse Dependency.Version.zeroed;
}
pub inline fn toExternal(this: Version) Version.External {
var bytes: Version.External = undefined;
bytes[0] = @enumToInt(this.tag);
bytes[1..9].* = this.literal.bytes;
return bytes;
}
pub inline fn eql(
lhs: Version,
rhs: 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.eql(lhs.literal.slice(lhs_buf), rhs.literal.slice(rhs_buf)) or
lhs.value.npm.eql(rhs.value.npm),
.folder, .dist_tag => lhs.literal.eql(rhs.literal, lhs_buf, rhs_buf),
.tarball => lhs.value.tarball.eql(rhs.value.tarball, 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,
/// TODO:
symlink = 5,
/// TODO:
workspace = 6,
/// TODO:
git = 7,
/// TODO:
github = 8,
pub inline fn isNPM(this: Tag) bool {
return @enumToInt(this) < 3;
}
pub inline fn isGitHubRepoPath(dependency: string) bool {
var slash_count: u8 = 0;
for (dependency) |c| {
slash_count += @as(u8, @boolToInt(c == '/'));
if (slash_count > 1 or c == '#') break;
// Must be alphanumeric
switch (c) {
'\\', '/', 'a'...'z', 'A'...'Z', '0'...'9', '%' => {},
else => return false,
}
}
return (slash_count == 1);
}
// this won't work for query string params
// 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");
}
pub fn infer(dependency: string) Tag {
switch (dependency[0]) {
// npm package
'=', '>', '<', '0'...'9', '^', '*', '|' => return Tag.npm,
'.' => return Tag.folder,
'~' => {
// https://docs.npmjs.com/cli/v8/configuring-npm/package-json#local-paths
if (dependency.len > 1 and dependency[1] == '/') {
return Tag.folder;
}
return Tag.npm;
},
'n' => {
if (dependency.len > 4 and strings.eqlComptimeIgnoreLen(dependency[0..4], "npm:")) {
return Tag.npm;
}
},
// MIGHT be semver, might not be.
'x', 'X' => {
if (dependency.len == 1) {
return Tag.npm;
}
if (dependency[1] == '.') {
return Tag.npm;
}
return .dist_tag;
},
// git://, git@, git+ssh
'g' => {
if (strings.eqlComptime(
dependency[0..@minimum("git://".len, dependency.len)],
"git://",
) or strings.eqlComptime(
dependency[0..@minimum("git@".len, dependency.len)],
"git@",
) or strings.eqlComptime(
dependency[0..@minimum("git+ssh".len, dependency.len)],
"git+ssh",
) or strings.eqlComptime(
dependency[0..@minimum("git+file".len, dependency.len)],
"git+file",
) or strings.eqlComptime(
dependency[0..@minimum("git+http".len, dependency.len)],
"git+http",
) or strings.eqlComptime(
dependency[0..@minimum("git+https".len, dependency.len)],
"git+https",
)) {
return .git;
}
if (strings.eqlComptime(
dependency[0..@minimum("github".len, dependency.len)],
"github",
) or isGitHubRepoPath(dependency)) {
return .github;
}
return .dist_tag;
},
'/' => {
if (isTarball(dependency)) {
return .tarball;
}
return .folder;
},
// https://, http://
'h' => {
if (isTarball(dependency)) {
return .tarball;
}
var remainder = dependency;
if (strings.eqlComptime(
remainder[0..@minimum("https://".len, remainder.len)],
"https://",
)) {
remainder = remainder["https://".len..];
}
if (strings.eqlComptime(
remainder[0..@minimum("http://".len, remainder.len)],
"http://",
)) {
remainder = remainder["http://".len..];
}
if (strings.eqlComptime(
remainder[0..@minimum("github".len, remainder.len)],
"github",
) or isGitHubRepoPath(remainder)) {
return .github;
}
return .dist_tag;
},
// Dependencies can start with v
// v1.0.0 is the same as 1.0.0
// However, a github repo or a tarball could start with v
'v' => {
if (isTarball(dependency)) {
return .tarball;
}
if (isGitHubRepoPath(dependency)) {
return .github;
}
return .npm;
},
// file:
'f' => {
if (isTarball(dependency))
return .tarball;
if (strings.eqlComptime(
dependency[0..@minimum("file:".len, dependency.len)],
"file:",
)) {
return .folder;
}
if (isGitHubRepoPath(dependency)) {
return .github;
}
return .dist_tag;
},
// link://
'l' => {
if (isTarball(dependency))
return .tarball;
if (strings.eqlComptime(
dependency[0..@minimum("link:".len, dependency.len)],
"link:",
)) {
return .symlink;
}
if (isGitHubRepoPath(dependency)) {
return .github;
}
return .dist_tag;
},
// workspace://
'w' => {
if (strings.eqlComptime(
dependency[0..@minimum("workspace://".len, dependency.len)],
"workspace://",
)) {
return .workspace;
}
if (isTarball(dependency))
return .tarball;
if (isGitHubRepoPath(dependency)) {
return .github;
}
return .dist_tag;
},
else => {},
}
if (isTarball(dependency))
return .tarball;
if (isGitHubRepoPath(dependency)) {
return .github;
}
return .dist_tag;
}
};
pub const Value = union {
uninitialized: void,
npm: Semver.Query.Group,
dist_tag: String,
tarball: URI,
folder: String,
/// Unsupported, but still parsed so an error can be thrown
symlink: void,
/// Unsupported, but still parsed so an error can be thrown
workspace: void,
/// Unsupported, but still parsed so an error can be thrown
git: void,
/// Unsupported, but still parsed so an error can be thrown
github: void,
};
};
pub fn eql(
a: Dependency,
b: 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 eqlResolved(a: Dependency, b: Dependency) bool {
if (a.isNPM() and b.tag.isNPM()) {
return a.resolution == b.resolution;
}
return @as(Dependency.Version.Tag, a.version) == @as(Dependency.Version.Tag, b.version) and a.resolution == b.resolution;
}
pub fn parse(
allocator: std.mem.Allocator,
dependency_: string,
sliced: *const SlicedString,
log: ?*logger.Log,
) ?Version {
var dependency = std.mem.trimLeft(u8, dependency_, " \t\n\r");
if (dependency.len == 0) return null;
const tag = Version.Tag.infer(dependency);
if (tag == .npm and dependency.len > 4 and strings.eqlComptimeIgnoreLen(dependency[0..4], "npm:")) {
dependency = dependency[4..];
}
// 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 (tag == .npm and dependency.len > 1 and dependency[0] == 'v') {
dependency = dependency[1..];
}
return parseWithTag(
allocator,
dependency,
tag,
sliced,
log,
);
}
pub fn parseWithTag(
allocator: std.mem.Allocator,
dependency: string,
tag: Dependency.Version.Tag,
sliced: *const SlicedString,
log_: ?*logger.Log,
) ?Version {
switch (tag) {
.npm => {
const version = Semver.Query.parse(
allocator,
dependency,
sliced.sub(dependency),
) catch |err| {
if (log_) |log| log.addErrorFmt(null, logger.Loc.Empty, allocator, "{s} parsing dependency \"{s}\"", .{ @errorName(err), dependency }) catch unreachable;
return null;
};
return Version{
.literal = sliced.value(),
.value = .{ .npm = version },
.tag = .npm,
};
},
.dist_tag => {
return Version{
.literal = sliced.value(),
.value = .{ .dist_tag = sliced.value() },
.tag = .dist_tag,
};
},
.tarball => {
if (strings.indexOf(dependency, "://")) |protocol| {
if (strings.eqlComptime(dependency[0..protocol], "file")) {
return Version{
.tag = .tarball,
.value = .{ .tarball = URI{ .local = sliced.sub(dependency[7..]).value() } },
};
} else if (strings.eqlComptime(dependency[0..protocol], "http") or strings.eqlComptime(dependency[0..protocol], "https")) {
return Version{
.tag = .tarball,
.value = .{ .tarball = URI{ .remote = sliced.sub(dependency).value() } },
};
} else {
if (log_) |log| log.addErrorFmt(null, logger.Loc.Empty, allocator, "invalid dependency \"{s}\"", .{dependency}) catch unreachable;
return null;
}
}
return Version{
.literal = sliced.value(),
.value = .{
.tarball = URI{
.local = sliced.value(),
},
},
.tag = .tarball,
};
},
.folder => {
if (strings.indexOf(dependency, ":")) |protocol| {
if (strings.eqlComptime(dependency[0..protocol], "file")) {
if (dependency.len <= protocol) {
if (log_) |log| log.addErrorFmt(null, logger.Loc.Empty, allocator, "\"file\" dependency missing a path", .{}) catch unreachable;
return null;
}
return Version{ .literal = sliced.value(), .value = .{ .folder = sliced.sub(dependency[protocol + 1 ..]).value() }, .tag = .folder };
}
if (log_) |log| log.addErrorFmt(null, logger.Loc.Empty, allocator, "Unsupported protocol {s}", .{dependency}) catch unreachable;
return null;
}
return Version{
.value = .{ .folder = sliced.value() },
.tag = .folder,
.literal = sliced.value(),
};
},
.uninitialized => return null,
.symlink, .workspace, .git, .github => {
if (log_) |log| log.addErrorFmt(null, logger.Loc.Empty, allocator, "Unsupported dependency type {s} for \"{s}\"", .{ @tagName(tag), dependency }) catch unreachable;
return null;
},
}
}
pub const Behavior = enum(u8) {
uninitialized = 0,
_,
pub const normal: u8 = 1 << 1;
pub const optional: u8 = 1 << 2;
pub const dev: u8 = 1 << 3;
pub const peer: u8 = 1 << 4;
pub inline fn isOptional(this: Behavior) bool {
return (@enumToInt(this) & Behavior.optional) != 0 and !this.isPeer();
}
pub inline fn isDev(this: Behavior) bool {
return (@enumToInt(this) & Behavior.dev) != 0;
}
pub inline fn isPeer(this: Behavior) bool {
return (@enumToInt(this) & Behavior.peer) != 0;
}
pub inline fn isNormal(this: Behavior) bool {
return (@enumToInt(this) & Behavior.normal) != 0;
}
pub inline fn setOptional(this: Behavior, value: bool) Behavior {
return @intToEnum(Behavior, @enumToInt(this) | (@as(u8, @boolToInt(value))) << 2);
}
pub inline fn setDev(this: Behavior, value: bool) Behavior {
return @intToEnum(Behavior, @enumToInt(this) | (@as(u8, @boolToInt(value))) << 2);
}
pub inline fn cmp(lhs: Behavior, rhs: Behavior) std.math.Order {
if (@enumToInt(lhs) == @enumToInt(rhs)) {
return .eq;
}
if (lhs.isNormal() != rhs.isNormal()) {
return if (lhs.isNormal())
.gt
else
.lt;
}
if (lhs.isDev() != rhs.isDev()) {
return if (lhs.isDev())
.gt
else
.lt;
}
if (lhs.isOptional() != rhs.isOptional()) {
return if (lhs.isOptional())
.gt
else
.lt;
}
if (lhs.isPeer() != rhs.isPeer()) {
return if (lhs.isPeer())
.gt
else
.lt;
}
return .eq;
}
pub inline fn isRequired(this: Behavior) bool {
return !isOptional(this);
}
pub fn isEnabled(this: Behavior, features: Features) bool {
return this.isNormal() or
(features.dev_dependencies and this.isDev()) or
(features.peer_dependencies and this.isPeer()) or
(features.optional_dependencies and this.isOptional());
}
};