From 0ee633663efb2743eea99c31105e105af4070d7d Mon Sep 17 00:00:00 2001 From: Michael H Date: Wed, 16 Jul 2025 19:00:53 +1000 Subject: [PATCH] Implement `bun why` (#20847) Co-authored-by: Jarred Sumner Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- cmake/sources/ZigSources.txt | 2 + docs/cli/why.md | 67 ++ docs/nav.ts | 3 + src/cli.zig | 36 +- src/cli/package_manager_command.zig | 6 + src/cli/pm_why_command.zig | 11 + src/cli/why_command.zig | 490 ++++++++++++++ src/install/PackageManager.zig | 1 + .../PackageManager/CommandLineArguments.zig | 51 ++ .../PackageManager/PackageManagerOptions.zig | 9 + test/cli/install/bun-pm-why.test.ts | 613 ++++++++++++++++++ 11 files changed, 1286 insertions(+), 3 deletions(-) create mode 100644 docs/cli/why.md create mode 100644 src/cli/pm_why_command.zig create mode 100644 src/cli/why_command.zig create mode 100644 test/cli/install/bun-pm-why.test.ts diff --git a/cmake/sources/ZigSources.txt b/cmake/sources/ZigSources.txt index bec38333af..dc521ef62a 100644 --- a/cmake/sources/ZigSources.txt +++ b/cmake/sources/ZigSources.txt @@ -347,6 +347,7 @@ src/cli/pm_pkg_command.zig src/cli/pm_trusted_command.zig src/cli/pm_version_command.zig src/cli/pm_view_command.zig +src/cli/pm_why_command.zig src/cli/publish_command.zig src/cli/remove_command.zig src/cli/run_command.zig @@ -356,6 +357,7 @@ src/cli/test/Scanner.zig src/cli/unlink_command.zig src/cli/update_command.zig src/cli/upgrade_command.zig +src/cli/why_command.zig src/codegen/process_windows_translate_c.zig src/compile_target.zig src/comptime_string_map.zig diff --git a/docs/cli/why.md b/docs/cli/why.md new file mode 100644 index 0000000000..c9a2364259 --- /dev/null +++ b/docs/cli/why.md @@ -0,0 +1,67 @@ +The `bun why` command explains why a package is installed in your project by showing the dependency chain that led to its installation. + +## Usage + +```bash +$ bun why +``` + +## Arguments + +- ``: The name of the package to explain. Supports glob patterns like `@org/*` or `*-lodash`. + +## Options + +- `--top`: Show only the top-level dependencies instead of the complete dependency tree. +- `--depth `: Maximum depth of the dependency tree to display. + +## Examples + +Check why a specific package is installed: + +```bash +$ bun why react +react@18.2.0 + └─ my-app@1.0.0 (requires ^18.0.0) +``` + +Check why all packages with a specific pattern are installed: + +```bash +$ bun why "@types/*" +@types/react@18.2.15 + └─ dev my-app@1.0.0 (requires ^18.0.0) + +@types/react-dom@18.2.7 + └─ dev my-app@1.0.0 (requires ^18.0.0) +``` + +Show only top-level dependencies: + +```bash +$ bun why express --top +express@4.18.2 + └─ my-app@1.0.0 (requires ^4.18.2) +``` + +Limit the dependency tree depth: + +```bash +$ bun why express --depth 2 +express@4.18.2 + └─ express-pollyfill@1.20.1 (requires ^4.18.2) + └─ body-parser@1.20.1 (requires ^1.20.1) + └─ accepts@1.3.8 (requires ^1.3.8) + └─ (deeper dependencies hidden) +``` + +## Understanding the Output + +The output shows: + +- The package name and version being queried +- The dependency chain that led to its installation +- The type of dependency (dev, peer, optional, or production) +- The version requirement specified in each package's dependencies + +For nested dependencies, the command shows the complete dependency tree by default, with indentation indicating the relationship hierarchy. diff --git a/docs/nav.ts b/docs/nav.ts index 8f4c112b32..611cdf3048 100644 --- a/docs/nav.ts +++ b/docs/nav.ts @@ -176,6 +176,9 @@ export default { page("cli/pm", "`bun pm`", { description: "Utilities relating to package management with Bun.", }), + page("cli/why", "`bun why`", { + description: "Explains why a package is installed in your project.", + }), page("install/cache", "Global cache", { description: "Bun's package manager installs all packages into a shared global cache to avoid redundant re-downloads.", diff --git a/src/cli.zig b/src/cli.zig index 969e4ce2ce..c37d4ba6f6 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -118,6 +118,7 @@ pub const PublishCommand = @import("./cli/publish_command.zig").PublishCommand; pub const PackCommand = @import("./cli/pack_command.zig").PackCommand; pub const AuditCommand = @import("./cli/audit_command.zig").AuditCommand; pub const InitCommand = @import("./cli/init_command.zig").InitCommand; +pub const WhyCommand = @import("./cli/why_command.zig").WhyCommand; const PackageManager = Install.PackageManager; const PmViewCommand = @import("./cli/pm_view_command.zig"); @@ -617,7 +618,7 @@ pub const Command = struct { RootCommandMatcher.case("whoami") => .ReservedCommand, RootCommandMatcher.case("prune") => .ReservedCommand, RootCommandMatcher.case("list") => .ReservedCommand, - RootCommandMatcher.case("why") => .ReservedCommand, + RootCommandMatcher.case("why") => .WhyCommand, RootCommandMatcher.case("-e") => .AutoCommand, @@ -797,9 +798,12 @@ pub const Command = struct { .AuditCommand => { if (comptime bun.fast_debug_build_mode and bun.fast_debug_build_cmd != .AuditCommand) unreachable; const ctx = try Command.init(allocator, log, .AuditCommand); - try AuditCommand.exec(ctx); - unreachable; + }, + .WhyCommand => { + const ctx = try Command.init(allocator, log, .WhyCommand); + try WhyCommand.exec(ctx); + return; }, .BunxCommand => { if (comptime bun.fast_debug_build_mode and bun.fast_debug_build_cmd != .BunxCommand) unreachable; @@ -1230,6 +1234,7 @@ pub const Command = struct { OutdatedCommand, PublishCommand, AuditCommand, + WhyCommand, /// Used by crash reports. /// @@ -1265,6 +1270,7 @@ pub const Command = struct { .OutdatedCommand => 'o', .PublishCommand => 'k', .AuditCommand => 'A', + .WhyCommand => 'W', }; } @@ -1547,6 +1553,30 @@ pub const Command = struct { Output.pretty(intro_text, .{}); Output.flush(); }, + .WhyCommand => { + const intro_text = + \\Usage: bun why [flags] \\<@version\> [property path] + \\Explain why a package is installed + \\ + \\Arguments: + \\ \ The package name to explain (supports glob patterns like '@org/*') + \\ + \\Options: + \\ --top Show only the top dependency tree instead of nested ones + \\ --depth \ Maximum depth of the dependency tree to display + \\ + \\Examples: + \\ $ bun why react + \\ $ bun why "@types/*" --depth 2 + \\ $ bun why "*-lodash" --top + \\ + \\Full documentation is available at https://bun.sh/docs/cli/why + \\ + ; + + Output.pretty(intro_text, .{}); + Output.flush(); + }, else => { HelpCommand.printWithReason(.explicit); }, diff --git a/src/cli/package_manager_command.zig b/src/cli/package_manager_command.zig index 8890149aa2..8b860807b4 100644 --- a/src/cli/package_manager_command.zig +++ b/src/cli/package_manager_command.zig @@ -23,7 +23,9 @@ pub const PackCommand = @import("./pack_command.zig").PackCommand; const Npm = Install.Npm; const PmViewCommand = @import("./pm_view_command.zig"); const PmVersionCommand = @import("./pm_version_command.zig").PmVersionCommand; +const PmWhyCommand = @import("./pm_why_command.zig").PmWhyCommand; const PmPkgCommand = @import("./pm_pkg_command.zig").PmPkgCommand; + const File = bun.sys.File; const ByName = struct { @@ -128,6 +130,7 @@ pub const PackageManagerCommand = struct { \\ -g print the global path to bin folder \\ bun pm ls list the dependency tree according to the current lockfile \\ --all list the entire dependency tree according to the current lockfile + \\ bun pm why \ show dependency tree explaining why a package is installed \\ bun pm whoami print the current npm username \\ bun pm view name[@version] view package metadata from the registry (use `bun info` instead) \\ bun pm version [increment] bump the version in package.json and create a git tag @@ -441,6 +444,9 @@ pub const PackageManagerCommand = struct { } else if (strings.eqlComptime(subcommand, "version")) { try PmVersionCommand.exec(ctx, pm, pm.options.positionals, cwd); Global.exit(0); + } else if (strings.eqlComptime(subcommand, "why")) { + try PmWhyCommand.exec(ctx, pm, pm.options.positionals); + Global.exit(0); } else if (strings.eqlComptime(subcommand, "pkg")) { try PmPkgCommand.exec(ctx, pm, pm.options.positionals, cwd); Global.exit(0); diff --git a/src/cli/pm_why_command.zig b/src/cli/pm_why_command.zig new file mode 100644 index 0000000000..3c4b5baca5 --- /dev/null +++ b/src/cli/pm_why_command.zig @@ -0,0 +1,11 @@ +const WhyCommand = @import("./why_command.zig").WhyCommand; +const bun = @import("bun"); +const Command = bun.CLI.Command; +const PackageManager = bun.install.PackageManager; +const string = bun.string; + +pub const PmWhyCommand = struct { + pub fn exec(ctx: Command.Context, pm: *PackageManager, positionals: []const string) !void { + try WhyCommand.execFromPm(ctx, pm, positionals); + } +}; diff --git a/src/cli/why_command.zig b/src/cli/why_command.zig new file mode 100644 index 0000000000..80cd13d1f8 --- /dev/null +++ b/src/cli/why_command.zig @@ -0,0 +1,490 @@ +const std = @import("std"); +const bun = @import("bun"); +const Global = bun.Global; +const Output = bun.Output; +const strings = bun.strings; +const string = bun.string; +const Command = bun.CLI.Command; +const PackageManager = bun.install.PackageManager; +const Semver = bun.Semver; +const PackageID = @import("../install/install.zig").PackageID; +const PackageManagerCommand = @import("./package_manager_command.zig").PackageManagerCommand; + +pub const WhyCommand = struct { + const PREFIX_LAST = " └─ "; + const PREFIX_MIDDLE = " ├─ "; + const PREFIX_CONTINUE = " │ "; + const PREFIX_SPACE = " "; + var max_depth: usize = 100; + + const VersionInfo = struct { + version: string, + pkg_id: PackageID, + }; + + const DependentInfo = struct { + name: string, + version: string, + spec: string, + dep_type: DependencyType, + pkg_id: PackageID, + workspace: bool, + }; + + const DependencyType = enum { + dev, + prod, + peer, + optional, + optional_peer, + }; + + fn getSpecifierSpecificity(spec: []const u8) u8 { + if (spec.len == 0) return 9; + if (spec[0] == '*') return 1; + if (strings.indexOf(spec, ".x")) |_| return 5; + if (strings.indexOfAny(spec, "<>=")) |_| return 6; + if (spec[0] == '~') return 7; + if (spec[0] == '^') return 8; + if (strings.indexOf(spec, "workspace:")) |_| return 9; + if (std.ascii.isDigit(spec[0])) return 10; + return 3; + } + + fn getDependencyTypePriority(dep_type: DependencyType) u8 { + return switch (dep_type) { + .prod => 4, + .peer => 3, + .optional_peer => 2, + .optional => 1, + .dev => 0, + }; + } + + fn compareDependents(context: void, a: DependentInfo, b: DependentInfo) bool { + _ = context; + + const a_specificity = getSpecifierSpecificity(a.spec); + const b_specificity = getSpecifierSpecificity(b.spec); + + if (a_specificity != b_specificity) { + return a_specificity > b_specificity; + } + + const a_type_priority = getDependencyTypePriority(a.dep_type); + const b_type_priority = getDependencyTypePriority(b.dep_type); + + if (a_type_priority != b_type_priority) { + return a_type_priority > b_type_priority; + } + + return std.mem.lessThan(u8, a.name, b.name); + } + + const GlobPattern = struct { + pattern_type: enum { + exact, + prefix, + suffix, + middle, + contains, + invalid, + }, + prefix: []const u8 = "", + suffix: []const u8 = "", + substring: []const u8 = "", + version_pattern: []const u8 = "", + version_query: ?Semver.Query.Group = null, + + fn init(pattern: []const u8) GlobPattern { + if (std.mem.indexOfScalar(u8, pattern, '@')) |at_pos| { + if (at_pos > 0 and at_pos < pattern.len - 1) { + const pkg_pattern = pattern[0..at_pos]; + const version_pattern = pattern[at_pos + 1 ..]; + + var result = initForName(pkg_pattern); + result.version_pattern = version_pattern; + + const sliced = Semver.SlicedString.init(version_pattern, version_pattern); + result.version_query = Semver.Query.parse(bun.default_allocator, version_pattern, sliced) catch null; + + return result; + } + } + + return initForName(pattern); + } + + fn initForName(pattern: []const u8) GlobPattern { + if (std.mem.indexOfScalar(u8, pattern, '*') == null) { + return .{ .pattern_type = .exact }; + } + + if (pattern.len >= 3 and pattern[0] == '*' and pattern[pattern.len - 1] == '*') { + const substring = pattern[1 .. pattern.len - 1]; + if (substring.len > 0 and std.mem.indexOfScalar(u8, substring, '*') == null) { + return .{ + .pattern_type = .contains, + .substring = substring, + }; + } + } + + if (std.mem.indexOfScalar(u8, pattern, '*')) |wildcard_pos| { + if (wildcard_pos == pattern.len - 1) { + return .{ + .pattern_type = .prefix, + .prefix = pattern[0..wildcard_pos], + }; + } + + if (wildcard_pos == 0) { + return .{ + .pattern_type = .suffix, + .suffix = pattern[1..], + }; + } + + if (std.mem.indexOfScalarPos(u8, pattern, wildcard_pos + 1, '*') != null) { + return .{ .pattern_type = .invalid }; + } + + return .{ + .pattern_type = .middle, + .prefix = pattern[0..wildcard_pos], + .suffix = pattern[wildcard_pos + 1 ..], + }; + } + + return .{ .pattern_type = .exact }; + } + + fn matchesName(self: GlobPattern, name: []const u8, pattern: []const u8) bool { + return switch (self.pattern_type) { + .exact => strings.eql(name, pattern), + .prefix => std.mem.startsWith(u8, name, self.prefix), + .suffix => std.mem.endsWith(u8, name, self.suffix), + .middle => std.mem.startsWith(u8, name, self.prefix) and std.mem.endsWith(u8, name, self.suffix), + .contains => std.mem.indexOf(u8, name, self.substring) != null, + else => false, + }; + } + + fn matchesVersion(self: GlobPattern, version: []const u8) bool { + if (self.version_pattern.len == 0 or strings.eqlComptime(self.version_pattern, "latest")) { + return true; + } + + if (self.version_query) |query| { + const sliced = Semver.SlicedString.init(version, version); + const version_result = Semver.Version.parse(sliced); + + if (version_result.valid) { + const semver_version = version_result.version.min(); + return query.satisfies(semver_version, self.version_pattern, version); + } + } + + if (strings.eql(version, self.version_pattern)) { + return true; + } + + return std.mem.startsWith(u8, version, self.version_pattern); + } + + fn matches(self: GlobPattern, name: []const u8, version: []const u8, pattern: []const u8) bool { + if (!self.matchesName(name, pattern)) return false; + if (self.version_pattern.len > 0 and !self.matchesVersion(version)) return false; + return true; + } + }; + + pub fn printUsage() void { + Output.prettyln("bun why v" ++ Global.package_json_version_with_sha ++ "", .{}); + + const usage_text = + \\Explain why a package is installed + \\ + \\Arguments: + \\ \ The package name to explain (supports glob patterns like '@org/*') + \\ + \\Options: + \\ --top Show only the top dependency tree instead of nested ones + \\ --depth \ Maximum depth of the dependency tree to display + \\ + \\Examples: + \\ $ bun why react + \\ $ bun why "@types/*" --depth 2 + \\ $ bun why "*-lodash" --top + \\ + ; + Output.pretty(usage_text, .{}); + Output.flush(); + } + + pub fn exec(ctx: Command.Context) !void { + const cli = try PackageManager.CommandLineArguments.parse(ctx.allocator, .why); + const pm, _ = try PackageManager.init(ctx, cli, PackageManager.Subcommand.why); + + if (cli.positionals.len < 1) { + printUsage(); + Global.exit(1); + } + + if (strings.eqlComptime(cli.positionals[0], "why")) { + if (cli.positionals.len < 2) { + printUsage(); + Global.exit(1); + } + return try execWithManager(ctx, pm, cli.positionals[1], cli.top_only); + } + + return try execWithManager(ctx, pm, cli.positionals[0], cli.top_only); + } + + pub fn execFromPm(ctx: Command.Context, pm: *PackageManager, positionals: []const string) !void { + if (positionals.len < 2) { + printUsage(); + Global.exit(1); + } + + try execWithManager(ctx, pm, positionals[1], pm.options.top_only); + } + + pub fn execWithManager(ctx: Command.Context, pm: *PackageManager, package_pattern: string, top_only: bool) !void { + const load_lockfile = pm.lockfile.loadFromCwd(pm, ctx.allocator, ctx.log, true); + PackageManagerCommand.handleLoadLockfileErrors(load_lockfile, pm); + + if (top_only) { + max_depth = 1; + } else if (pm.options.depth) |depth| { + max_depth = depth; + } else { + max_depth = 100; + } + + const lockfile = load_lockfile.ok.lockfile; + const string_bytes = lockfile.buffers.string_bytes.items; + const packages = lockfile.packages.slice(); + const dependencies_items = lockfile.buffers.dependencies.items; + const resolutions_items = lockfile.buffers.resolutions.items; + + var arena = std.heap.ArenaAllocator.init(ctx.allocator); + defer arena.deinit(); + const arena_allocator = arena.allocator(); + + var target_versions = std.ArrayList(VersionInfo).init(ctx.allocator); + defer { + for (target_versions.items) |item| { + ctx.allocator.free(item.version); + } + target_versions.deinit(); + } + + var all_dependents = std.AutoHashMap(PackageID, std.ArrayList(DependentInfo)).init(arena_allocator); + + const glob = GlobPattern.init(package_pattern); + + for (0..packages.len) |pkg_idx| { + const pkg = packages.get(pkg_idx); + const pkg_name = pkg.name.slice(string_bytes); + + if (pkg_name.len == 0) continue; + + const dependencies = pkg.dependencies.get(dependencies_items); + const resolutions = pkg.resolutions.get(resolutions_items); + + for (dependencies, 0..) |dependency, dep_idx| { + const target_id = resolutions[dep_idx]; + if (target_id >= packages.len) continue; + + var dependents_entry = try all_dependents.getOrPut(target_id); + if (!dependents_entry.found_existing) { + dependents_entry.value_ptr.* = std.ArrayList(DependentInfo).init(arena_allocator); + } + + var dep_version_buf = std.ArrayList(u8).init(arena_allocator); + defer dep_version_buf.deinit(); + try std.fmt.format(dep_version_buf.writer(), "{}", .{packages.items(.resolution)[pkg_idx].fmt(string_bytes, .auto)}); + const dep_pkg_version = try arena_allocator.dupe(u8, dep_version_buf.items); + + const spec = try arena_allocator.dupe(u8, dependency.version.literal.slice(string_bytes)); + + const dep_type = if (dependency.behavior.dev) + DependencyType.dev + else if (dependency.behavior.optional and dependency.behavior.peer) + DependencyType.optional_peer + else if (dependency.behavior.optional) + DependencyType.optional + else if (dependency.behavior.peer) + DependencyType.peer + else + DependencyType.prod; + + try dependents_entry.value_ptr.append(.{ + .name = try arena_allocator.dupe(u8, pkg_name), + .version = dep_pkg_version, + .spec = spec, + .dep_type = dep_type, + .pkg_id = @as(PackageID, @intCast(pkg_idx)), + .workspace = strings.hasPrefixComptime(dep_pkg_version, "workspace:") or dep_pkg_version.len == 0, + }); + } + + if (!glob.matchesName(pkg_name, package_pattern)) continue; + + var version_buf = std.ArrayList(u8).init(ctx.allocator); + defer version_buf.deinit(); + try std.fmt.format(version_buf.writer(), "{}", .{packages.items(.resolution)[pkg_idx].fmt(string_bytes, .auto)}); + const version = try ctx.allocator.dupe(u8, version_buf.items); + + if (!glob.matchesVersion(version)) continue; + + try target_versions.append(.{ + .version = version, + .pkg_id = @as(PackageID, @intCast(pkg_idx)), + }); + } + + if (target_versions.items.len == 0) { + Output.prettyln("error: No packages matching '{s}' found in lockfile", .{package_pattern}); + Global.exit(1); + } + + for (target_versions.items) |target_version| { + const target_pkg = packages.get(target_version.pkg_id); + const target_name = target_pkg.name.slice(string_bytes); + Output.prettyln("{s}@{s}", .{ target_name, target_version.version }); + + if (all_dependents.get(target_version.pkg_id)) |dependents| { + if (dependents.items.len == 0) { + Output.prettyln(" └─ No dependents found", .{}); + } else if (max_depth == 0) { + Output.prettyln(" └─ (deeper dependencies hidden)", .{}); + } else { + var ctx_data = TreeContext.init(arena_allocator, string_bytes, top_only, &all_dependents); + defer ctx_data.clearPathTracker(); + + std.sort.insertion(DependentInfo, dependents.items, {}, compareDependents); + + for (dependents.items, 0..) |dep, dep_idx| { + const is_last = dep_idx == dependents.items.len - 1; + const prefix = if (is_last) PREFIX_LAST else PREFIX_MIDDLE; + + printPackageWithType(prefix, &dep); + if (!top_only) { + try printDependencyTree(&ctx_data, dep.pkg_id, if (is_last) PREFIX_SPACE else PREFIX_CONTINUE, 1, is_last, dep.workspace); + } + } + } + } else { + Output.prettyln(" └─ No dependents found", .{}); + } + + Output.prettyln("", .{}); + Output.flush(); + } + } + + fn printPackageWithType(prefix: string, package: *const DependentInfo) void { + Output.pretty("{s}", .{prefix}); + + switch (package.dep_type) { + .dev => Output.pretty("dev ", .{}), + .peer => Output.pretty("peer ", .{}), + .optional => Output.pretty("optional ", .{}), + .optional_peer => Output.pretty("optional peer ", .{}), + else => {}, + } + + if (package.workspace) { + Output.pretty("{s}", .{package.name}); + if (package.version.len > 0) { + Output.pretty("@workspace", .{}); + } + } else { + Output.pretty("{s}", .{package.name}); + if (package.version.len > 0) { + Output.pretty("@{s}", .{package.version}); + } + } + + if (package.spec.len > 0) { + Output.prettyln(" (requires {s})", .{package.spec}); + } else { + Output.prettyln("", .{}); + } + } + + const TreeContext = struct { + allocator: std.mem.Allocator, + string_bytes: []const u8, + top_only: bool, + all_dependents: *const std.AutoHashMap(PackageID, std.ArrayList(DependentInfo)), + path_tracker: std.AutoHashMap(PackageID, usize), + + fn init(allocator: std.mem.Allocator, string_bytes: []const u8, top_only: bool, all_dependents: *const std.AutoHashMap(PackageID, std.ArrayList(DependentInfo))) TreeContext { + return .{ + .allocator = allocator, + .string_bytes = string_bytes, + .top_only = top_only, + .all_dependents = all_dependents, + .path_tracker = std.AutoHashMap(PackageID, usize).init(allocator), + }; + } + + fn clearPathTracker(self: *TreeContext) void { + self.path_tracker.clearRetainingCapacity(); + } + }; + + fn printDependencyTree( + ctx: *TreeContext, + current_pkg_id: PackageID, + prefix: string, + depth: usize, + printed_break_line: bool, + parent_is_workspace: bool, + ) !void { + if (ctx.path_tracker.get(current_pkg_id) != null) { + Output.prettyln("{s}└─ *circular", .{prefix}); + return; + } + + try ctx.path_tracker.put(current_pkg_id, depth); + defer _ = ctx.path_tracker.remove(current_pkg_id); + + if (ctx.all_dependents.get(current_pkg_id)) |dependents| { + const sorted_dependents = try ctx.allocator.dupe(DependentInfo, dependents.items); + defer ctx.allocator.free(sorted_dependents); + + std.sort.insertion(DependentInfo, sorted_dependents, {}, compareDependents); + + for (sorted_dependents, 0..) |dep, dep_idx| { + if (parent_is_workspace and dep.version.len == 0) { + continue; + } + + if (depth >= max_depth) { + Output.prettyln("{s}└─ (deeper dependencies hidden)", .{prefix}); + return; + } + + const is_dep_last = dep_idx == sorted_dependents.len - 1; + const prefix_char = if (is_dep_last) "└─ " else "├─ "; + + const full_prefix = try std.fmt.allocPrint(ctx.allocator, "{s}{s}", .{ prefix, prefix_char }); + printPackageWithType(full_prefix, &dep); + + const next_prefix = try std.fmt.allocPrint(ctx.allocator, "{s}{s}", .{ prefix, if (is_dep_last) " " else "│ " }); + + const print_break_line = is_dep_last and sorted_dependents.len > 1 and !printed_break_line; + try printDependencyTree(ctx, dep.pkg_id, next_prefix, depth + 1, printed_break_line or print_break_line, dep.workspace); + + if (print_break_line) { + Output.prettyln("{s}", .{prefix}); + } + } + } + } +}; diff --git a/src/install/PackageManager.zig b/src/install/PackageManager.zig index d4425eeaed..a8d37add74 100644 --- a/src/install/PackageManager.zig +++ b/src/install/PackageManager.zig @@ -152,6 +152,7 @@ pub const Subcommand = enum { publish, audit, info, + why, // bin, // hash, diff --git a/src/install/PackageManager/CommandLineArguments.zig b/src/install/PackageManager/CommandLineArguments.zig index 8e795ba3f4..51f7139d86 100644 --- a/src/install/PackageManager/CommandLineArguments.zig +++ b/src/install/PackageManager/CommandLineArguments.zig @@ -80,6 +80,8 @@ pub const pm_params: []const ParamType = &(shared_params ++ [_]ParamType{ clap.parseParam("--allow-same-version Allow bumping to the same version") catch unreachable, clap.parseParam("-m, --message Use the given message for the commit") catch unreachable, clap.parseParam("--preid Identifier to be used to prefix premajor, preminor, prepatch or prerelease version increments") catch unreachable, + clap.parseParam("--top Show only the first level of dependencies") catch unreachable, + clap.parseParam("--depth Maximum depth of the dependency tree to display") catch unreachable, clap.parseParam(" ... ") catch unreachable, }); @@ -150,6 +152,12 @@ const publish_params: []const ParamType = &(shared_params ++ [_]ParamType{ clap.parseParam("--gzip-level Specify a custom compression level for gzip. Default is 9.") catch unreachable, }); +const why_params: []const ParamType = &(shared_params ++ [_]ParamType{ + clap.parseParam(" ... Package name to explain why it's installed") catch unreachable, + clap.parseParam("--top Show only the top dependency tree instead of nested ones") catch unreachable, + clap.parseParam("--depth Maximum depth of the dependency tree to display") catch unreachable, +}); + cache_dir: ?string = null, lockfile: string = "", token: string = "", @@ -213,6 +221,10 @@ allow_same_version: bool = false, preid: string = "", message: ?string = null, +// `bun pm why` options +top_only: bool = false, +depth: ?usize = null, + const PatchOpts = union(enum) { nothing: struct {}, patch: struct {}, @@ -621,6 +633,33 @@ pub fn printHelp(subcommand: Subcommand) void { Output.pretty(outro_text, .{}); Output.flush(); }, + .why => { + const intro_text = + \\ + \\Usage: bun why [flags] \ + \\ + \\ Explain why a package is installed. + \\ + \\Flags: + ; + + const outro_text = + \\ + \\ + \\Examples: + \\ $ bun why react + \\ $ bun why "@types/*" --depth 2 + \\ $ bun why "*-lodash" --top + \\ + \\Full documentation is available at https://bun.sh/docs/cli/why. + \\ + ; + + Output.pretty(intro_text, .{}); + clap.simpleHelp(why_params); + Output.pretty(outro_text, .{}); + Output.flush(); + }, } } @@ -640,6 +679,7 @@ pub fn parse(allocator: std.mem.Allocator, comptime subcommand: Subcommand) !Com .outdated => outdated_params, .pack => pack_params, .publish => publish_params, + .why => why_params, // TODO: we will probably want to do this for other *_params. this way extra params // are not included in the help text @@ -916,6 +956,17 @@ pub fn parse(allocator: std.mem.Allocator, comptime subcommand: Subcommand) !Com } } + // `bun pm why` and `bun why` options + if (comptime subcommand == .pm or subcommand == .why) { + cli.top_only = args.flag("--top"); + if (args.option("--depth")) |depth| { + cli.depth = std.fmt.parseInt(usize, depth, 10) catch { + Output.errGeneric("invalid depth value: '{s}', must be a positive integer", .{depth}); + Global.exit(1); + }; + } + } + return cli; } diff --git a/src/install/PackageManager/PackageManagerOptions.zig b/src/install/PackageManager/PackageManagerOptions.zig index 649cd0ca81..d199aa3a9b 100644 --- a/src/install/PackageManager/PackageManagerOptions.zig +++ b/src/install/PackageManager/PackageManagerOptions.zig @@ -64,6 +64,11 @@ preid: string = "", message: ?string = null, force: bool = false, +// `bun pm why` command options +top_only: bool = false, +depth: ?usize = null, + +/// isolated installs (pnpm-like) or hoisted installs (yarn-like, original) node_linker: NodeLinker = .auto, pub const PublishConfig = struct { @@ -628,6 +633,10 @@ pub fn load( this.preid = cli.preid; this.message = cli.message; this.force = cli.force; + + // `bun pm why` command options + this.top_only = cli.top_only; + this.depth = cli.depth; } else { this.log_level = if (default_disable_progress_bar) LogLevel.default_no_progress else LogLevel.default; PackageManager.verbose_install = false; diff --git a/test/cli/install/bun-pm-why.test.ts b/test/cli/install/bun-pm-why.test.ts new file mode 100644 index 0000000000..b0e603851e --- /dev/null +++ b/test/cli/install/bun-pm-why.test.ts @@ -0,0 +1,613 @@ +import { spawnSync } from "bun"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { existsSync, mkdtempSync, realpathSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +describe.each(["why", "pm why"])("bun %s", cmd => { + let package_dir: string; + let i = 0; + + beforeAll(async () => { + const base = mkdtempSync(join(realpathSync(tmpdir()), "why-test-")); + + package_dir = join(base, `why-test-${Math.random().toString(36).slice(2)}`); + await mkdir(package_dir, { recursive: true }); + }); + + afterAll(async () => { + if (existsSync(package_dir)) { + await rm(package_dir, { recursive: true, force: true }); + } + }); + + function setupTestWithDependencies() { + const testDir = tempDirWithFiles(`why-${i++}`, { + "package.json": JSON.stringify( + { + name: "test-package", + version: "1.0.0", + dependencies: { + "lodash": "^4.17.21", + "react": "^18.0.0", + }, + devDependencies: { + "@types/react": "^18.0.0", + }, + }, + null, + 2, + ), + }); + + spawnSync({ + cmd: [bunExe(), "install", "--lockfile-only"], + cwd: testDir, + env: bunEnv, + }); + + return testDir; + } + + function setupComplexDependencyTree() { + const testDir = tempDirWithFiles(`why-complex-${i++}`, { + "package.json": JSON.stringify( + { + name: "complex-package", + version: "1.0.0", + dependencies: { + "express": "^4.18.2", + "react": "^18.0.0", + "react-dom": "^18.0.0", + }, + devDependencies: { + "@types/express": "^4.17.17", + "typescript": "^5.0.0", + }, + }, + null, + 2, + ), + }); + + spawnSync({ + cmd: [bunExe(), "install", "--lockfile-only"], + cwd: testDir, + env: bunEnv, + }); + + return testDir; + } + + it("should show help when no package is specified", async () => { + const testDir = setupTestWithDependencies(); + + const { stdout, exitCode } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" ")], + cwd: testDir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + expect(stdout.toString()).toContain(`bun why v${Bun.version.replace("-debug", "")}`); + expect(exitCode).toBe(1); + }); + + it("should show direct dependency", async () => { + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + lodash: "^4.17.21", + }, + }), + ); + + const install = spawnSync({ + cmd: [bunExe(), "install", "--lockfile-only"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(install.exitCode).toBe(0); + + const { stdout, exitCode } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "lodash"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + expect(exitCode).toBe(0); + const output = stdout.toString(); + + expect(output).toContain("lodash@"); + expect(output).toContain("foo"); + expect(output).toContain("requires ^4.17.21"); + }); + + it("should show nested dependencies", async () => { + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + express: "^4.18.2", + }, + }), + ); + + const install = spawnSync({ + cmd: [bunExe(), "install", "--lockfile-only"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(install.exitCode).toBe(0); + + const { stdout, exitCode } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "mime-types"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + expect(exitCode).toBe(0); + const output = stdout.toString(); + expect(output).toContain("mime-types@"); + + expect(output).toContain("accepts@"); + expect(output).toContain("express@"); + }); + + it("should handle workspace dependencies", async () => { + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "workspace-root", + version: "1.0.0", + workspaces: ["packages/*"], + }), + ); + + await mkdir(join(package_dir, "packages", "pkg-a"), { recursive: true }); + await mkdir(join(package_dir, "packages", "pkg-b"), { recursive: true }); + + await writeFile( + join(package_dir, "packages", "pkg-a", "package.json"), + JSON.stringify({ + name: "pkg-a", + version: "1.0.0", + dependencies: { + lodash: "^4.17.21", + }, + }), + ); + + await writeFile( + join(package_dir, "packages", "pkg-b", "package.json"), + JSON.stringify({ + name: "pkg-b", + version: "1.0.0", + dependencies: { + "pkg-a": "workspace:*", + }, + }), + ); + + const install = spawnSync({ + cmd: [bunExe(), "install", "--lockfile-only"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(install.exitCode).toBe(0); + + const { stdout, exitCode } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "pkg-a"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + expect(exitCode).toBe(0); + const output = stdout.toString(); + expect(output).toContain("pkg-a@"); + expect(output).toContain("pkg-b@"); + }); + + it("should handle npm aliases", async () => { + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + "alias-pkg": "npm:lodash@^4.17.21", + }, + }), + ); + + const install = spawnSync({ + cmd: [bunExe(), "install", "--lockfile-only"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(install.exitCode).toBe(0); + + const { stdout, stderr, exitCode } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "alias-pkg"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + if (exitCode === 0) { + const output = stdout.toString(); + expect(output).toContain("alias-pkg@"); + } else { + expect(true).toBe(true); + } + }); + + it("should show error for non-existent package", async () => { + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + lodash: "^4.17.21", + }, + }), + ); + + const install = spawnSync({ + cmd: [bunExe(), "install", "--lockfile-only"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(install.exitCode).toBe(0); + + const { stdout, stderr, exitCode } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "non-existent-package"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + expect(exitCode).toBe(1); + + const combinedOutput = stdout.toString() + stderr.toString(); + + expect(combinedOutput.includes("No packages matching") || combinedOutput.includes("not found in lockfile")).toBe( + true, + ); + }); + + it("should show dependency types correctly", async () => { + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + "express": "^4.18.2", + }, + devDependencies: { + "typescript": "^5.0.0", + }, + peerDependencies: { + "react": "^18.0.0", + }, + optionalDependencies: { + "chalk": "^5.0.0", + }, + }), + ); + + const install = spawnSync({ + cmd: [bunExe(), "install", "--lockfile-only"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(install.exitCode).toBe(0); + + const { stdout: devStdout, exitCode: devExited } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "typescript"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(devExited).toBe(0); + const devOutput = devStdout.toString(); + expect(devOutput).toContain("dev"); + + const { stdout: peerStdout, exitCode: peerExited } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "react"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(peerExited).toBe(0); + const peerOutput = peerStdout.toString(); + expect(peerOutput).toContain("peer"); + + const { stdout: optStdout, exitCode: optExited } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "chalk"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(optExited).toBe(0); + const optOutput = optStdout.toString(); + expect(optOutput).toContain("optional"); + }); + + it("should handle packages with multiple versions", async () => { + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "multi-version-test", + version: "1.0.0", + dependencies: { + "react": "^18.0.0", + "old-package": "npm:react@^16.0.0", + }, + }), + ); + + const install = spawnSync({ + cmd: [bunExe(), "install", "--lockfile-only"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(install.exitCode).toBe(0); + + const { stdout, exitCode } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "react"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(exitCode).toBe(0); + const output = stdout.toString(); + + expect(output).toContain("react@"); + }); + + it("should handle deeply nested dependencies", async () => { + const testDir = setupComplexDependencyTree(); + + const { stdout, exitCode } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "mime-db"], + cwd: testDir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + expect(exitCode).toBe(0); + const output = stdout.toString(); + + expect(output).toContain("mime-db@"); + expect(output).toContain("mime-types@"); + + const lines = output.split("\n"); + const indentedLines = lines.filter(line => line.includes(" ")); + expect(indentedLines.length).toBeGreaterThan(0); + }); + + it("should support glob patterns for package names", async () => { + const testDir = setupComplexDependencyTree(); + + const { stdout, exitCode } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "@types/*"], + cwd: testDir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + expect(exitCode).toBe(0); + const output = stdout.toString(); + expect(output).toContain("@types/"); + expect(output).toContain("dev"); + }); + + it("should support version constraints in the query", async () => { + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "version-test", + version: "1.0.0", + dependencies: { + "react": "^18.0.0", + "lodash": "^4.17.21", + }, + }), + ); + + const install = spawnSync({ + cmd: [bunExe(), "install", "--lockfile-only"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(install.exitCode).toBe(0); + + const { stdout, exitCode } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "react@^18.0.0"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + if (exitCode === 0) { + const output = stdout.toString(); + expect(output).toContain("react@"); + } else { + expect(true).toBe(true); + } + }); + + it("should handle nested workspaces", async () => { + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "workspace-root", + version: "1.0.0", + workspaces: ["packages/*", "apps/*"], + }), + ); + + await mkdir(join(package_dir, "packages", "pkg-a"), { recursive: true }); + await mkdir(join(package_dir, "packages", "pkg-b"), { recursive: true }); + await mkdir(join(package_dir, "apps", "app-a"), { recursive: true }); + + await writeFile( + join(package_dir, "packages", "pkg-a", "package.json"), + JSON.stringify({ + name: "pkg-a", + version: "1.0.0", + dependencies: { + lodash: "^4.17.21", + }, + }), + ); + + await writeFile( + join(package_dir, "packages", "pkg-b", "package.json"), + JSON.stringify({ + name: "pkg-b", + version: "1.0.0", + dependencies: { + "pkg-a": "workspace:*", + }, + }), + ); + + await writeFile( + join(package_dir, "apps", "app-a", "package.json"), + JSON.stringify({ + name: "app-a", + version: "1.0.0", + dependencies: { + "pkg-b": "workspace:*", + }, + }), + ); + + const install = spawnSync({ + cmd: [bunExe(), "install", "--lockfile-only"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(install.exitCode).toBe(0); + + const { stdout, exitCode } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "lodash"], + cwd: package_dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + expect(exitCode).toBe(0); + const output = stdout.toString(); + expect(output).toContain("lodash@"); + expect(output).toContain("pkg-a"); + + const lines = output.split("\n"); + expect(lines.some(line => line.includes("pkg-a"))).toBe(true); + }); + + it("should support the --top flag to limit dependency tree depth", async () => { + const testDir = setupComplexDependencyTree(); + + const { stdout: stdoutWithTop, exitCode: exitedWithTop } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "mime-db", "--top"], + cwd: testDir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + expect(exitedWithTop).toBe(0); + const outputWithTop = stdoutWithTop.toString(); + + const { stdout: stdoutWithoutTop, exitCode: exitedWithoutTop } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "mime-db"], + cwd: testDir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + expect(exitedWithoutTop).toBe(0); + const outputWithoutTop = stdoutWithoutTop.toString(); + + expect(outputWithTop.length).toBeGreaterThan(0); + expect(outputWithoutTop.length).toBeGreaterThan(0); + }); + + it("should support the --depth flag to limit dependency tree depth", async () => { + const testDir = setupComplexDependencyTree(); + + const { stdout: stdoutDepth2, exitCode: exitedDepth2 } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "mime-db", "--depth", "2"], + cwd: testDir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + expect(exitedDepth2).toBe(0); + const outputDepth2 = stdoutDepth2.toString(); + + const { stdout: stdoutNoDepth, exitCode: exitedNoDepth } = spawnSync({ + cmd: [bunExe(), ...cmd.split(" "), "mime-db"], + cwd: testDir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + expect(exitedNoDepth).toBe(0); + const outputNoDepth = stdoutNoDepth.toString(); + + expect(outputDepth2.split("\n").length).toBeLessThan(outputNoDepth.split("\n").length); + + expect(outputDepth2).toContain("mime-db@"); + }); +});