mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ca6206f18 | ||
|
|
211a4cf30d |
No files matched your search
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<")
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
Reference in new issue
Block a user