diff options
Diffstat (limited to '')
-rw-r--r-- | src/cli.zig | 35 | ||||
-rw-r--r-- | src/cli/link_command.zig | 8 | ||||
-rw-r--r-- | src/cli/unlink_command.zig | 8 | ||||
-rw-r--r-- | src/deps/zig-clap/clap.zig | 4 | ||||
-rw-r--r-- | src/deps/zig-clap/clap/comptime.zig | 17 | ||||
-rw-r--r-- | src/install/bin.zig | 154 | ||||
-rw-r--r-- | src/install/dependency.zig | 28 | ||||
-rw-r--r-- | src/install/install.zig | 471 | ||||
-rw-r--r-- | src/install/lockfile.zig | 5 | ||||
-rw-r--r-- | src/install/resolution.zig | 3 | ||||
-rw-r--r-- | src/install/resolvers/folder_resolver.zig | 161 | ||||
-rw-r--r-- | src/string_immutable.zig | 38 |
12 files changed, 853 insertions, 79 deletions
diff --git a/src/cli.zig b/src/cli.zig index b878b31a4..743527a93 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -44,6 +44,8 @@ const CreateListExamplesCommand = @import("./cli/create_command.zig").CreateList const DevCommand = @import("./cli/dev_command.zig").DevCommand; const DiscordCommand = @import("./cli/discord_command.zig").DiscordCommand; const InstallCommand = @import("./cli/install_command.zig").InstallCommand; +const LinkCommand = @import("./cli/link_command.zig").LinkCommand; +const UnlinkCommand = @import("./cli/unlink_command.zig").UnlinkCommand; const InstallCompletionsCommand = @import("./cli/install_completions_command.zig").InstallCompletionsCommand; const PackageManagerCommand = @import("./cli/package_manager_command.zig").PackageManagerCommand; const RemoveCommand = @import("./cli/remove_command.zig").RemoveCommand; @@ -854,6 +856,8 @@ pub const Command = struct { RootCommandMatcher.case("upgrade") => .UpgradeCommand, RootCommandMatcher.case("completions") => .InstallCompletionsCommand, RootCommandMatcher.case("getcompletes") => .GetCompletionsCommand, + RootCommandMatcher.case("link") => .LinkCommand, + RootCommandMatcher.case("unlink") => .UnlinkCommand, RootCommandMatcher.case("i"), RootCommandMatcher.case("install") => brk: { for (args_iter.buf) |arg| { @@ -951,6 +955,18 @@ pub const Command = struct { try RemoveCommand.exec(ctx); return; }, + .LinkCommand => { + const ctx = try Command.Context.create(allocator, log, .LinkCommand); + + try LinkCommand.exec(ctx); + return; + }, + .UnlinkCommand => { + const ctx = try Command.Context.create(allocator, log, .UnlinkCommand); + + try UnlinkCommand.exec(ctx); + return; + }, .PackageManagerCommand => { const ctx = try Command.Context.create(allocator, log, .PackageManagerCommand); @@ -1244,6 +1260,8 @@ pub const Command = struct { UpgradeCommand, PackageManagerCommand, TestCommand, + LinkCommand, + UnlinkCommand, pub fn params(comptime cmd: Tag) []const Arguments.ParamType { return &comptime switch (cmd) { @@ -1261,7 +1279,7 @@ pub const Command = struct { pub fn isNPMRelated(this: Tag) bool { return switch (this) { - .PackageManagerCommand, .InstallCommand, .AddCommand, .RemoveCommand => true, + .LinkCommand, .UnlinkCommand, .PackageManagerCommand, .InstallCommand, .AddCommand, .RemoveCommand => true, else => false, }; } @@ -1273,12 +1291,17 @@ pub const Command = struct { .DevCommand = true, .RunCommand = true, .TestCommand = true, - .InstallCommand = true, - .AddCommand = true, - .RemoveCommand = true, }); - pub const loads_config = cares_about_bun_file; + pub const loads_config = brk: { + var cares = cares_about_bun_file; + cares.set(.InstallCommand, true); + cares.set(.AddCommand, true); + cares.set(.RemoveCommand, true); + cares.set(.LinkCommand, true); + cares.set(.UnlinkCommand, true); + break :brk cares; + }; pub const always_loads_config: std.EnumArray(Tag, bool) = std.EnumArray(Tag, bool).initDefault(false, .{ .BuildCommand = true, .BunCommand = true, @@ -1296,6 +1319,8 @@ pub const Command = struct { .AddCommand = false, .RemoveCommand = false, .PackageManagerCommand = false, + .LinkCommand = false, + .UnlinkCommand = false, }); }; }; diff --git a/src/cli/link_command.zig b/src/cli/link_command.zig new file mode 100644 index 000000000..2dca97999 --- /dev/null +++ b/src/cli/link_command.zig @@ -0,0 +1,8 @@ +const Command = @import("../cli.zig").Command; +const PackageManager = @import("../install/install.zig").PackageManager; + +pub const LinkCommand = struct { + pub fn exec(ctx: Command.Context) !void { + try PackageManager.link(ctx); + } +}; diff --git a/src/cli/unlink_command.zig b/src/cli/unlink_command.zig new file mode 100644 index 000000000..91e97547e --- /dev/null +++ b/src/cli/unlink_command.zig @@ -0,0 +1,8 @@ +const Command = @import("../cli.zig").Command; +const PackageManager = @import("../install/install.zig").PackageManager; + +pub const UnlinkCommand = struct { + pub fn exec(ctx: Command.Context) !void { + try PackageManager.unlink(ctx); + } +}; diff --git a/src/deps/zig-clap/clap.zig b/src/deps/zig-clap/clap.zig index 8e1d55da2..6a49f6a15 100644 --- a/src/deps/zig-clap/clap.zig +++ b/src/deps/zig-clap/clap.zig @@ -264,6 +264,10 @@ pub fn Args(comptime Id: type, comptime params: []const Param(Id)) type { pub fn positionals(a: @This()) []const []const u8 { return a.clap.positionals(); } + + pub fn hasFlag(comptime name: []const u8) bool { + return ComptimeClap(Id, params).hasFlag(name); + } }; } diff --git a/src/deps/zig-clap/clap/comptime.zig b/src/deps/zig-clap/clap/comptime.zig index f55be3f1a..9631c9ca7 100644 --- a/src/deps/zig-clap/clap/comptime.zig +++ b/src/deps/zig-clap/clap/comptime.zig @@ -130,6 +130,23 @@ pub fn ComptimeClap( return parser.pos; } + pub fn hasFlag(comptime name: []const u8) bool { + comptime { + for (converted_params) |param| { + if (param.names.short) |s| { + if (mem.eql(u8, name, "-" ++ [_]u8{s})) + return true; + } + if (param.names.long) |l| { + if (mem.eql(u8, name, "--" ++ l)) + return true; + } + } + + return false; + } + } + fn findParam(comptime name: []const u8) clap.Param(usize) { comptime { for (converted_params) |param| { diff --git a/src/install/bin.zig b/src/install/bin.zig index d7d99d480..49462ed78 100644 --- a/src/install/bin.zig +++ b/src/install/bin.zig @@ -484,5 +484,159 @@ pub const Bin = extern struct { }, } } + + pub fn unlink(this: *Linker, link_global: bool) void { + var target_buf: [bun.MAX_PATH_BYTES]u8 = undefined; + var dest_buf: [bun.MAX_PATH_BYTES]u8 = undefined; + var from_remain: []u8 = &target_buf; + var remain: []u8 = &dest_buf; + + if (!link_global) { + target_buf[0..".bin/".len].* = ".bin/".*; + from_remain = target_buf[".bin/".len..]; + dest_buf[0.."../".len].* = "../".*; + remain = dest_buf["../".len..]; + } else { + if (this.global_bin_dir.fd >= std.math.maxInt(std.os.fd_t)) { + this.err = error.MissingGlobalBinDir; + return; + } + + @memcpy(&target_buf, this.global_bin_path.ptr, this.global_bin_path.len); + from_remain = target_buf[this.global_bin_path.len..]; + from_remain[0] = std.fs.path.sep; + from_remain = from_remain[1..]; + const abs = std.os.getFdPath(this.root_node_modules_folder, &dest_buf) catch |err| { + this.err = err; + return; + }; + remain = remain[abs.len..]; + remain[0] = std.fs.path.sep; + remain = remain[1..]; + + this.root_node_modules_folder = this.global_bin_dir.fd; + } + + const name = this.package_name.slice(); + std.mem.copy(u8, remain, name); + remain = remain[name.len..]; + remain[0] = std.fs.path.sep; + remain = remain[1..]; + + if (comptime Environment.isWindows) { + @compileError("Bin.Linker.unlink() needs to be updated to generate .cmd files on Windows"); + } + + switch (this.bin.tag) { + .none => { + if (comptime Environment.isDebug) { + unreachable; + } + }, + .file => { + // we need to use the unscoped package name here + // this is why @babel/parser would fail to link + const unscoped_name = unscopedPackageName(name); + std.mem.copy(u8, from_remain, unscoped_name); + from_remain = from_remain[unscoped_name.len..]; + from_remain[0] = 0; + var dest_path: [:0]u8 = target_buf[0 .. @ptrToInt(from_remain.ptr) - @ptrToInt(&target_buf) :0]; + + std.os.unlinkatZ(this.root_node_modules_folder.fd, dest_path, 0) catch {}; + }, + .named_file => { + var name_to_use = this.bin.value.named_file[0].slice(this.string_buf); + std.mem.copy(u8, from_remain, name_to_use); + from_remain = from_remain[name_to_use.len..]; + from_remain[0] = 0; + var dest_path: [:0]u8 = target_buf[0 .. @ptrToInt(from_remain.ptr) - @ptrToInt(&target_buf) :0]; + + std.os.unlinkatZ(this.root_node_modules_folder.fd, dest_path, 0) catch {}; + }, + .map => { + var extern_string_i: u32 = this.bin.value.map.off; + const end = this.bin.value.map.len + extern_string_i; + const _from_remain = from_remain; + const _remain = remain; + while (extern_string_i < end) : (extern_string_i += 2) { + from_remain = _from_remain; + remain = _remain; + const name_in_terminal = this.extern_string_buf[extern_string_i]; + const name_in_filesystem = this.extern_string_buf[extern_string_i + 1]; + + var target = name_in_filesystem.slice(this.string_buf); + if (strings.hasPrefix(target, "./")) { + target = target[2..]; + } + std.mem.copy(u8, remain, target); + remain = remain[target.len..]; + remain[0] = 0; + remain = remain[1..]; + + var name_to_use = name_in_terminal.slice(this.string_buf); + std.mem.copy(u8, from_remain, name_to_use); + from_remain = from_remain[name_to_use.len..]; + from_remain[0] = 0; + var dest_path: [:0]u8 = target_buf[0 .. @ptrToInt(from_remain.ptr) - @ptrToInt(&target_buf) :0]; + + std.os.unlinkatZ(this.root_node_modules_folder.fd, dest_path, 0) catch {}; + } + }, + .dir => { + var target = this.bin.value.dir.slice(this.string_buf); + if (strings.hasPrefix(target, "./")) { + target = target[2..]; + } + + var parts = [_][]const u8{ name, target }; + + std.mem.copy(u8, remain, target); + remain = remain[target.len..]; + + var dir = std.fs.Dir{ .fd = this.package_installed_node_modules }; + + var joined = Path.joinStringBuf(&target_buf, &parts, .auto); + @intToPtr([*]u8, @ptrToInt(joined.ptr))[joined.len] = 0; + var joined_: [:0]const u8 = joined.ptr[0..joined.len :0]; + var child_dir = dir.openDirZ(joined_, .{ .iterate = true }) catch |err| { + this.err = err; + return; + }; + defer child_dir.close(); + + var iter = child_dir.iterate(); + + var basedir_path = std.os.getFdPath(child_dir.fd, &target_buf) catch |err| { + this.err = err; + return; + }; + target_buf[basedir_path.len] = std.fs.path.sep; + var target_buf_remain = target_buf[basedir_path.len + 1 ..]; + var prev_target_buf_remain = target_buf_remain; + + while (iter.next() catch null) |entry_| { + const entry: std.fs.Dir.Entry = entry_; + switch (entry.kind) { + std.fs.Dir.Entry.Kind.SymLink, std.fs.Dir.Entry.Kind.File => { + target_buf_remain = prev_target_buf_remain; + std.mem.copy(u8, target_buf_remain, entry.name); + target_buf_remain = target_buf_remain[entry.name.len..]; + target_buf_remain[0] = 0; + var to_path = if (!link_global) + std.fmt.bufPrintZ(&dest_buf, ".bin/{s}", .{entry.name}) catch continue + else + std.fmt.bufPrintZ(&dest_buf, "{s}", .{entry.name}) catch continue; + + std.os.unlinkatZ( + this.root_node_modules_folder.fd, + to_path, + ) catch continue; + }, + else => {}, + } + } + }, + } + } }; }; diff --git a/src/install/dependency.zig b/src/install/dependency.zig index f83366073..f4c3c7173 100644 --- a/src/install/dependency.zig +++ b/src/install/dependency.zig @@ -190,6 +190,7 @@ pub const Version = struct { 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), + .symlink => lhs.value.symlink.eql(rhs.value.symlink, lhs_buf, rhs_buf), else => true, }; } @@ -209,8 +210,11 @@ pub const Version = struct { /// Local folder folder = 4, - /// TODO: + /// 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, + /// TODO: workspace = 6, /// TODO: @@ -448,8 +452,9 @@ pub const Version = struct { tarball: URI, folder: String, - /// Unsupported, but still parsed so an error can be thrown - symlink: void, + /// Equivalent to npm link + symlink: String, + /// Unsupported, but still parsed so an error can be thrown workspace: void, /// Unsupported, but still parsed so an error can be thrown @@ -588,7 +593,22 @@ pub fn parseWithTag( }; }, .uninitialized => return null, - .symlink, .workspace, .git, .github => { + .symlink => { + if (strings.indexOfChar(dependency, ':')) |colon| { + return Version{ + .value = .{ .symlink = sliced.sub(dependency[colon + 1 ..]).value() }, + .tag = .symlink, + .literal = sliced.value(), + }; + } + + return Version{ + .value = .{ .symlink = sliced.value() }, + .tag = .symlink, + .literal = sliced.value(), + }; + }, + .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; }, diff --git a/src/install/install.zig b/src/install/install.zig index d396b2248..a60fe405f 100644 --- a/src/install/install.zig +++ b/src/install/install.zig @@ -47,6 +47,7 @@ const ExtractTarball = @import("./extract_tarball.zig"); const Npm = @import("./npm.zig"); const Bitset = @import("./bit_set.zig").DynamicBitSetUnmanaged; const z_allocator = @import("../memory_allocator.zig").z_allocator; +const Syscall = @import("javascript_core").Node.Syscall; const RunCommand = @import("../cli/run_command.zig").RunCommand; threadlocal var initialized_store = false; @@ -446,6 +447,15 @@ pub const Features = struct { .dependencies = true, }; + pub const link = Features{ + .optional_dependencies = false, + .dev_dependencies = false, + .scripts = false, + .peer_dependencies = false, + .is_main = false, + .dependencies = false, + }; + pub const npm = Features{ .optional_dependencies = true, }; @@ -853,6 +863,7 @@ const PackageInstall = struct { copyfile, opening_cache_dir, copying_files, + linking, }; const CloneFileError = error{ @@ -1225,6 +1236,61 @@ const PackageInstall = struct { pub fn uninstall(this: *PackageInstall) !void { try this.destination_dir.deleteTree(std.mem.span(this.destination_dir_subpath)); } + + fn isDanglingSymlink(path: [:0]const u8) bool { + if (comptime Environment.isLinux) { + const rc = Syscall.system.open(path, @as(u31, std.os.O.PATH | 0), @as(u31, 0)); + switch (Syscall.getErrno(rc)) { + .SUCCESS => { + _ = Syscall.system.close(rc); + return false; + }, + else => return true, + } + } else { + const rc = Syscall.system.open(path, @as(u31, 0), @as(u31, 0)); + switch (Syscall.getErrno(rc)) { + .SUCCESS => { + _ = Syscall.system.close(rc); + return false; + }, + else => return true, + } + } + } + + pub fn installFromLink(this: *PackageInstall, skip_delete: bool) Result { + + // If this fails, we don't care. + // we'll catch it the next error + if (!skip_delete and !strings.eqlComptime(this.destination_dir_subpath, ".")) this.uninstall() catch {}; + + // cache_dir_subpath in here is actually the full path to the symlink pointing to the linked package + const symlinked_path = this.cache_dir_subpath; + + std.os.symlinkatZ(symlinked_path, this.destination_dir.fd, this.destination_dir_subpath) catch |err| { + return Result{ + .fail = .{ + .err = err, + .step = .linking, + }, + }; + }; + + if (isDanglingSymlink(symlinked_path)) { + return Result{ + .fail = .{ + .err = error.DanglingSymlink, + .step = .linking, + }, + }; + } + + return Result{ + .success = void{}, + }; + } + pub fn install(this: *PackageInstall, skip_delete: bool) Result { // If this fails, we don't care. @@ -1381,7 +1447,6 @@ pub const PackageManager = struct { manifests: PackageManifestMap = PackageManifestMap{}, folders: FolderResolution.Map = FolderResolution.Map{}, - resolved_package_index: PackageIndex = PackageIndex{}, task_queue: TaskDependencyQueue = .{}, network_dedupe_map: NetworkTaskQueue = .{}, @@ -1398,6 +1463,10 @@ pub const PackageManager = struct { options: Options = Options{}, preinstall_state: std.ArrayListUnmanaged(PreinstallState) = std.ArrayListUnmanaged(PreinstallState){}, + global_link_dir: ?std.fs.Dir = null, + global_dir: ?std.fs.Dir = null, + global_link_dir_path: string = "", + const PreallocatedNetworkTasks = std.BoundedArray(NetworkTask, 1024); const NetworkTaskQueue = std.HashMapUnmanaged(u64, void, IdentityContext(u64), 80); const PackageIndex = std.AutoHashMapUnmanaged(u64, *Package); @@ -1410,6 +1479,23 @@ pub const PackageManager = struct { 80, ); + pub fn globalLinkDir(this: *PackageManager) !std.fs.Dir { + return this.global_link_dir orelse brk: { + var global_dir = try Options.openGlobalDir(this.options.explicit_global_directory); + this.global_dir = global_dir; + this.global_link_dir = try global_dir.makeOpenPath("node_modules", .{ .iterate = true }); + var buf: [bun.MAX_PATH_BYTES]u8 = undefined; + const _path = try std.os.getFdPath(this.global_link_dir.?.fd, &buf); + this.global_link_dir_path = try Fs.FileSystem.DirnameStore.instance.append([]const u8, _path); + break :brk this.global_link_dir.?; + }; + } + + pub fn globalLinkDirPath(this: *PackageManager) ![]const u8 { + _ = try this.globalLinkDir(); + return this.global_link_dir_path; + } + fn ensurePreinstallStateListCapacity(this: *PackageManager, count: usize) !void { if (this.preinstall_state.items.len >= count) { return; @@ -1907,7 +1993,24 @@ pub const PackageManager = struct { }, .folder => { - const res = FolderResolution.getOrPut(version.value.folder.slice(this.lockfile.buffers.string_bytes.items), this); + // relative to cwd + const res = FolderResolution.getOrPut(.{ .relative = void{} }, version, version.value.folder.slice(this.lockfile.buffers.string_bytes.items), this); + + switch (res) { + .err => |err| return err, + .package_id => |package_id| { + this.lockfile.buffers.resolutions.items[dependency_id] = package_id; + return ResolvedPackageResult{ .package = this.lockfile.packages.get(package_id) }; + }, + + .new_package_id => |package_id| { + this.lockfile.buffers.resolutions.items[dependency_id] = package_id; + return ResolvedPackageResult{ .package = this.lockfile.packages.get(package_id), .is_first_time = true }; + }, + } + }, + .symlink => { + const res = FolderResolution.getOrPut(.{ .global = try this.globalLinkDirPath() }, version, version.value.symlink.slice(this.lockfile.buffers.string_bytes.items), this); switch (res) { .err => |err| return err, @@ -2177,6 +2280,8 @@ pub const PackageManager = struct { this.enqueueNetworkTask(network_task); } + std.debug.assert(task_id != 0); + var manifest_entry_parse = try this.task_queue.getOrPutContext(this.allocator, task_id, .{}); if (!manifest_entry_parse.found_existing) { manifest_entry_parse.value_ptr.* = TaskCallbackList{}; @@ -2189,6 +2294,74 @@ pub const PackageManager = struct { } return; }, + .symlink => { + const _result = this.getOrPutResolvedPackage( + name_hash, + name, + version, + dependency.behavior, + id, + resolution, + ) catch |err| brk: { + if (err == error.MissingPackageJSON) { + break :brk null; + } + + return err; + }; + + const not_found_fmt = + \\package \"{[name]s}\" is not linked + \\ + \\To install a linked package: + \\ <cyan>bun link my-pkg-name-from-package-json<r> + \\ + \\Tip: the package name is from package.json, which can differ from the folder name. + \\ + ; + if (_result) |result| { + // First time? + if (result.is_first_time) { + if (PackageManager.verbose_install) { + const label: string = this.lockfile.str(version.literal); + + Output.prettyErrorln(" -> \"{s}\": \"{s}\" -> {s}@{}", .{ + this.lockfile.str(result.package.name), + label, + this.lockfile.str(result.package.name), + result.package.resolution.fmt(this.lockfile.buffers.string_bytes.items), + }); + } + // We shouldn't see any dependencies + if (result.package.dependencies.len > 0) { + try this.lockfile.scratch.dependency_list_queue.writeItem(result.package.dependencies); + } + } + + // should not trigger a network call + std.debug.assert(result.network_task == null); + } else if (dependency.behavior.isRequired()) { + this.log.addErrorFmt( + null, + logger.Loc.Empty, + this.allocator, + not_found_fmt, + .{ + .name = this.lockfile.str(name), + }, + ) catch unreachable; + } else if (this.options.log_level.isVerbose()) { + this.log.addWarningFmt( + null, + logger.Loc.Empty, + this.allocator, + not_found_fmt, + .{ + .name = this.lockfile.str(name), + }, + ) catch unreachable; + } + }, else => {}, } @@ -2662,6 +2835,7 @@ pub const PackageManager = struct { global: bool = false, global_bin_dir: std.fs.Dir = std.fs.Dir{ .fd = std.math.maxInt(std.os.fd_t) }, + explicit_global_directory: string = "", /// destination directory to link bins into // must be a variable due to global installs and bunx bin_path: stringZ = "node_modules/.bin", @@ -2751,17 +2925,13 @@ pub const PackageManager = struct { optional: bool = false, }; - pub fn openGlobalDir(opts_: ?*Api.BunInstall) !std.fs.Dir { + pub fn openGlobalDir(explicit_global_dir: string) !std.fs.Dir { if (std.os.getenvZ("BUN_INSTALL_GLOBAL_DIR")) |home_dir| { return try std.fs.cwd().makeOpenPath(home_dir, .{ .iterate = true }); } - if (opts_) |opts| { - if (opts.global_dir) |home_dir| { - if (home_dir.len > 0) { - return try std.fs.cwd().makeOpenPath(home_dir, .{ .iterate = true }); - } - } + if (explicit_global_dir.len > 0) { + return try std.fs.cwd().makeOpenPath(explicit_global_dir, .{ .iterate = true }); } if (std.os.getenvZ("BUN_INSTALL")) |home_dir| { @@ -2908,6 +3078,8 @@ pub const PackageManager = struct { this.save_lockfile_path = try allocator.dupeZ(u8, save); } } + + this.explicit_global_directory = bun_install.global_dir orelse this.explicit_global_directory; } const default_disable_progress_bar: bool = brk: { @@ -3070,11 +3242,14 @@ pub const PackageManager = struct { if (cli_) |cli| { if (cli.no_save) { this.do.save_lockfile = false; + this.do.write_package_json = false; } if (cli.dry_run) { this.do.install_packages = false; this.dry_run = true; + this.do.write_package_json = false; + this.do.save_lockfile = false; } if (cli.no_cache) { @@ -3159,6 +3334,7 @@ pub const PackageManager = struct { save_lockfile: bool = true, load_lockfile: bool = true, install_packages: bool = true, + write_package_json: bool = true, run_scripts: bool = true, save_yarn_lock: bool = false, print_meta_hash_string: bool = false, @@ -3393,7 +3569,11 @@ pub const PackageManager = struct { try NetworkThread.warmup(); if (cli.global) { - var global_dir = try Options.openGlobalDir(ctx.install); + var explicit_global_dir: string = ""; + if (ctx.install) |opts| { + explicit_global_dir = opts.global_dir orelse explicit_global_dir; + } + var global_dir = try Options.openGlobalDir(explicit_global_dir); try global_dir.setAsCwd(); } @@ -3545,6 +3725,182 @@ pub const PackageManager = struct { try updatePackageJSONAndInstall(ctx, .remove, &remove_params); } + pub inline fn link( + ctx: Command.Context, + ) !void { + var manager = PackageManager.init(ctx, null, &link_params) catch |err| brk: { + switch (err) { + error.MissingPackageJSON => { + var package_json_file = std.fs.cwd().createFileZ("package.json", .{ .read = true }) catch |err2| { + Output.prettyErrorln("<r><red>error:<r> {s} create package.json", .{@errorName(err2)}); + Global.crash(); + }; + try package_json_file.pwriteAll("{\"dependencies\": {}}", 0); + + break :brk try PackageManager.init(ctx, package_json_file, &link_params); + }, + else => return err, + } + + unreachable; + }; + + if (manager.options.log_level != .silent) { + Output.prettyErrorln("<r><b>bun link <r><d>v" ++ Global.package_json_version ++ "<r>\n", .{}); + Output.flush(); + } + + if (manager.options.positionals.len == 1) { + // bun link + + var lockfile: Lockfile = undefined; + var name: string = ""; + var package: Lockfile.Package = Lockfile.Package{}; + + // Step 1. parse the nearest package.json file + { + var current_package_json_stat = try manager.root_package_json_file.stat(); + var current_package_json_buf = try ctx.allocator.alloc(u8, current_package_json_stat.size + 64); + const current_package_json_contents_len = try manager.root_package_json_file.preadAll( + current_package_json_buf, + 0, + ); + + const package_json_source = logger.Source.initPathString( + package_json_cwd_buf[0 .. FileSystem.instance.top_level_dir.len + "package.json".len], + current_package_json_buf[0..current_package_json_contents_len], + ); + try lockfile.initEmpty(ctx.allocator); + + try Lockfile.Package.parseMain(&lockfile, &package, ctx.allocator, manager.log, package_json_source, Features.folder); + name = lockfile.str(package.name); + if (name.len == 0) { + if (manager.options.log_level != .silent) + Output.prettyErrorln("<r><red>error:<r> package.json missing \"name\" <d>in \"{s}\"<r>", .{package_json_source.path.text}); + Global.crash(); + } else if (!strings.isNPMPackageName(name)) { + if (manager.options.log_level != .silent) + Output.prettyErrorln("<r><red>error:<r> invalid package.json name \"{s}\" <d>in \"{s}\"<r>", .{ + name, + package_json_source.path.text, + }); + Global.crash(); + } + } + + // Step 2. Setup the global directory + var node_modules: std.fs.Dir = brk: { + Bin.Linker.umask = C.umask(0); + var explicit_global_dir: string = ""; + if (ctx.install) |install_| { + explicit_global_dir = install_.global_dir orelse explicit_global_dir; + } + manager.global_dir = try Options.openGlobalDir(explicit_global_dir); + + try manager.setupGlobalDir(&ctx); + + break :brk manager.global_dir.?.makeOpenPath("node_modules", .{ .iterate = true }) catch |err| { + if (manager.options.log_level != .silent) + Output.prettyErrorln("<r><red>error:<r> failed to create node_modules in global dir due to error {s}", .{@errorName(err)}); + Global.crash(); + }; + }; + + // Step 3a. symlink to the node_modules folder + { + // delete it if it exists + node_modules.deleteTree(name) catch {}; + + // create the symlink + node_modules.symLink(Fs.FileSystem.instance.topLevelDirWithoutTrailingSlash(), name, .{ .is_directory = true }) catch |err| { + if (manager.options.log_level != .silent) + Output.prettyErrorln("<r><red>error:<r> failed to create symlink to node_modules in global dir due to error {s}", .{@errorName(err)}); + Global.crash(); + }; + } + + // Step 3b. Link any global bins + if (package.bin.tag != .none) { + var bin_linker = Bin.Linker{ + .bin = package.bin, + .package_installed_node_modules = node_modules.fd, + .global_bin_path = manager.options.bin_path, + .global_bin_dir = manager.options.global_bin_dir, + + // .destination_dir_subpath = destination_dir_subpath, + .root_node_modules_folder = node_modules.fd, + .package_name = strings.StringOrTinyString.init(name), + .string_buf = lockfile.buffers.string_bytes.items, + .extern_string_buf = lockfile.buffers.extern_strings.items, + }; + bin_linker.link(true); + + if (bin_linker.err) |err| { + if (manager.options.log_level != .silent) + Output.prettyErrorln("<r><red>error:<r> failed to link bin due to error {s}", .{@errorName(err)}); + Global.crash(); + } + } + + Output.flush(); + + // Done + if (manager.options.log_level != .silent) + Output.prettyln( + \\<r><green>Success!<r> Registered \"{[name]s}\" + \\ + \\To use {[name]s} in a project, run: + \\ <cyan>bun link {[name]s}<r> + \\ + \\Or add it in dependencies in your package.json file: + \\ <cyan>"{[name]s}": "link:{[name]s}"<r> + \\ + , + .{ + .name = name, + }, + ); + + Output.flush(); + Global.exit(0); + } else { + // bun link lodash + switch (manager.options.log_level) { + .default => try updatePackageJSONAndInstallWithManager(ctx, manager, .link, .default), + .verbose => try updatePackageJSONAndInstallWithManager(ctx, manager, .link, .verbose), + .silent => try updatePackageJSONAndInstallWithManager(ctx, manager, .link, .silent), + .default_no_progress => try updatePackageJSONAndInstallWithManager(ctx, manager, .link, .default_no_progress), + .verbose_no_progress => try updatePackageJSONAndInstallWithManager(ctx, manager, .link, .verbose_no_progress), + } + } + } + + pub inline fn unlink( + ctx: Command.Context, + ) !void { + var manager = PackageManager.init(ctx, null, &unlink_params) catch |err| brk: { + switch (err) { + error.MissingPackageJSON => { + var package_json_file = std.fs.cwd().createFileZ("package.json", .{ .read = true }) catch |err2| { + Output.prettyErrorln("<r><red>error:<r> {s} create package.json", .{@errorName(err2)}); + Global.crash(); + }; + try package_json_file.pwriteAll("{\"dependencies\": {}}", 0); + + break :brk try PackageManager.init(ctx, package_json_file, &unlink_params); + }, + else => return err, + } + + unreachable; + }; + + if (manager.options.log_level != .silent) { + Output.prettyErrorln("<r><b>bun unlink <r><d>v" ++ Global.package_json_version ++ "<r>\n", .{}); + Output.flush(); + } + } + const ParamType = clap.Param(clap.Help); const platform_specific_backend_label = if (Environment.isMac) "Possible values: \"clonefile\" (default), \"hardlink\", \"symlink\", \"copyfile\"" @@ -3591,6 +3947,16 @@ pub const PackageManager = struct { clap.parseParam("<POS> ... \"name\" of packages to remove from package.json") catch unreachable, }; + pub const link_params = install_params_ ++ [_]ParamType{ + clap.parseParam("--save Save to package.json") catch unreachable, + clap.parseParam("<POS> ... \"name\" install package as a link") catch unreachable, + }; + + pub const unlink_params = install_params_ ++ [_]ParamType{ + clap.parseParam("--save Save to package.json") catch unreachable, + clap.parseParam("<POS> ... \"name\" uninstall package as a link") catch unreachable, + }; + pub const CommandLineArguments = struct { registry: string = "", cache_dir: string = "", @@ -3678,6 +4044,13 @@ pub const PackageManager = struct { cli.silent = args.flag("--silent"); cli.verbose = args.flag("--verbose"); cli.ignore_scripts = args.flag("--ignore-scripts"); + if (comptime @TypeOf(args).hasFlag("--save")) { + cli.no_save = true; + + if (args.flag("--save")) { + cli.no_save = false; + } + } if (args.option("--config")) |opt| { cli.config = opt; @@ -3834,6 +4207,10 @@ pub const PackageManager = struct { Global.exit(1); } + if ((op == .link or op == .unlink) and !strings.hasPrefixComptime(request.version_buf, "link:")) { + request.version_buf = std.fmt.allocPrint(allocator, "link:{s}", .{request.name}) catch unreachable; + } + if (request.version_buf.len == 0) { request.missing_version = true; } else { @@ -3990,6 +4367,7 @@ pub const PackageManager = struct { Global.exit(0); }, + else => {}, } } @@ -4115,7 +4493,7 @@ pub const PackageManager = struct { } manager.to_remove = updates; }, - .add, .update => { + .link, .unlink, .add, .update => { try PackageJSONEditor.edit(ctx.allocator, updates, ¤t_package_json, dependency_list); manager.package_json_updates = updates; }, @@ -4147,7 +4525,7 @@ pub const PackageManager = struct { try installWithManager(ctx, manager, new_package_json_source, log_level); - if (op == .update or op == .add) { + if (op == .update or op == .add or op == .link or op == .unlink) { for (manager.package_json_updates) |update| { if (update.failed) { Global.exit(1); @@ -4183,7 +4561,7 @@ pub const PackageManager = struct { new_package_json_source = try ctx.allocator.dupe(u8, package_json_writer_two.ctx.writtenWithoutTrailingZero()); } - if (!manager.options.dry_run) { + if (manager.options.do.write_package_json) { // Now that we've run the install step // We can save our in-memory package.json to disk try manager.root_package_json_file.pwriteAll(new_package_json_source, 0); @@ -4378,13 +4756,69 @@ pub const PackageManager = struct { installer.cache_dir = std.fs.cwd(); } }, + .symlink => { + const directory = this.manager.globalLinkDir() catch |err| { + if (comptime log_level != .silent) { + const fmt = "\n<r><red>error:<r> unable to access global directory while installing <b>{s}<r>: {s}\n"; + const args = .{ name, @errorName(err) }; + + if (comptime log_level.showProgress()) { + if (Output.enable_ansi_colors) { + this.progress.log(comptime Output.prettyFmt(fmt, true), args); + } else { + this.progress.log(comptime Output.prettyFmt(fmt, false), args); + } + } else { + Output.prettyErrorln(fmt, args); + } + } + + if (this.manager.options.enable.fail_early) { + Global.exit(1); + } + + Output.flush(); + this.summary.fail += 1; + return; + }; + + const folder = resolution.value.symlink.slice(buf); + + if (folder.len == 0 or (folder.len == 1 and folder[0] == '.')) { + installer.cache_dir_subpath = "."; + installer.cache_dir = std.fs.cwd(); + } else { + const global_link_dir = this.manager.globalLinkDirPath() catch unreachable; + var ptr = &this.folder_path_buf; + var remain: []u8 = this.folder_path_buf[0..]; + @memcpy(ptr, global_link_dir.ptr, global_link_dir.len); + remain = remain[global_link_dir.len..]; + if (global_link_dir[global_link_dir.len - 1] != std.fs.path.sep) { + remain[0] = std.fs.path.sep; + remain = remain[1..]; + } + @memcpy(remain.ptr, folder.ptr, folder.len); + remain = remain[folder.len..]; + remain[0] = 0; + const len = @ptrToInt(remain.ptr) - @ptrToInt(ptr); + installer.cache_dir_subpath = std.meta.assumeSentinel( + this.folder_path_buf[0..len :0], + 0, + ); + installer.cache_dir = directory; + } + }, else => return, } + const needs_install = this.force_install or this.skip_verify_installed_version_number or !installer.verify(); this.summary.skipped += @as(u32, @boolToInt(!needs_install)); if (needs_install) { - const result = installer.install(this.skip_delete); + const result: PackageInstall.Result = switch (resolution.tag) { + .symlink => installer.installFromLink(this.skip_delete), + else => installer.install(this.skip_delete), + }; switch (result) { .success => { const is_duplicate = this.successfully_installed.isSet(package_id); @@ -4492,6 +4926,12 @@ pub const PackageManager = struct { this.summary.fail += 1; }, } + } else if (cause.err == error.DanglingSymlink) { + Output.prettyErrorln( + "<r><red>error<r>: <b>{s}<r> \"link:{s}\" not found (try running 'bun link' in the intended package's folder)<r>", + .{ @errorName(cause.err), this.names[package_id].slice(buf) }, + ); + this.summary.fail += 1; } else { Output.prettyErrorln( "<r><red>error<r>: <b><red>{s}<r> installing <b>{s}<r>", @@ -5300,7 +5740,8 @@ pub const PackageManager = struct { } if (install_summary.fail > 0) { - Output.prettyln("<r> Failed to install <red><b>{d}<r> packages\n", .{install_summary.fail}); + Output.prettyln("<r>Failed to install <red><b>{d}<r> packages\n", .{install_summary.fail}); + Output.flush(); } if (run_lifecycle_scripts and install_summary.fail == 0) { diff --git a/src/install/lockfile.zig b/src/install/lockfile.zig index d6b3b4824..cca34b20b 100644 --- a/src/install/lockfile.zig +++ b/src/install/lockfile.zig @@ -1480,7 +1480,7 @@ pub fn initEmpty(this: *Lockfile, allocator: std.mem.Allocator) !void { pub fn getPackageID( this: *Lockfile, name_hash: u64, - // if it's a peer dependency + // if it's a peer dependency, a folder, or a symlink version: ?Dependency.Version, resolution: Resolution, ) ?PackageID { @@ -1601,7 +1601,6 @@ pub fn appendPackageWithID(this: *Lockfile, package_: Lockfile.Package, id: Pack defer { if (comptime Environment.isDebug) { std.debug.assert(this.getPackageID(package_.name_hash, null, package_.resolution) != null); - std.debug.assert(this.getPackageID(package_.name_hash, null, package_.resolution).? == id); } } var package = package_; @@ -2137,6 +2136,8 @@ pub const Package = extern struct { add, remove, update, + unlink, + link, }; pub const Summary = struct { diff --git a/src/install/resolution.zig b/src/install/resolution.zig index 231ff3475..baf31b2ad 100644 --- a/src/install/resolution.zig +++ b/src/install/resolution.zig @@ -212,7 +212,7 @@ pub const Resolution = extern struct { .github => try formatter.resolution.value.github.formatAs("github", formatter.buf, layout, opts, writer), .gitlab => try formatter.resolution.value.gitlab.formatAs("gitlab", formatter.buf, layout, opts, writer), .workspace => try std.fmt.format(writer, "workspace://{s}", .{formatter.resolution.value.workspace.slice(formatter.buf)}), - .symlink => try std.fmt.format(writer, "link://{s}", .{formatter.resolution.value.symlink.slice(formatter.buf)}), + .symlink => try std.fmt.format(writer, "link:{s}", .{formatter.resolution.value.symlink.slice(formatter.buf)}), .single_file_module => try std.fmt.format(writer, "link://{s}", .{formatter.resolution.value.symlink.slice(formatter.buf)}), else => {}, } @@ -242,7 +242,6 @@ pub const Resolution = extern struct { workspace: String, /// global link - /// not implemented yet symlink: String, single_file_module: String, diff --git a/src/install/resolvers/folder_resolver.zig b/src/install/resolvers/folder_resolver.zig index 1c82e6b4f..bf9d5a78b 100644 --- a/src/install/resolvers/folder_resolver.zig +++ b/src/install/resolvers/folder_resolver.zig @@ -13,6 +13,7 @@ const strings = @import("strings"); const Resolution = @import("../resolution.zig").Resolution; const String = @import("../semver.zig").String; const bun = @import("../../global.zig"); +const Dependency = @import("../dependency.zig"); pub const FolderResolution = union(Tag) { package_id: PackageID, new_package_id: PackageID, @@ -30,30 +31,32 @@ pub const FolderResolution = union(Tag) { return std.hash.Wyhash.hash(0, normalized_path); } - pub const Resolver = struct { - folder_path: string, + pub fn NewResolver(comptime tag: Resolution.Tag) type { + return struct { + folder_path: string, - pub fn resolve(this: Resolver, comptime Builder: type, builder: Builder, _: JSAst.Expr) !Resolution { - return Resolution{ - .tag = .folder, - .value = .{ - .folder = builder.append(String, this.folder_path), - }, - }; - } + pub fn resolve(this: @This(), comptime Builder: type, builder: Builder, _: JSAst.Expr) !Resolution { + return Resolution{ + .tag = tag, + .value = @unionInit(Resolution.Value, @tagName(tag), builder.append(String, this.folder_path)), + }; + } - pub fn count(this: Resolver, comptime Builder: type, builder: Builder, _: JSAst.Expr) void { - builder.count(this.folder_path); - } - }; + pub fn count(this: @This(), comptime Builder: type, builder: Builder, _: JSAst.Expr) void { + builder.count(this.folder_path); + } + }; + } - pub fn getOrPut(non_normalized_path: string, manager: *PackageManager) FolderResolution { + pub const Resolver = NewResolver(Resolution.Tag.folder); + pub const SymlinkResolver = NewResolver(Resolution.Tag.symlink); - // We consider it valid if there is a package.json in the folder - const normalized = std.mem.trimRight(u8, normalize(non_normalized_path), std.fs.path.sep_str); - var joined: [bun.MAX_PATH_BYTES]u8 = undefined; + pub fn normalizePackageJSONPath(global_or_relative: GlobalOrRelative, joined: *[bun.MAX_PATH_BYTES]u8, non_normalized_path: string) [2]string { var abs: string = ""; var rel: string = ""; + // We consider it valid if there is a package.json in the folder + const normalized = std.mem.trimRight(u8, normalize(non_normalized_path), std.fs.path.sep_str); + if (strings.startsWithChar(normalized, '.')) { var tempcat: [bun.MAX_PATH_BYTES]u8 = undefined; @@ -61,64 +64,120 @@ pub const FolderResolution = union(Tag) { tempcat[normalized.len] = std.fs.path.sep; std.mem.copy(u8, tempcat[normalized.len + 1 ..], "package.json"); var parts = [_]string{ FileSystem.instance.top_level_dir, tempcat[0 .. normalized.len + 1 + "package.json".len] }; - abs = FileSystem.instance.absBuf(&parts, &joined); + abs = FileSystem.instance.absBuf(&parts, joined); rel = FileSystem.instance.relative(FileSystem.instance.top_level_dir, abs[0 .. abs.len - "/package.json".len]); } else { - std.mem.copy(u8, &joined, normalized); - joined[normalized.len] = std.fs.path.sep; - joined[normalized.len + 1 ..][0.."package.json".len].* = "package.json".*; - abs = joined[0 .. normalized.len + 1 + "package.json".len]; + var remain: []u8 = joined[0..]; + switch (global_or_relative) { + .global => |path| { + const offset = path.len - @as(usize, @boolToInt(path[path.len - 1] == std.fs.path.sep)); + @memcpy(remain.ptr, path.ptr, offset); + remain = remain[offset..]; + if ((path[path.len - 1] != std.fs.path.sep) and (normalized[0] != std.fs.path.sep)) { + remain[0] = std.fs.path.sep; + remain = remain[1..]; + } + }, + .relative => {}, + } + std.mem.copy(u8, remain, normalized); + remain[normalized.len] = std.fs.path.sep; + remain[normalized.len + 1 ..][0.."package.json".len].* = "package.json".*; + remain = remain[normalized.len + 1 + "package.json".len ..]; + abs = joined[0 .. joined.len - remain.len]; // We store the folder name without package.json rel = abs[0 .. abs.len - "/package.json".len]; } - var entry = manager.folders.getOrPut(manager.allocator, hash(abs)) catch unreachable; - if (entry.found_existing) return entry.value_ptr.*; - - joined[abs.len] = 0; - var joinedZ: [:0]u8 = joined[0..abs.len :0]; + return .{ abs, rel }; + } - var package_json: std.fs.File = std.fs.cwd().openFileZ(joinedZ, .{ .mode = .read_only }) catch |err| { - entry.value_ptr.* = .{ .err = err }; - return entry.value_ptr.*; - }; + pub fn readPackageJSONFromDisk( + manager: *PackageManager, + joinedZ: [:0]const u8, + abs: []const u8, + version: Dependency.Version, + comptime features: Features, + comptime ResolverType: type, + resolver: ResolverType, + ) !Lockfile.Package { + var package_json: std.fs.File = try std.fs.cwd().openFileZ(joinedZ, .{ .mode = .read_only }); + defer package_json.close(); var package = Lockfile.Package{}; var body = Npm.Registry.BodyPool.get(manager.allocator); - defer Npm.Registry.BodyPool.release(body); - const len = package_json.getEndPos() catch |err| { - entry.value_ptr.* = .{ .err = err }; - return entry.value_ptr.*; - }; + const len = try package_json.getEndPos(); body.data.reset(); body.data.inflate(@maximum(len, 2048)) catch unreachable; body.data.list.expandToCapacity(); - const source_buf = package_json.readAll(body.data.list.items) catch |err| { - entry.value_ptr.* = .{ .err = err }; - return entry.value_ptr.*; - }; - var resolver = Resolver{ - .folder_path = rel, - }; + const source_buf = try package_json.readAll(body.data.list.items); + const source = logger.Source.initPathString(abs, body.data.list.items[0..source_buf]); - Lockfile.Package.parse( + try Lockfile.Package.parse( manager.lockfile, &package, manager.allocator, manager.log, source, - Resolver, + ResolverType, resolver, - Features.folder, - ) catch |err| { - // Folders are considered dependency-less - entry.value_ptr.* = .{ .err = err }; + features, + ); + + if (manager.lockfile.getPackageID(package.name_hash, version, package.resolution)) |existing_id| { + return manager.lockfile.packages.get(existing_id); + } + + return manager.lockfile.appendPackage(package) catch unreachable; + } + + pub const GlobalOrRelative = union(enum) { + global: []const u8, + relative: void, + }; + + pub fn getOrPut(global_or_relative: GlobalOrRelative, version: Dependency.Version, non_normalized_path: string, manager: *PackageManager) FolderResolution { + var joined: [bun.MAX_PATH_BYTES]u8 = undefined; + const paths = normalizePackageJSONPath(global_or_relative, &joined, non_normalized_path); + const abs = paths[0]; + const rel = paths[1]; + + var entry = manager.folders.getOrPut(manager.allocator, hash(abs)) catch unreachable; + if (entry.found_existing) return entry.value_ptr.*; + + joined[abs.len] = 0; + var joinedZ: [:0]u8 = joined[0..abs.len :0]; + const package = switch (global_or_relative) { + .global => readPackageJSONFromDisk( + manager, + joinedZ, + abs, + version, + Features.link, + SymlinkResolver, + SymlinkResolver{ .folder_path = non_normalized_path }, + ), + .relative => readPackageJSONFromDisk( + manager, + joinedZ, + abs, + version, + Features.folder, + Resolver, + Resolver{ .folder_path = rel }, + ), + } catch |err| { + if (err == error.FileNotFound) { + entry.value_ptr.* = .{ .err = error.MissingPackageJSON }; + } else { + entry.value_ptr.* = .{ .err = err }; + } + return entry.value_ptr.*; }; - package = manager.lockfile.appendPackage(package) catch unreachable; entry.value_ptr.* = .{ .package_id = package.meta.id }; return FolderResolution{ .new_package_id = package.meta.id }; } diff --git a/src/string_immutable.zig b/src/string_immutable.zig index 4d856b966..540572b6f 100644 --- a/src/string_immutable.zig +++ b/src/string_immutable.zig @@ -82,6 +82,44 @@ pub inline fn containsAny(in: anytype, target: string) bool { return false; } +/// https://docs.npmjs.com/cli/v8/configuring-npm/package-json +/// - The name must be less than or equal to 214 characters. This includes the scope for scoped packages. +/// - The names of scoped packages can begin with a dot or an underscore. This is not permitted without a scope. +/// - New packages must not have uppercase letters in the name. +/// - The name ends up being part of a URL, an argument on the command line, and +/// a folder name. Therefore, the name can't contain any non-URL-safe +/// characters. +pub inline fn isNPMPackageName(target: string) bool { + if (target.len >= 215) return false; + switch (target[0]) { + 'a'...'z', + '0'...'9', + '$', + '@', + '-', + => {}, + else => return false, + } + if (target.len == 1) return true; + + var slash_count: usize = 0; + + for (target[1..]) |c| { + switch (c) { + 'A'...'Z', 'a'...'z', '0'...'9', '$', '-', '_', '.' => {}, + '/' => { + if (slash_count > 0) { + return false; + } + slash_count += 1; + }, + else => return false, + } + } + + return true; +} + pub inline fn indexAny(in: anytype, target: string) ?usize { for (in) |str, i| if (indexOf(str, target) != null) return i; return null; |