Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/pm/cli/outdated.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ bun outdated '!@types/*'
| typescript (dev) | 5.7.2 | 5.7.3 | 5.7.3 |
```

An aliased dependency such as `"lodash3": "npm:lodash@^3.0.0"` is listed with both of its names, and a pattern selects it by either name:

```sh terminal icon="terminal"
bun outdated lodash
```

```txt
| Package | Current | Update | Latest |
| -------------------- | ------- | ------ | ------- |
| lodash3@npm:lodash | 3.10.1 | 3.10.1 | 4.17.21 |
```

### Workspace Filters

Use the `--filter` flag to check for outdated dependencies in a different workspace package:
Expand Down
13 changes: 12 additions & 1 deletion docs/pm/cli/why.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ bun why <package>

## Arguments

- `<package>`: The name of the package to explain. Supports glob patterns like `@org/*` or `*-lodash`.
- `<package>`: The name of the package to explain, or the alias a `package.json` gives it. Supports glob patterns like `@org/*` or `*-lodash`.

## Options

Expand Down Expand Up @@ -47,6 +47,17 @@ bun why "@types/*"
└─ dev my-app@1.0.0 (requires ^18.0.0)
```

Find the package behind an alias. With `"lodash3": "npm:lodash@^3.0.0"` in `package.json`, both `bun why lodash3` and `bun why lodash` print:

```bash terminal icon="terminal"
bun why lodash3
```

```txt
lodash@3.10.1
└─ my-app@1.0.0 (requires lodash3@npm:lodash@^3.0.0)
```

Show only top-level dependencies:

```bash terminal icon="terminal"
Expand Down
11 changes: 11 additions & 0 deletions src/install/dependency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ pub trait DependencyExt {
builder: &mut SB,
) -> Result<Dependency, crate::Error>;
fn realname(&self) -> String;
/// The name package.json gives this dependency, when it is not
/// `package_name`, the name of the package it resolves to: the `my-alias`
/// of `"my-alias": "npm:dep@1.0.0"`. Unlike `realname`, this also holds for
/// a `catalog:` or overridden dependency. A command that takes or prints a
/// package name has to handle both names.
fn alias_for<'a>(&'a self, package_name: &[u8], buf: &'a [u8]) -> Option<&'a [u8]>;
fn eql(&self, b: &Dependency, lhs_buf: &[u8], rhs_buf: &[u8]) -> bool;
fn is_remote_tarball(dep: &[u8]) -> bool;
fn parse<'a, 'b>(
Expand Down Expand Up @@ -245,6 +251,11 @@ impl DependencyExt for Dependency {
}
}

fn alias_for<'a>(&'a self, package_name: &[u8], buf: &'a [u8]) -> Option<&'a [u8]> {
let name = self.name.slice(buf);
(name != package_name).then_some(name)
}

fn eql(&self, b: &Dependency, lhs_buf: &[u8], rhs_buf: &[u8]) -> bool {
self.name_hash == b.name_hash
&& self.name.len() == b.name.len()
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2192,7 +2192,7 @@ Full documentation is available at <magenta>https://bun.com/docs/cli/info<r>
Explain why a package is installed

<b>Arguments:<r>
<blue>\\<package\\><r> <d>The package name to explain (supports glob patterns like '@org/*')<r>
<blue>\\<package\\><r> <d>The package name or alias to explain (supports glob patterns like '@org/*')<r>

<b>Options:<r>
<cyan>--top<r> <d>Show only the top dependency tree instead of nested ones<r>
Expand Down
50 changes: 40 additions & 10 deletions src/runtime/cli/outdated_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ use bun_install::lockfile::{LoadResult, LoadStep};
use bun_install::package_manager::{
LogLevel, Subcommand, WorkspaceFilter, populate_manifest_cache,
};
use bun_install::{CommandLineArguments, DependencyID, PackageID, PackageManager, resolution};
use bun_install::{
CommandLineArguments, DependencyExt as _, DependencyID, PackageID, PackageManager, resolution,
};
use bun_wyhash::hash;

use crate::Command;
Expand All @@ -34,6 +36,10 @@ struct GroupedOutdatedInfo {
grouped_workspace_names: Option<Box<[u8]>>,
}

fn visible_width(text: &[u8]) -> usize {
strings::visible::width::exclude_ansi_colors::utf8(text)
}

/// The rows of the `bun outdated` table and the column widths that fit them.
struct OutdatedTable {
rows: Vec<GroupedOutdatedInfo>,
Expand Down Expand Up @@ -64,6 +70,27 @@ impl<'a> FilterType<'a> {
// *NOTE*: name and path are not allocated → no Drop impl needed.
}

/// Printed between the two names of an aliased dependency: the row for
/// `"my-alias": "npm:dep@1.0.0"` reads `my-alias@npm:dep`, which is what
/// `bun add` takes and prints.
const ALIAS_SEPARATOR: &str = "@npm:";

/// A pattern selects a dependency by the name of its package or by its alias,
/// so every name the table prints can be passed back as a filter. A `!pattern`
/// keeps the dependency only when neither name matches.
fn name_pattern_matches(pattern: &[u8], package_name: &[u8], alias: Option<&[u8]>) -> bool {
let by_package_name = glob::r#match(pattern, package_name);
let Some(alias) = alias else {
return by_package_name.matches();
};
let by_alias = glob::r#match(pattern, alias);
if by_alias.is_negated() {
by_alias.matches() && by_package_name.matches()
} else {
by_alias.matches() || by_package_name.matches()
}
}

impl OutdatedCommand {
pub(crate) fn exec(ctx: Command::Context) -> crate::Result<()> {
bun_core::prettyln!(
Expand Down Expand Up @@ -390,7 +417,10 @@ impl OutdatedCommand {
continue;
}

// package patterns match against dependency name (name in package.json)
let package_name =
manager.lockfile.packages.items_name()[package_id as usize].slice(string_buf);
let alias = dep.alias_for(package_name, string_buf);

if let Some(patterns) = &package_patterns {
let matched = 'match_: {
for pattern in patterns {
Expand All @@ -400,9 +430,7 @@ impl OutdatedCommand {
if name_pattern.is_empty() {
continue;
}
if !glob::r#match(name_pattern, dep.name.slice(string_buf))
.matches()
{
if !name_pattern_matches(name_pattern, package_name, alias) {
break 'match_ false;
}
}
Expand All @@ -416,8 +444,6 @@ impl OutdatedCommand {
}
}

let package_name =
manager.lockfile.packages.items_name()[package_id as usize].slice(string_buf);
let scope = manager.options.scope_for_package_name(package_name).clone();
let mut expired = false;
let Some(manifest) = manager.manifests.by_name_allow_expired(
Expand Down Expand Up @@ -465,6 +491,7 @@ impl OutdatedCommand {
}

let package_name_len = package_name.len()
+ alias.map_or(0, |alias| visible_width(alias) + ALIAS_SEPARATOR.len())
Comment thread
robobun marked this conversation as resolved.
+ if dep.behavior.is_dev() {
" (dev)".len()
} else if dep.behavior.is_peer() {
Expand Down Expand Up @@ -696,10 +723,13 @@ impl OutdatedCommand {
for _ in 0..COLUMN_LEFT_PAD {
bun_core::pretty!(" ");
}
let mut name_len = package_name.len() + behavior_str.len();
if let Some(alias) = dep.alias_for(package_name, string_buf) {
bun_core::pretty!("{}<d>{}<r>", BStr::new(alias), ALIAS_SEPARATOR);
Comment thread
robobun marked this conversation as resolved.
name_len += visible_width(alias) + ALIAS_SEPARATOR.len();
}
bun_core::pretty!("{}<d>{}<r>", BStr::new(package_name), behavior_str);
for _ in package_name.len() + behavior_str.len()
..package_column_inside_length + COLUMN_RIGHT_PAD
{
for _ in name_len..package_column_inside_length + COLUMN_RIGHT_PAD {
bun_core::pretty!(" ");
}
}
Expand Down
43 changes: 37 additions & 6 deletions src/runtime/cli/why_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ use bun_core::{Global, Output};
use bun_install::dependency::Behavior;
use bun_install::lockfile::Lockfile;
use bun_install::lockfile::package::PackageColumns as _;
use bun_install::{CommandLineArguments, PackageID, PackageManager, Subcommand, package_manager};
use bun_install::{
CommandLineArguments, DependencyExt as _, PackageID, PackageManager, Subcommand,
package_manager,
};
use bun_semver as semver;

use crate::command;
Expand All @@ -37,6 +40,8 @@ struct VersionInfo {
struct DependentInfo {
name: Box<[u8]>,
version: Box<[u8]>,
/// What this dependent calls the package, when that is not the package's name.
alias: Option<Box<[u8]>>,
spec: Box<[u8]>,
dep_type: DependencyType,
pkg_id: PackageID,
Expand Down Expand Up @@ -278,7 +283,7 @@ impl WhyCommand {
"Explain why a package is installed\n\
\n\
<b>Arguments:<r>\n\
<blue>\\<package\\><r> <d>The package name to explain (supports glob patterns like '@org/*')<r>\n\
<blue>\\<package\\><r> <d>The package name or alias to explain (supports glob patterns like '@org/*')<r>\n\
\n\
<b>Options:<r>\n\
<cyan>--top<r> <d>Show only the top dependency tree instead of nested ones<r>\n\
Expand Down Expand Up @@ -385,6 +390,17 @@ impl WhyCommand {
let _pkg_resolutions = packages.items_resolutions();
let pkg_resolution = packages.items_resolution();

// The pattern also selects a package through the name of a dependency
// that resolves to it, so an alias finds the package behind it.
let mut matched_by_dependency_name = vec![false; packages.len()];
for (dependency, &target_id) in dependencies_items.iter().zip(resolutions_items) {
if (target_id as usize) < packages.len()
&& glob.matches_name(dependency.name.slice(string_bytes), package_pattern)
Comment thread
robobun marked this conversation as resolved.
{
matched_by_dependency_name[target_id as usize] = true;
Comment thread
robobun marked this conversation as resolved.
}
}

for pkg_idx in 0..packages.len() {
let pkg_name = pkg_names[pkg_idx].slice(string_bytes);

Expand Down Expand Up @@ -415,6 +431,13 @@ impl WhyCommand {
let spec: Box<[u8]> =
Box::<[u8]>::from(dependency.version.literal.slice(string_bytes));

let alias = dependency
.alias_for(
pkg_names[target_id as usize].slice(string_bytes),
string_bytes,
)
.map(Box::<[u8]>::from);

let dep_type = if dependency.behavior.contains(Behavior::DEV) {
DependencyType::Dev
} else if dependency.behavior.contains(Behavior::OPTIONAL)
Expand All @@ -435,14 +458,16 @@ impl WhyCommand {
dependents_entry.push(DependentInfo {
name: Box::<[u8]>::from(pkg_name),
version: dep_pkg_version,
alias,
spec,
dep_type,
pkg_id: PackageID::try_from(pkg_idx).expect("int cast"),
workspace,
});
}

if !glob.matches_name(pkg_name, package_pattern) {
if !glob.matches_name(pkg_name, package_pattern) && !matched_by_dependency_name[pkg_idx]
{
continue;
}

Expand Down Expand Up @@ -557,10 +582,16 @@ fn print_package_with_type(prefix: &[u8], package: &DependentInfo) {
}
}

if !package.spec.is_empty() {
bun_core::prettyln!(" <d>(requires {})<r>", BStr::new(&package.spec));
} else {
if package.spec.is_empty() {
bun_core::prettyln!("");
} else if let Some(alias) = &package.alias {
bun_core::prettyln!(
" <d>(requires {}@{})<r>",
BStr::new(alias),
BStr::new(&package.spec)
);
} else {
bun_core::prettyln!(" <d>(requires {})<r>", BStr::new(&package.spec));
}
}

Expand Down
66 changes: 66 additions & 0 deletions test/cli/install/bun-install-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9569,6 +9569,72 @@ describe("outdated", () => {
expect(out).toContain("prereleases-1");
});

test("an aliased dependency prints both of its names and a pattern matches either", async () => {
await write(
packageJson,
JSON.stringify({
name: "foo",
dependencies: {
"a-dep": "1.0.1",
"my-alias": "npm:no-deps@1.0.0",
// Two columns wide per character.
"別名": "npm:no-deps@1.0.0",
},
devDependencies: {
"dev-alias": "npm:no-deps@1.0.0",
},
}),
);
await runBunInstall(env, packageDir);

const table = async (...args: string[]) => {
const out = await runBunOutdated({ ...env, NO_COLOR: "1" }, packageDir, ...args);
return out
.slice(out.indexOf("\n") + 1)
.trimEnd()
.split("\n");
};
// The Package column of every row.
const rows = async (...args: string[]) =>
(await table(...args))
.filter(line => line.startsWith("| ") && !line.startsWith("| Package "))
.map(line => line.split("|")[1].trim());

const myAlias = "my-alias@npm:no-deps";
const wideAlias = "別名@npm:no-deps";
const devAlias = "dev-alias@npm:no-deps (dev)";
const all = await table();
expect({
all,
byPackageName: await rows("no-deps"),
byAlias: await rows("my-alias"),
byAliasGlob: await rows("*-alias"),
notPackageName: await rows("!no-deps"),
notAlias: await rows("!my-alias"),
}).toEqual({
all: [
"|---------------------------------------------------------|",
"| Package | Current | Update | Latest |",
"|-----------------------------|---------|--------|--------|",
"| a-dep | 1.0.1 | 1.0.1 | 1.0.10 |",
"|-----------------------------|---------|--------|--------|",
"| my-alias@npm:no-deps | 1.0.0 | 1.0.0 | 2.0.0 |",
"|-----------------------------|---------|--------|--------|",
"| 別名@npm:no-deps | 1.0.0 | 1.0.0 | 2.0.0 |",
"|-----------------------------|---------|--------|--------|",
"| dev-alias@npm:no-deps (dev) | 1.0.0 | 1.0.0 | 2.0.0 |",
"|---------------------------------------------------------|",
],
byPackageName: [myAlias, wideAlias, devAlias],
byAlias: [myAlias],
byAliasGlob: [myAlias, devAlias],
notPackageName: ["a-dep"],
notAlias: ["a-dep", wideAlias, devAlias],
});
// The borders line up: every line takes the same number of terminal columns.
expect(all.map(line => Bun.stringWidth(line))).toEqual(all.map(() => 59));
});

test("scoped workspace names", async () => {
await Promise.all([
write(
Expand Down
Loading
Loading