From 211a4cf30d2f9eb365672b24ac448d8965312391 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:47:25 +0800 Subject: [PATCH] feat(maven): Dependency Analyzer with conflicts, usage paths and Exclude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maven sync now reads `dependency:tree -Dverbose` (maven-dependency-plugin 3.8.1, the project's own version when offline), so the cached trees keep the occurrences Maven omitted: "omitted for conflict with X", "omitted for duplicate", "omitted for cycle" and "version managed from X". Caches written before this are marked stale so the next reload fills them in. Omitted occurrences stay out of the Maven tool window and External Libraries. Analyze Dependencies… (`maven.analyzeDependencies`) opens a Dependency Analyzer editor tab from the Maven toolbar, a module's or its Dependencies node's context menu, and the editor / tab / project view menus of a module's pom.xml: - module selector, search, "Show conflicts only", scope filter; - flat list of resolved artifacts or the transitive tree, conflicts marked; - for the selected artifact, every path from the module to each occurrence with requested and resolved versions and why it was omitted; - Exclude adds an to the direct dependency bringing that occurrence in (in the module's POM, else its nearest parent POM in the project), keeping the POM's indentation and line endings, then reloads; - Jump to Source selects the dependency's declaration; Copy Coordinates. Fixes #87 Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- crates/ide-build/src/lib.rs | 21 +- crates/ide-build/src/maven.rs | 151 ++++++- crates/ide-build/src/pom_edit.rs | 336 ++++++++++++++ crates/ide-build/src/sync.rs | 49 +- crates/ide-build/src/xml.rs | 49 +- .../testdata/dependency-tree-verbose.txt | 24 + src-tauri/src/commands/build.rs | 73 ++- src-tauri/src/commands/mod.rs | 2 + src/bindings.ts | 29 ++ src/features/build-tools/BuildToolView.tsx | 4 + .../build-tools/DependencyAnalyzer.module.css | 205 +++++++++ .../build-tools/DependencyAnalyzer.test.tsx | 118 +++++ .../build-tools/DependencyAnalyzer.tsx | 420 ++++++++++++++++++ src/features/build-tools/actions.test.tsx | 1 + src/features/build-tools/analyzer.test.ts | 124 ++++++ src/features/build-tools/analyzer.ts | 188 ++++++++ src/features/build-tools/index.ts | 55 ++- src/features/build-tools/model.ts | 17 +- src/features/build-tools/store.ts | 17 +- src/platform/locales/text/zh.json | 28 +- 20 files changed, 1874 insertions(+), 37 deletions(-) create mode 100644 crates/ide-build/src/pom_edit.rs create mode 100644 crates/ide-build/testdata/dependency-tree-verbose.txt create mode 100644 src/features/build-tools/DependencyAnalyzer.module.css create mode 100644 src/features/build-tools/DependencyAnalyzer.test.tsx create mode 100644 src/features/build-tools/DependencyAnalyzer.tsx create mode 100644 src/features/build-tools/analyzer.test.ts create mode 100644 src/features/build-tools/analyzer.ts diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index db686b0f7..9e57b8b24 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -8,12 +8,13 @@ pub mod compile; pub mod gradle; pub mod maven; +pub mod pom_edit; mod process; mod sync; mod xml; pub use gradle::{GradleModel, GradleProject, GradleTask}; -pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject}; +pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission}; pub use sync::{SyncOptions, SyncProgress, sync_gradle, sync_maven}; use serde::{Deserialize, Serialize}; @@ -69,6 +70,10 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec = m.projects.iter().map(MavenProject::key).collect(); let mut stack: Vec<&MavenDependency> = m.projects.iter().flat_map(|p| &p.dependencies).collect(); while let Some(d) = stack.pop() { + // Omitted occurrences (conflict losers, duplicates) are not on any classpath. + if !d.resolved() { + continue; + } stack.extend(&d.children); let Some(version) = d.version.as_deref().filter(|v| !v.is_empty() && !v.contains("${")) else { continue; @@ -150,7 +155,19 @@ mod tests { artifact_id: "app".into(), dependencies: vec![ dep("me", "core", Some("1"), vec![]), - dep("org.b", "y", Some("2"), vec![dep("org.c", "z", Some("3"), vec![])]), + dep( + "org.b", + "y", + Some("2"), + vec![ + dep("org.c", "z", Some("3"), vec![]), + MavenDependency { + omitted: Some(Omission::Conflict), + conflict_with: Some("3".into()), + ..dep("org.c", "z", Some("1"), vec![]) + }, + ], + ), dep("org.d", "unresolved", Some("${v}"), vec![]), ], ..Default::default() diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 6a8c29c4e..b5e29650e 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -130,6 +130,37 @@ pub struct MavenDependency { pub optional: bool, /// Transitive dependencies (only after a sync). pub children: Vec, + /// Why Maven left this occurrence out of the resolved graph (`dependency:tree -Dverbose`). + #[serde(default)] + #[specta(optional)] + pub omitted: Option, + /// The version that won when `omitted` is `Conflict`. + #[serde(default)] + #[specta(optional)] + pub conflict_with: Option, + /// The version requested before `` replaced it. + #[serde(default)] + #[specta(optional)] + pub managed_from: Option, +} + +impl MavenDependency { + /// Part of the resolved graph (not a verbose-tree placeholder for an omitted occurrence). + pub fn resolved(&self) -> bool { + self.omitted.is_none() + } +} + +/// Why a dependency occurrence is not part of the resolved graph. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum Omission { + /// Another occurrence of the same version is nearer the root. + Duplicate, + /// Another version is nearer the root (or declared first) and wins. + Conflict, + /// The artifact depends on itself through this path. + Cycle, } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -871,7 +902,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option) -> MavenM classifier: d.classifier.clone(), scope: d.scope.clone().or_else(|| managed_scope(d)).unwrap_or_else(|| "compile".into()), optional: d.optional, - children: Vec::new(), + ..MavenDependency::default() }) .collect(); @@ -968,7 +999,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec, Vec Vec<(String, Vec, Vec Option { let s = s.trim(); - let (coords, extra) = match s.find(" (") { - Some(i) => (&s[..i], &s[i..]), - None => (s, ""), + let (s, wrapped) = match s.strip_prefix('(').and_then(|r| r.strip_suffix(')')) { + Some(inner) => (inner, true), + None => (s, false), }; + let (coords, notes) = s.split_once(' ').unwrap_or((s, "")); let parts: Vec<&str> = coords.split(':').collect(); let (g, a, t, c, v, scope) = match parts.as_slice() { [g, a, t, v] => (g, a, t, None, v, None), @@ -990,16 +1024,35 @@ fn parse_coords(s: &str) -> Option { [g, a, t, c, v, s] => (g, a, t, Some(*c), v, Some(*s)), _ => return None, }; - Some(MavenDependency { + let mut dep = MavenDependency { group_id: (*g).to_owned(), artifact_id: (*a).to_owned(), version: Some((*v).to_owned()), kind: (*t).to_owned(), classifier: c.map(|c| (*c).to_owned()), scope: scope.unwrap_or("compile").to_owned(), - optional: extra.contains("optional"), - children: Vec::new(), - }) + ..MavenDependency::default() + }; + let notes = notes.replace(['(', ')'], ";").replace(" - ", ";"); + for note in notes.split(';').map(|n| n.trim().trim_start_matches("- ").trim()) { + if note == "optional" { + dep.optional = true; + } else if let Some(winner) = note.strip_prefix("omitted for conflict with ") { + dep.omitted = Some(Omission::Conflict); + dep.conflict_with = Some(winner.trim().to_owned()); + } else if note == "omitted for duplicate" { + dep.omitted = Some(Omission::Duplicate); + } else if note == "omitted for cycle" { + dep.omitted = Some(Omission::Cycle); + } else if let Some(from) = note.strip_prefix("version managed from ") { + dep.managed_from = Some(from.trim().to_owned()); + } + } + // A parenthesised line is always an omitted occurrence, even with an unknown reason. + if wrapped && dep.omitted.is_none() { + dep.omitted = Some(Omission::Duplicate); + } + Some(dep) } fn insert_at(list: &mut Vec, depth: usize, dep: MavenDependency) { @@ -1327,4 +1380,82 @@ mod tests { ); assert_eq!(trees[2].1[0].scope, "compile"); } + + /// `mvn org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree -Dverbose` on a project + /// whose libraries ask for different commons-lang3 / commons-text / guava versions. + #[test] + fn verbose_dependency_tree_output() { + let trees = parse_dependency_tree(include_str!("../testdata/dependency-tree-verbose.txt")); + assert_eq!(trees.len(), 1); + let (key, deps) = &trees[0]; + assert_eq!(key, "demo:conflict-demo"); + let direct: Vec<&str> = deps.iter().map(|d| d.artifact_id.as_str()).collect(); + assert_eq!(direct, ["hamcrest-core", "commons-text", "commons-configuration2", "guava", "guice", "junit"]); + assert!(deps.iter().all(MavenDependency::resolved)); + + let config = &deps[2].children; + let lang3 = &config[0]; + assert_eq!( + (lang3.artifact_id.as_str(), lang3.version.as_deref(), lang3.omitted, lang3.conflict_with.as_deref()), + ("commons-lang3", Some("3.14.0"), Some(Omission::Conflict), Some("3.12.0")) + ); + assert_eq!(lang3.scope, "compile"); + let logging = &config[2]; + assert_eq!( + (logging.version.as_deref(), logging.managed_from.as_deref(), logging.omitted), + (Some("1.2"), Some("1.3.0"), None) + ); + let guava = &deps[4].children[2]; + assert_eq!((guava.version.as_deref(), guava.conflict_with.as_deref()), (Some("30.1-jre"), Some("32.1.3-jre"))); + let hamcrest = &deps[5].children[0]; + assert_eq!((hamcrest.omitted, hamcrest.scope.as_str()), (Some(Omission::Duplicate), "test")); + assert_eq!(deps[1].children[0].omitted, None, "the winning commons-lang3"); + } + + #[test] + fn verbose_annotations() { + // (line, omitted, conflict winner, managed from, optional) + type Case<'a> = (&'a str, Option, Option<&'a str>, Option<&'a str>, bool); + let cases: &[Case] = &[ + ("(g:a:jar:1:compile - omitted for cycle)", Some(Omission::Cycle), None, None, false), + ( + "(g:a:jar:1:compile - version managed from 0.9; omitted for duplicate)", + Some(Omission::Duplicate), + None, + Some("0.9"), + false, + ), + ("g:a:jar:1:compile (optional)", None, None, None, true), + ( + "(g:a:jar:1:runtime (optional) - omitted for conflict with 2)", + Some(Omission::Conflict), + Some("2"), + None, + true, + ), + ( + "(g:a:jar:1:compile - scope updated from runtime; omitted for duplicate)", + Some(Omission::Duplicate), + None, + None, + false, + ), + ( + "g:a:jar:1:compile (version managed from 0.9; scope managed from runtime)", + None, + None, + Some("0.9"), + false, + ), + ]; + for (line, omitted, winner, managed, optional) in cases { + let d = parse_coords(line).unwrap(); + assert_eq!( + (d.omitted, d.conflict_with.as_deref(), d.managed_from.as_deref(), d.optional), + (*omitted, *winner, *managed, *optional), + "{line}" + ); + assert_eq!((d.group_id.as_str(), d.version.as_deref()), ("g", Some("1")), "{line}"); + } + } } diff --git a/crates/ide-build/src/pom_edit.rs b/crates/ide-build/src/pom_edit.rs new file mode 100644 index 000000000..56db1c9b5 --- /dev/null +++ b/crates/ide-build/src/pom_edit.rs @@ -0,0 +1,336 @@ +//! Edits of `pom.xml` text for the dependency analyzer: finding where a direct +//! dependency is declared and adding an `` to it, in the style +//! (indentation, line endings, one-line elements) of the surrounding POM. + +use crate::maven::{self, MavenModel}; +use crate::xml::{self, Element}; +use ide_core::{Pos, Range, TextEdit}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::{HashMap, HashSet}; + +/// `groupId:artifactId` of a dependency. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactKey { + pub group_id: String, + pub artifact_id: String, +} + +impl ArtifactKey { + pub fn new(group_id: impl Into, artifact_id: impl Into) -> Self { + Self { group_id: group_id.into(), artifact_id: artifact_id.into() } + } +} + +impl std::fmt::Display for ArtifactKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.group_id, self.artifact_id) + } +} + +/// What adding an exclusion does to a POM. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Exclusion { + /// The edit that adds it. + Edit(TextEdit), + /// The dependency already excludes it (or everything, `*:*`). + Present, +} + +/// Properties the coordinates of a POM's dependencies may refer to. +fn properties(project: &Element) -> HashMap { + let mut props: HashMap = project + .child("properties") + .map(|p| p.children.iter().map(|c| (c.name.clone(), c.text.trim().to_owned())).collect()) + .unwrap_or_default(); + let own_or_parent = |name: &str| project.text_of(name).or_else(|| project.child("parent")?.text_of(name)); + for (key, value) in [ + ("project.groupId", own_or_parent("groupId")), + ("project.version", own_or_parent("version")), + ("project.artifactId", project.text_of("artifactId")), + ] { + if let Some(v) = value { + props.insert(key.into(), v); + } + } + props +} + +fn matches(e: &Element, key: &ArtifactKey, props: &HashMap) -> bool { + let text = |name: &str| e.text_of(name).map(|t| maven::interpolate(&t, props)); + text("groupId").as_deref() == Some(key.group_id.as_str()) + && text("artifactId").as_deref() == Some(key.artifact_id.as_str()) +} + +/// The `` declaring `key` in the project's ``, else in a +/// profile's (never ``, which declares no dependency). +fn find_dependency<'a>(project: &'a Element, key: &ArtifactKey) -> Option<&'a Element> { + let props = properties(project); + let own = project.list("dependencies", "dependency"); + let profiles = project.list("profiles", "profile").into_iter().flat_map(|p| p.list("dependencies", "dependency")); + own.into_iter().chain(profiles).find(|d| matches(d, key, &props)) +} + +/// 0-based line / UTF-16 column of byte offset `at`. +fn pos_at(text: &str, at: usize) -> Pos { + let before = &text[..at]; + let line_start = before.rfind('\n').map_or(0, |i| i + 1); + Pos::new(before.matches('\n').count() as u32, before[line_start..].encode_utf16().count() as u32) +} + +fn line_start(text: &str, at: usize) -> usize { + text[..at].rfind('\n').map_or(0, |i| i + 1) +} + +/// The whitespace before `at` on its line, or `None` when other text precedes it there. +fn indent_before(text: &str, at: usize) -> Option<&str> { + let ws = &text[line_start(text, at)..at]; + ws.chars().all(|c| c == ' ' || c == '\t').then_some(ws) +} + +/// One level of indentation: what the first child of `e` adds to `e`'s, else two spaces. +fn indent_unit(text: &str, e: &Element) -> String { + let outer = indent_before(text, e.start); + let inner = e.children.first().and_then(|c| indent_before(text, c.start)); + match (outer, inner) { + (Some(o), Some(i)) if i.len() > o.len() && i.starts_with(o) => i[o.len()..].to_owned(), + (_, Some(i)) if i.starts_with('\t') => "\t".into(), + _ => " ".into(), + } +} + +fn escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<") +} + +/// Range of the `` of the dependency declaring `key` in `text` (a POM), or of +/// the whole `` when it has none; `None` when the POM does not declare it. +pub fn declaration(text: &str, key: &ArtifactKey) -> Option { + let project = xml::parse(text)?; + let dep = find_dependency(&project, key)?; + let target = dep.child("artifactId").unwrap_or(dep); + Some(Range::new(pos_at(text, target.start), pos_at(text, target.end))) +} + +/// Adds `exclude` to the `` of the dependency declaring `dependency` in `text` +/// (a POM); `None` when the POM does not declare it. +pub fn add_exclusion(text: &str, dependency: &ArtifactKey, exclude: &ArtifactKey) -> Option { + let project = xml::parse(text)?; + let dep = find_dependency(&project, dependency)?; + let nl = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let unit = indent_unit(text, dep); + let (g, a) = (escape(&exclude.group_id), escape(&exclude.artifact_id)); + + // Insert `block` (lines relative to `parent`'s indentation + one level) before `parent`'s + // end tag; on one line (`compact`) when `parent` is not laid out one element per line. + let insert = |parent: &Element, lines: &[(usize, String)], compact: String| { + let close_on_own_line = indent_before(text, parent.inner_end).is_some() && parent.inner_end < parent.end; + let (at, new_text) = match indent_before(text, parent.start) { + Some(base) if close_on_own_line => { + let mut out = String::new(); + for (depth, line) in lines { + out.push_str(base); + out.push_str(&unit.repeat(depth + 1)); + out.push_str(line); + out.push_str(nl); + } + (line_start(text, parent.inner_end), out) + } + _ => (parent.inner_end, compact), + }; + let p = pos_at(text, at); + Exclusion::Edit(TextEdit { range: Range::point(p), new_text }) + }; + let exclusion_lines = |depth: usize| { + vec![ + (depth, "".to_owned()), + (depth + 1, format!("{g}")), + (depth + 1, format!("{a}")), + (depth, "".to_owned()), + ] + }; + let compact = format!("{g}{a}"); + + if let Some(exclusions) = dep.child("exclusions") { + let props = properties(&project); + let wildcard = ArtifactKey::new("*", "*"); + let any_artifact = ArtifactKey::new(exclude.group_id.clone(), "*"); + if exclusions + .children_named("exclusion") + .any(|e| [exclude, &wildcard, &any_artifact].iter().any(|k| matches(e, k, &props))) + { + return Some(Exclusion::Present); + } + return Some(insert(exclusions, &exclusion_lines(0), compact)); + } + let mut lines = vec![(0, "".to_owned())]; + lines.extend(exclusion_lines(1)); + lines.push((0, "".to_owned())); + Some(insert(dep, &lines, format!("{compact}"))) +} + +/// The POMs that may declare a dependency of the module `pom` (project-relative), nearest +/// first: the module's own, then its parents that are part of the project. +pub fn declaring_poms(model: &MavenModel, pom: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut current = model.projects.iter().find(|p| p.pom == pom); + while let Some(p) = current { + if !seen.insert(p.pom.clone()) { + break; + } + out.push(p.pom.clone()); + current = p.parent.as_deref().and_then(|coords| { + model.projects.iter().find(|q| format!("{}:{}:{}", q.group_id, q.artifact_id, q.version) == coords) + }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn apply(text: &str, edit: &TextEdit) -> String { + let offset = |p: Pos| { + let start: usize = text.split_inclusive('\n').take(p.line as usize).map(str::len).sum(); + let line = &text[start..]; + start + line.char_indices().nth(p.col as usize).map_or(line.len(), |(i, _)| i) + }; + let (from, to) = (offset(edit.range.start), offset(edit.range.end)); + format!("{}{}{}", &text[..from], edit.new_text, &text[to..]) + } + + fn exclude(text: &str, dep: (&str, &str), excl: (&str, &str)) -> Option { + match add_exclusion(text, &ArtifactKey::new(dep.0, dep.1), &ArtifactKey::new(excl.0, excl.1))? { + Exclusion::Edit(e) => Some(apply(text, &e)), + Exclusion::Present => Some(text.to_owned()), + } + } + + const POM: &str = " + demo + + com.google.inject + + + + + com.google.inject + guice + 5.1.0 + + + + + + ${guice.group} + guice + + + +"; + + #[test] + fn adds_exclusions_to_the_declaration_not_the_managed_entry() { + let out = exclude(POM, ("com.google.inject", "guice"), ("com.google.guava", "guava")).unwrap(); + let expected = POM.replace( + " guice\n \n ", + " guice + + + com.google.guava + guava + + + + ", + ); + assert_eq!(out, expected); + // A second exclusion joins the first; excluding it again changes nothing. + let twice = exclude(&out, ("com.google.inject", "guice"), ("aopalliance", "aopalliance")).unwrap(); + assert!(twice.contains( + " + + aopalliance + aopalliance + + " + )); + assert_eq!(exclude(&twice, ("com.google.inject", "guice"), ("aopalliance", "aopalliance")).unwrap(), twice); + } + + #[test] + fn keeps_tabs_crlf_and_one_line_dependencies() { + let tabs = "\r\n\t\r\n\t\t\r\n\t\t\tg\r\n\t\t\ta\r\n\t\t\r\n\t\r\n\r\n"; + let out = exclude(tabs, ("g", "a"), ("x", "y")).unwrap(); + assert!(out.contains("a\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tx\r\n")); + assert!(out.contains("\t\t\t\r\n\t\t")); + + let inline = "ga"; + assert_eq!( + exclude(inline, ("g", "a"), ("x", "y")).unwrap(), + "gaxy" + ); + } + + #[test] + fn wildcards_profiles_and_missing_declarations() { + let pom = " + + + + + ga + x* + + + + +"; + let key = ArtifactKey::new("g", "a"); + assert_eq!(add_exclusion(pom, &key, &ArtifactKey::new("x", "y")), Some(Exclusion::Present)); + let out = exclude(pom, ("g", "a"), ("z", "w")).unwrap(); + assert!(out.contains( + "zw" + )); + assert_eq!(add_exclusion(pom, &ArtifactKey::new("g", "other"), &key), None); + assert_eq!(add_exclusion("not xml", &key, &key), None); + } + + #[test] + fn declaration_ranges_use_utf16_columns() { + let pom = "\n ga\n"; + let r = declaration(pom, &ArtifactKey::new("g", "a")).unwrap(); + assert_eq!(r, Range::new(Pos::new(1, 34), Pos::new(1, 60))); + let first = "ga"; + let r = declaration(&format!("{first}"), &ArtifactKey::new("g", "a")) + .unwrap(); + let start = first.find("").unwrap(); + assert_eq!(r.start.col as usize, first[..start].encode_utf16().count()); + } + + #[test] + fn declaring_poms_follow_parents_inside_the_project() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let write = |rel: &str, text: &str| { + let p = root.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); + }; + write( + "pom.xml", + "dparent1pomapp", + ); + write( + "app/pom.xml", + "dparent1app", + ); + let model = maven::load_with_repo(root, None); + assert_eq!(declaring_poms(&model, "app/pom.xml"), ["app/pom.xml", "pom.xml"]); + assert_eq!(declaring_poms(&model, "pom.xml"), ["pom.xml"]); + assert!(declaring_poms(&model, "missing/pom.xml").is_empty()); + } +} diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index 2470c4254..d29c1d197 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -1,5 +1,5 @@ //! Reading the effective build from Maven / Gradle itself ("Reload" in the tool -//! windows): `mvn help:effective-pom dependency:tree` and `gradle tasks --all`, +//! windows): `mvn help:effective-pom dependency:tree -Dverbose` and `gradle tasks --all`, //! run in the background with progress and cancellation. Results are cached //! per project (`maven.json`, `gradle.json` in the cache directory) and merged //! into the static model by [`crate::load`]; a changed build file marks them stale. @@ -49,6 +49,9 @@ struct MavenCache { at_ms: f64, error: Option, projects: Vec, + /// The trees list omitted occurrences (`-Dverbose`); older caches are reloaded. + #[serde(default)] + verbose: bool, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -60,6 +63,21 @@ struct GradleCache { } const MAVEN_CACHE: &str = "maven.json"; + +/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven +/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2. +/// Offline, the project's own plugin version is used instead, since this one may not be cached. +const DEPENDENCY_TREE: &str = "org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree"; + +/// The goals that read the effective POM into `effective` and the verbose dependency trees. +fn maven_sync_goals(effective: &Path, offline: bool) -> Vec { + vec![ + "help:effective-pom".into(), + format!("-Doutput={}", effective.to_string_lossy()), + if offline { "dependency:tree".into() } else { DEPENDENCY_TREE.into() }, + "-Dverbose".into(), + ] +} const GRADLE_CACHE: &str = "gradle.json"; fn read_cache(dir: &Path, name: &str) -> Option { @@ -110,7 +128,7 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) } model.synced_at = Some(cache.at_ms); model.sync_error = cache.error; - model.stale = cache.hash != hash_files(&maven::build_files(root, model)); + model.stale = !cache.verbose || cache.hash != hash_files(&maven::build_files(root, model)); } /// Merges the cached task list into `model`. @@ -182,7 +200,8 @@ pub fn sync_maven( ) -> IdeResult<()> { let model = maven::load(root); std::fs::create_dir_all(cache_dir)?; - let mut cache = MavenCache { hash: hash_files(&maven::build_files(root, &model)), ..MavenCache::default() }; + let mut cache = + MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() }; let mut errors: Vec = Vec::new(); for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() { let effective = cache_dir.join(format!("effective-pom-{n}.xml")); @@ -195,9 +214,7 @@ pub fn sync_maven( if !options.profiles.is_empty() { args.push(format!("-P{}", options.profiles.join(","))); } - args.push("help:effective-pom".into()); - args.push(format!("-Doutput={}", effective.to_string_lossy())); - args.push("dependency:tree".into()); + args.extend(maven_sync_goals(&effective, options.offline)); progress(SyncProgress { message: format!("Reading {}", top.display_name), fraction: None }); let command = options.maven_command.as_deref().unwrap_or(&top.command); let out = run(command, &args, &root.join(&top.dir), &options.env, cancel, |line| { @@ -277,6 +294,7 @@ mod tests { hash: hash_files(&maven::build_files(&root, &model)), at_ms: 5.0, error: None, + verbose: true, projects: vec![CachedMavenProject { key: "g:a".into(), plugins: vec![maven::plugin( @@ -327,6 +345,25 @@ mod tests { assert!(!model.stale); } + #[test] + fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() { + let online = maven_sync_goals(Path::new("e.xml"), false); + assert_eq!(online, ["help:effective-pom", "-Doutput=e.xml", DEPENDENCY_TREE, "-Dverbose"]); + let offline = maven_sync_goals(Path::new("e.xml"), true); + assert_eq!(offline[2], "dependency:tree"); + assert!(offline.contains(&"-Dverbose".to_owned())); + } + + #[test] + fn cache_written_before_omissions_existed_still_loads() { + let json = r#"{"hash":"x","at_ms":1.0,"error":null,"projects":[{"key":"g:a","plugins":[], + "dependencies":[{"groupId":"g","artifactId":"d","version":"1","type":"jar","classifier":null, + "scope":"compile","optional":false,"children":[]}]}]}"#; + let cache: MavenCache = serde_json::from_str(json).unwrap(); + assert!(cache.projects[0].dependencies[0].resolved()); + assert!(!cache.verbose, "reloaded to read the omitted occurrences"); + } + #[test] fn progress_and_error_lines() { assert_eq!( diff --git a/crates/ide-build/src/xml.rs b/crates/ide-build/src/xml.rs index 38f96ed10..86dec9db2 100644 --- a/crates/ide-build/src/xml.rs +++ b/crates/ide-build/src/xml.rs @@ -1,12 +1,19 @@ //! A small, forgiving XML element reader for POM files: elements, text and //! CDATA; attributes, comments, processing instructions and DOCTYPEs are //! skipped. Namespace prefixes are dropped (`` → `project`). +//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Element { pub name: String, pub text: String, pub children: Vec, + /// Byte offset of the start tag's `<`. + pub start: usize, + /// Byte offset of the end tag's `<` (`end` for an empty or unclosed element). + pub inner_end: usize, + /// Byte offset just past the end tag (past the start tag for ``). + pub end: usize, } impl Element { @@ -76,6 +83,7 @@ fn local(name: &str) -> String { pub fn parse(text: &str) -> Option { let mut stack: Vec = vec![Element::default()]; let mut rest = text; + let at = |rest: &str| text.len() - rest.len(); while !rest.is_empty() { let Some(lt) = rest.find('<') else { decode_entities(rest, &mut stack.last_mut()?.text); @@ -98,13 +106,19 @@ pub fn parse(text: &str) -> Option { } else if let Some(body) = tag.strip_prefix("').unwrap_or(body.len()); let name = local(body[..end].trim()); - if let Some(at) = stack.iter().rposition(|e| e.name == name).filter(|&i| i > 0) { - while stack.len() > at { - let done = stack.pop()?; + let close_start = at(tag); + rest = body.get(end + 1..).unwrap_or(""); + let close_end = at(rest); + if let Some(i) = stack.iter().rposition(|e| e.name == name).filter(|&i| i > 0) { + while stack.len() > i { + let mut done = stack.pop()?; + // Elements closed implicitly by an outer end tag end where it starts. + let matched = stack.len() == i; + done.inner_end = close_start; + done.end = if matched { close_end } else { close_start }; stack.last_mut()?.children.push(done); } } - rest = body.get(end + 1..).unwrap_or(""); } else { // Start or empty tag; skip quoted attribute values when looking for `>`. let bytes = tag.as_bytes(); @@ -124,17 +138,22 @@ pub fn parse(text: &str) -> Option { let empty = inner.ends_with('/'); let inner = inner.trim_end_matches('/'); let name_end = inner.find(|c: char| c.is_whitespace()).unwrap_or(inner.len()); - let el = Element { name: local(&inner[..name_end]), ..Element::default() }; + let start = at(tag); + rest = tag.get(i + 1..).unwrap_or(""); + let after = at(rest); + let el = + Element { name: local(&inner[..name_end]), start, inner_end: after, end: after, ..Element::default() }; if empty { stack.last_mut()?.children.push(el); } else { stack.push(el); } - rest = tag.get(i + 1..).unwrap_or(""); } } while stack.len() > 1 { - let done = stack.pop()?; + let mut done = stack.pop()?; + done.inner_end = text.len(); + done.end = text.len(); stack.last_mut()?.children.push(done); } stack.pop()?.children.into_iter().next() @@ -167,6 +186,22 @@ mod tests { assert_eq!(root.children_named("name").count(), 1); } + #[test] + fn element_offsets() { + let doc = "\n x\n"; + let root = parse(doc).unwrap(); + assert_eq!((root.start, root.inner_end, root.end), (0, doc.len() - 4, doc.len())); + let b = root.child("b").unwrap(); + assert_eq!(&doc[b.start..b.end], "x"); + assert_eq!(&doc[b.start..b.inner_end], "x"); + let c = root.child("c").unwrap(); + assert_eq!(&doc[c.start..c.end], ""); + let broken = parse("x").unwrap(); + let b = broken.child("b").unwrap(); + assert_eq!((b.inner_end, b.end), (7, 7)); + assert_eq!(broken.end, 11); + } + #[test] fn tolerates_broken_documents() { let root = parse("xy").unwrap(); diff --git a/crates/ide-build/testdata/dependency-tree-verbose.txt b/crates/ide-build/testdata/dependency-tree-verbose.txt new file mode 100644 index 000000000..217b46a3f --- /dev/null +++ b/crates/ide-build/testdata/dependency-tree-verbose.txt @@ -0,0 +1,24 @@ +[INFO] --- dependency:3.8.1:tree (default-cli) @ conflict-demo --- +[INFO] demo:conflict-demo:jar:1.0-SNAPSHOT +[INFO] +- org.hamcrest:hamcrest-core:jar:1.3:compile +[INFO] +- org.apache.commons:commons-text:jar:1.10.0:compile +[INFO] | \- org.apache.commons:commons-lang3:jar:3.12.0:compile +[INFO] +- org.apache.commons:commons-configuration2:jar:2.10.1:compile +[INFO] | +- (org.apache.commons:commons-lang3:jar:3.14.0:compile - omitted for conflict with 3.12.0) +[INFO] | +- (org.apache.commons:commons-text:jar:1.11.0:compile - omitted for conflict with 1.10.0) +[INFO] | \- commons-logging:commons-logging:jar:1.2:compile (version managed from 1.3.0) +[INFO] +- com.google.guava:guava:jar:32.1.3-jre:compile +[INFO] | +- com.google.guava:failureaccess:jar:1.0.1:compile +[INFO] | +- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile +[INFO] | +- com.google.code.findbugs:jsr305:jar:3.0.2:compile +[INFO] | +- org.checkerframework:checker-qual:jar:3.37.0:compile +[INFO] | +- com.google.errorprone:error_prone_annotations:jar:2.21.1:compile +[INFO] | \- com.google.j2objc:j2objc-annotations:jar:2.8:compile +[INFO] +- com.google.inject:guice:jar:5.1.0:compile +[INFO] | +- javax.inject:javax.inject:jar:1:compile +[INFO] | +- aopalliance:aopalliance:jar:1.0:compile +[INFO] | \- (com.google.guava:guava:jar:30.1-jre:compile - omitted for conflict with 32.1.3-jre) +[INFO] \- junit:junit:jar:4.13.2:test +[INFO] \- (org.hamcrest:hamcrest-core:jar:1.3:test - omitted for duplicate) +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index 05bb1336a..30d0e2bb0 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -1,11 +1,12 @@ //! Maven / Gradle tool windows (the build model and its background sync) and //! Build › Build Project (compiling with the project's build system). -use crate::state::AppState; +use crate::state::{AppState, apply_workspace_edit}; use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult}; +use ide_build::pom_edit::{ArtifactKey, Exclusion}; use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress}; use ide_core::settings::Settings; -use ide_core::{IdeError, IdeEvent, IdeResult, RequestId}; +use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Range, RequestId, WorkspaceEdit}; use std::collections::BTreeMap; use std::path::Path; use tauri::ipc::Channel; @@ -126,6 +127,74 @@ pub async fn build_sync( result? } +/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the +/// module's own, else its nearest parent in the project; its path, text and declaration. +fn declaring_pom( + project: &crate::state::Project, + pom: &str, + dependency: &ArtifactKey, +) -> IdeResult<(std::path::PathBuf, String, Range)> { + let model = ide_build::maven::load(&project.root); + for rel in ide_build::pom_edit::declaring_poms(&model, pom) { + let path = project.root.join(&rel); + let text = project.text_of(&path)?; + if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) { + return Ok((path, text, range)); + } + } + Err(IdeError::not_found(format!( + "{dependency} is not declared in a pom.xml of this project (it may come from an imported BOM or an external parent POM)" + ))) +} + +/// Where the direct dependency `dependency` of the Maven module `pom` is declared. +#[tauri::command] +#[specta::specta] +pub async fn maven_dependency_declaration( + window: Window, + state: State<'_, AppState>, + pom: String, + dependency: ArtifactKey, +) -> IdeResult { + let project = state.project(window.label())?; + tauri::async_runtime::spawn_blocking(move || { + let (path, _, range) = declaring_pom(&project, &pom, &dependency)?; + Ok(Location::file(path, range)) + }) + .await + .map_err(join_err)? +} + +/// Adds an `` of `exclusion` to the declaration of the direct dependency +/// `dependency` of the Maven module `pom`, through the open document when there is one +/// (saved). Returns the declaration; nothing changes when it already excludes it. +#[tauri::command] +#[specta::specta] +pub async fn maven_exclude_dependency( + window: Window, + state: State<'_, AppState>, + pom: String, + dependency: ArtifactKey, + exclusion: ArtifactKey, +) -> IdeResult { + let project = state.project(window.label())?; + tauri::async_runtime::spawn_blocking(move || { + let (path, text, range) = declaring_pom(&project, &pom, &dependency)?; + match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) { + Some(Exclusion::Edit(edit)) => { + let edit = + WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] }; + apply_workspace_edit(&project.documents, &edit)?; + } + Some(Exclusion::Present) => {} + None => return Err(IdeError::not_found(format!("{dependency} is not declared in {}", path.display()))), + } + Ok(Location::file(path, range)) + }) + .await + .map_err(join_err)? +} + /// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the /// module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable /// with `cancel_request(request)`. Diagnostics of `clear` (the files of the previous build) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 45c4f0449..cf554d86a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -246,6 +246,8 @@ pub fn builder() -> tauri_specta::Builder { run::run_test_reports, build::build_model, build::build_sync, + build::maven_dependency_declaration, + build::maven_exclude_dependency, build::build_run, java::java_resolve_launch, java::java_debug_server, diff --git a/src/bindings.ts b/src/bindings.ts index d9149fa57..b9af2a9b3 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -478,6 +478,14 @@ 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) => typedError(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, onProgress })), + /** Where the direct dependency `dependency` of the Maven module `pom` is declared. */ + mavenDependencyDeclaration: (pom: string, dependency: ArtifactKey) => typedError(__TAURI_INVOKE("maven_dependency_declaration", { pom, dependency })), + /** + * Adds an `` of `exclusion` to the declaration of the direct dependency + * `dependency` of the Maven module `pom`, through the open document when there is one + * (saved). Returns the declaration; nothing changes when it already excludes it. + */ + mavenExcludeDependency: (pom: string, dependency: ArtifactKey, exclusion: ArtifactKey) => typedError(__TAURI_INVOKE("maven_exclude_dependency", { pom, dependency, exclusion })), /** * Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the * module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable @@ -1097,6 +1105,12 @@ export type ApplyOutcome = { conflicts: string[], }; +/** `groupId:artifactId` of a dependency. */ +export type ArtifactKey = { + groupId: string, + artifactId: string, +}; + export type AttachKind = "java" | "node" | "python" | "go" | "native"; export type AttachableProcess = { @@ -3507,6 +3521,12 @@ export type MavenDependency = { optional: boolean, /** Transitive dependencies (only after a sync). */ children: MavenDependency[], + /** Why Maven left this occurrence out of the resolved graph (`dependency:tree -Dverbose`). */ + omitted?: Omission | null, + /** The version that won when `omitted` is `Conflict`. */ + conflictWith?: string | null, + /** The version requested before `` replaced it. */ + managedFrom?: string | null, }; export type MavenModel = { @@ -3718,6 +3738,15 @@ export type NotificationSettings = { export type NotifyLevel = "info" | "warning" | "error"; +/** Why a dependency occurrence is not part of the resolved graph. */ +export type Omission = +/** Another occurrence of the same version is nearer the root. */ +"duplicate" | +/** Another version is nearer the root (or declared first) and wins. */ +"conflict" | +/** The artifact depends on itself through this path. */ +"cycle"; + /** How `open_project_window` satisfied the request. */ export type OpenOutcome = /** Already open in `label`; that window was focused. */ diff --git a/src/features/build-tools/BuildToolView.tsx b/src/features/build-tools/BuildToolView.tsx index f4b64d6d4..8f6e98d1c 100644 --- a/src/features/build-tools/BuildToolView.tsx +++ b/src/features/build-tools/BuildToolView.tsx @@ -152,6 +152,7 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[] action={`${tool}.toggleOffline`} pressed={maven ? settings.maven.offline : settings.gradle.offline} /> + {maven && } {maven && ( * { + flex: 1; + min-width: 0; +} +.panel { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} +.panel > * { + flex: 1; + min-height: 0; +} + +.row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; + white-space: nowrap; +} +.icon { + flex: none; +} +.label { + flex: 0 1 auto; + min-width: 24px; + overflow: hidden; + text-overflow: ellipsis; + color: var(--fg); +} +.selectedLabel { + color: var(--fg-strong); +} +.version { + flex: none; + font-family: var(--font-mono); + font-size: var(--fs-small); + color: var(--fg-secondary); +} +.detail { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--fg-muted); + font-size: var(--fs-small); +} +.conflict { + color: var(--warning); +} +.omitted .label, +.omitted .version { + color: var(--fg-muted); +} + +.usages { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} +.usagesHeader { + flex: none; + display: flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 0 10px; + border-bottom: 1px solid var(--border); +} +.usagesTitle { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + color: var(--fg-strong); +} +.conflictNote { + flex: none; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + font-size: var(--fs-small); + color: var(--fg-secondary); + background: var(--bg-feedback-warning); + border-bottom: 1px solid var(--border-feedback-warning); +} +.usageList { + flex: 1; + min-height: 0; + overflow: auto; + padding: 4px 0; +} +.usage { + padding: 6px 10px 8px; + border-bottom: 1px solid var(--border); +} +.usageHeader { + display: flex; + align-items: center; + gap: 6px; + min-height: 26px; +} +.usageVersion { + flex: none; + font-family: var(--font-mono); + color: var(--fg); +} +.usageStatus { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--fs-small); + color: var(--fg-muted); +} +.spacer { + flex: 1; +} +.path { + margin: 4px 0 0; + padding: 0; + list-style: none; +} +.step { + display: flex; + align-items: center; + gap: 6px; + min-height: 22px; + white-space: nowrap; +} +.stepLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--fg-secondary); +} +.stepTarget { + color: var(--fg-strong); +} diff --git a/src/features/build-tools/DependencyAnalyzer.test.tsx b/src/features/build-tools/DependencyAnalyzer.test.tsx new file mode 100644 index 000000000..deb09ba03 --- /dev/null +++ b/src/features/build-tools/DependencyAnalyzer.test.tsx @@ -0,0 +1,118 @@ +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 { 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() })); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + call: (value: unknown) => value, + commands: { ...real.commands, mavenExcludeDependency: ipc.exclude, mavenDependencyDeclaration: ipc.declaration }, + }; +}); + +function dep(coords: string, extra: Partial = {}, children: MavenDependency[] = []): MavenDependency { + const [groupId, artifactId, version] = coords.split(":"); + return { + groupId: groupId!, + artifactId: artifactId!, + version: version!, + type: "jar", + classifier: null, + scope: "compile", + optional: false, + children, + ...extra, + }; +} + +const model: BuildModel = { + maven: { + projects: [ + { + pom: "pom.xml", + dir: "", + groupId: "demo", + artifactId: "conflict-demo", + version: "1.0", + packaging: "jar", + displayName: "conflict-demo", + parent: null, + modules: [], + aggregated: false, + plugins: [], + dependencies: [ + dep("com.google.guava:guava:32.1.3-jre"), + dep("com.google.inject:guice:5.1.0", {}, [ + dep("com.google.guava:guava:30.1-jre", { omitted: "conflict", conflictWith: "32.1.3-jre" }), + ]), + ], + profiles: [], + javaVersion: 17, + command: "mvn", + options: [], + env: {}, + }, + ], + profiles: [], + syncedAt: 1, + stale: false, + syncError: null, + }, + gradle: null, +}; + +describe("dependency analyzer", () => { + let dispose: () => void = () => {}; + const openInput = vi.fn(() => Promise.resolve()); + const openFile = vi.fn(() => Promise.resolve()); + beforeEach(() => { + provideEditorService({ openInput, openFile, activePath: () => null } as unknown as EditorService); + setContext("projectOpen", true); + dispose = activate(); + act(() => useBuild.setState({ root: "/p", model })); + }); + afterEach(() => { + dispose(); + setContext("projectOpen", false); + vi.clearAllMocks(); + }); + + 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"); + 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" } }); + } + expect(menuGroupItems("editor", { paths: ["/p/other/pom.xml"] })).toEqual([]); + expect(menuGroupItems("editor", { paths: ["/p/src/Main.java"] })).toEqual([]); + }); + + it("marks conflicts, shows their usages and excludes through the direct dependency", async () => { + ipc.exclude.mockResolvedValue({ path: "/p/pom.xml", range: {} }); + render(); + fireEvent.click(screen.getByRole("checkbox", { name: "Show conflicts only" })); + expect(screen.queryByText("com.google.inject:guice")).toBeNull(); + fireEvent.mouseDown(screen.getByText("com.google.guava:guava").closest("[role=treeitem]")!, { button: 0 }); + expect(screen.getByText("omitted for conflict with 32.1.3-jre")).toBeTruthy(); + expect(screen.getByText("com.google.inject:guice:5.1.0")).toBeTruthy(); + // Only the transitive usage can be excluded. + const exclude = screen.getAllByRole("button", { name: "Exclude" }); + expect(exclude).toHaveLength(1); + await act(async () => fireEvent.click(exclude[0]!)); + expect(ipc.exclude).toHaveBeenCalledWith( + "pom.xml", + { groupId: "com.google.inject", artifactId: "guice" }, + { groupId: "com.google.guava", artifactId: "guava" }, + ); + }); +}); diff --git a/src/features/build-tools/DependencyAnalyzer.tsx b/src/features/build-tools/DependencyAnalyzer.tsx new file mode 100644 index 000000000..ac769f0ba --- /dev/null +++ b/src/features/build-tools/DependencyAnalyzer.tsx @@ -0,0 +1,420 @@ +/** + * 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. + */ +import { useMemo, useState, type MouseEvent } from "react"; +import clsx from "clsx"; +import { call, commands, type MavenDependency, type MavenProject } from "@/platform/ipc"; +import { useTx, useTxTemplate } from "@/platform/i18n"; +import { editorService, notifications, type EditorInput } from "@/workbench/contributions"; +import { + Button, + Checkbox, + EmptyState, + Icon, + IconButton, + SearchField, + Select, + SplitGroup, + SplitHandle, + SplitPanel, + Spinner, + T, + Tree, + copyText, + showContextMenu, + type MenuItem, + type SelectOption, +} from "@/ui"; +import { + analyze, + artifactKey, + coordinates, + exclusionFor, + filterArtifacts, + filterTree, + scopesOf, + sortedUsages, + statusText, + usageStatus, + type AnalyzedArtifact, + type DependencyTreeNode, + type DependencyUsage, +} from "./analyzer"; +import { syncTool, useAnalyzer, useBuild } from "./store"; +import styles from "./DependencyAnalyzer.module.css"; + +type Mode = "list" | "tree"; +type Node = + | { kind: "artifact"; key: string; artifact: AnalyzedArtifact } + | { kind: "node"; key: string; node: DependencyTreeNode; children: Node[] }; + +const ALL_SCOPES = "*"; + +function errorText(e: unknown) { + return e instanceof Error ? e.message : String(e); +} + +async function jumpToDeclaration(pom: string, d: MavenDependency) { + try { + const loc = await call(commands.mavenDependencyDeclaration(pom, { groupId: d.groupId, artifactId: d.artifactId })); + const { start, end } = loc.range; + await editorService().openFile(loc.path, { + focus: true, + selection: { line: start.line, col: start.col, endLine: end.line, endCol: end.col }, + }); + } catch (e) { + notifications().notify({ + level: "warning", + group: "tools", + title: "Cannot find the declaration", + body: errorText(e), + }); + } +} + +async function exclude(pom: string, usage: DependencyUsage) { + const target = exclusionFor(usage); + if (!target) return; + const what = `${target.exclusion.groupId}:${target.exclusion.artifactId}`; + try { + await call(commands.mavenExcludeDependency(pom, target.dependency, target.exclusion)); + } catch (e) { + notifications().notify({ level: "error", group: "tools", title: `Cannot exclude ${what}`, body: errorText(e) }); + return; + } + await syncTool("maven"); +} + +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 ArtifactRow({ artifact, selected }: { artifact: AnalyzedArtifact; selected: boolean }) { + const tx = useTx(); + return ( + + + + {artifact.key} + + {artifact.version ?? tx("omitted")} + {artifact.conflict && ( + {artifact.requested.filter((v) => v !== artifact.version).join(", ")} + )} + {artifact.scope !== "compile" && ({artifact.scope})} + + ); +} + +function TreeRow({ node, conflict, selected }: { node: DependencyTreeNode; conflict: boolean; selected: boolean }) { + const tx = useTxTemplate(); + const d = node.dependency; + const status = statusText(usageStatus(d)); + return ( + + + + {artifactKey(d)} + + {versionLabel(d)} + {d.scope !== "compile" && ({d.scope})} + {status && {tx(status)}} + + ); +} + +function UsageBlock({ usage, module, pom }: { usage: DependencyUsage; module: MavenProject; pom: string }) { + const tx = useTxTemplate(); + const status = usageStatus(usage.dependency); + const text = statusText(status); + const direct = usage.path[0]!; + const canExclude = !!exclusionFor(usage); + return ( +
+
+ + {status.version} + + {text ? tx(text) : tx("resolved")} + + + {canExclude && ( + + )} + void jumpToDeclaration(pom, direct)} + /> +
+
    +
  1. + + {module.displayName} +
  2. + {usage.path.map((d, i) => ( +
  3. + + + {coordinates(d)} + + {d.scope !== "compile" && ({d.scope})} +
  4. + ))} +
+
+ ); +} + +function UsagesPane({ + artifact, + module, + pom, +}: { + artifact: AnalyzedArtifact | null; + module: MavenProject; + pom: string; +}) { + if (!artifact) return ; + const usages = sortedUsages(artifact.usages); + const coords = coordinates({ ...artifact, version: artifact.version }); + return ( +
+
+ {coords} + void copyText(coords)} /> +
+ {artifact.conflict && ( +
+ + + Versions requested: {artifact.requested.join(", ")} + +
+ )} +
+ {usages.map((u, i) => ( + + ))} +
+
+ ); +} + +export function DependencyAnalyzer(_props: { input: EditorInput }) { + const tx = useTx(); + const maven = useBuild((s) => s.model?.maven ?? null); + const syncing = useBuild((s) => s.syncing.maven); + const chosen = useAnalyzer((s) => s.pom); + const [query, setQuery] = useState(""); + const [conflictsOnly, setConflictsOnly] = useState(false); + const [scope, setScope] = useState(ALL_SCOPES); + const [mode, setMode] = useState("list"); + const [selectedKey, setSelectedKey] = useState(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 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], + ); + const listed = useMemo(() => filterArtifacts(artifacts, filter), [artifacts, filter]); + const tree = useMemo(() => filterTree(module?.dependencies ?? [], filter, conflicted), [module, filter, conflicted]); + const roots: Node[] = useMemo(() => { + const toNode = (node: DependencyTreeNode): Node => ({ + kind: "node", + key: `n:${node.key}`, + node, + children: node.children.map(toNode), + }); + return mode === "list" + ? listed.map((artifact) => ({ kind: "artifact", key: `a:${artifact.key}`, artifact })) + : tree.map(toNode); + }, [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 [manual, setManual] = useState<{ id: string; keys: Set } | null>(null); + const expanded = useMemo(() => { + if (manual?.id === filterId) return manual.keys; + const keys = new Set(); + if (filter.conflictsOnly || filter.scope != null || filter.query.trim()) { + const walk = (nodes: readonly Node[]) => { + for (const n of nodes) + if (n.kind === "node" && n.children.length) { + keys.add(n.key); + walk(n.children); + } + }; + walk(roots); + } + return keys; + }, [manual, filterId, filter, roots]); + + const selected = artifacts.find((a) => a.key === selectedKey) ?? null; + const scopeOptions: SelectOption[] = [ + { value: ALL_SCOPES, label: tx("All scopes") }, + ...scopesOf(artifacts).map((s) => ({ value: s, label: s })), + ]; + + 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. */ + const usageOf = (n: Node): DependencyUsage | undefined => { + if (n.kind === "node") + return artifacts + .find((a) => a.key === nodeArtifactKey(n)) + ?.usages.find((u) => u.dependency === n.node.dependency); + return n.artifact.usages.find((u) => u.path.length === 1) ?? n.artifact.usages[0]; + }; + + const contextMenu = (n: Node | null, _sel: Set, e: MouseEvent) => { + if (!n || !pom) 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) }); + items.push({ label: "Copy Coordinates", icon: "copy", run: () => void copyText(coordinates(usage.dependency)) }); + showContextMenu({ x: e.clientX, y: e.clientY }, items); + }; + + if (!maven) return ; + + return ( +
+
+ + + setMode("list")} + /> + setMode("tree")} + /> + + {syncing ? ( + + ) : ( + + )} +
+ {syncing ? ( +
+ + {syncing.message} +
+ ) : maven.stale && maven.syncedAt != null ? ( +
+ + + Dependency trees may be out of date + + +
+ ) : null} + {maven.syncedAt == null ? ( + void syncTool("maven")} disabled={!!syncing}> + Reload All Maven Projects + + } + /> + ) : !module || !pom ? ( + + ) : ( +
+ + + + 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" ? ( + + ) : ( + + ) + } + onActivate={(n) => { + const usage = usageOf(n); + if (usage) void jumpToDeclaration(pom, usage.path[0]!); + }} + onContextMenu={contextMenu} + multiSelect={false} + empty={} + /> + + + + + + +
+ )} +
+ ); +} diff --git a/src/features/build-tools/actions.test.tsx b/src/features/build-tools/actions.test.tsx index 1ecd76bf7..bb8ac6019 100644 --- a/src/features/build-tools/actions.test.tsx +++ b/src/features/build-tools/actions.test.tsx @@ -70,6 +70,7 @@ describe("build tools feature", () => { it("offers its actions in Search Everywhere only for Maven projects and toggles run options", async () => { const visible = () => allActions().filter((a) => !a.hidden && a.id.startsWith("maven.")); expect(visible().map((a) => a.id).sort()).toEqual([ + "maven.analyzeDependencies", "maven.executeGoal", "maven.reload", "maven.toggleOffline", diff --git a/src/features/build-tools/analyzer.test.ts b/src/features/build-tools/analyzer.test.ts new file mode 100644 index 000000000..db2811897 --- /dev/null +++ b/src/features/build-tools/analyzer.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import type { MavenDependency } from "@/platform/ipc"; +import { + analyze, + coordinates, + exclusionFor, + filterArtifacts, + filterTree, + scopesOf, + sortedUsages, + statusText, + usageStatus, + type AnalyzerFilter, +} from "./analyzer"; + +function dep(coords: string, extra: Partial = {}, children: MavenDependency[] = []): MavenDependency { + const [groupId, artifactId, version, scope = "compile"] = coords.split(":"); + return { + groupId: groupId!, + artifactId: artifactId!, + version: version!, + type: "jar", + classifier: null, + scope, + optional: false, + children, + ...extra, + }; +} + +/** The tree `dependency:tree -Dverbose` printed for the conflict demo project. */ +const TREE: MavenDependency[] = [ + dep("org.hamcrest:hamcrest-core:1.3"), + dep("org.apache.commons:commons-text:1.10.0", {}, [dep("org.apache.commons:commons-lang3:3.12.0")]), + dep("org.apache.commons:commons-configuration2:2.10.1", {}, [ + dep("org.apache.commons:commons-lang3:3.14.0", { omitted: "conflict", conflictWith: "3.12.0" }), + dep("org.apache.commons:commons-text:1.11.0", { omitted: "conflict", conflictWith: "1.10.0" }), + dep("commons-logging:commons-logging:1.2", { managedFrom: "1.3.0" }), + ]), + dep("com.google.guava:guava:32.1.3-jre", {}, [dep("com.google.guava:failureaccess:1.0.1")]), + dep("com.google.inject:guice:5.1.0", {}, [ + dep("javax.inject:javax.inject:1"), + dep("com.google.guava:guava:30.1-jre", { omitted: "conflict", conflictWith: "32.1.3-jre" }), + ]), + dep("junit:junit:4.13.2:test", {}, [dep("org.hamcrest:hamcrest-core:1.3:test", { omitted: "duplicate" })]), +]; + +const ALL: AnalyzerFilter = { query: "", conflictsOnly: false, scope: null }; +const byKey = (key: string) => analyze(TREE).find((a) => a.key === key)!; + +describe("dependency analyzer", () => { + it("collects every artifact with its resolved version and conflicts", () => { + const artifacts = analyze(TREE); + expect(artifacts.filter((a) => a.conflict).map((a) => a.key)).toEqual([ + "com.google.guava:guava", + "org.apache.commons:commons-lang3", + "org.apache.commons:commons-text", + ]); + const lang3 = byKey("org.apache.commons:commons-lang3"); + expect(lang3).toMatchObject({ version: "3.12.0", requested: ["3.12.0", "3.14.0"], scope: "compile" }); + const hamcrest = byKey("org.hamcrest:hamcrest-core"); + expect(hamcrest.conflict).toBe(false); + expect(hamcrest.usages).toHaveLength(2); + // Managed versions count the version that was asked for. + expect(byKey("commons-logging:commons-logging").requested).toEqual(["1.3.0"]); + }); + + it("lists usage paths from the direct dependency, resolved first", () => { + const usages = sortedUsages(byKey("com.google.guava:guava").usages); + expect(usages.map((u) => u.path.map((d) => d.artifactId))).toEqual([["guava"], ["guice", "guava"]]); + expect(usages.map((u) => statusText(usageStatus(u.dependency)))).toEqual([ + null, + "omitted for conflict with 32.1.3-jre", + ]); + expect(statusText(usageStatus(byKey("commons-logging:commons-logging").usages[0]!.dependency))).toBe( + "version managed from 1.3.0", + ); + expect(statusText(usageStatus(dep("a:b:1", { omitted: "duplicate" })))).toBe("omitted for duplicate"); + }); + + it("excludes through the direct dependency that brings an occurrence in", () => { + const [direct, transitive] = sortedUsages(byKey("com.google.guava:guava").usages); + expect(exclusionFor(direct!)).toBeNull(); + expect(exclusionFor(transitive!)).toEqual({ + dependency: { groupId: "com.google.inject", artifactId: "guice" }, + exclusion: { groupId: "com.google.guava", artifactId: "guava" }, + }); + }); + + it("filters by conflicts, scope and text (including requested versions)", () => { + const keys = (f: Partial) => filterArtifacts(analyze(TREE), { ...ALL, ...f }).map((a) => a.key); + expect(keys({ conflictsOnly: true })).toHaveLength(3); + expect(keys({ scope: "test" })).toEqual(["junit:junit", "org.hamcrest:hamcrest-core"]); + expect(keys({ query: "LANG3" })).toEqual(["org.apache.commons:commons-lang3"]); + expect(keys({ query: "30.1-jre" })).toEqual(["com.google.guava:guava"]); + expect(scopesOf(analyze(TREE))).toEqual(["compile", "test"]); + }); + + it("keeps the paths to matches in the tree view", () => { + const conflicted = new Set( + analyze(TREE) + .filter((a) => a.conflict) + .map((a) => a.key), + ); + expect(filterTree(TREE, ALL, conflicted)).toHaveLength(TREE.length); + const tree = filterTree(TREE, { ...ALL, conflictsOnly: true }, conflicted); + expect(tree.map((n) => n.dependency.artifactId)).toEqual([ + "commons-text", + "commons-configuration2", + "guava", + "guice", + ]); + expect(tree[1]!.children.map((n) => n.dependency.artifactId)).toEqual(["commons-lang3", "commons-text"]); + expect(tree[3]!.children.map((n) => n.key)).toEqual(["/4/1"]); + expect(filterTree(TREE, { ...ALL, query: "hamcrest", scope: "test" }, conflicted).map((n) => n.key)).toEqual([ + "/5", + ]); + }); + + it("formats coordinates with the classifier", () => { + expect(coordinates(dep("g:a:1", { classifier: "linux" }))).toBe("g:a:linux:1"); + expect(coordinates(dep("g:a:1"))).toBe("g:a:1"); + }); +}); diff --git a/src/features/build-tools/analyzer.ts b/src/features/build-tools/analyzer.ts new file mode 100644 index 000000000..2f82006d6 --- /dev/null +++ b/src/features/build-tools/analyzer.ts @@ -0,0 +1,188 @@ +/** + * 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. + */ +import type { ArtifactKey, 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 { + /** `path[0]` is the module's direct dependency, the last element the occurrence. */ + path: MavenDependency[]; + dependency: MavenDependency; +} + +export interface AnalyzedArtifact { + /** `groupId:artifactId[:classifier]`. */ + key: string; + groupId: string; + artifactId: string; + classifier: string | null; + /** Version on the classpath; `null` when every occurrence was omitted. */ + version: string | null; + /** Scope of the resolved occurrence (else of the first one). */ + scope: string; + /** Distinct versions asked for (before conflict resolution and dependency management). */ + requested: string[]; + /** Several versions were asked for and some lost. */ + conflict: boolean; + usages: DependencyUsage[]; +} + +export interface AnalyzerFilter { + query: string; + conflictsOnly: boolean; + /** `null` = any scope. */ + scope: string | null; +} + +export const artifactKey = (d: Pick) => + [d.groupId, d.artifactId, d.classifier].filter(Boolean).join(":"); + +/** `groupId:artifactId[:classifier]:version`. */ +export function coordinates(d: Pick): string { + return [d.groupId, d.artifactId, d.classifier, d.version].filter(Boolean).join(":"); +} + +const isResolved = (d: MavenDependency) => d.omitted == null; + +/** The version a usage asked for: before `` replaced it, if it did. */ +export const requestedVersion = (d: MavenDependency) => d.managedFrom ?? d.version ?? null; + +/** Every artifact of `deps` (a module's dependency tree), sorted by key. */ +export function analyze(deps: readonly MavenDependency[]): AnalyzedArtifact[] { + const byKey = new Map(); + const walk = (list: readonly MavenDependency[], parents: MavenDependency[]) => { + for (const d of list) { + const path = [...parents, d]; + const key = artifactKey(d); + let a = byKey.get(key); + if (!a) { + a = { + key, + groupId: d.groupId, + artifactId: d.artifactId, + classifier: d.classifier ?? null, + version: null, + scope: d.scope, + requested: [], + conflict: false, + usages: [], + }; + byKey.set(key, a); + } + a.usages.push({ path, dependency: d }); + if (isResolved(d) && a.version == null) { + a.version = d.version ?? null; + a.scope = d.scope; + } + const requested = requestedVersion(d); + if (requested && !a.requested.includes(requested)) a.requested.push(requested); + if (d.omitted === "conflict") a.conflict = true; + walk(d.children, path); + } + }; + walk(deps, []); + return [...byKey.values()].sort((x, y) => x.key.localeCompare(y.key)); +} + +/** Scopes used in the tree, in Maven's order. */ +export function scopesOf(artifacts: readonly AnalyzedArtifact[]): string[] { + const order = ["compile", "provided", "runtime", "test", "system", "import"]; + const found = new Set(artifacts.flatMap((a) => a.usages.map((u) => u.dependency.scope))); + return [...found].sort((x, y) => (order.indexOf(x) + 1 || 99) - (order.indexOf(y) + 1 || 99) || x.localeCompare(y)); +} + +function matchesQuery(d: Pick, query: string) { + const q = query.trim().toLowerCase(); + return !q || coordinates(d).toLowerCase().includes(q); +} + +export function filterArtifacts(artifacts: readonly AnalyzedArtifact[], f: AnalyzerFilter): AnalyzedArtifact[] { + return artifacts.filter( + (a) => + (!f.conflictsOnly || a.conflict) && + (f.scope == null || a.usages.some((u) => u.dependency.scope === f.scope)) && + (matchesQuery(a, f.query) || a.requested.some((v) => matchesQuery({ ...a, version: v }, f.query))), + ); +} + +/** A node of the tree view; `key` is unique (index path). */ +export interface DependencyTreeNode { + key: string; + dependency: MavenDependency; + children: DependencyTreeNode[]; +} + +/** + * The tree view: nodes that pass the filter, with their ancestors (so every match keeps + * its path), and every descendant of a match when no filter narrows them. + */ +export function filterTree( + deps: readonly MavenDependency[], + f: AnalyzerFilter, + conflicted: ReadonlySet, +): DependencyTreeNode[] { + const narrowing = f.conflictsOnly || f.scope != null || !!f.query.trim(); + const passes = (d: MavenDependency) => + (!f.conflictsOnly || conflicted.has(artifactKey(d))) && + (f.scope == null || d.scope === f.scope) && + matchesQuery(d, f.query); + const build = (list: readonly MavenDependency[], parent: string): DependencyTreeNode[] => + list.flatMap((dependency, i) => { + const key = `${parent}/${i}`; + const children = build(dependency.children, key); + return !narrowing || passes(dependency) || children.length ? [{ key, dependency, children }] : []; + }); + return build(deps, ""); +} + +export type UsageStatus = + | { kind: "resolved"; version: string | null; managedFrom: 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; + switch (d.omitted) { + case "conflict": + return { kind: "conflict", version, winner: d.conflictWith ?? null }; + case "duplicate": + return { kind: "duplicate", version }; + case "cycle": + return { kind: "cycle", version }; + default: + return { kind: "resolved", version, managedFrom: d.managedFrom ?? null }; + } +} + +/** Human-readable status, e.g. `omitted for conflict with 3.12.0`. */ +export function statusText(s: UsageStatus): string | null { + switch (s.kind) { + case "conflict": + return s.winner ? `omitted for conflict with ${s.winner}` : "omitted for conflict"; + case "duplicate": + return "omitted for duplicate"; + case "cycle": + return "omitted for cycle"; + default: + return s.managedFrom ? `version managed from ${s.managedFrom}` : null; + } +} + +/** What Exclude on `usage` adds: an exclusion of the occurrence to the direct dependency bringing it in. */ +export function exclusionFor(usage: DependencyUsage): { dependency: ArtifactKey; exclusion: ArtifactKey } | null { + const [direct] = usage.path; + if (!direct || usage.path.length < 2) return null; + return { + dependency: { groupId: direct.groupId, artifactId: direct.artifactId }, + exclusion: { groupId: usage.dependency.groupId, artifactId: usage.dependency.artifactId }, + }; +} + +/** The resolved usage first, then the ones that lost a conflict, then the rest; shorter paths first. */ +export function sortedUsages(usages: readonly DependencyUsage[]): DependencyUsage[] { + const rank = (u: DependencyUsage) => (isResolved(u.dependency) ? 0 : u.dependency.omitted === "conflict" ? 1 : 2); + return [...usages].sort((a, b) => rank(a) - rank(b) || a.path.length - b.path.length); +} diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index 066810adc..f70182fdd 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -7,14 +7,23 @@ import { registerActions } from "@/platform/actions"; import { setContext } from "@/platform/context"; import { onIdeEvent, type BuildModel, type BuildTool } from "@/platform/ipc"; import { useProject } from "@/platform/project"; -import { registerOverlay, registerRunAnythingProvider, registerToolWindow } from "@/workbench/contributions"; +import { + registerEditorKind, + registerMenuGroup, + registerOverlay, + registerRunAnythingProvider, + registerToolWindow, +} from "@/workbench/contributions"; +import { lazyComponent } from "@/ui"; import { GradleView, MavenView } from "./BuildToolView"; import { ExecutePopupHost } from "./ExecutePopup"; import { buildToolsRunAnything } from "./runAnything"; import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots } from "./model"; import { + ANALYZER_KIND, cancelSync, loadModel, + openAnalyzer, resetBuild, setExpanded, syncTool, @@ -45,6 +54,19 @@ function openExecute(tool: BuildTool) { useBuild.setState({ executeOpen: tool }); } +/** Project-relative `pom` of a Maven project from an action argument: `{ pom }`, or `{ path }` of a pom.xml. */ +function analyzerPom(arg: unknown): string | null { + const { model, root, current } = useBuild.getState(); + const projects = model?.maven?.projects ?? []; + const a = (arg ?? {}) as { pom?: unknown; path?: unknown }; + if (typeof a.pom === "string") return a.pom; + if (typeof a.path === "string" && root) { + const rel = a.path.startsWith(`${root}/`) ? a.path.slice(root.length + 1) : a.path; + if (projects.some((p) => p.pom === rel)) return rel; + } + return current.maven ?? null; +} + const synced = new Set(); /** Registers / unregisters the tool windows to match the model; first sync of a never-synced build. */ @@ -85,6 +107,27 @@ export function activate(): () => void { const disposers: Array<() => void> = [ registerOverlay({ id: "build-tools.execute", component: ExecutePopupHost }), registerRunAnythingProvider(buildToolsRunAnything), + registerEditorKind({ + kind: ANALYZER_KIND, + component: lazyComponent(() => import("./DependencyAnalyzer").then((m) => m.DependencyAnalyzer)), + title: () => "Dependency Analyzer", + icon: () => "build-dependencies", + restorable: true, + }), + // pom.xml in the editor, its tab and the project view: Analyze Dependencies of that module. + registerMenuGroup({ + id: "maven.analyzeDependencies", + menus: ["editor", "editorTab", "projectView"], + order: 30, + items: (_menu, { paths, dirs }) => { + const path = paths.length === 1 && !dirs?.[0] ? paths[0]! : null; + if (!path || !path.endsWith("/pom.xml")) return []; + const { model, root } = useBuild.getState(); + const pom = root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : null; + if (!pom || !model?.maven?.projects.some((p) => p.pom === pom)) return []; + return [{ kind: "action", action: "maven.analyzeDependencies", arg: { pom }, label: "Analyze Dependencies…" }]; + }, + }), useBuild.subscribe((s, prev) => { if (s.model !== prev.model || s.root !== prev.root) apply(s.root ? s.model : null, registered); }), @@ -130,6 +173,16 @@ export function activate(): () => void { keywords: ["maven", "mvn", "goal", "run anything"], run: () => openExecute("maven"), }, + { + id: "maven.analyzeDependencies", + title: "Analyze Dependencies…", + description: "Shows a Maven module's dependency tree, version conflicts and where each library comes from", + category: "Maven", + icon: "build-dependencies", + when: MAVEN_WHEN, + keywords: ["maven", "dependency", "dependencies", "conflict", "tree", "exclude", "analyzer"], + run: ({ arg }) => openAnalyzer(analyzerPom(arg)), + }, { id: "maven.toggleOffline", title: "Toggle Offline Mode", diff --git a/src/features/build-tools/model.ts b/src/features/build-tools/model.ts index 78ed69311..036dd86f4 100644 --- a/src/features/build-tools/model.ts +++ b/src/features/build-tools/model.ts @@ -181,8 +181,10 @@ export function mavenChildren(node: BuildNode, model: MavenModel, configs: reado })); case "dependencies": return dependencyNodes(node.key, node.dependencies); - case "dependency": - return node.dependency.children.length ? dependencyNodes(node.key, node.dependency.children) : undefined; + case "dependency": { + const kids = dependencyNodes(node.key, node.dependency.children); + return kids.length ? kids : undefined; + } case "runConfigs": return node.configs.map((config) => ({ kind: "runConfig", key: `${node.key}:${config.id}`, config })); default: @@ -190,12 +192,13 @@ export function mavenChildren(node: BuildNode, model: MavenModel, configs: reado } } +/** The resolved dependencies; occurrences Maven omitted are shown by the Dependency Analyzer only. */ function dependencyNodes(parent: string, deps: readonly MavenDependency[]): BuildNode[] { - return deps.map((dependency, i) => ({ - kind: "dependency", - key: `${parent}/${dependency.groupId}:${dependency.artifactId}:${i}`, - dependency, - })); + return deps.flatMap((dependency, i) => + dependency.omitted == null + ? [{ kind: "dependency" as const, key: `${parent}/${dependency.groupId}:${dependency.artifactId}:${i}`, dependency }] + : [], + ); } // --------------------------------------------------------------------------- diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 1d12067bd..d26f964b6 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -22,7 +22,7 @@ import { } from "@/platform/ipc"; import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; -import { notifications } from "@/workbench/contributions"; +import { editorService, notifications } from "@/workbench/contributions"; import { removeTask, upsertTask } from "@/workbench/tasks"; import { profilesList, type GradleRunOptions, type MavenRunOptions } from "./model"; @@ -232,6 +232,7 @@ export function cancelSync(tool: BuildTool) { export function resetBuild() { generation++; + useAnalyzer.setState({ pom: null }); for (const tool of ["maven", "gradle"] as const) { cancelSync(tool); removeTask(`build-sync:${tool}`); @@ -269,3 +270,17 @@ export function setExpanded(tool: BuildTool, expanded: Set) { export function setCurrentProject(tool: BuildTool, id: string | undefined) { if (useBuild.getState().current[tool] !== id) useBuild.setState((s) => ({ current: { ...s.current, [tool]: id } })); } + +// --------------------------------------------------------------------------- +// Dependency Analyzer (an editor tab) + +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 })); + +/** 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 }); + await editorService().openInput({ kind: ANALYZER_KIND, key: ANALYZER_KIND, data: {} }, { focus: true }); +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 8a74109fb..6cae243ac 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3374,5 +3374,31 @@ "Attach to a Node.js process started with --inspect here or on another machine": "附加到以 --inspect 启动的 Node.js 进程(本机或其他机器)", "Copied": "已复制", "'{name}' can only be debugged": "'{name}' 只能调试", - "It attaches the debugger to a process started elsewhere.": "它将调试器附加到在别处启动的进程。" + "It attaches the debugger to a process started elsewhere.": "它将调试器附加到在别处启动的进程。", + "Analyze Dependencies…": "分析依赖…", + "Shows a Maven module's dependency tree, version conflicts and where each library comes from": "显示 Maven 模块的依赖树、版本冲突以及每个库的引入路径", + "Dependency Analyzer": "依赖分析器", + "Search dependencies": "搜索依赖", + "Show conflicts only": "仅显示冲突", + "All scopes": "所有范围", + "Show as List": "以列表显示", + "Show as Tree": "以树形显示", + "Dependency trees may be out of date": "依赖树可能已过期", + "Dependency trees are read when Maven projects are reloaded": "重新加载 Maven 项目时会读取依赖树", + "No Maven modules": "没有 Maven 模块", + "Dependencies": "依赖", + "No dependencies match the filter": "没有符合筛选条件的依赖", + "No dependencies": "没有依赖", + "Select a dependency to see its usages": "选择一个依赖以查看其用法", + "Versions requested": "请求的版本", + "omitted": "已省略", + "resolved": "已解析", + "omitted for conflict with {version}": "因与 {version} 冲突而省略", + "omitted for conflict": "因冲突而省略", + "omitted for duplicate": "因重复而省略", + "omitted for cycle": "因循环依赖而省略", + "version managed from {version}": "版本由 {version} 经依赖管理调整", + "Cannot find the declaration": "找不到声明", + "Cannot exclude {name}": "无法排除 {name}", + "Load Maven Changes": "加载 Maven 更改" }