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 更改" }