feat(gradle): analyze a Gradle project's dependencies per configuration (#1181)

The Dependency Analyzer lists Gradle projects next to Maven modules. For a Gradle project it runs `<project>:dependencies --configuration <name>` (compile/runtime/test classpaths) in the background (on the project host for remote projects), cancellable and cached per session, and reuses the list/tree views, search, conflicts-only filter and usage paths: an exact request Gradle replaced (1.0 -> 1.1) is a conflict showing the requested version, constraints are left out, unresolved dependencies are marked and build projects (quoted or not) are recognised. Exclude / Jump to Source stay Maven-only. Opened from the Gradle tool window toolbar, a Gradle project's context menu and a build script's menus.

Closes #1005
This commit is contained in:
l0ng-ai
2026-11-04 13:51:40 +08:00
committed by GitHub
parent 16df2ae472
commit 4fbe41194f
15 changed files with 939 additions and 130 deletions
+257
View File
@@ -0,0 +1,257 @@
//! A Gradle project's resolved dependencies for the Dependency Analyzer:
//! `gradle <project>:dependencies --configuration <name>`, parsed from its tree report.
use crate::gradle;
use crate::process;
use crate::sync::{SyncOptions, error_summary};
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::Path;
/// One line of the report: an occurrence of a dependency where it is requested.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GradleDependency {
/// `group`, empty for a project dependency.
pub group: String,
/// Artifact name, or the project path (`:core`) of a project dependency.
pub name: String,
/// The version asked for here; `None` when a platform or constraint supplies it.
pub requested: Option<String>,
/// The version Gradle selected (`-> 1.2`), when it differs from the request.
pub selected: Option<String>,
/// A project of the build (`project :core`, or a module substituted by one).
pub project: bool,
/// `(*)`: listed with its dependencies earlier in the tree.
pub repeated: bool,
/// `(c)`: a dependency constraint rather than a dependency.
pub constraint: bool,
/// `(n)` / `FAILED`: not resolved.
pub unresolved: bool,
pub children: Vec<GradleDependency>,
}
/// Parses the tree of a `dependencies --configuration` report (lines `+--- g:a:1.0 -> 1.1 (*)`).
pub fn parse_report(output: &str) -> Vec<GradleDependency> {
let mut roots: Vec<GradleDependency> = Vec::new();
// Path of child indexes from the roots to the last node of each depth.
let mut path: Vec<usize> = Vec::new();
for line in output.lines() {
let Some((depth, text)) = tree_line(line) else { continue };
let node = parse_entry(text);
path.truncate(depth);
if depth > path.len() {
continue; // A line deeper than its parent allows: not part of a well-formed tree.
}
let siblings = if depth == 0 {
&mut roots
} else {
let mut list = &mut roots;
for &i in &path[..depth - 1] {
list = &mut list[i].children;
}
let Some(parent) = list.get_mut(path[depth - 1]) else { continue };
&mut parent.children
};
siblings.push(node);
path.push(siblings.len() - 1);
}
roots
}
/// `| \--- g:a:1.0` → (1, "g:a:1.0").
fn tree_line(line: &str) -> Option<(usize, &str)> {
let mut rest = line;
let mut depth = 0;
loop {
if let Some(r) = rest.strip_prefix("+--- ").or_else(|| rest.strip_prefix("\\--- ")) {
return Some((depth, r.trim()));
}
rest = rest.strip_prefix("| ").or_else(|| rest.strip_prefix(" "))?;
depth += 1;
}
}
fn parse_entry(text: &str) -> GradleDependency {
let mut d = GradleDependency::default();
let mut text = text.trim();
loop {
if let Some(t) = text.strip_suffix(" (*)") {
d.repeated = true;
text = t;
} else if let Some(t) = text.strip_suffix(" (c)") {
d.constraint = true;
text = t;
} else if let Some(t) = text.strip_suffix(" (n)").or_else(|| text.strip_suffix(" FAILED")) {
d.unresolved = true;
text = t;
} else {
break;
}
text = text.trim_end();
}
let (left, right) = match text.split_once(" -> ") {
Some((l, r)) => (l.trim(), Some(r.trim())),
None => (text, None),
};
if let Some(project) = left.strip_prefix("project ") {
d.project = true;
d.name = project_path(project);
} else {
let mut parts = left.splitn(3, ':');
d.group = parts.next().unwrap_or_default().to_owned();
d.name = parts.next().unwrap_or_default().to_owned();
d.requested = parts.next().filter(|v| !v.is_empty()).map(str::to_owned);
}
if let Some(right) = right {
if let Some(project) = right.strip_prefix("project ") {
// A module substituted by a project of the build.
d.project = true;
d.selected = Some(project_path(project));
} else {
d.selected = Some(right.to_owned());
}
}
d
}
/// `:core`, or `':core'` as Gradle 9 quotes it.
fn project_path(text: &str) -> String {
let t = text.trim();
t.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')).unwrap_or(t).to_owned()
}
/// Runs the `dependencies` report of `project` (a Gradle path, `:` for the root) for
/// `configuration` and returns its tree.
pub fn gradle_dependencies(
root: &Path,
options: &SyncOptions,
project: &str,
configuration: &str,
cancel: &CancellationToken,
) -> IdeResult<Vec<GradleDependency>> {
if !configuration.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') || configuration.is_empty() {
return Err(IdeError::invalid(format!("not a configuration name: {configuration}")));
}
if !project.starts_with(':') || project.chars().any(char::is_whitespace) {
return Err(IdeError::invalid(format!("not a Gradle project path: {project}")));
}
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
let task = if project == ":" { ":dependencies".to_owned() } else { format!("{project}:dependencies") };
let mut args: Vec<String> =
vec!["--console=plain".into(), "-q".into(), task, "--configuration".into(), configuration.into()];
if options.offline {
args.push("--offline".into());
}
let out = process::run(
options.gradle_command.as_deref().unwrap_or(&model.command),
&args,
&root.join(&model.dir),
&options.env,
cancel,
|_, _| {},
)?;
if !out.success {
return Err(IdeError::unavailable(error_summary(&out.text)));
}
Ok(parse_report(&out.text))
}
#[cfg(test)]
mod tests {
use super::*;
fn dep(group: &str, name: &str, requested: Option<&str>) -> GradleDependency {
GradleDependency {
group: group.into(),
name: name.into(),
requested: requested.map(Into::into),
..Default::default()
}
}
#[test]
fn parses_the_tree_with_selected_versions_and_markers() {
let report = "\
------------------------------------------------------------
Project ':app'
------------------------------------------------------------
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.springframework.boot:spring-boot-starter-web -> 3.2.0
| +--- org.springframework.boot:spring-boot-starter:3.2.0
| | \\--- org.yaml:snakeyaml:2.2
| \\--- com.fasterxml.jackson.core:jackson-databind:2.15.0 -> 2.15.3 (*)
+--- project ':core'
| \\--- com.google.guava:guava:{strictly 32.1.3-jre} -> 32.1.3-jre (c)
+--- org.example:legacy:1.0 -> project :legacy
\\--- org.missing:gone:9 FAILED
(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree.
(*) - Indicates repeated occurrences of a transitive dependency subtree. Gradle expands transitive dependency subtrees only once per project; repeat occurrences only display the root of the subtree, followed by this annotation.
";
let tree = parse_report(report);
assert_eq!(tree.len(), 4);
let web = &tree[0];
assert_eq!((web.group.as_str(), web.name.as_str()), ("org.springframework.boot", "spring-boot-starter-web"));
assert_eq!((web.requested.as_deref(), web.selected.as_deref()), (None, Some("3.2.0")));
assert_eq!(web.children.len(), 2);
assert_eq!(web.children[0].children, vec![dep("org.yaml", "snakeyaml", Some("2.2"))]);
let databind = &web.children[1];
assert_eq!((databind.requested.as_deref(), databind.selected.as_deref()), (Some("2.15.0"), Some("2.15.3")));
assert!(databind.repeated);
let core = &tree[1];
assert!(core.project && core.group.is_empty());
assert_eq!(core.name, ":core");
let guava = &core.children[0];
assert!(guava.constraint);
assert_eq!(guava.requested.as_deref(), Some("{strictly 32.1.3-jre}"));
let legacy = &tree[2];
assert!(legacy.project);
assert_eq!((legacy.name.as_str(), legacy.selected.as_deref()), ("legacy", Some(":legacy")));
assert!(tree[3].unresolved);
assert_eq!(tree[3].requested.as_deref(), Some("9"));
}
#[test]
fn an_empty_configuration_has_no_dependencies() {
assert!(
parse_report("compileClasspath - Compile classpath for source set 'main'.\nNo dependencies\n").is_empty()
);
}
#[test]
fn rejects_names_that_are_not_configurations_or_projects() {
let tmp = tempfile::tempdir().unwrap();
let options = SyncOptions::default();
let cancel = CancellationToken::new();
assert!(gradle_dependencies(tmp.path(), &options, ":", "x --init-script y", &cancel).is_err());
assert!(gradle_dependencies(tmp.path(), &options, "app", "runtimeClasspath", &cancel).is_err());
}
#[cfg(unix)]
#[test]
fn runs_the_report_of_the_project_and_configuration() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("settings.gradle"), "include 'app'").unwrap();
std::fs::write(root.join("build.gradle"), "").unwrap();
let launcher = root.join("fake-gradle");
std::fs::write(&launcher, "#!/bin/sh\necho \"$@\" > args\necho '\\--- org.a:b:1.0'\n").unwrap();
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
let options = SyncOptions {
gradle_command: Some(launcher.to_string_lossy().into_owned()),
offline: true,
..Default::default()
};
let tree =
gradle_dependencies(root, &options, ":app", "testRuntimeClasspath", &CancellationToken::new()).unwrap();
assert_eq!(tree, vec![dep("org.a", "b", Some("1.0"))]);
assert_eq!(
std::fs::read_to_string(root.join("args")).unwrap().trim(),
"--console=plain -q :app:dependencies --configuration testRuntimeClasspath --offline"
);
}
}
+2
View File
@@ -8,6 +8,7 @@
pub mod application_update;
pub mod compile;
pub mod gradle;
mod gradle_deps;
pub mod kotlin_bridge;
pub mod maven;
pub mod pom_edit;
@@ -17,6 +18,7 @@ pub mod web;
mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
pub use gradle_deps::{GradleDependency, gradle_dependencies};
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
+41
View File
@@ -238,6 +238,36 @@ pub async fn gradle_published_catalog(
.map_err(join_err)
}
/// The resolved dependency tree of the Gradle project `gradle_path` (`:`, `:app`) for
/// `configuration`, from Gradle's `dependencies` report; cancellable with `cancel_request(request)`.
pub async fn gradle_dependencies(
host: &Host,
window: &str,
gradle_path: String,
configuration: String,
offline: bool,
request: RequestId,
) -> IdeResult<Vec<ide_build::GradleDependency>> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let gradle = load(&root, &cache, &settings).gradle;
let options = SyncOptions {
offline,
env: gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
gradle_command: gradle.as_ref().map(|g| g.command.clone()),
..Default::default()
};
ide_build::gradle_dependencies(&root, &options, &gradle_path, &configuration, &token)
})
.await
.map_err(join_err);
host.end_request(request);
result?
}
/// Where the direct dependency `dependency` of the Maven module `pom` is declared.
pub async fn maven_dependency_declaration(
host: &Host,
@@ -367,6 +397,17 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("gradle_published_catalog", |c| async move {
gradle_published_catalog(&c.host, &c.window, c.arg("coordinates")?).await
});
d.add("gradle_dependencies", |c| async move {
gradle_dependencies(
&c.host,
&c.window,
c.arg("gradlePath")?,
c.arg("configuration")?,
c.arg("offline")?,
c.arg("request")?,
)
.await
});
d.add("maven_dependency_declaration", |c| async move {
maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await
});
+15
View File
@@ -43,6 +43,21 @@ pub async fn build_sync(
.await
}
/// The resolved dependency tree of a Gradle project for one configuration (Dependency Analyzer).
#[tauri::command]
#[specta::specta]
pub async fn gradle_dependencies(
window: Window,
state: State<'_, AppState>,
gradle_path: String,
configuration: String,
offline: bool,
request: RequestId,
) -> IdeResult<Vec<ide_build::GradleDependency>> {
ide_host::ops::build::gradle_dependencies(&state.host, window.label(), gradle_path, configuration, offline, request)
.await
}
/// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
/// build has downloaded it.
#[tauri::command]
+1
View File
@@ -340,6 +340,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
run::run_test_reports,
build::build_model,
build::build_sync,
build::gradle_dependencies,
build::gradle_published_catalog,
build::maven_dependency_declaration,
build::maven_exclude_dependency,
+23
View File
@@ -647,6 +647,8 @@ export const commands = {
* Returns the refreshed model; a failed build is reported in its `syncError`.
*/
buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], onProgress: Channel<SyncProgress>) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, onProgress })),
/** The resolved dependency tree of a Gradle project for one configuration (Dependency Analyzer). */
gradleDependencies: (gradlePath: string, configuration: string, offline: boolean, request: RequestId) => typedError<GradleDependency[], IdeError>(__TAURI_INVOKE("gradle_dependencies", { gradlePath, configuration, offline, request })),
/**
* The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
* build has downloaded it.
@@ -3713,6 +3715,27 @@ export type GoSettings = {
export type GotoKind = "definition" | "declaration" | "typeDefinition" | "implementation";
/** One line of the report: an occurrence of a dependency where it is requested. */
export type GradleDependency = {
/** `group`, empty for a project dependency. */
group: string,
/** Artifact name, or the project path (`:core`) of a project dependency. */
name: string,
/** The version asked for here; `None` when a platform or constraint supplies it. */
requested: string | null,
/** The version Gradle selected (`-> 1.2`), when it differs from the request. */
selected: string | null,
/** A project of the build (`project :core`, or a module substituted by one). */
project: boolean,
/** `(*)`: listed with its dependencies earlier in the tree. */
repeated: boolean,
/** `(c)`: a dependency constraint rather than a dependency. */
constraint: boolean,
/** `(n)` / `FAILED`: not resolved. */
unresolved: boolean,
children: GradleDependency[],
};
export type GradleDistribution = "wrapper" | "local";
export type GradleModel = {
+7 -1
View File
@@ -154,7 +154,12 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
action={`${tool}.toggleOffline`}
pressed={maven ? settings.maven.offline : settings.gradle.offline}
/>
{maven && <IconButton icon="build-dependencies" size="small" iconSize={16} action="maven.analyzeDependencies" />}
<IconButton
icon="build-dependencies"
size="small"
iconSize={16}
action={maven ? "maven.analyzeDependencies" : "gradle.analyzeDependencies"}
/>
{maven && (
<IconButton
icon="skip-tests"
@@ -331,6 +336,7 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
items.push(
{ kind: "action", action: "gradle.reload", label: "Reload All Gradle Projects" },
{ kind: "action", action: "gradle.executeTask" },
{ kind: "action", action: "gradle.analyzeDependencies", arg: { gradlePath: node.project.path } },
);
}
if (node.kind === "dependency") {
@@ -20,7 +20,7 @@
max-width: 260px;
}
.search {
flex: 1 1 200px;
flex: 1 1 140px;
min-width: 140px;
max-width: 360px;
}
@@ -203,3 +203,10 @@
.stepTarget {
color: var(--fg-strong);
}
.errorText {
max-width: 560px;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--fg-muted);
}
@@ -2,19 +2,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import type { BuildModel, MavenDependency } from "@/platform/ipc";
import type { BuildModel, GradleDependency, GradleModel, MavenDependency } from "@/platform/ipc";
import { menuGroupItems, provideEditorService, type EditorService } from "@/workbench/contributions";
import { DependencyAnalyzer } from "./DependencyAnalyzer";
import { activate } from "./index";
import { ANALYZER_KIND, useAnalyzer, useBuild } from "./store";
const ipc = vi.hoisted(() => ({ exclude: vi.fn(), declaration: vi.fn() }));
const ipc = vi.hoisted(() => ({ exclude: vi.fn(), declaration: vi.fn(), gradle: vi.fn(), cancel: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
call: (value: unknown) => value,
commands: { ...real.commands, mavenExcludeDependency: ipc.exclude, mavenDependencyDeclaration: ipc.declaration },
commands: {
...real.commands,
mavenExcludeDependency: ipc.exclude,
mavenDependencyDeclaration: ipc.declaration,
gradleDependencies: ipc.gradle,
cancelRequest: ipc.cancel,
},
};
});
@@ -88,7 +94,7 @@ describe("dependency analyzer", () => {
it("opens as an editor tab from the action and from a pom.xml's menus", async () => {
await executeAction("maven.analyzeDependencies", { pom: "pom.xml" });
expect(openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: ANALYZER_KIND }), { focus: true });
expect(useAnalyzer.getState().pom).toBe("pom.xml");
expect(useAnalyzer.getState().module).toBe("pom.xml");
for (const menu of ["editor", "projectView", "editorTab"] as const) {
const items = menuGroupItems(menu, { paths: ["/p/pom.xml"] });
expect(items[0]).toMatchObject({ action: "maven.analyzeDependencies", arg: { pom: "pom.xml" } });
@@ -116,3 +122,96 @@ describe("dependency analyzer", () => {
);
});
});
function gradleDep(coords: string, selected: string | null, children: GradleDependency[] = []): GradleDependency {
const [group, name, requested] = coords.split(":");
return {
group: group!,
name: name!,
requested: requested ?? null,
selected,
project: false,
repeated: false,
constraint: false,
unresolved: false,
children,
};
}
const gradle: GradleModel = {
dir: "",
projects: [
{ path: ":", name: "demo", dir: "", buildFile: "build.gradle", tasks: [] },
{ path: ":app", name: "app", dir: "app", buildFile: "app/build.gradle.kts", tasks: [] },
],
javaVersion: null,
command: "gradle",
env: {},
syncedAt: null,
stale: false,
syncError: null,
};
describe("dependency analyzer for Gradle", () => {
let dispose: () => void = () => {};
const openInput = vi.fn(() => Promise.resolve());
beforeEach(() => {
provideEditorService({ openInput, activePath: () => null } as unknown as EditorService);
setContext("projectOpen", true);
dispose = activate();
act(() => useBuild.setState({ root: "/g", model: { maven: null, gradle } }));
});
afterEach(() => {
dispose();
setContext("projectOpen", false);
vi.clearAllMocks();
});
it("opens for a project from the action and from its build script's menus", async () => {
await executeAction("gradle.analyzeDependencies", { gradlePath: ":app" });
expect(openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: ANALYZER_KIND }), { focus: true });
expect(useAnalyzer.getState().module).toBe("gradle::app");
const items = menuGroupItems("projectView", { paths: ["/g/app/build.gradle.kts"] });
expect(items).toContainEqual(
expect.objectContaining({ action: "gradle.analyzeDependencies", arg: { path: "/g/app/build.gradle.kts" } }),
);
await executeAction("gradle.analyzeDependencies", { path: "/g/build.gradle" });
expect(useAnalyzer.getState().module).toBe("gradle::");
expect(menuGroupItems("editor", { paths: ["/g/other/build.gradle"] })).toEqual([]);
});
it("reads the configuration's report and marks versions Gradle replaced", async () => {
ipc.gradle.mockResolvedValue([
gradleDep("org.springframework.boot:spring-boot-starter-json", "3.2.0", [
gradleDep("com.fasterxml.jackson.core:jackson-databind:2.15.0", "2.15.3"),
]),
gradleDep("com.fasterxml.jackson.core:jackson-databind:2.15.3", null),
]);
act(() => useAnalyzer.setState({ module: "gradle::app" }));
render(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
expect(ipc.gradle).toHaveBeenCalledWith(":app", "runtimeClasspath", false, expect.any(Number));
await screen.findByText("com.fasterxml.jackson.core:jackson-databind");
fireEvent.click(screen.getByRole("checkbox", { name: "Show conflicts only" }));
expect(screen.queryByText("org.springframework.boot:spring-boot-starter-json")).toBeNull();
fireEvent.mouseDown(screen.getByText("com.fasterxml.jackson.core:jackson-databind").closest("[role=treeitem]")!, {
button: 0,
});
expect(screen.getByText("requested 2.15.0")).toBeTruthy();
expect(screen.getByText("org.springframework.boot:spring-boot-starter-json:3.2.0")).toBeTruthy();
// Exclude and Jump to Source edit a pom.xml: not offered for Gradle.
expect(screen.queryByRole("button", { name: "Exclude" })).toBeNull();
expect(screen.queryByRole("button", { name: "Jump to Source" })).toBeNull();
});
it("shows why Gradle could not resolve the configuration", async () => {
ipc.gradle.mockRejectedValue(new Error("Could not resolve all files for configuration ':app:runtimeClasspath'."));
act(() => useAnalyzer.setState({ module: "gradle::" }));
render(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
expect(await screen.findByText("Cannot resolve dependencies")).toBeTruthy();
expect(screen.getByText(/Could not resolve all files/)).toBeTruthy();
ipc.gradle.mockResolvedValue([]);
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Retry" })));
expect(ipc.gradle).toHaveBeenCalledTimes(2);
expect(await screen.findByText("No dependencies")).toBeTruthy();
});
});
+327 -110
View File
@@ -1,11 +1,20 @@
/**
* Dependency Analyzer editor tab: a Maven module's resolved artifacts (flat list or
* the transitive tree) with conflicts marked, and for the selected artifact every
* path that brings it in, with Exclude / Jump to Source / Copy Coordinates.
* Dependency Analyzer editor tab: a Maven module's or a Gradle project's resolved
* artifacts (flat list or the transitive tree) with conflicts marked, and for the
* selected artifact every path that brings it in, with Exclude / Jump to Source (Maven)
* and Copy Coordinates. Gradle trees are read per configuration, on demand.
*/
import { useMemo, useState, type MouseEvent } from "react";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import clsx from "clsx";
import { call, commands, type MavenDependency, type MavenProject } from "@/platform/ipc";
import {
call,
commands,
isCancelled,
nextRequestId,
type GradleProject,
type MavenDependency,
type MavenProject,
} from "@/platform/ipc";
import { useTx, useTxTemplate, tx } from "@/platform/i18n";
import { editorService, notifications, type EditorInput } from "@/workbench/contributions";
import {
@@ -34,6 +43,8 @@ import {
exclusionFor,
filterArtifacts,
filterTree,
fromGradle,
isBuildProject,
scopesOf,
sortedUsages,
statusText,
@@ -51,6 +62,66 @@ type Node =
| { kind: "node"; key: string; node: DependencyTreeNode; children: Node[] };
const ALL_SCOPES = "*";
const GRADLE = "gradle:";
/** The resolvable configurations of the Java plugins. */
const CONFIGURATIONS = ["compileClasspath", "runtimeClasspath", "testCompileClasspath", "testRuntimeClasspath"];
type GradleTree = { deps: MavenDependency[] | null; error: string | null; loading: boolean };
/** Reports already read in this session, by `root|path|configuration`: reopening the tab doesn't run Gradle again. */
const gradleTrees = new Map<string, MavenDependency[]>();
/** The `dependencies` report of `path` for `configuration`, cached; a new `generation` runs Gradle again. */
function useGradleTree(path: string | null, configuration: string, generation: number) {
const root = useBuild((s) => s.root);
const key = path && root ? `${root}|${path}|${configuration}` : null;
const [state, setState] = useState<GradleTree & { key: string | null }>({
key: null,
deps: null,
error: null,
loading: false,
});
const served = useRef(generation);
const running = useRef<number | null>(null);
useEffect(() => {
if (!key || !path) return;
const forced = served.current !== generation;
served.current = generation;
const cached = gradleTrees.get(key);
if (cached && !forced) {
setState({ key, deps: cached, error: null, loading: false });
return;
}
const request = nextRequestId();
running.current = request;
// Answers of a run this effect no longer owns (a cancelled one included) are dropped.
let live = true;
let done = false;
setState({ key, deps: cached ?? null, error: null, loading: true });
const offline = useBuild.getState().settings.gradle.offline;
call(commands.gradleDependencies(path, configuration, offline, request))
.then((deps) => {
const tree = fromGradle(deps);
gradleTrees.set(key, tree);
if (live) setState({ key, deps: tree, error: null, loading: false });
})
.catch((e) => {
if (live) setState({ key, deps: cached ?? null, error: isCancelled(e) ? null : errorText(e), loading: false });
})
.finally(() => {
done = true;
if (running.current === request) running.current = null;
});
return () => {
live = false;
if (!done) void commands.cancelRequest(request).catch(() => {});
};
}, [key, path, configuration, generation]);
const current: GradleTree = state.key === key ? state : { deps: null, error: null, loading: !!key };
const cancel = () => {
if (running.current != null) void commands.cancelRequest(running.current).catch(() => {});
};
return { ...current, cancel };
}
function errorText(e: unknown) {
return e instanceof Error ? e.message : String(e);
@@ -91,17 +162,32 @@ function versionLabel(d: MavenDependency) {
return d.version ?? "";
}
function moduleOptions(projects: readonly MavenProject[]): SelectOption[] {
return projects
.filter((p) => p.packaging !== "pom" || p.dependencies.length > 0)
.map((p) => ({ value: p.pom, label: p.displayName, detail: p.pom, icon: "maven-project" }));
function moduleOptions(maven: readonly MavenProject[], gradle: readonly GradleProject[]): SelectOption[] {
return [
...maven
.filter((p) => p.packaging !== "pom" || p.dependencies.length > 0)
.map((p) => ({ value: p.pom, label: p.displayName, detail: p.pom, icon: "maven-project" })),
// buildSrc's tasks can't be run from the build.
...gradle
.filter((p) => p.build !== ":buildSrc")
.map((p) => ({
value: `${GRADLE}${p.path}`,
label: p.name,
detail: p.path,
icon: p.path === ":" ? "gradle-project" : "gradle-module",
})),
];
}
function ArtifactRow({ artifact, selected }: { artifact: AnalyzedArtifact; selected: boolean }) {
const tx = useTx();
return (
<span className={styles.row}>
<Icon name={artifact.conflict ? "warning" : "library"} size={16} className={styles.icon} />
<Icon
name={artifact.conflict ? "warning" : isBuildProject(artifact) ? "gradle-module" : "library"}
size={16}
className={styles.icon}
/>
<span className={clsx(styles.label, artifact.conflict && styles.conflict, selected && styles.selectedLabel)}>
{artifact.key}
</span>
@@ -120,7 +206,11 @@ function TreeRow({ node, conflict, selected }: { node: DependencyTreeNode; confl
const status = statusText(usageStatus(d));
return (
<span className={clsx(styles.row, d.omitted != null && styles.omitted)}>
<Icon name={conflict ? "warning" : "library"} size={16} className={styles.icon} />
<Icon
name={conflict ? "warning" : isBuildProject(d) ? "gradle-module" : "library"}
size={16}
className={styles.icon}
/>
<span className={clsx(styles.label, conflict && styles.conflict, selected && styles.selectedLabel)}>
{artifactKey(d)}
</span>
@@ -131,17 +221,36 @@ function TreeRow({ node, conflict, selected }: { node: DependencyTreeNode; confl
);
}
function UsageBlock({ usage, module, pom }: { usage: DependencyUsage; module: MavenProject; pom: string }) {
function UsageBlock({
usage,
moduleName,
moduleIcon,
pom,
}: {
usage: DependencyUsage;
moduleName: string;
moduleIcon: string;
/** The Maven module's pom; `null` for Gradle (no Exclude / Jump to Source). */
pom: string | null;
}) {
const tx = useTxTemplate();
const status = usageStatus(usage.dependency);
const text = statusText(status);
const direct = usage.path[0]!;
const canExclude = !!exclusionFor(usage);
const canExclude = pom != null && !!exclusionFor(usage);
return (
<section className={styles.usage} aria-label={coordinates(usage.dependency)}>
<div className={styles.usageHeader}>
<Icon
name={status.kind === "conflict" ? "warning" : status.kind === "resolved" ? "success" : "info"}
name={
status.kind === "conflict" ||
status.kind === "unresolved" ||
(status.kind === "resolved" && status.requested)
? "warning"
: status.kind === "resolved"
? "success"
: "info"
}
size={16}
className={styles.icon}
/>
@@ -155,17 +264,19 @@ function UsageBlock({ usage, module, pom }: { usage: DependencyUsage; module: Ma
Exclude
</Button>
)}
<IconButton
icon="autoscroll-to-source"
size="small"
title="Jump to Source"
onClick={() => void jumpToDeclaration(pom, direct)}
/>
{pom != null && (
<IconButton
icon="autoscroll-to-source"
size="small"
title="Jump to Source"
onClick={() => void jumpToDeclaration(pom, direct)}
/>
)}
</div>
<ol className={styles.path}>
<li className={styles.step} style={{ paddingLeft: 0 }}>
<Icon name="maven-project" size={16} className={styles.icon} />
<span className={styles.stepLabel}>{module.displayName}</span>
<Icon name={moduleIcon} size={16} className={styles.icon} />
<span className={styles.stepLabel}>{moduleName}</span>
</li>
{usage.path.map((d, i) => (
<li key={i} className={styles.step} style={{ paddingLeft: (i + 1) * 16 }}>
@@ -183,12 +294,14 @@ function UsageBlock({ usage, module, pom }: { usage: DependencyUsage; module: Ma
function UsagesPane({
artifact,
module,
moduleName,
moduleIcon,
pom,
}: {
artifact: AnalyzedArtifact | null;
module: MavenProject;
pom: string;
moduleName: string;
moduleIcon: string;
pom: string | null;
}) {
if (!artifact) return <EmptyState title="Select a dependency to see its usages" />;
const usages = sortedUsages(artifact.usages);
@@ -209,7 +322,7 @@ function UsagesPane({
)}
<div className={styles.usageList} aria-label={tx("Usages")}>
{usages.map((u, i) => (
<UsageBlock key={i} usage={u} module={module} pom={pom} />
<UsageBlock key={i} usage={u} moduleName={moduleName} moduleIcon={moduleIcon} pom={pom} />
))}
</div>
</div>
@@ -219,25 +332,36 @@ function UsagesPane({
export function DependencyAnalyzer(_props: { input: EditorInput }) {
const tx = useTx();
const maven = useBuild((s) => s.model?.maven ?? null);
const gradle = useBuild((s) => s.model?.gradle ?? null);
const syncing = useBuild((s) => s.syncing.maven);
const chosen = useAnalyzer((s) => s.pom);
const chosen = useAnalyzer((s) => s.module);
const [query, setQuery] = useState("");
const [conflictsOnly, setConflictsOnly] = useState(false);
const [scope, setScope] = useState<string>(ALL_SCOPES);
const [configuration, setConfiguration] = useState("runtimeClasspath");
const [generation, setGeneration] = useState(0);
const [mode, setMode] = useState<Mode>("list");
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const options = useMemo(() => moduleOptions(maven?.projects ?? []), [maven]);
const pom = options.some((o) => o.value === chosen) ? chosen! : (options[0]?.value ?? null);
const module = maven?.projects.find((p) => p.pom === pom) ?? null;
const artifacts = useMemo(() => analyze(module?.dependencies ?? []), [module]);
const options = useMemo(() => moduleOptions(maven?.projects ?? [], gradle?.projects ?? []), [maven, gradle]);
const value = options.some((o) => o.value === chosen) ? chosen! : (options[0]?.value ?? null);
const gradlePath = value?.startsWith(GRADLE) ? value.slice(GRADLE.length) : null;
const pom = value != null && gradlePath == null ? value : null;
const module = pom ? (maven?.projects.find((p) => p.pom === pom) ?? null) : null;
const gradleProject = gradlePath != null ? (gradle?.projects.find((p) => p.path === gradlePath) ?? null) : null;
const report = useGradleTree(gradlePath, configuration, generation);
const deps = useMemo(
() => (gradlePath != null ? (report.deps ?? []) : (module?.dependencies ?? [])),
[gradlePath, report.deps, module],
);
const artifacts = useMemo(() => analyze(deps), [deps]);
const conflicted = useMemo(() => new Set(artifacts.filter((a) => a.conflict).map((a) => a.key)), [artifacts]);
const filter = useMemo(
() => ({ query, conflictsOnly, scope: scope === ALL_SCOPES ? null : scope }),
[query, conflictsOnly, scope],
() => ({ query, conflictsOnly, scope: scope === ALL_SCOPES || gradlePath != null ? null : scope }),
[query, conflictsOnly, scope, gradlePath],
);
const listed = useMemo(() => filterArtifacts(artifacts, filter), [artifacts, filter]);
const tree = useMemo(() => filterTree(module?.dependencies ?? [], filter, conflicted), [module, filter, conflicted]);
const tree = useMemo(() => filterTree(deps, filter, conflicted), [deps, filter, conflicted]);
const roots: Node[] = useMemo(() => {
const toNode = (node: DependencyTreeNode): Node => ({
kind: "node",
@@ -251,7 +375,7 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
}, [mode, listed, tree]);
// A narrowing filter opens the tree down to its matches until the user expands or collapses.
const filterId = `${mode}|${filter.query}|${filter.conflictsOnly}|${filter.scope}|${pom}`;
const filterId = `${mode}|${filter.query}|${filter.conflictsOnly}|${filter.scope}|${value}|${configuration}`;
const [manual, setManual] = useState<{ id: string; keys: Set<string> } | null>(null);
const expanded = useMemo(() => {
if (manual?.id === filterId) return manual.keys;
@@ -274,6 +398,7 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
{ value: ALL_SCOPES, label: tx("All scopes") },
...scopesOf(artifacts).map((s) => ({ value: s, label: s })),
];
const configurationOptions: SelectOption[] = CONFIGURATIONS.map((c) => ({ value: c, label: c }));
const nodeArtifactKey = (n: Node) => (n.kind === "artifact" ? n.artifact.key : artifactKey(n.node.dependency));
/** The usage a row stands for: a tree node's own, else the artifact's direct (or first) one. */
@@ -286,19 +411,71 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
};
const contextMenu = (n: Node | null, _sel: Set<string>, e: MouseEvent) => {
if (!n || !pom) return;
if (!n || !value) return;
e.preventDefault();
const usage = usageOf(n);
if (!usage) return;
const items: MenuItem[] = [
{ label: "Jump to Source", icon: "autoscroll-to-source", run: () => void jumpToDeclaration(pom, usage.path[0]!) },
];
if (exclusionFor(usage)) items.push({ label: "Exclude", run: () => void exclude(pom, usage) });
const items: MenuItem[] = [];
if (pom) {
items.push({
label: "Jump to Source",
icon: "autoscroll-to-source",
run: () => void jumpToDeclaration(pom, usage.path[0]!),
});
if (exclusionFor(usage)) items.push({ label: "Exclude", run: () => void exclude(pom, usage) });
}
items.push({ label: "Copy Coordinates", icon: "copy", run: () => void copyText(coordinates(usage.dependency)) });
showContextMenu({ x: e.clientX, y: e.clientY }, items);
};
if (!maven) return <EmptyState title="No Maven projects" />;
if (!maven && !gradle) return <EmptyState title="No Maven or Gradle projects" />;
const treeView = (
<div className={styles.body}>
<SplitGroup orientation="horizontal" id="maven-dependency-analyzer-split">
<SplitPanel id="dependencies" minSize={200} className={styles.panel}>
<Tree<Node>
aria-label="Dependencies"
roots={roots}
getKey={(n) => n.key}
getLabel={(n) => (n.kind === "artifact" ? n.artifact.key : artifactKey(n.node.dependency))}
getChildren={(n) => (n.kind === "node" ? n.children : undefined)}
isExpandable={(n) => n.kind === "node" && n.node.children.length > 0}
expanded={expanded}
onExpandedChange={(next) => setManual({ id: filterId, keys: next })}
onSelectionChange={(_keys, lead) => setSelectedKey(lead ? nodeArtifactKey(lead) : null)}
renderNode={(n, state) =>
n.kind === "artifact" ? (
<ArtifactRow artifact={n.artifact} selected={state.selected} />
) : (
<TreeRow
node={n.node}
conflict={conflicted.has(artifactKey(n.node.dependency))}
selected={state.selected}
/>
)
}
onActivate={(n) => {
const usage = usageOf(n);
if (usage && pom) void jumpToDeclaration(pom, usage.path[0]!);
}}
onContextMenu={contextMenu}
multiSelect={false}
empty={<EmptyState title={artifacts.length ? "No dependencies match the filter" : "No dependencies"} />}
/>
</SplitPanel>
<SplitHandle />
<SplitPanel id="usages" minSize={240} className={styles.panel}>
<UsagesPane
artifact={selected}
moduleName={module?.displayName ?? gradleProject?.name ?? ""}
moduleIcon={pom ? "maven-project" : gradlePath === ":" ? "gradle-project" : "gradle-module"}
pom={pom}
/>
</SplitPanel>
</SplitGroup>
</div>
);
return (
<div className={styles.view}>
@@ -306,10 +483,10 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
<Select
aria-label="Module"
className={styles.module}
value={pom}
value={value}
options={options}
onChange={(v) => {
useAnalyzer.setState({ pom: v });
useAnalyzer.setState({ module: v });
setSelectedKey(null);
}}
/>
@@ -322,7 +499,26 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
clearable
/>
<Checkbox checked={conflictsOnly} onChange={setConflictsOnly} label="Show conflicts only" />
<Select aria-label="Scope" className={styles.scope} value={scope} options={scopeOptions} onChange={setScope} />
{gradlePath != null ? (
<Select
aria-label="Configuration"
className={styles.scope}
value={configuration}
options={configurationOptions}
onChange={(c) => {
setConfiguration(c);
setSelectedKey(null);
}}
/>
) : (
<Select
aria-label="Scope"
className={styles.scope}
value={scope}
options={scopeOptions}
onChange={setScope}
/>
)}
<span className={styles.sep} />
<IconButton
icon="list"
@@ -341,79 +537,100 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) {
onClick={() => setMode("tree")}
/>
<span className={styles.sep} />
{syncing ? (
{gradlePath != null ? (
report.loading ? (
<IconButton icon="stop" size="small" iconSize={16} title="Stop" onClick={report.cancel} />
) : (
<IconButton
icon="refresh"
size="small"
iconSize={16}
title="Refresh"
onClick={() => setGeneration((g) => g + 1)}
/>
)
) : syncing ? (
<IconButton icon="stop" size="small" iconSize={16} action="maven.cancelReload" />
) : (
<IconButton icon="refresh" size="small" iconSize={16} action="maven.reload" />
)}
</div>
{syncing ? (
<div className={styles.banner} role="status">
<Spinner size={12} />
<span className={styles.bannerText}>{syncing.message}</span>
</div>
) : maven.stale && maven.syncedAt != null ? (
<div className={styles.banner}>
<Icon name="maven-load-changes" size={16} />
<span className={styles.bannerText}>
<T>Dependency trees may be out of date</T>
</span>
<Button size="small" variant="ghost" onClick={() => void syncTool("maven")}>
Load Maven Changes
</Button>
</div>
) : null}
{maven.syncedAt == null ? (
<EmptyState
title="Dependency trees are read when Maven projects are reloaded"
actions={
<Button size="small" onClick={() => void syncTool("maven")} disabled={!!syncing}>
Reload All Maven Projects
</Button>
}
/>
) : !module || !pom ? (
{gradlePath != null ? (
<>
{report.loading ? (
<div className={styles.banner} role="status">
<Spinner size={12} />
<span className={styles.bannerText}>
<T>Resolving dependencies…</T>
</span>
</div>
) : report.error && report.deps ? (
<div className={styles.banner} role="alert" title={report.error}>
<Icon name="warning" size={16} />
<span className={styles.bannerText}>
<T>Cannot resolve dependencies</T>: {report.error}
</span>
</div>
) : null}
{report.error && !report.deps ? (
<EmptyState
title="Cannot resolve dependencies"
actions={
<Button size="small" onClick={() => setGeneration((g) => g + 1)}>
Retry
</Button>
}
>
<span className={styles.errorText}>{report.error}</span>
</EmptyState>
) : report.deps ? (
treeView
) : report.loading ? null : (
<EmptyState
title="Stopped"
actions={
<Button size="small" onClick={() => setGeneration((g) => g + 1)}>
Retry
</Button>
}
/>
)}
</>
) : !maven ? (
<EmptyState title="No Maven modules" />
) : (
<div className={styles.body}>
<SplitGroup orientation="horizontal" id="maven-dependency-analyzer-split">
<SplitPanel id="dependencies" minSize={200} className={styles.panel}>
<Tree<Node>
aria-label="Dependencies"
roots={roots}
getKey={(n) => n.key}
getLabel={(n) => (n.kind === "artifact" ? n.artifact.key : artifactKey(n.node.dependency))}
getChildren={(n) => (n.kind === "node" ? n.children : undefined)}
isExpandable={(n) => n.kind === "node" && n.node.children.length > 0}
expanded={expanded}
onExpandedChange={(next) => setManual({ id: filterId, keys: next })}
onSelectionChange={(_keys, lead) => setSelectedKey(lead ? nodeArtifactKey(lead) : null)}
renderNode={(n, state) =>
n.kind === "artifact" ? (
<ArtifactRow artifact={n.artifact} selected={state.selected} />
) : (
<TreeRow
node={n.node}
conflict={conflicted.has(artifactKey(n.node.dependency))}
selected={state.selected}
/>
)
}
onActivate={(n) => {
const usage = usageOf(n);
if (usage) void jumpToDeclaration(pom, usage.path[0]!);
}}
onContextMenu={contextMenu}
multiSelect={false}
empty={<EmptyState title={artifacts.length ? "No dependencies match the filter" : "No dependencies"} />}
/>
</SplitPanel>
<SplitHandle />
<SplitPanel id="usages" minSize={240} className={styles.panel}>
<UsagesPane artifact={selected} module={module} pom={pom} />
</SplitPanel>
</SplitGroup>
</div>
<>
{syncing ? (
<div className={styles.banner} role="status">
<Spinner size={12} />
<span className={styles.bannerText}>{syncing.message}</span>
</div>
) : maven.stale && maven.syncedAt != null ? (
<div className={styles.banner}>
<Icon name="maven-load-changes" size={16} />
<span className={styles.bannerText}>
<T>Dependency trees may be out of date</T>
</span>
<Button size="small" variant="ghost" onClick={() => void syncTool("maven")}>
Load Maven Changes
</Button>
</div>
) : null}
{maven.syncedAt == null ? (
<EmptyState
title="Dependency trees are read when Maven projects are reloaded"
actions={
<Button size="small" onClick={() => void syncTool("maven")} disabled={!!syncing}>
Reload All Maven Projects
</Button>
}
/>
) : !module || !pom ? (
<EmptyState title="No Maven modules" />
) : (
treeView
)}
</>
)}
</div>
);
+46 -1
View File
@@ -1,11 +1,12 @@
import { describe, expect, it } from "vitest";
import type { MavenDependency } from "@/platform/ipc";
import type { GradleDependency, MavenDependency } from "@/platform/ipc";
import {
analyze,
coordinates,
exclusionFor,
filterArtifacts,
filterTree,
fromGradle,
scopesOf,
sortedUsages,
statusText,
@@ -122,3 +123,47 @@ describe("dependency analyzer", () => {
expect(coordinates(dep("g:a:1"))).toBe("g:a:1");
});
});
describe("Gradle reports", () => {
const g = (coords: string, extra: Partial<GradleDependency> = {}): GradleDependency => {
const [group, name, requested] = coords.split(":");
return {
group: group!,
name: name!,
requested: requested ?? null,
selected: null,
project: false,
repeated: false,
constraint: false,
unresolved: false,
children: [],
...extra,
};
};
it("keeps dependencies, drops constraints and marks only exact requests that lost as conflicts", () => {
const deps = fromGradle([
g("a:replaced:1.0", { selected: "1.1" }),
g("a:strict:{strictly 2.0}", { selected: "2.0" }),
g("a:dynamic:1.+", { selected: "1.5" }),
g("a:platform", { selected: "3.0" }),
g("a:constraint:4.0", { constraint: true }),
g("a:legacy:1.0", { selected: ":legacy", project: true }),
g("a:missing:9", { unresolved: true }),
g("x:y", { group: "", name: ":core", project: true }),
]);
const artifacts = analyze(deps);
expect(artifacts.map((a) => [a.key, a.version, a.conflict])).toEqual([
[":core", "", false],
["a:dynamic", "1.5", false],
["a:legacy", "project :legacy", false],
["a:missing", "9", false],
["a:platform", "3.0", false],
["a:replaced", "1.1", true],
["a:strict", "2.0", false],
]);
expect(artifacts.find((a) => a.key === "a:replaced")!.requested).toEqual(["1.0"]);
expect(statusText(usageStatus(deps[0]!))).toBe("requested 1.0");
expect(statusText(usageStatus(deps[5]!))).toBe("not resolved");
});
});
+58 -7
View File
@@ -1,9 +1,10 @@
/**
* Dependency Analyzer model: the artifacts of a Maven module's dependency tree
* (read with `dependency:tree -Dverbose`, so it also lists the occurrences Maven
* omitted), which of them conflict, and every path that brings them in.
* omitted) or of a Gradle configuration (`dependencies` report), which of them
* conflict, and every path that brings them in.
*/
import type { ArtifactKey, MavenDependency } from "@/platform/ipc";
import type { ArtifactKey, GradleDependency, MavenDependency } from "@/platform/ipc";
/** One occurrence of an artifact in the tree, with the path from a direct dependency down to it. */
export interface DependencyUsage {
@@ -36,6 +37,10 @@ export interface AnalyzerFilter {
scope: string | null;
}
/** A Gradle project of the build (`project :core`): no group, its path as the name. */
export const isBuildProject = (d: Pick<MavenDependency, "groupId" | "artifactId">) =>
!d.groupId && d.artifactId.startsWith(":");
export const artifactKey = (d: Pick<MavenDependency, "groupId" | "artifactId" | "classifier">) =>
[d.groupId, d.artifactId, d.classifier].filter(Boolean).join(":");
@@ -44,10 +49,50 @@ export function coordinates(d: Pick<MavenDependency, "groupId" | "artifactId" |
return [d.groupId, d.artifactId, d.classifier, d.version].filter(Boolean).join(":");
}
/**
* A Gradle occurrence in the analyzer's terms: `version` is the one Gradle selected,
* `requested` the one this path asked for when conflict resolution replaced it.
*/
type GradleOccurrence = MavenDependency & { requested?: string | null; unresolved?: boolean };
const isResolved = (d: MavenDependency) => d.omitted == null;
/** The version a usage asked for: before `<dependencyManagement>` replaced it, if it did. */
export const requestedVersion = (d: MavenDependency) => d.managedFrom ?? d.version ?? null;
/** The version a usage asked for: before `<dependencyManagement>` (or Gradle) replaced it, if it did. */
export const requestedVersion = (d: MavenDependency) =>
(d as GradleOccurrence).requested ?? d.managedFrom ?? d.version ?? null;
/** `{strictly 1.0}` / `{require 1.0}` / `{prefer 1.0}` → `1.0`. */
const plainVersion = (v: string) => v.replace(/^\{(?:strictly|require|prefer) ([^}]*)\}$/, "$1");
/** A version that names one release (not a range or dynamic version). */
const isExact = (v: string) => !/[[\](),+{}]|latest\./.test(v);
/**
* A Gradle configuration's report as analyzer dependencies. Constraints (`(c)`) are left
* out: they are not dependencies. A path whose exact request lost to another version
* (`1.0 -> 1.1`) is a conflict.
*/
export function fromGradle(deps: readonly GradleDependency[]): MavenDependency[] {
return deps
.filter((d) => !d.constraint)
.map((d): GradleOccurrence => {
const requested = d.requested ? plainVersion(d.requested) : null;
// A project of the build has no version (`""`, not `null`: it isn't omitted).
const version = d.project ? (d.selected ? `project ${d.selected}` : "") : (d.selected ?? requested);
return {
groupId: d.group,
artifactId: d.name,
version,
type: "jar",
classifier: null,
scope: "compile",
optional: false,
children: fromGradle(d.children),
requested: !d.project && requested && version && requested !== version && isExact(requested) ? requested : null,
unresolved: d.unresolved,
};
});
}
/** Every artifact of `deps` (a module's dependency tree), sorted by key. */
export function analyze(deps: readonly MavenDependency[]): AnalyzedArtifact[] {
@@ -78,7 +123,7 @@ export function analyze(deps: readonly MavenDependency[]): AnalyzedArtifact[] {
}
const requested = requestedVersion(d);
if (requested && !a.requested.includes(requested)) a.requested.push(requested);
if (d.omitted === "conflict") a.conflict = true;
if (d.omitted === "conflict" || (d as GradleOccurrence).requested) a.conflict = true;
walk(d.children, path);
}
};
@@ -138,13 +183,16 @@ export function filterTree(
}
export type UsageStatus =
| { kind: "resolved"; version: string | null; managedFrom: string | null }
| { kind: "resolved"; version: string | null; managedFrom: string | null; requested: string | null }
| { kind: "unresolved"; version: string | null }
| { kind: "conflict"; version: string | null; winner: string | null }
| { kind: "duplicate"; version: string | null }
| { kind: "cycle"; version: string | null };
export function usageStatus(d: MavenDependency): UsageStatus {
const version = d.version ?? null;
const gradle = d as GradleOccurrence;
if (gradle.unresolved) return { kind: "unresolved", version };
switch (d.omitted) {
case "conflict":
return { kind: "conflict", version, winner: d.conflictWith ?? null };
@@ -153,7 +201,7 @@ export function usageStatus(d: MavenDependency): UsageStatus {
case "cycle":
return { kind: "cycle", version };
default:
return { kind: "resolved", version, managedFrom: d.managedFrom ?? null };
return { kind: "resolved", version, managedFrom: d.managedFrom ?? null, requested: gradle.requested ?? null };
}
}
@@ -166,7 +214,10 @@ export function statusText(s: UsageStatus): string | null {
return "omitted for duplicate";
case "cycle":
return "omitted for cycle";
case "unresolved":
return "not resolved";
default:
if (s.requested) return `requested ${s.requested}`;
return s.managedFrom ? `version managed from ${s.managedFrom}` : null;
}
}
+36
View File
@@ -81,6 +81,18 @@ function analyzerPom(arg: unknown): string | null {
return current.maven ?? null;
}
/** The analyzer module of a Gradle project from an action argument: `{ gradlePath }`, or `{ path }` of its build script. */
function analyzerGradle(arg: unknown): string | null {
const { model, root } = useBuild.getState();
const a = (arg ?? {}) as { gradlePath?: unknown; path?: unknown };
if (typeof a.gradlePath === "string") return `gradle:${a.gradlePath}`;
const project =
typeof a.path === "string" && root
? model?.gradle?.projects.find((p) => p.buildFile && `${root}/${p.buildFile}` === a.path)
: undefined;
return project ? `gradle:${project.path}` : null;
}
const synced = new Set<string>();
/** Registers / unregisters the tool windows to match the model; first sync of a never-synced build. */
@@ -143,6 +155,19 @@ export function activate(): () => void {
icon: () => "build-dependencies",
restorable: true,
}),
// A Gradle build script in the editor, its tab and the project view: that project's dependencies.
registerMenuGroup({
id: "gradle.analyzeDependencies",
menus: ["editor", "editorTab", "projectView"],
order: 30,
items: (_menu, { paths, dirs }) => {
const path = paths.length === 1 && !dirs?.[0] ? paths[0]! : null;
if (!path || !/\/build\.gradle(\.kts)?$/.test(path) || !analyzerGradle({ path })) return [];
return [
{ kind: "action", action: "gradle.analyzeDependencies", arg: { path }, label: "Analyze Dependencies…" },
];
},
}),
// pom.xml in the editor, its tab and the project view: Analyze Dependencies of that module.
registerMenuGroup({
id: "maven.analyzeDependencies",
@@ -296,6 +321,17 @@ export function activate(): () => void {
keywords: ["gradle", "sync", "refresh"],
run: () => syncTool("gradle"),
},
{
id: "gradle.analyzeDependencies",
title: "Analyze Dependencies…",
description:
"Shows a Gradle project's resolved dependencies per configuration, version conflicts and where each library comes from",
category: "Gradle",
icon: "build-dependencies",
when: GRADLE_WHEN,
keywords: ["gradle", "dependency", "dependencies", "conflict", "tree", "configuration", "analyzer"],
run: ({ arg }) => openAnalyzer(analyzerGradle(arg)),
},
{
id: "gradle.cancelReload",
title: "Stop Reloading Gradle Projects",
+9 -6
View File
@@ -374,7 +374,7 @@ export function resetBuild() {
clearTimeout(profileReload);
profileReload = undefined;
queuedSyncs.clear();
useAnalyzer.setState({ pom: null });
useAnalyzer.setState({ module: null });
for (const tool of ["maven", "gradle"] as const) {
cancelSync(tool);
removeTask(`build-sync:${tool}`);
@@ -418,11 +418,14 @@ export function setCurrentProject(tool: BuildTool, id: string | undefined) {
export const ANALYZER_KIND = "maven.dependencyAnalyzer";
/** Module the Dependency Analyzer shows (`pom`, project-relative); `null` = the first one. */
export const useAnalyzer = create<{ pom: string | null }>(() => ({ pom: null }));
/**
* Module the Dependency Analyzer shows: a Maven `pom` (project-relative) or `gradle:<path>`
* (`gradle::app`) for a Gradle project; `null` = the first one.
*/
export const useAnalyzer = create<{ module: string | null }>(() => ({ module: null }));
/** Opens (or focuses) the Dependency Analyzer tab, showing the module `pom` when given. */
export async function openAnalyzer(pom: string | null) {
if (pom) useAnalyzer.setState({ pom });
/** Opens (or focuses) the Dependency Analyzer tab, showing `module` when given. */
export async function openAnalyzer(module: string | null) {
if (module) useAnalyzer.setState({ module });
await editorService().openInput({ kind: ANALYZER_KIND, key: ANALYZER_KIND, data: {} }, { focus: true });
}
+6
View File
@@ -3922,6 +3922,12 @@
"omitted for duplicate": "因重复而省略",
"omitted for cycle": "因循环依赖而省略",
"version managed from {version}": "版本由 {version} 经依赖管理调整",
"No Maven or Gradle projects": "没有 Maven 或 Gradle 项目",
"Resolving dependencies…": "正在解析依赖…",
"Cannot resolve dependencies": "无法解析依赖",
"requested {version}": "请求的版本为 {version}",
"not resolved": "未解析",
"Shows a Gradle project's resolved dependencies per configuration, version conflicts and where each library comes from": "按配置显示 Gradle 项目解析后的依赖、版本冲突以及每个库的来源",
"Cannot find the declaration": "找不到声明",
"Cannot exclude {name}": "无法排除 {name}",
"Solarized Light": "Solarized 浅色",