Compare commits

...
Author SHA1 Message Date
l0ng-ai 8ca6206f18 Merge remote-tracking branch 'origin/main' into feat/maven-dependency-analyzer
# Conflicts:
#	src/platform/locales/text/zh.json
2026-10-27 18:48:57 +08:00
l0ng-ai 211a4cf30d feat(maven): Dependency Analyzer with conflicts, usage paths and Exclude
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 <exclusion> 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
2026-10-27 18:47:25 +08:00
20 changed files with 1873 additions and 37 deletions

No files matched your search

+19 -2
View File
@@ -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<Dependency
let modules: std::collections::HashSet<String> = 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()
+141 -10
View File
@@ -130,6 +130,37 @@ pub struct MavenDependency {
pub optional: bool,
/// Transitive dependencies (only after a sync).
pub children: Vec<MavenDependency>,
/// Why Maven left this occurrence out of the resolved graph (`dependency:tree -Dverbose`).
#[serde(default)]
#[specta(optional)]
pub omitted: Option<Omission>,
/// The version that won when `omitted` is `Conflict`.
#[serde(default)]
#[specta(optional)]
pub conflict_with: Option<String>,
/// The version requested before `<dependencyManagement>` replaced it.
#[serde(default)]
#[specta(optional)]
pub managed_from: Option<String>,
}
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<PathBuf>) -> 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<MavenPlugin>, Vec<Mav
classifier: d.classifier.clone(),
scope: d.scope.clone().unwrap_or_else(|| "compile".into()),
optional: d.optional,
children: Vec::new(),
..MavenDependency::default()
})
.collect();
(format!("{group}:{}", raw.artifact_id), plugins, deps)
@@ -976,13 +1007,16 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<Mav
.collect()
}
/// `g:a:type[:classifier]:version[:scope]` from a `dependency:tree` line.
/// `g:a:type[:classifier]:version[:scope]` from a `dependency:tree` line, with the
/// `-Dverbose` annotations: `(g:a:jar:1:compile - omitted for conflict with 2)`,
/// `g:a:jar:1:compile (version managed from 0.9)`, `(optional)`.
fn parse_coords(s: &str) -> Option<MavenDependency> {
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<MavenDependency> {
[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<MavenDependency>, 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<Omission>, 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}");
}
}
}
+336
View File
@@ -0,0 +1,336 @@
//! Edits of `pom.xml` text for the dependency analyzer: finding where a direct
//! dependency is declared and adding an `<exclusion>` 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<String>, artifact_id: impl Into<String>) -> 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<String, String> {
let mut props: HashMap<String, String> = 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<String, String>) -> 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 `<dependency>` declaring `key` in the project's `<dependencies>`, else in a
/// profile's (never `<dependencyManagement>`, 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('&', "&amp;").replace('<', "&lt;")
}
/// Range of the `<artifactId>` of the dependency declaring `key` in `text` (a POM), or of
/// the whole `<dependency>` when it has none; `None` when the POM does not declare it.
pub fn declaration(text: &str, key: &ArtifactKey) -> Option<Range> {
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 `<exclusions>` 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<Exclusion> {
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, "<exclusion>".to_owned()),
(depth + 1, format!("<groupId>{g}</groupId>")),
(depth + 1, format!("<artifactId>{a}</artifactId>")),
(depth, "</exclusion>".to_owned()),
]
};
let compact = format!("<exclusion><groupId>{g}</groupId><artifactId>{a}</artifactId></exclusion>");
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, "<exclusions>".to_owned())];
lines.extend(exclusion_lines(1));
lines.push((0, "</exclusions>".to_owned()));
Some(insert(dep, &lines, format!("<exclusions>{compact}</exclusions>")))
}
/// 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<String> {
let mut out: Vec<String> = Vec::new();
let mut seen: HashSet<String> = 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<String> {
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 = "<project>
<groupId>demo</groupId>
<properties>
<guice.group>com.google.inject</guice.group>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
<version>5.1.0</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>${guice.group}</groupId>
<artifactId>guice</artifactId>
</dependency>
</dependencies>
</project>
";
#[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(
" <artifactId>guice</artifactId>\n </dependency>\n </dependencies>",
" <artifactId>guice</artifactId>
<exclusions>
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>",
);
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(
" </exclusion>
<exclusion>
<groupId>aopalliance</groupId>
<artifactId>aopalliance</artifactId>
</exclusion>
</exclusions>"
));
assert_eq!(exclude(&twice, ("com.google.inject", "guice"), ("aopalliance", "aopalliance")).unwrap(), twice);
}
#[test]
fn keeps_tabs_crlf_and_one_line_dependencies() {
let tabs = "<project>\r\n\t<dependencies>\r\n\t\t<dependency>\r\n\t\t\t<groupId>g</groupId>\r\n\t\t\t<artifactId>a</artifactId>\r\n\t\t</dependency>\r\n\t</dependencies>\r\n</project>\r\n";
let out = exclude(tabs, ("g", "a"), ("x", "y")).unwrap();
assert!(out.contains("<artifactId>a</artifactId>\r\n\t\t\t<exclusions>\r\n\t\t\t\t<exclusion>\r\n\t\t\t\t\t<groupId>x</groupId>\r\n"));
assert!(out.contains("\t\t\t</exclusions>\r\n\t\t</dependency>"));
let inline = "<project><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId></dependency></dependencies></project>";
assert_eq!(
exclude(inline, ("g", "a"), ("x", "y")).unwrap(),
"<project><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId><exclusions><exclusion><groupId>x</groupId><artifactId>y</artifactId></exclusion></exclusions></dependency></dependencies></project>"
);
}
#[test]
fn wildcards_profiles_and_missing_declarations() {
let pom = "<project>
<profiles>
<profile>
<dependencies>
<dependency>
<groupId>g</groupId><artifactId>a</artifactId>
<exclusions><exclusion><groupId>x</groupId><artifactId>*</artifactId></exclusion></exclusions>
</dependency>
</dependencies>
</profile>
</profiles>
</project>";
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(
"</exclusion><exclusion><groupId>z</groupId><artifactId>w</artifactId></exclusion></exclusions>"
));
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 = "<project><!-- é😀 --><dependencies>\n <dependency><groupId>g</groupId><artifactId>a</artifactId></dependency>\n</dependencies></project>";
let r = declaration(pom, &ArtifactKey::new("g", "a")).unwrap();
assert_eq!(r, Range::new(Pos::new(1, 34), Pos::new(1, 60)));
let first = "<project><!-- é😀 --><dependencies><dependency><groupId>g</groupId><artifactId>a</artifactId>";
let r = declaration(&format!("{first}</dependency></dependencies></project>"), &ArtifactKey::new("g", "a"))
.unwrap();
let start = first.find("<artifactId>").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",
"<project><groupId>d</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>app</module></modules></project>",
);
write(
"app/pom.xml",
"<project><parent><groupId>d</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>app</artifactId></project>",
);
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());
}
}
+43 -6
View File
@@ -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<String>,
projects: Vec<CachedMavenProject>,
/// 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<String> {
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<T: DeserializeOwned>(dir: &Path, name: &str) -> Option<T> {
@@ -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<String> = 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!(
+42 -7
View File
@@ -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 (`<x:project>` → `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<Element>,
/// 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 `<empty/>`).
pub end: usize,
}
impl Element {
@@ -76,6 +83,7 @@ fn local(name: &str) -> String {
pub fn parse(text: &str) -> Option<Element> {
let mut stack: Vec<Element> = 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<Element> {
} else if let Some(body) = tag.strip_prefix("</") {
let end = body.find('>').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<Element> {
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 = "<a>\n <b>x</b><c/>\n</a>";
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], "<b>x</b>");
assert_eq!(&doc[b.start..b.inner_end], "<b>x");
let c = root.child("c").unwrap();
assert_eq!(&doc[c.start..c.end], "<c/>");
let broken = parse("<a><b>x</a>").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("<a><b>x</c><d>y").unwrap();
+24
View File
@@ -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
+71 -2
View File
@@ -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<Location> {
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 `<exclusion>` 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<Location> {
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)
+2
View File
@@ -246,6 +246,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+29
View File
@@ -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<SyncProgress>) => typedError<BuildModel, IdeError>(__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<Location, IdeError>(__TAURI_INVOKE("maven_dependency_declaration", { pom, dependency })),
/**
* Adds an `<exclusion>` 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<Location, IdeError>(__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
@@ -1102,6 +1110,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 = {
@@ -3512,6 +3526,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 `<dependencyManagement>` replaced it. */
managedFrom?: string | null,
};
export type MavenModel = {
@@ -3723,6 +3743,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. */
@@ -152,6 +152,7 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
action={`${tool}.toggleOffline`}
pressed={maven ? settings.maven.offline : settings.gradle.offline}
/>
{maven && <IconButton icon="build-dependencies" size="small" iconSize={16} action="maven.analyzeDependencies" />}
{maven && (
<IconButton
icon="skip-tests"
@@ -308,8 +309,11 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
},
{ kind: "action", action: "maven.reload", label: "Reload All Maven Projects" },
{ kind: "action", action: "maven.executeGoal" },
{ kind: "action", action: "maven.analyzeDependencies", arg: { pom: node.project.pom } },
);
}
if (node.kind === "dependencies")
items.push({ kind: "action", action: "maven.analyzeDependencies", arg: { pom: node.project.pom } });
if (node.kind === "gradleProject") {
const file = node.project.buildFile;
if (file)
@@ -0,0 +1,205 @@
.view {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--bg-editor);
}
.toolbar {
flex: none;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-height: 36px;
padding: 4px 8px;
border-bottom: 1px solid var(--border);
}
.module {
min-width: 160px;
max-width: 260px;
}
.search {
flex: 1 1 200px;
min-width: 140px;
max-width: 360px;
}
.scope {
min-width: 110px;
}
.sep {
width: 1px;
height: 16px;
margin: 0 2px;
background: var(--border);
}
.banner {
flex: none;
display: flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 2px 10px;
font-size: var(--fs-small);
color: var(--fg-secondary);
border-bottom: 1px solid var(--border);
}
.bannerText {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
flex: 1;
min-height: 0;
display: flex;
}
.body > * {
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);
}
@@ -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<typeof import("@/platform/ipc")>();
return {
...real,
call: (value: unknown) => value,
commands: { ...real.commands, mavenExcludeDependency: ipc.exclude, mavenDependencyDeclaration: ipc.declaration },
};
});
function dep(coords: string, extra: Partial<MavenDependency> = {}, 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(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
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" },
);
});
});
@@ -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 (
<span className={styles.row}>
<Icon name={artifact.conflict ? "warning" : "library"} size={16} className={styles.icon} />
<span className={clsx(styles.label, artifact.conflict && styles.conflict, selected && styles.selectedLabel)}>
{artifact.key}
</span>
<span className={styles.version}>{artifact.version ?? tx("omitted")}</span>
{artifact.conflict && (
<span className={styles.detail}>{artifact.requested.filter((v) => v !== artifact.version).join(", ")}</span>
)}
{artifact.scope !== "compile" && <span className={styles.detail}>({artifact.scope})</span>}
</span>
);
}
function TreeRow({ node, conflict, selected }: { node: DependencyTreeNode; conflict: boolean; selected: boolean }) {
const tx = useTxTemplate();
const d = node.dependency;
const status = statusText(usageStatus(d));
return (
<span className={clsx(styles.row, d.omitted != null && styles.omitted)}>
<Icon name={conflict ? "warning" : "library"} size={16} className={styles.icon} />
<span className={clsx(styles.label, conflict && styles.conflict, selected && styles.selectedLabel)}>
{artifactKey(d)}
</span>
<span className={styles.version}>{versionLabel(d)}</span>
{d.scope !== "compile" && <span className={styles.detail}>({d.scope})</span>}
{status && <span className={styles.detail}>{tx(status)}</span>}
</span>
);
}
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 (
<section className={styles.usage} aria-label={coordinates(usage.dependency)}>
<div className={styles.usageHeader}>
<Icon
name={status.kind === "conflict" ? "warning" : status.kind === "resolved" ? "success" : "info"}
size={16}
className={styles.icon}
/>
<span className={styles.usageVersion}>{status.version}</span>
<span className={clsx(styles.usageStatus, status.kind === "conflict" && styles.conflict)}>
{text ? tx(text) : tx("resolved")}
</span>
<span className={styles.spacer} />
{canExclude && (
<Button size="small" variant="ghost" onClick={() => void exclude(pom, usage)}>
Exclude
</Button>
)}
<IconButton
icon="autoscroll-to-source"
size="small"
title="Jump to Source"
onClick={() => void jumpToDeclaration(pom, direct)}
/>
</div>
<ol className={styles.path}>
<li className={styles.step} style={{ paddingLeft: 0 }}>
<Icon name="maven-project" size={16} className={styles.icon} />
<span className={styles.stepLabel}>{module.displayName}</span>
</li>
{usage.path.map((d, i) => (
<li key={i} className={styles.step} style={{ paddingLeft: (i + 1) * 16 }}>
<Icon name="library" size={16} className={styles.icon} />
<span className={clsx(styles.stepLabel, i === usage.path.length - 1 && styles.stepTarget)}>
{coordinates(d)}
</span>
{d.scope !== "compile" && <span className={styles.detail}>({d.scope})</span>}
</li>
))}
</ol>
</section>
);
}
function UsagesPane({
artifact,
module,
pom,
}: {
artifact: AnalyzedArtifact | null;
module: MavenProject;
pom: string;
}) {
if (!artifact) return <EmptyState title="Select a dependency to see its usages" />;
const usages = sortedUsages(artifact.usages);
const coords = coordinates({ ...artifact, version: artifact.version });
return (
<div className={styles.usages}>
<div className={styles.usagesHeader}>
<span className={clsx(styles.usagesTitle, artifact.conflict && styles.conflict)}>{coords}</span>
<IconButton icon="copy" size="small" title="Copy Coordinates" onClick={() => void copyText(coords)} />
</div>
{artifact.conflict && (
<div className={styles.conflictNote} role="note">
<Icon name="warning" size={16} />
<span>
<T>Versions requested</T>: {artifact.requested.join(", ")}
</span>
</div>
)}
<div className={styles.usageList} aria-label="Usages">
{usages.map((u, i) => (
<UsageBlock key={i} usage={u} module={module} pom={pom} />
))}
</div>
</div>
);
}
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<string>(ALL_SCOPES);
const [mode, setMode] = useState<Mode>("list");
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const options = useMemo(() => moduleOptions(maven?.projects ?? []), [maven]);
const pom = options.some((o) => o.value === chosen) ? chosen! : (options[0]?.value ?? null);
const module = maven?.projects.find((p) => p.pom === pom) ?? null;
const artifacts = useMemo(() => analyze(module?.dependencies ?? []), [module]);
const 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<string> } | null>(null);
const expanded = useMemo(() => {
if (manual?.id === filterId) return manual.keys;
const keys = new Set<string>();
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<string>, 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 <EmptyState title="No Maven projects" />;
return (
<div className={styles.view}>
<div className={styles.toolbar} role="toolbar" aria-label="Dependency Analyzer">
<Select
aria-label="Module"
className={styles.module}
value={pom}
options={options}
onChange={(v) => {
useAnalyzer.setState({ pom: v });
setSelectedKey(null);
}}
/>
<SearchField
className={styles.search}
value={query}
onChange={setQuery}
placeholder={tx("Search dependencies")}
aria-label="Search dependencies"
clearable
/>
<Checkbox checked={conflictsOnly} onChange={setConflictsOnly} label="Show conflicts only" />
<Select aria-label="Scope" className={styles.scope} value={scope} options={scopeOptions} onChange={setScope} />
<span className={styles.sep} />
<IconButton
icon="list"
size="small"
iconSize={16}
title="Show as List"
pressed={mode === "list"}
onClick={() => setMode("list")}
/>
<IconButton
icon="hierarchy"
size="small"
iconSize={16}
title="Show as Tree"
pressed={mode === "tree"}
onClick={() => setMode("tree")}
/>
<span className={styles.sep} />
{syncing ? (
<IconButton icon="stop" size="small" iconSize={16} action="maven.cancelReload" />
) : (
<IconButton icon="refresh" size="small" iconSize={16} action="maven.reload" />
)}
</div>
{syncing ? (
<div className={styles.banner} role="status">
<Spinner size={12} />
<span className={styles.bannerText}>{syncing.message}</span>
</div>
) : maven.stale && maven.syncedAt != null ? (
<div className={styles.banner}>
<Icon name="maven-load-changes" size={16} />
<span className={styles.bannerText}>
<T>Dependency trees may be out of date</T>
</span>
<Button size="small" variant="ghost" onClick={() => void syncTool("maven")}>
Load Maven Changes
</Button>
</div>
) : null}
{maven.syncedAt == null ? (
<EmptyState
title="Dependency trees are read when Maven projects are reloaded"
actions={
<Button size="small" onClick={() => void syncTool("maven")} disabled={!!syncing}>
Reload All Maven Projects
</Button>
}
/>
) : !module || !pom ? (
<EmptyState title="No Maven modules" />
) : (
<div className={styles.body}>
<SplitGroup orientation="horizontal" id="maven-dependency-analyzer-split">
<SplitPanel id="dependencies" minSize={200} className={styles.panel}>
<Tree<Node>
aria-label="Dependencies"
roots={roots}
getKey={(n) => n.key}
getLabel={(n) => (n.kind === "artifact" ? n.artifact.key : artifactKey(n.node.dependency))}
getChildren={(n) => (n.kind === "node" ? n.children : undefined)}
isExpandable={(n) => n.kind === "node" && n.node.children.length > 0}
expanded={expanded}
onExpandedChange={(next) => setManual({ id: filterId, keys: next })}
onSelectionChange={(_keys, lead) => setSelectedKey(lead ? nodeArtifactKey(lead) : null)}
renderNode={(n, state) =>
n.kind === "artifact" ? (
<ArtifactRow artifact={n.artifact} selected={state.selected} />
) : (
<TreeRow
node={n.node}
conflict={conflicted.has(artifactKey(n.node.dependency))}
selected={state.selected}
/>
)
}
onActivate={(n) => {
const usage = usageOf(n);
if (usage) void jumpToDeclaration(pom, usage.path[0]!);
}}
onContextMenu={contextMenu}
multiSelect={false}
empty={<EmptyState title={artifacts.length ? "No dependencies match the filter" : "No dependencies"} />}
/>
</SplitPanel>
<SplitHandle />
<SplitPanel id="usages" minSize={240} className={styles.panel}>
<UsagesPane artifact={selected} module={module} pom={pom} />
</SplitPanel>
</SplitGroup>
</div>
)}
</div>
);
}
@@ -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",
+124
View File
@@ -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<MavenDependency> = {}, 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<AnalyzerFilter>) => 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");
});
});
+188
View File
@@ -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<MavenDependency, "groupId" | "artifactId" | "classifier">) =>
[d.groupId, d.artifactId, d.classifier].filter(Boolean).join(":");
/** `groupId:artifactId[:classifier]:version`. */
export function coordinates(d: Pick<MavenDependency, "groupId" | "artifactId" | "classifier" | "version">): 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 `<dependencyManagement>` 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<string, AnalyzedArtifact>();
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<MavenDependency, "groupId" | "artifactId" | "classifier" | "version">, 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<string>,
): 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);
}
+54 -1
View File
@@ -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<string>();
/** 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",
+10 -7
View File
@@ -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 }]
: [],
);
}
// ---------------------------------------------------------------------------
+16 -1
View File
@@ -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<string>) {
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 });
}
+26 -1
View File
@@ -3656,5 +3656,30 @@
"Sort Folders First": "文件夹排在前面",
"Open Files with Single Click": "单击打开文件",
"Expand Directories with Single Click": "单击展开目录",
"Enable Preview Tab": "启用预览标签页"
"Enable Preview Tab": "启用预览标签页",
"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}"
}