дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
Maven sync ran a plain `dependency:tree`, which drops the nodes Maven omits (conflict losers, duplicates), so there was no data to show where a conflict comes from. Sync now runs `maven-dependency-plugin:3.8.1:tree -Dverbose` (the project's own plugin version when offline) and records omitted / conflictWith / managedFrom per node; omitted nodes stay out of the Maven tool window and External Libraries, and older caches are marked stale. The new Dependency Analyzer (Maven toolbar, module / Dependencies node menus, pom.xml menus, Find Action) lists a module's resolved artifacts or tree with conflict markers, search and scope filters, and every path to the selected artifact with its status. Exclude writes an `<exclusion>` into the declaring pom (module or in-project parent), keeping indentation and line endings, then reloads; Jump to Source and Copy Coordinates are on each path. Gradle is not covered yet. Fixes #87 Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
1462 рядки
59 KiB
Rust
1462 рядки
59 KiB
Rust
//! Maven projects for the Maven tool window, read straight from `pom.xml`:
|
|
//! coordinates, `<modules>` (aggregators), profiles, plugins (declared plus
|
|
//! the default lifecycle bindings of the packaging) and direct dependencies.
|
|
//!
|
|
//! `${properties}` are resolved through the parent chain, which is followed
|
|
//! into the local repository (`~/.m2/repository`) when the parent is not in the
|
|
//! project, together with imported BOMs. Nothing here runs Maven: [`crate::sync`]
|
|
//! later replaces plugins and dependencies with the effective build.
|
|
|
|
use crate::xml::{self, Element};
|
|
use serde::{Deserialize, Serialize};
|
|
use specta::Type;
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Phases shown under "Lifecycle" (IDEA's default list).
|
|
pub const LIFECYCLE: &[&str] =
|
|
&["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"];
|
|
|
|
/// Every phase of the clean, default and site lifecycles (Execute Maven Goal completion).
|
|
pub const ALL_PHASES: &[&str] = &[
|
|
"pre-clean",
|
|
"clean",
|
|
"post-clean",
|
|
"validate",
|
|
"initialize",
|
|
"generate-sources",
|
|
"process-sources",
|
|
"generate-resources",
|
|
"process-resources",
|
|
"compile",
|
|
"process-classes",
|
|
"generate-test-sources",
|
|
"process-test-sources",
|
|
"generate-test-resources",
|
|
"process-test-resources",
|
|
"test-compile",
|
|
"process-test-classes",
|
|
"test",
|
|
"prepare-package",
|
|
"package",
|
|
"pre-integration-test",
|
|
"integration-test",
|
|
"post-integration-test",
|
|
"verify",
|
|
"install",
|
|
"deploy",
|
|
"pre-site",
|
|
"site",
|
|
"post-site",
|
|
"site-deploy",
|
|
];
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MavenModel {
|
|
/// Pre-order: every aggregator comes before its modules.
|
|
pub projects: Vec<MavenProject>,
|
|
/// Profiles declared by any project, first declaration wins.
|
|
pub profiles: Vec<MavenProfile>,
|
|
/// When the effective build was last read (Unix ms); `None` = only the static POM parse.
|
|
pub synced_at: Option<f64>,
|
|
/// Build files changed since that sync.
|
|
pub stale: bool,
|
|
pub sync_error: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MavenProject {
|
|
/// Project-relative, `/`-separated (`pom.xml`, `core/pom.xml`).
|
|
pub pom: String,
|
|
/// Project-relative directory; `""` = the project root.
|
|
pub dir: String,
|
|
pub group_id: String,
|
|
pub artifact_id: String,
|
|
pub version: String,
|
|
pub packaging: String,
|
|
/// `<name>` (resolved), else the artifactId.
|
|
pub display_name: String,
|
|
/// `groupId:artifactId:version` of the parent POM.
|
|
pub parent: Option<String>,
|
|
/// `pom` paths of this aggregator's modules.
|
|
pub modules: Vec<String>,
|
|
/// Listed as a module of another project here.
|
|
pub aggregated: bool,
|
|
pub plugins: Vec<MavenPlugin>,
|
|
pub dependencies: Vec<MavenDependency>,
|
|
/// Profile ids declared in this POM.
|
|
pub profiles: Vec<String>,
|
|
/// Java feature version the project compiles for (`maven.compiler.release`, `java.version`…).
|
|
pub java_version: Option<u32>,
|
|
/// Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`.
|
|
pub command: String,
|
|
/// Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings.
|
|
pub options: Vec<String>,
|
|
/// Environment for runs (`JAVA_HOME` of the matching JDK); filled in by the shell.
|
|
pub env: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl MavenProject {
|
|
pub fn key(&self) -> String {
|
|
format!("{}:{}", self.group_id, self.artifact_id)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MavenPlugin {
|
|
pub group_id: String,
|
|
pub artifact_id: String,
|
|
pub version: Option<String>,
|
|
/// Goal prefix (`clean`, `spring-boot`): goals run as `<prefix>:<goal>`.
|
|
pub prefix: String,
|
|
/// Sorted goal names.
|
|
pub goals: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MavenDependency {
|
|
pub group_id: String,
|
|
pub artifact_id: String,
|
|
pub version: Option<String>,
|
|
/// `jar`, `pom`, `test-jar`…
|
|
#[serde(rename = "type")]
|
|
pub kind: String,
|
|
pub classifier: Option<String>,
|
|
pub scope: String,
|
|
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)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MavenProfile {
|
|
pub id: String,
|
|
pub active_by_default: bool,
|
|
}
|
|
|
|
// ---------------------------------------------------------------- raw POM
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct ParentRef {
|
|
group_id: String,
|
|
artifact_id: String,
|
|
version: String,
|
|
relative_path: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct RawDep {
|
|
group_id: String,
|
|
artifact_id: String,
|
|
version: Option<String>,
|
|
kind: Option<String>,
|
|
classifier: Option<String>,
|
|
scope: Option<String>,
|
|
optional: bool,
|
|
}
|
|
|
|
impl RawDep {
|
|
fn key(&self) -> String {
|
|
format!(
|
|
"{}:{}:{}:{}",
|
|
self.group_id,
|
|
self.artifact_id,
|
|
self.kind.as_deref().unwrap_or("jar"),
|
|
self.classifier.as_deref().unwrap_or("")
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct RawPlugin {
|
|
group_id: String,
|
|
artifact_id: String,
|
|
version: Option<String>,
|
|
/// Goals bound by `<executions>`.
|
|
goals: Vec<String>,
|
|
}
|
|
|
|
impl RawPlugin {
|
|
fn key(&self) -> String {
|
|
format!("{}:{}", self.group_id, self.artifact_id)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct RawProfile {
|
|
id: String,
|
|
active_by_default: bool,
|
|
modules: Vec<String>,
|
|
properties: Vec<(String, String)>,
|
|
deps: Vec<RawDep>,
|
|
plugins: Vec<RawPlugin>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct RawPom {
|
|
group_id: Option<String>,
|
|
artifact_id: String,
|
|
version: Option<String>,
|
|
packaging: Option<String>,
|
|
name: Option<String>,
|
|
parent: Option<ParentRef>,
|
|
properties: Vec<(String, String)>,
|
|
modules: Vec<String>,
|
|
deps: Vec<RawDep>,
|
|
managed: Vec<RawDep>,
|
|
plugins: Vec<RawPlugin>,
|
|
managed_plugins: Vec<RawPlugin>,
|
|
profiles: Vec<RawProfile>,
|
|
/// `<build><plugins>` `maven-compiler-plugin` `<release>` / `<source>`.
|
|
compiler_release: Option<String>,
|
|
}
|
|
|
|
fn read_dep(e: &Element) -> Option<RawDep> {
|
|
Some(RawDep {
|
|
group_id: e.text_of("groupId")?,
|
|
artifact_id: e.text_of("artifactId")?,
|
|
version: e.text_of("version"),
|
|
kind: e.text_of("type"),
|
|
classifier: e.text_of("classifier"),
|
|
scope: e.text_of("scope"),
|
|
optional: e.text_of("optional").is_some_and(|o| o == "true"),
|
|
})
|
|
}
|
|
|
|
fn read_plugin(e: &Element) -> Option<RawPlugin> {
|
|
let mut goals: Vec<String> = Vec::new();
|
|
for exec in e.list("executions", "execution") {
|
|
for g in exec.list("goals", "goal") {
|
|
let g = g.text.trim();
|
|
if !g.is_empty() && !goals.iter().any(|x| x == g) {
|
|
goals.push(g.to_owned());
|
|
}
|
|
}
|
|
}
|
|
Some(RawPlugin {
|
|
group_id: e.text_of("groupId").unwrap_or_else(|| "org.apache.maven.plugins".into()),
|
|
artifact_id: e.text_of("artifactId")?,
|
|
version: e.text_of("version"),
|
|
goals,
|
|
})
|
|
}
|
|
|
|
fn read_properties(e: Option<&Element>) -> Vec<(String, String)> {
|
|
e.map(|p| p.children.iter().map(|c| (c.name.clone(), c.text.trim().to_owned())).collect()).unwrap_or_default()
|
|
}
|
|
|
|
fn texts(e: &Element, path: &str, item: &str) -> Vec<String> {
|
|
e.list(path, item).iter().map(|m| m.text.trim().to_owned()).filter(|m| !m.is_empty()).collect()
|
|
}
|
|
|
|
fn read_build(e: &Element, plugins: &mut Vec<RawPlugin>, managed: &mut Vec<RawPlugin>) {
|
|
plugins.extend(e.list("build/plugins", "plugin").into_iter().filter_map(read_plugin));
|
|
managed.extend(e.list("build/pluginManagement/plugins", "plugin").into_iter().filter_map(read_plugin));
|
|
}
|
|
|
|
fn read_raw(project: &Element) -> RawPom {
|
|
let parent = project.child("parent").and_then(|p| {
|
|
Some(ParentRef {
|
|
group_id: p.text_of("groupId")?,
|
|
artifact_id: p.text_of("artifactId")?,
|
|
version: p.text_of("version").unwrap_or_default(),
|
|
// `<relativePath/>` (empty) means "look it up in the repository".
|
|
relative_path: p.child("relativePath").map(|r| r.text.trim().to_owned()),
|
|
})
|
|
});
|
|
let mut pom = RawPom {
|
|
group_id: project.text_of("groupId"),
|
|
artifact_id: project.text_of("artifactId").unwrap_or_default(),
|
|
version: project.text_of("version"),
|
|
packaging: project.text_of("packaging"),
|
|
name: project.text_of("name"),
|
|
parent,
|
|
properties: read_properties(project.child("properties")),
|
|
modules: texts(project, "modules", "module"),
|
|
deps: project.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
|
|
managed: project
|
|
.list("dependencyManagement/dependencies", "dependency")
|
|
.into_iter()
|
|
.filter_map(read_dep)
|
|
.collect(),
|
|
..RawPom::default()
|
|
};
|
|
read_build(project, &mut pom.plugins, &mut pom.managed_plugins);
|
|
if let Some(c) = pom.plugins.iter().position(|p| p.artifact_id == "maven-compiler-plugin") {
|
|
let plugin =
|
|
project.list("build/plugins", "plugin").into_iter().filter(|p| p.text_of("artifactId").is_some()).nth(c);
|
|
if let Some(cfg) = plugin.and_then(|p| p.child("configuration")) {
|
|
pom.compiler_release = cfg.text_of("release").or_else(|| cfg.text_of("source"));
|
|
}
|
|
}
|
|
for p in project.list("profiles", "profile") {
|
|
let mut profile = RawProfile {
|
|
id: p.text_of("id").unwrap_or_else(|| "default".into()),
|
|
active_by_default: p.at("activation/activeByDefault").is_some_and(|a| a.text.trim() == "true"),
|
|
modules: texts(p, "modules", "module"),
|
|
properties: read_properties(p.child("properties")),
|
|
deps: p.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
|
|
..RawProfile::default()
|
|
};
|
|
let mut managed = Vec::new();
|
|
read_build(p, &mut profile.plugins, &mut managed);
|
|
pom.profiles.push(profile);
|
|
}
|
|
pom
|
|
}
|
|
|
|
fn parse_pom(text: &str) -> Option<RawPom> {
|
|
let root = xml::parse(text)?;
|
|
(root.name == "project").then(|| read_raw(&root))
|
|
}
|
|
|
|
// ---------------------------------------------------------------- properties
|
|
|
|
/// Replaces `${key}` with `props[key]` (also `pom.x` → `project.x`, `env.X`), repeatedly
|
|
/// for nested references; unknown keys are kept as written.
|
|
pub(crate) fn interpolate(s: &str, props: &HashMap<String, String>) -> String {
|
|
let mut out = s.to_owned();
|
|
for _ in 0..10 {
|
|
if !out.contains("${") {
|
|
break;
|
|
}
|
|
let mut next = String::with_capacity(out.len());
|
|
let mut rest = out.as_str();
|
|
while let Some(i) = rest.find("${") {
|
|
next.push_str(&rest[..i]);
|
|
let tail = &rest[i + 2..];
|
|
let Some(end) = tail.find('}') else {
|
|
next.push_str(&rest[i..]);
|
|
rest = "";
|
|
break;
|
|
};
|
|
let key = &tail[..end];
|
|
let alias = key.strip_prefix("pom.").map(|k| format!("project.{k}"));
|
|
let value = props
|
|
.get(key)
|
|
.or_else(|| alias.as_ref().and_then(|a| props.get(a)))
|
|
.cloned()
|
|
.or_else(|| key.strip_prefix("env.").and_then(|v| std::env::var(v).ok()));
|
|
match value {
|
|
Some(v) => next.push_str(&v),
|
|
None => next.push_str(&rest[i..i + 3 + end]),
|
|
}
|
|
rest = &tail[end + 1..];
|
|
}
|
|
next.push_str(rest);
|
|
if next == out {
|
|
break;
|
|
}
|
|
out = next;
|
|
}
|
|
out
|
|
}
|
|
|
|
// ---------------------------------------------------------------- inheritance
|
|
|
|
/// Everything a POM inherits, with raw (uninterpolated) values: Maven interpolates
|
|
/// after inheritance, so `${project.version}` in a parent means the child's version.
|
|
#[derive(Clone, Debug, Default)]
|
|
struct Inherited {
|
|
group_id: Option<String>,
|
|
version: Option<String>,
|
|
properties: Vec<(String, String)>,
|
|
deps: Vec<RawDep>,
|
|
managed: Vec<RawDep>,
|
|
plugins: Vec<RawPlugin>,
|
|
managed_plugins: Vec<RawPlugin>,
|
|
}
|
|
|
|
fn merge_by<T: Clone>(base: &mut Vec<T>, own: &[T], key: impl Fn(&T) -> String) {
|
|
let mut index: HashMap<String, usize> = base.iter().enumerate().map(|(i, b)| (key(b), i)).collect();
|
|
for item in own {
|
|
match index.get(&key(item)) {
|
|
Some(&i) => base[i] = item.clone(),
|
|
None => {
|
|
index.insert(key(item), base.len());
|
|
base.push(item.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn merge_plugins(base: &mut Vec<RawPlugin>, own: &[RawPlugin]) {
|
|
for p in own {
|
|
match base.iter_mut().find(|b| b.key() == p.key()) {
|
|
Some(b) => {
|
|
if p.version.is_some() {
|
|
b.version = p.version.clone();
|
|
}
|
|
for g in &p.goals {
|
|
if !b.goals.contains(g) {
|
|
b.goals.push(g.clone());
|
|
}
|
|
}
|
|
}
|
|
None => base.push(p.clone()),
|
|
}
|
|
}
|
|
}
|
|
|
|
const MAX_PARENT_DEPTH: usize = 10;
|
|
|
|
struct Resolver {
|
|
local_repo: Option<PathBuf>,
|
|
poms: HashMap<PathBuf, Option<RawPom>>,
|
|
contexts: HashMap<PathBuf, Inherited>,
|
|
}
|
|
|
|
/// `~/.m2/repository`, or `<localRepository>` from `~/.m2/settings.xml`.
|
|
pub(crate) fn default_local_repo() -> Option<PathBuf> {
|
|
let m2 = dirs::home_dir()?.join(".m2");
|
|
if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t))
|
|
&& let Some(repo) = settings.text_of("localRepository")
|
|
{
|
|
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
|
|
return Some(PathBuf::from(repo.replace("${user.home}", &home)));
|
|
}
|
|
Some(m2.join("repository"))
|
|
}
|
|
|
|
impl Resolver {
|
|
fn new(local_repo: Option<PathBuf>) -> Self {
|
|
Self { local_repo, poms: HashMap::new(), contexts: HashMap::new() }
|
|
}
|
|
|
|
fn pom(&mut self, path: &Path) -> Option<RawPom> {
|
|
self.poms
|
|
.entry(path.to_path_buf())
|
|
.or_insert_with(|| std::fs::read_to_string(path).ok().and_then(|t| parse_pom(&t)))
|
|
.clone()
|
|
}
|
|
|
|
fn repo_pom(&self, group: &str, artifact: &str, version: &str) -> Option<PathBuf> {
|
|
if version.is_empty() || version.contains("${") {
|
|
return None;
|
|
}
|
|
let mut p = self.local_repo.clone()?;
|
|
p.extend(group.split('.'));
|
|
p.push(artifact);
|
|
p.push(version);
|
|
p.push(format!("{artifact}-{version}.pom"));
|
|
p.is_file().then_some(p)
|
|
}
|
|
|
|
/// The parent POM file of the POM in `dir`: in the project via `relativePath`
|
|
/// (default `../pom.xml`) when its coordinates match, else in the local repository.
|
|
fn parent_path(&mut self, dir: &Path, parent: &ParentRef) -> Option<PathBuf> {
|
|
let rel = parent.relative_path.clone().unwrap_or_else(|| "../pom.xml".into());
|
|
if !rel.is_empty() {
|
|
let mut candidate = dir.join(&rel);
|
|
if candidate.is_dir() {
|
|
candidate.push("pom.xml");
|
|
}
|
|
if let Some(p) = self.pom(&candidate) {
|
|
let group = p.group_id.clone().or_else(|| p.parent.as_ref().map(|pp| pp.group_id.clone()));
|
|
if p.artifact_id == parent.artifact_id && group.as_deref() == Some(parent.group_id.as_str()) {
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
}
|
|
self.repo_pom(&parent.group_id, &parent.artifact_id, &parent.version)
|
|
}
|
|
|
|
/// What the POM at `path` hands down to its children (its parents' plus its own).
|
|
fn context(&mut self, path: &Path, depth: usize) -> Inherited {
|
|
if let Some(c) = self.contexts.get(path) {
|
|
return c.clone();
|
|
}
|
|
let Some(pom) = self.pom(path) else { return Inherited::default() };
|
|
let dir = path.parent().unwrap_or(Path::new("")).to_path_buf();
|
|
let mut ctx = match &pom.parent {
|
|
Some(parent) if depth < MAX_PARENT_DEPTH => match self.parent_path(&dir, parent) {
|
|
Some(pp) => self.context(&pp, depth + 1),
|
|
None => Inherited::default(),
|
|
},
|
|
_ => Inherited::default(),
|
|
};
|
|
let parent_ref = pom.parent.clone().unwrap_or_default();
|
|
ctx.group_id = pom.group_id.clone().or(ctx.group_id).or(Some(parent_ref.group_id.clone()));
|
|
ctx.version = pom.version.clone().or(ctx.version).or(Some(parent_ref.version.clone()));
|
|
merge_by(&mut ctx.properties, &pom.properties, |(k, _)| k.clone());
|
|
merge_by(&mut ctx.deps, &pom.deps, RawDep::key);
|
|
merge_plugins(&mut ctx.plugins, &pom.plugins);
|
|
merge_plugins(&mut ctx.managed_plugins, &pom.managed_plugins);
|
|
for p in pom.profiles.iter().filter(|p| p.active_by_default) {
|
|
merge_by(&mut ctx.properties, &p.properties, |(k, _)| k.clone());
|
|
merge_by(&mut ctx.deps, &p.deps, RawDep::key);
|
|
merge_plugins(&mut ctx.plugins, &p.plugins);
|
|
}
|
|
// Managed dependencies: own entries win; imported BOMs fill the gaps.
|
|
let props = self.properties_for(&pom, &ctx, &dir);
|
|
let mut imported: Vec<RawDep> = Vec::new();
|
|
for m in &pom.managed {
|
|
if m.scope.as_deref() == Some("import") && depth < MAX_PARENT_DEPTH {
|
|
let (g, a) = (interpolate(&m.group_id, &props), interpolate(&m.artifact_id, &props));
|
|
let v = interpolate(m.version.as_deref().unwrap_or_default(), &props);
|
|
if let Some(bom) = self.repo_pom(&g, &a, &v) {
|
|
imported.extend(self.resolved_managed(&bom, depth + 1));
|
|
}
|
|
}
|
|
}
|
|
let own: Vec<RawDep> = pom.managed.iter().filter(|m| m.scope.as_deref() != Some("import")).cloned().collect();
|
|
merge_by(&mut ctx.managed, &own, RawDep::key);
|
|
let mut known: HashSet<String> = ctx.managed.iter().map(RawDep::key).collect();
|
|
for d in imported {
|
|
if known.insert(d.key()) {
|
|
ctx.managed.push(d);
|
|
}
|
|
}
|
|
self.contexts.insert(path.to_path_buf(), ctx.clone());
|
|
ctx
|
|
}
|
|
|
|
/// Managed dependencies of a BOM, interpolated with the BOM's own properties.
|
|
fn resolved_managed(&mut self, bom: &Path, depth: usize) -> Vec<RawDep> {
|
|
let ctx = self.context(bom, depth);
|
|
let Some(pom) = self.pom(bom) else { return Vec::new() };
|
|
let dir = bom.parent().unwrap_or(Path::new("")).to_path_buf();
|
|
let props = self.properties_for(&pom, &ctx, &dir);
|
|
ctx.managed
|
|
.iter()
|
|
.map(|d| RawDep {
|
|
group_id: interpolate(&d.group_id, &props),
|
|
artifact_id: interpolate(&d.artifact_id, &props),
|
|
version: d.version.as_deref().map(|v| interpolate(v, &props)),
|
|
..d.clone()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Properties visible in `pom`: inherited and own `<properties>` plus `project.*` built-ins.
|
|
fn properties_for(&self, pom: &RawPom, ctx: &Inherited, dir: &Path) -> HashMap<String, String> {
|
|
let mut props: HashMap<String, String> = ctx.properties.iter().cloned().collect();
|
|
let group = pom.group_id.clone().or(ctx.group_id.clone()).unwrap_or_default();
|
|
let version = pom.version.clone().or(ctx.version.clone()).unwrap_or_default();
|
|
let basedir = dir.to_string_lossy().into_owned();
|
|
let mut set = |k: &str, v: &str| {
|
|
props.insert(k.to_owned(), v.to_owned());
|
|
};
|
|
set("project.groupId", &group);
|
|
set("project.artifactId", &pom.artifact_id);
|
|
set("project.version", &version);
|
|
set("project.packaging", pom.packaging.as_deref().unwrap_or("jar"));
|
|
set("project.basedir", &basedir);
|
|
set("basedir", &basedir);
|
|
set("project.build.directory", &format!("{basedir}/target"));
|
|
if let Some(name) = &pom.name {
|
|
set("project.name", name);
|
|
}
|
|
if let Some(p) = &pom.parent {
|
|
set("project.parent.groupId", &p.group_id);
|
|
set("project.parent.artifactId", &p.artifact_id);
|
|
set("project.parent.version", &p.version);
|
|
set("parent.version", &p.version);
|
|
}
|
|
props
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- plugins
|
|
|
|
/// `maven-clean-plugin` → `clean`, `spring-boot-maven-plugin` → `spring-boot`.
|
|
pub fn goal_prefix(artifact_id: &str) -> String {
|
|
if let Some(p) = artifact_id.strip_prefix("maven-").and_then(|r| r.strip_suffix("-plugin")) {
|
|
return p.to_owned();
|
|
}
|
|
if let Some(p) = artifact_id.strip_suffix("-maven-plugin") {
|
|
return p.to_owned();
|
|
}
|
|
artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned()
|
|
}
|
|
|
|
/// Goals of well-known plugins (their plugin descriptors are not read).
|
|
fn known_goals(artifact_id: &str) -> &'static [&'static str] {
|
|
match artifact_id {
|
|
"maven-clean-plugin" => &["clean", "help"],
|
|
"maven-resources-plugin" => &["copy-resources", "help", "resources", "testResources"],
|
|
"maven-compiler-plugin" => &["compile", "help", "testCompile"],
|
|
"maven-surefire-plugin" => &["help", "test"],
|
|
"maven-failsafe-plugin" => &["help", "integration-test", "verify"],
|
|
"maven-jar-plugin" => &["help", "jar", "test-jar"],
|
|
"maven-war-plugin" => &["exploded", "help", "inplace", "war"],
|
|
"maven-ear-plugin" => &["ear", "generate-application-xml", "help"],
|
|
"maven-install-plugin" => &["help", "install", "install-file"],
|
|
"maven-deploy-plugin" => &["deploy", "deploy-file", "help"],
|
|
"maven-site-plugin" => {
|
|
&["attach-descriptor", "deploy", "effective-site", "help", "jar", "run", "site", "stage", "stage-deploy"]
|
|
}
|
|
"maven-source-plugin" => &["aggregate", "help", "jar", "jar-no-fork", "test-jar", "test-jar-no-fork"],
|
|
"maven-javadoc-plugin" => {
|
|
&["aggregate", "aggregate-jar", "fix", "help", "jar", "javadoc", "test-jar", "test-javadoc"]
|
|
}
|
|
"maven-dependency-plugin" => &[
|
|
"analyze",
|
|
"analyze-dep-mgt",
|
|
"build-classpath",
|
|
"copy",
|
|
"copy-dependencies",
|
|
"get",
|
|
"go-offline",
|
|
"help",
|
|
"list",
|
|
"properties",
|
|
"purge-local-repository",
|
|
"resolve",
|
|
"resolve-plugins",
|
|
"sources",
|
|
"tree",
|
|
"unpack",
|
|
"unpack-dependencies",
|
|
],
|
|
"maven-help-plugin" => &[
|
|
"active-profiles",
|
|
"all-profiles",
|
|
"describe",
|
|
"effective-pom",
|
|
"effective-settings",
|
|
"evaluate",
|
|
"help",
|
|
"system",
|
|
],
|
|
"maven-enforcer-plugin" => &["display-info", "enforce", "help"],
|
|
"maven-shade-plugin" => &["help", "shade"],
|
|
"maven-assembly-plugin" => &["help", "single"],
|
|
"maven-antrun-plugin" => &["help", "run"],
|
|
"maven-release-plugin" => {
|
|
&["branch", "clean", "help", "perform", "prepare", "rollback", "stage", "update-versions"]
|
|
}
|
|
"maven-plugin-plugin" => &["addPluginArtifactMetadata", "descriptor", "help", "helpmojo", "report"],
|
|
"maven-gpg-plugin" => &["help", "sign", "sign-and-deploy-file"],
|
|
"spring-boot-maven-plugin" => &[
|
|
"build-image",
|
|
"build-image-no-fork",
|
|
"build-info",
|
|
"help",
|
|
"process-aot",
|
|
"process-test-aot",
|
|
"repackage",
|
|
"run",
|
|
"start",
|
|
"stop",
|
|
"test-run",
|
|
],
|
|
"exec-maven-plugin" => &["exec", "help", "java"],
|
|
"jacoco-maven-plugin" => &[
|
|
"check",
|
|
"dump",
|
|
"help",
|
|
"instrument",
|
|
"merge",
|
|
"prepare-agent",
|
|
"prepare-agent-integration",
|
|
"report",
|
|
"restore-instrumented-classes",
|
|
],
|
|
"build-helper-maven-plugin" => &[
|
|
"add-resource",
|
|
"add-source",
|
|
"add-test-resource",
|
|
"add-test-source",
|
|
"attach-artifact",
|
|
"help",
|
|
"parse-version",
|
|
"regex-property",
|
|
"reserve-network-port",
|
|
"timestamp-property",
|
|
],
|
|
"versions-maven-plugin" => &[
|
|
"display-dependency-updates",
|
|
"display-plugin-updates",
|
|
"display-property-updates",
|
|
"help",
|
|
"set",
|
|
"update-parent",
|
|
"update-properties",
|
|
"use-latest-releases",
|
|
],
|
|
"kotlin-maven-plugin" => &["compile", "help", "kapt", "test-compile", "test-kapt"],
|
|
"flatten-maven-plugin" => &["clean", "flatten", "help"],
|
|
"native-maven-plugin" => {
|
|
&["add-reachability-metadata", "compile", "compile-no-fork", "generate-metadata", "help", "test"]
|
|
}
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
/// Plugins Maven binds to the default lifecycle of `packaging` (plus clean and site).
|
|
fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] {
|
|
match packaging {
|
|
"pom" => &["maven-clean-plugin", "maven-install-plugin", "maven-deploy-plugin", "maven-site-plugin"],
|
|
"war" => &[
|
|
"maven-clean-plugin",
|
|
"maven-resources-plugin",
|
|
"maven-compiler-plugin",
|
|
"maven-surefire-plugin",
|
|
"maven-war-plugin",
|
|
"maven-install-plugin",
|
|
"maven-deploy-plugin",
|
|
"maven-site-plugin",
|
|
],
|
|
"maven-plugin" => &[
|
|
"maven-clean-plugin",
|
|
"maven-resources-plugin",
|
|
"maven-compiler-plugin",
|
|
"maven-plugin-plugin",
|
|
"maven-surefire-plugin",
|
|
"maven-jar-plugin",
|
|
"maven-install-plugin",
|
|
"maven-deploy-plugin",
|
|
"maven-site-plugin",
|
|
],
|
|
_ => &[
|
|
"maven-clean-plugin",
|
|
"maven-resources-plugin",
|
|
"maven-compiler-plugin",
|
|
"maven-surefire-plugin",
|
|
"maven-jar-plugin",
|
|
"maven-install-plugin",
|
|
"maven-deploy-plugin",
|
|
"maven-site-plugin",
|
|
],
|
|
}
|
|
}
|
|
|
|
/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted.
|
|
pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option<String>, goals: &[String]) -> MavenPlugin {
|
|
let mut all: Vec<String> = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect();
|
|
for g in goals {
|
|
if !all.contains(g) {
|
|
all.push(g.clone());
|
|
}
|
|
}
|
|
all.sort();
|
|
MavenPlugin {
|
|
group_id: group_id.to_owned(),
|
|
artifact_id: artifact_id.to_owned(),
|
|
version,
|
|
prefix: goal_prefix(artifact_id),
|
|
goals: all,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- loading
|
|
|
|
/// Directories never searched for POMs.
|
|
const SKIP_DIRS: &[&str] = &["node_modules", "target", "build", "out", "dist", "src", "vendor"];
|
|
const MAX_PROJECTS: usize = 1000;
|
|
const POM_SEARCH_DEPTH: usize = 3;
|
|
|
|
fn rel_path(root: &Path, p: &Path) -> String {
|
|
p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/")
|
|
}
|
|
|
|
/// The POMs that are not modules of another one: the root `pom.xml`, else the
|
|
/// shallowest `pom.xml` of each branch (a folder holding several builds).
|
|
fn top_level_poms(root: &Path) -> Vec<PathBuf> {
|
|
let top = root.join("pom.xml");
|
|
if top.is_file() {
|
|
return vec![top];
|
|
}
|
|
let mut out = Vec::new();
|
|
let mut queue: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
|
|
while let Some((dir, depth)) = queue.pop() {
|
|
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
|
let mut dirs: Vec<PathBuf> = Vec::new();
|
|
for e in entries.flatten() {
|
|
let name = e.file_name().to_string_lossy().into_owned();
|
|
if e.file_type().is_ok_and(|t| t.is_dir()) && !name.starts_with('.') && !SKIP_DIRS.contains(&name.as_str())
|
|
{
|
|
dirs.push(e.path());
|
|
}
|
|
}
|
|
dirs.sort();
|
|
for d in dirs.into_iter().rev() {
|
|
if d.join("pom.xml").is_file() {
|
|
out.push(d.join("pom.xml"));
|
|
} else if depth + 1 < POM_SEARCH_DEPTH {
|
|
queue.push((d, depth + 1));
|
|
}
|
|
}
|
|
}
|
|
out.sort();
|
|
out
|
|
}
|
|
|
|
/// Whether `root` holds a Maven build (cheap: the same search as [`load`] without parsing).
|
|
pub fn is_maven_project(root: &Path) -> bool {
|
|
!top_level_poms(root).is_empty()
|
|
}
|
|
|
|
/// The `mvnw` nearest to `dir`, looking up to `root`.
|
|
fn wrapper(root: &Path, dir: &Path) -> String {
|
|
let name = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" };
|
|
let mut cur = Some(dir);
|
|
while let Some(d) = cur {
|
|
if d.join(name).is_file() {
|
|
return d.join(name).to_string_lossy().into_owned();
|
|
}
|
|
if d == root {
|
|
break;
|
|
}
|
|
cur = d.parent();
|
|
}
|
|
if cfg!(windows) { "mvn.cmd".into() } else { "mvn".into() }
|
|
}
|
|
|
|
fn java_version(v: &str) -> Option<u32> {
|
|
let v = v.trim();
|
|
let mut parts = v.split('.');
|
|
let first: u32 = parts.next()?.trim().parse().ok()?;
|
|
if first == 1 { parts.next()?.trim().parse().ok() } else { Some(first) }
|
|
}
|
|
|
|
/// Reads every Maven project under `root` from its POMs (no Maven run).
|
|
pub fn load(root: &Path) -> MavenModel {
|
|
load_with_repo(root, default_local_repo())
|
|
}
|
|
|
|
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
|
|
let mut resolver = Resolver::new(local_repo);
|
|
let mut model = MavenModel::default();
|
|
let mut seen: HashSet<PathBuf> = HashSet::new();
|
|
// (pom, aggregated)
|
|
let mut stack: Vec<(PathBuf, bool)> = top_level_poms(root).into_iter().rev().map(|p| (p, false)).collect();
|
|
while let Some((path, aggregated)) = stack.pop() {
|
|
if model.projects.len() >= MAX_PROJECTS {
|
|
break;
|
|
}
|
|
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
|
if !seen.insert(canonical) {
|
|
continue;
|
|
}
|
|
let Some(pom) = resolver.pom(&path) else { continue };
|
|
let dir = path.parent().unwrap_or(root).to_path_buf();
|
|
let ctx = resolver.context(&path, 0);
|
|
let props = resolver.properties_for(&pom, &ctx, &dir);
|
|
let i = |s: &str| interpolate(s, &props);
|
|
|
|
let mut module_names: Vec<String> = pom.modules.clone();
|
|
for p in pom.profiles.iter().filter(|p| p.active_by_default) {
|
|
module_names.extend(p.modules.iter().cloned());
|
|
}
|
|
let mut modules: Vec<PathBuf> = Vec::new();
|
|
for m in module_names {
|
|
let mut mp = dir.join(i(&m));
|
|
if mp.is_dir() || mp.extension().is_none_or(|e| e != "xml") {
|
|
mp.push("pom.xml");
|
|
}
|
|
if mp.is_file() && !modules.contains(&mp) {
|
|
modules.push(mp);
|
|
}
|
|
}
|
|
|
|
let packaging = i(pom.packaging.as_deref().unwrap_or("jar"));
|
|
let managed: HashMap<String, &RawDep> = ctx.managed.iter().map(|m| (m.key(), m)).collect();
|
|
let managed_version = |d: &RawDep| managed.get(&d.key()).and_then(|m| m.version.clone());
|
|
let managed_scope = |d: &RawDep| managed.get(&d.key()).and_then(|m| m.scope.clone());
|
|
let dependencies = ctx
|
|
.deps
|
|
.iter()
|
|
.map(|d| MavenDependency {
|
|
group_id: i(&d.group_id),
|
|
artifact_id: i(&d.artifact_id),
|
|
version: d.version.clone().or_else(|| managed_version(d)).map(|v| i(&v)),
|
|
kind: d.kind.clone().unwrap_or_else(|| "jar".into()),
|
|
classifier: d.classifier.clone(),
|
|
scope: d.scope.clone().or_else(|| managed_scope(d)).unwrap_or_else(|| "compile".into()),
|
|
optional: d.optional,
|
|
..MavenDependency::default()
|
|
})
|
|
.collect();
|
|
|
|
let mut plugins: Vec<RawPlugin> = ctx.plugins.clone();
|
|
for a in lifecycle_plugins(&packaging) {
|
|
if !plugins.iter().any(|p| p.artifact_id == *a) {
|
|
plugins.push(RawPlugin {
|
|
group_id: "org.apache.maven.plugins".into(),
|
|
artifact_id: (*a).into(),
|
|
..RawPlugin::default()
|
|
});
|
|
}
|
|
}
|
|
let plugins = plugins
|
|
.iter()
|
|
.map(|p| {
|
|
let version = p
|
|
.version
|
|
.clone()
|
|
.or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone()))
|
|
.map(|v| i(&v))
|
|
.filter(|v| !v.contains("${"));
|
|
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals)
|
|
})
|
|
.collect();
|
|
|
|
let jv = ["maven.compiler.release", "java.version", "maven.compiler.source", "maven.compiler.target"]
|
|
.iter()
|
|
.find_map(|k| props.get(*k).map(|v| i(v)))
|
|
.or_else(|| pom.compiler_release.as_deref().map(i))
|
|
.and_then(|v| java_version(&v));
|
|
|
|
for p in &pom.profiles {
|
|
if !model.profiles.iter().any(|x| x.id == p.id) {
|
|
model.profiles.push(MavenProfile { id: p.id.clone(), active_by_default: p.active_by_default });
|
|
}
|
|
}
|
|
let artifact_id = i(&pom.artifact_id);
|
|
model.projects.push(MavenProject {
|
|
pom: rel_path(root, &path),
|
|
dir: rel_path(root, &dir),
|
|
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
|
|
version: i(props.get("project.version").map(String::as_str).unwrap_or_default()),
|
|
display_name: pom.name.as_deref().map(i).filter(|n| !n.is_empty()).unwrap_or_else(|| artifact_id.clone()),
|
|
artifact_id,
|
|
packaging,
|
|
parent: pom.parent.as_ref().map(|p| format!("{}:{}:{}", p.group_id, p.artifact_id, i(&p.version))),
|
|
modules: modules.iter().map(|m| rel_path(root, m)).collect(),
|
|
aggregated,
|
|
plugins,
|
|
dependencies,
|
|
profiles: pom.profiles.iter().map(|p| p.id.clone()).collect(),
|
|
java_version: jv,
|
|
command: wrapper(root, &dir),
|
|
options: Vec::new(),
|
|
env: BTreeMap::new(),
|
|
});
|
|
for m in modules.into_iter().rev() {
|
|
stack.push((m, true));
|
|
}
|
|
}
|
|
model
|
|
}
|
|
|
|
/// Every POM of the model (for change detection).
|
|
pub fn build_files(root: &Path, model: &MavenModel) -> Vec<PathBuf> {
|
|
model.projects.iter().map(|p| root.join(&p.pom)).collect()
|
|
}
|
|
|
|
/// Projects and their plugins / dependencies from `mvn help:effective-pom` output
|
|
/// (`<project>` or `<projects>`), keyed `groupId:artifactId`.
|
|
pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec<MavenPlugin>, Vec<MavenDependency>)> {
|
|
let Some(root) = xml::parse(text) else { return Vec::new() };
|
|
let projects: Vec<&Element> =
|
|
if root.name == "projects" { root.children_named("project").collect() } else { vec![&root] };
|
|
projects
|
|
.into_iter()
|
|
.map(|p| {
|
|
let raw = read_raw(p);
|
|
let group = raw.group_id.clone().or(raw.parent.as_ref().map(|pp| pp.group_id.clone())).unwrap_or_default();
|
|
let plugins = raw
|
|
.plugins
|
|
.iter()
|
|
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals))
|
|
.collect();
|
|
let deps = raw
|
|
.deps
|
|
.iter()
|
|
.map(|d| MavenDependency {
|
|
group_id: d.group_id.clone(),
|
|
artifact_id: d.artifact_id.clone(),
|
|
version: d.version.clone(),
|
|
kind: d.kind.clone().unwrap_or_else(|| "jar".into()),
|
|
classifier: d.classifier.clone(),
|
|
scope: d.scope.clone().unwrap_or_else(|| "compile".into()),
|
|
optional: d.optional,
|
|
..MavenDependency::default()
|
|
})
|
|
.collect();
|
|
(format!("{group}:{}", raw.artifact_id), plugins, deps)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// `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 (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),
|
|
[g, a, t, v, s] => (g, a, t, None, v, Some(*s)),
|
|
[g, a, t, c, v, s] => (g, a, t, Some(*c), v, Some(*s)),
|
|
_ => return None,
|
|
};
|
|
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(),
|
|
..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) {
|
|
if depth == 0 {
|
|
list.push(dep);
|
|
return;
|
|
}
|
|
match list.last_mut() {
|
|
Some(last) => insert_at(&mut last.children, depth - 1, dep),
|
|
None => list.push(dep),
|
|
}
|
|
}
|
|
|
|
/// Dependency trees from `mvn dependency:tree` console output, keyed `groupId:artifactId`
|
|
/// of the module; each value is the module's direct dependencies with their transitive ones.
|
|
pub fn parse_dependency_tree(output: &str) -> Vec<(String, Vec<MavenDependency>)> {
|
|
let mut out: Vec<(String, Vec<MavenDependency>)> = Vec::new();
|
|
let mut current: Option<usize> = None;
|
|
let mut expect_root = false;
|
|
for line in output.lines() {
|
|
let line = line.trim_end();
|
|
let body = line.strip_prefix("[INFO] ").or_else(|| line.strip_prefix("[INFO]"));
|
|
let Some(body) = body else {
|
|
current = None;
|
|
continue;
|
|
};
|
|
if body.starts_with("--- ") && body.contains(":tree ") {
|
|
expect_root = true;
|
|
current = None;
|
|
continue;
|
|
}
|
|
if expect_root {
|
|
// The module's own coordinates; Maven may log other lines first ("Loaded … prefixes").
|
|
if let Some(root) = parse_coords(body).filter(|_| !body.contains(' ')) {
|
|
expect_root = false;
|
|
out.push((format!("{}:{}", root.group_id, root.artifact_id), Vec::new()));
|
|
current = Some(out.len() - 1);
|
|
} else if body.starts_with("---") || body.starts_with("BUILD") {
|
|
expect_root = false;
|
|
}
|
|
continue;
|
|
}
|
|
let Some(idx) = current else { continue };
|
|
// Tree lines: depth = 3 characters of `| ` / ` ` per level before `+- ` / `\- `.
|
|
let Some(marker) = body.find("+- ").or_else(|| body.find("\\- ")) else {
|
|
current = None;
|
|
continue;
|
|
};
|
|
let prefix = &body[..marker];
|
|
if !prefix.chars().all(|c| c == ' ' || c == '|') {
|
|
current = None;
|
|
continue;
|
|
}
|
|
if let Some(dep) = parse_coords(&body[marker + 3..]) {
|
|
insert_at(&mut out[idx].1, marker / 3, dep);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn write(dir: &Path, rel: &str, text: &str) {
|
|
let p = dir.join(rel);
|
|
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
|
std::fs::write(p, text).unwrap();
|
|
}
|
|
|
|
const PARENT: &str = r#"<project>
|
|
<groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version>
|
|
<packaging>pom</packaging><name>${project.artifactId} build</name>
|
|
<modules><module>core</module><module>app</module></modules>
|
|
<properties><junit.version>5.10.2</junit.version><maven.compiler.release>21</maven.compiler.release></properties>
|
|
<dependencyManagement><dependencies>
|
|
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>${junit.version}</version><scope>test</scope></dependency>
|
|
</dependencies></dependencyManagement>
|
|
<build><pluginManagement><plugins>
|
|
<plugin><artifactId>maven-surefire-plugin</artifactId><version>3.2.5</version></plugin>
|
|
</plugins></pluginManagement></build>
|
|
<profiles>
|
|
<profile><id>dev</id><activation><activeByDefault>true</activeByDefault></activation></profile>
|
|
<profile><id>extra</id><modules><module>extra</module></modules></profile>
|
|
</profiles>
|
|
</project>"#;
|
|
|
|
#[test]
|
|
fn multi_module_parent_properties_and_managed_versions() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
write(root, "pom.xml", PARENT);
|
|
write(
|
|
root,
|
|
"core/pom.xml",
|
|
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version></parent>
|
|
<artifactId>core</artifactId>
|
|
<dependencies><dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId></dependency></dependencies>
|
|
</project>"#,
|
|
);
|
|
write(
|
|
root,
|
|
"app/pom.xml",
|
|
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version></parent>
|
|
<artifactId>app</artifactId><name>Demo App</name><packaging>war</packaging>
|
|
<dependencies>
|
|
<dependency><groupId>${project.groupId}</groupId><artifactId>core</artifactId><version>${project.version}</version></dependency>
|
|
<dependency><groupId>x</groupId><artifactId>opt</artifactId><version>2</version><optional>true</optional><scope>provided</scope></dependency>
|
|
</dependencies>
|
|
<build><plugins>
|
|
<plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId>
|
|
<executions><execution><goals><goal>repackage</goal><goal>custom</goal></goals></execution></executions>
|
|
</plugin>
|
|
</plugins></build>
|
|
</project>"#,
|
|
);
|
|
write(root, "extra/pom.xml", "<project><artifactId>extra</artifactId></project>");
|
|
let model = load_with_repo(root, None);
|
|
let names: Vec<(&str, &str, bool)> =
|
|
model.projects.iter().map(|p| (p.pom.as_str(), p.display_name.as_str(), p.aggregated)).collect();
|
|
assert_eq!(
|
|
names,
|
|
[("pom.xml", "parent build", false), ("core/pom.xml", "core", true), ("app/pom.xml", "Demo App", true)]
|
|
);
|
|
let parent = &model.projects[0];
|
|
assert_eq!(parent.modules, ["core/pom.xml", "app/pom.xml"]);
|
|
assert_eq!(parent.packaging, "pom");
|
|
assert_eq!(parent.dir, "");
|
|
let prefixes: Vec<&str> = parent.plugins.iter().map(|p| p.prefix.as_str()).collect();
|
|
assert_eq!(prefixes, ["clean", "install", "deploy", "site"]);
|
|
|
|
let core = &model.projects[1];
|
|
assert_eq!((core.group_id.as_str(), core.version.as_str(), core.dir.as_str()), ("demo", "1.0", "core"));
|
|
assert_eq!(core.parent.as_deref(), Some("demo:parent:1.0"));
|
|
assert_eq!(core.java_version, Some(21));
|
|
let junit = &core.dependencies[0];
|
|
assert_eq!((junit.version.as_deref(), junit.scope.as_str()), (Some("5.10.2"), "test"));
|
|
let surefire = core.plugins.iter().find(|p| p.prefix == "surefire").unwrap();
|
|
assert_eq!(surefire.version.as_deref(), Some("3.2.5"));
|
|
assert_eq!(surefire.goals, ["help", "test"]);
|
|
assert!(core.plugins.iter().any(|p| p.prefix == "jar"));
|
|
|
|
let app = &model.projects[2];
|
|
assert_eq!(app.packaging, "war");
|
|
let deps: Vec<(String, Option<String>, String, bool)> = app
|
|
.dependencies
|
|
.iter()
|
|
.map(|d| (d.artifact_id.clone(), d.version.clone(), d.scope.clone(), d.optional))
|
|
.collect();
|
|
assert_eq!(
|
|
deps,
|
|
[
|
|
("core".into(), Some("1.0".into()), "compile".into(), false),
|
|
("opt".into(), Some("2".into()), "provided".into(), true)
|
|
]
|
|
);
|
|
assert_eq!(app.dependencies[0].group_id, "demo");
|
|
let boot = &app.plugins[0];
|
|
assert_eq!((boot.prefix.as_str(), boot.group_id.as_str()), ("spring-boot", "org.springframework.boot"));
|
|
assert!(boot.goals.contains(&"run".to_owned()) && boot.goals.contains(&"custom".to_owned()));
|
|
assert!(boot.goals.windows(2).all(|w| w[0] <= w[1]), "sorted");
|
|
assert!(app.plugins.iter().any(|p| p.prefix == "war") && !app.plugins.iter().any(|p| p.prefix == "jar"));
|
|
|
|
let profiles: Vec<(&str, bool)> = model.profiles.iter().map(|p| (p.id.as_str(), p.active_by_default)).collect();
|
|
assert_eq!(profiles, [("dev", true), ("extra", false)]);
|
|
assert_eq!(parent.profiles, ["dev", "extra"]);
|
|
assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" }));
|
|
}
|
|
|
|
#[test]
|
|
fn parent_and_bom_from_the_local_repository_and_wrapper() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("repo");
|
|
let root = tmp.path().join("proj");
|
|
write(
|
|
&repo,
|
|
"org/boot/boot-parent/3.0/boot-parent-3.0.pom",
|
|
r#"<project><groupId>org.boot</groupId><artifactId>boot-parent</artifactId><version>3.0</version>
|
|
<properties><java.version>17</java.version><lib.version>9.9</lib.version></properties>
|
|
<dependencyManagement><dependencies>
|
|
<dependency><groupId>org.boot</groupId><artifactId>bom</artifactId><version>${project.parent.version}</version><type>pom</type><scope>import</scope></dependency>
|
|
<dependency><groupId>org.boot</groupId><artifactId>web</artifactId><version>${lib.version}</version></dependency>
|
|
</dependencies></dependencyManagement>
|
|
<build><plugins><plugin><groupId>org.boot</groupId><artifactId>boot-maven-plugin</artifactId><version>${project.version}</version></plugin></plugins></build>
|
|
<parent><groupId>org.boot</groupId><artifactId>root</artifactId><version>3.0</version></parent>
|
|
</project>"#,
|
|
);
|
|
write(
|
|
&repo,
|
|
"org/boot/root/3.0/root-3.0.pom",
|
|
"<project><groupId>org.boot</groupId><artifactId>root</artifactId><version>3.0</version></project>",
|
|
);
|
|
write(
|
|
&repo,
|
|
"org/boot/bom/3.0/bom-3.0.pom",
|
|
r#"<project><groupId>org.boot</groupId><artifactId>bom</artifactId><version>3.0</version>
|
|
<properties><json.version>2.1</json.version></properties>
|
|
<dependencyManagement><dependencies>
|
|
<dependency><groupId>org.json</groupId><artifactId>json</artifactId><version>${json.version}</version></dependency>
|
|
<dependency><groupId>org.boot</groupId><artifactId>web</artifactId><version>0.1</version></dependency>
|
|
</dependencies></dependencyManagement>
|
|
</project>"#,
|
|
);
|
|
write(
|
|
&root,
|
|
"pom.xml",
|
|
r#"<project><parent><groupId>org.boot</groupId><artifactId>boot-parent</artifactId><version>3.0</version><relativePath/></parent>
|
|
<groupId>com.example</groupId><artifactId>demo</artifactId><version>0.1-SNAPSHOT</version>
|
|
<dependencies>
|
|
<dependency><groupId>org.boot</groupId><artifactId>web</artifactId></dependency>
|
|
<dependency><groupId>org.json</groupId><artifactId>json</artifactId></dependency>
|
|
<dependency><groupId>unknown</groupId><artifactId>x</artifactId></dependency>
|
|
</dependencies>
|
|
</project>"#,
|
|
);
|
|
let wrapper = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" };
|
|
write(&root, wrapper, "#!/bin/sh");
|
|
let model = load_with_repo(&root, Some(repo));
|
|
let p = &model.projects[0];
|
|
assert_eq!(p.java_version, Some(17));
|
|
let versions: Vec<Option<&str>> = p.dependencies.iter().map(|d| d.version.as_deref()).collect();
|
|
assert_eq!(versions, [Some("9.9"), Some("2.1"), None], "parent's own entry wins over its BOM");
|
|
let boot = p.plugins.iter().find(|pl| pl.artifact_id == "boot-maven-plugin").unwrap();
|
|
assert_eq!(boot.version.as_deref(), Some("0.1-SNAPSHOT"), "interpolated with the child's version");
|
|
assert_eq!(boot.prefix, "boot");
|
|
assert!(p.command.ends_with(wrapper) && Path::new(&p.command).is_absolute());
|
|
}
|
|
|
|
#[test]
|
|
fn folders_of_separate_builds_and_module_cycles() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
write(
|
|
root,
|
|
"a/pom.xml",
|
|
"<project><groupId>g</groupId><artifactId>a</artifactId><modules><module>../b</module></modules></project>",
|
|
);
|
|
write(
|
|
root,
|
|
"b/pom.xml",
|
|
"<project><groupId>g</groupId><artifactId>b</artifactId><modules><module>../a</module></modules></project>",
|
|
);
|
|
write(root, "c/target/pom.xml", "<project><artifactId>ignored</artifactId></project>");
|
|
let model = load_with_repo(root, None);
|
|
let names: Vec<&str> = model.projects.iter().map(|p| p.artifact_id.as_str()).collect();
|
|
assert_eq!(names, ["a", "b"]);
|
|
assert!(is_maven_project(root));
|
|
assert!(!is_maven_project(&root.join("c")));
|
|
}
|
|
|
|
#[test]
|
|
fn property_interpolation() {
|
|
let props: HashMap<String, String> =
|
|
[("a", "${b}-x"), ("b", "1"), ("project.version", "2"), ("self", "${self}")]
|
|
.iter()
|
|
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
|
|
.collect();
|
|
assert_eq!(interpolate("v${a}/${pom.version}/${missing}/${", &props), "v1-x/2/${missing}/${");
|
|
assert_eq!(interpolate("${self}", &props), "${self}");
|
|
}
|
|
|
|
#[test]
|
|
fn prefixes() {
|
|
assert_eq!(goal_prefix("maven-surefire-plugin"), "surefire");
|
|
assert_eq!(goal_prefix("spring-boot-maven-plugin"), "spring-boot");
|
|
assert_eq!(goal_prefix("frontend-plugin"), "frontend");
|
|
assert_eq!(goal_prefix("odd"), "odd");
|
|
}
|
|
|
|
#[test]
|
|
fn effective_pom_projects() {
|
|
let text = r#"<?xml version="1.0"?><projects>
|
|
<project><parent><groupId>demo</groupId><artifactId>p</artifactId><version>1</version></parent>
|
|
<artifactId>core</artifactId>
|
|
<dependencies><dependency><groupId>j</groupId><artifactId>junit</artifactId><version>5</version><scope>test</scope></dependency></dependencies>
|
|
<build><plugins><plugin><artifactId>maven-clean-plugin</artifactId><version>3.2.0</version>
|
|
<executions><execution><id>default-clean</id><phase>clean</phase><goals><goal>clean</goal></goals></execution></executions>
|
|
</plugin></plugins></build>
|
|
</project>
|
|
<project><groupId>demo</groupId><artifactId>p</artifactId></project>
|
|
</projects>"#;
|
|
let projects = parse_effective_pom(text);
|
|
assert_eq!(projects.len(), 2);
|
|
let (key, plugins, deps) = &projects[0];
|
|
assert_eq!(key, "demo:core");
|
|
assert_eq!((plugins[0].prefix.as_str(), plugins[0].version.as_deref()), ("clean", Some("3.2.0")));
|
|
assert_eq!(plugins[0].goals, ["clean", "help"]);
|
|
assert_eq!((deps[0].artifact_id.as_str(), deps[0].scope.as_str()), ("junit", "test"));
|
|
assert_eq!(projects[1].0, "demo:p");
|
|
}
|
|
|
|
#[test]
|
|
fn dependency_tree_output() {
|
|
let out = "\
|
|
[INFO] --- dependency:3.7.0:tree (default-cli) @ multi-parent ---
|
|
[INFO] demo:multi-parent:pom:1.0-SNAPSHOT
|
|
[INFO]
|
|
[INFO] --- dependency:3.7.0:tree (default-cli) @ core ---
|
|
[INFO] demo:core:jar:1.0-SNAPSHOT
|
|
[INFO] +- org.junit.jupiter:junit-jupiter:jar:5.10.2:test
|
|
[INFO] | +- org.junit.jupiter:junit-jupiter-api:jar:5.10.2:test
|
|
[INFO] | | \\- org.opentest4j:opentest4j:jar:1.3.0:test
|
|
[INFO] | \\- org.junit.jupiter:junit-jupiter-engine:jar:5.10.2:test
|
|
[INFO] \\- com.x:native:jar:linux:1.0:compile (optional)
|
|
[INFO] ------------------------------------------------------------------------
|
|
[INFO] --- dependency:3.10.0:tree (default-cli) @ app ---
|
|
[INFO] Loaded 74 auto-discovered prefixes for remote repository apache.snapshots (prefixes-apache.snapshots.txt)
|
|
[INFO] demo:app:jar:1.0-SNAPSHOT
|
|
[INFO] \\- demo:core:jar:1.0-SNAPSHOT:compile
|
|
";
|
|
let trees = parse_dependency_tree(out);
|
|
let keys: Vec<&str> = trees.iter().map(|(k, _)| k.as_str()).collect();
|
|
assert_eq!(keys, ["demo:multi-parent", "demo:core", "demo:app"]);
|
|
assert!(trees[0].1.is_empty());
|
|
let core = &trees[1].1;
|
|
assert_eq!(core.len(), 2);
|
|
assert_eq!(core[0].artifact_id, "junit-jupiter");
|
|
let kids: Vec<&str> = core[0].children.iter().map(|d| d.artifact_id.as_str()).collect();
|
|
assert_eq!(kids, ["junit-jupiter-api", "junit-jupiter-engine"]);
|
|
assert_eq!(core[0].children[0].children[0].artifact_id, "opentest4j");
|
|
let native = &core[1];
|
|
assert_eq!(
|
|
(native.classifier.as_deref(), native.version.as_deref(), native.optional),
|
|
(Some("linux"), Some("1.0"), true)
|
|
);
|
|
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}");
|
|
}
|
|
}
|
|
}
|