mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
336 lines
13 KiB
Rust
336 lines
13 KiB
Rust
//! Reading the effective build from Maven / Gradle itself ("Reload" in the tool
|
|
//! windows): `mvn help:effective-pom dependency:tree` 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.
|
|
|
|
use crate::gradle::{self, GradleModel, GradleTask};
|
|
use crate::maven::{self, MavenDependency, MavenModel, MavenPlugin};
|
|
use crate::process::{self, Output};
|
|
use ide_core::{CancellationToken, IdeError, IdeResult};
|
|
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
|
use specta::Type;
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SyncProgress {
|
|
pub message: String,
|
|
/// 0..1, or `None` when indeterminate.
|
|
pub fraction: Option<f32>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct SyncOptions {
|
|
/// `-o` / `--offline`.
|
|
pub offline: bool,
|
|
/// Maven profiles (`-P`), `!id` deactivates.
|
|
pub profiles: Vec<String>,
|
|
/// Extra environment (`JAVA_HOME`).
|
|
pub env: BTreeMap<String, String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
struct CachedMavenProject {
|
|
key: String,
|
|
plugins: Vec<MavenPlugin>,
|
|
/// Effective direct dependencies, with transitive ones when `dependency:tree` ran.
|
|
dependencies: Vec<MavenDependency>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
struct MavenCache {
|
|
hash: String,
|
|
at_ms: f64,
|
|
error: Option<String>,
|
|
projects: Vec<CachedMavenProject>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
struct GradleCache {
|
|
hash: String,
|
|
at_ms: f64,
|
|
error: Option<String>,
|
|
tasks: Vec<(String, GradleTask)>,
|
|
}
|
|
|
|
const MAVEN_CACHE: &str = "maven.json";
|
|
const GRADLE_CACHE: &str = "gradle.json";
|
|
|
|
fn read_cache<T: DeserializeOwned>(dir: &Path, name: &str) -> Option<T> {
|
|
serde_json::from_slice(&std::fs::read(dir.join(name)).ok()?).ok()
|
|
}
|
|
|
|
fn write_cache<T: Serialize>(dir: &Path, name: &str, value: &T) -> IdeResult<()> {
|
|
std::fs::create_dir_all(dir)?;
|
|
let json = serde_json::to_vec(value).map_err(|e| IdeError::internal(e.to_string()))?;
|
|
let tmp = dir.join(format!("{name}.tmp"));
|
|
std::fs::write(&tmp, json)?;
|
|
std::fs::rename(&tmp, dir.join(name))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// FNV-1a over the paths and contents of `files` (missing files count as empty).
|
|
fn hash_files(files: &[PathBuf]) -> String {
|
|
let mut h: u64 = 0xcbf29ce484222325;
|
|
let mut feed = |bytes: &[u8]| {
|
|
for b in bytes {
|
|
h ^= u64::from(*b);
|
|
h = h.wrapping_mul(0x100000001b3);
|
|
}
|
|
};
|
|
for f in files {
|
|
feed(f.to_string_lossy().as_bytes());
|
|
feed(&[0]);
|
|
feed(&std::fs::read(f).unwrap_or_default());
|
|
feed(&[0]);
|
|
}
|
|
format!("{h:016x}")
|
|
}
|
|
|
|
fn now_ms() -> f64 {
|
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_millis() as f64)
|
|
}
|
|
|
|
/// Merges the cached effective build into `model`.
|
|
pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) {
|
|
let Some(cache) = read_cache::<MavenCache>(cache_dir, MAVEN_CACHE) else { return };
|
|
for p in &mut model.projects {
|
|
if let Some(c) = cache.projects.iter().find(|c| c.key == p.key()) {
|
|
if !c.plugins.is_empty() {
|
|
p.plugins = c.plugins.clone();
|
|
}
|
|
p.dependencies = c.dependencies.clone();
|
|
}
|
|
}
|
|
model.synced_at = Some(cache.at_ms);
|
|
model.sync_error = cache.error;
|
|
model.stale = cache.hash != hash_files(&maven::build_files(root, model));
|
|
}
|
|
|
|
/// Merges the cached task list into `model`.
|
|
pub fn apply_gradle_cache(root: &Path, cache_dir: &Path, model: &mut GradleModel) {
|
|
let Some(cache) = read_cache::<GradleCache>(cache_dir, GRADLE_CACHE) else { return };
|
|
if !cache.tasks.is_empty() {
|
|
for p in &mut model.projects {
|
|
let tasks: Vec<GradleTask> =
|
|
cache.tasks.iter().filter(|(path, _)| *path == p.path).map(|(_, t)| t.clone()).collect();
|
|
if !tasks.is_empty() {
|
|
p.tasks = tasks;
|
|
}
|
|
}
|
|
}
|
|
model.synced_at = Some(cache.at_ms);
|
|
model.sync_error = cache.error;
|
|
model.stale = cache.hash != hash_files(&gradle::build_files(root, model));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- processes
|
|
|
|
fn run(
|
|
program: &str,
|
|
args: &[String],
|
|
cwd: &Path,
|
|
env: &BTreeMap<String, String>,
|
|
cancel: &CancellationToken,
|
|
mut on_line: impl FnMut(&str),
|
|
) -> IdeResult<Output> {
|
|
process::run(program, args, cwd, env, cancel, |_, line| on_line(line))
|
|
}
|
|
|
|
/// The first error lines of a failed build, for the tool window.
|
|
pub(crate) fn error_summary(text: &str) -> String {
|
|
let lines: Vec<&str> = text
|
|
.lines()
|
|
.filter(|l| {
|
|
l.starts_with("[ERROR]")
|
|
|| l.starts_with("FAILURE:")
|
|
|| l.starts_with("* What went wrong")
|
|
|| l.starts_with("> ")
|
|
})
|
|
.map(|l| l.trim_start_matches("[ERROR]").trim())
|
|
.filter(|l| !l.is_empty() && !l.starts_with("Re-run Maven") && !l.starts_with("To see the full stack trace"))
|
|
.take(4)
|
|
.collect();
|
|
if lines.is_empty() {
|
|
text.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("the build failed").trim().to_owned()
|
|
} else {
|
|
lines.join("\n")
|
|
}
|
|
}
|
|
|
|
/// `[INFO] Building core 1.0-SNAPSHOT [2/3]` → ("core 1.0-SNAPSHOT", 2, 3).
|
|
pub(crate) fn maven_building(line: &str) -> Option<(String, u32, u32)> {
|
|
let body = line.strip_prefix("[INFO] Building ")?;
|
|
let open = body.rfind('[')?;
|
|
let (n, m) = body[open + 1..].trim_end_matches(']').split_once('/')?;
|
|
Some((body[..open].trim().to_owned(), n.trim().parse().ok()?, m.trim().parse().ok()?))
|
|
}
|
|
|
|
/// Runs Maven on every top-level project and caches effective plugins and dependency trees.
|
|
pub fn sync_maven(
|
|
root: &Path,
|
|
cache_dir: &Path,
|
|
options: &SyncOptions,
|
|
cancel: &CancellationToken,
|
|
progress: &dyn Fn(SyncProgress),
|
|
) -> 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 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"));
|
|
let _ = std::fs::remove_file(&effective);
|
|
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into()];
|
|
if options.offline {
|
|
args.push("-o".into());
|
|
}
|
|
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());
|
|
progress(SyncProgress { message: format!("Reading {}", top.display_name), fraction: None });
|
|
let out = run(&top.command, &args, &root.join(&top.dir), &options.env, cancel, |line| {
|
|
if let Some((name, i, total)) = maven_building(line) {
|
|
let fraction = (i.saturating_sub(1) as f32 / total.max(1) as f32).min(1.0);
|
|
progress(SyncProgress { message: format!("Building model of {name}"), fraction: Some(fraction) });
|
|
} else if let Some(url) =
|
|
line.strip_prefix("Downloading from ").and_then(|l| l.split_once(": ")).map(|(_, u)| u)
|
|
{
|
|
progress(SyncProgress {
|
|
message: format!("Downloading {}", url.rsplit('/').next().unwrap_or(url)),
|
|
fraction: None,
|
|
});
|
|
}
|
|
})?;
|
|
let effective_projects =
|
|
std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).unwrap_or_default();
|
|
let trees = maven::parse_dependency_tree(&out.text);
|
|
for (key, plugins, deps) in effective_projects {
|
|
let dependencies = trees.iter().find(|(k, _)| *k == key).map(|(_, t)| t.clone()).unwrap_or(deps);
|
|
cache.projects.push(CachedMavenProject { key, plugins, dependencies });
|
|
}
|
|
// Modules whose effective POM could not be written still get their tree.
|
|
for (key, tree) in trees {
|
|
if !cache.projects.iter().any(|c| c.key == key) {
|
|
cache.projects.push(CachedMavenProject { key, plugins: Vec::new(), dependencies: tree });
|
|
}
|
|
}
|
|
if !out.success {
|
|
errors.push(error_summary(&out.text));
|
|
}
|
|
}
|
|
cache.at_ms = now_ms();
|
|
cache.error = (!errors.is_empty()).then(|| errors.join("\n"));
|
|
write_cache(cache_dir, MAVEN_CACHE, &cache)
|
|
}
|
|
|
|
/// Runs `gradle tasks --all` and caches the task list.
|
|
pub fn sync_gradle(
|
|
root: &Path,
|
|
cache_dir: &Path,
|
|
options: &SyncOptions,
|
|
cancel: &CancellationToken,
|
|
progress: &dyn Fn(SyncProgress),
|
|
) -> IdeResult<()> {
|
|
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
|
|
let mut args: Vec<String> = vec!["--console=plain".into(), "-q".into(), "tasks".into(), "--all".into()];
|
|
if options.offline {
|
|
args.push("--offline".into());
|
|
}
|
|
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
|
|
let out = run(&model.command, &args, &root.join(&model.dir), &options.env, cancel, |_| {})?;
|
|
let cache = GradleCache {
|
|
hash: hash_files(&gradle::build_files(root, &model)),
|
|
at_ms: now_ms(),
|
|
error: (!out.success).then(|| error_summary(&out.text)),
|
|
tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() },
|
|
};
|
|
write_cache(cache_dir, GRADLE_CACHE, &cache)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn caches_merge_and_go_stale() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join("p");
|
|
let cache_dir = tmp.path().join("cache");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
std::fs::write(root.join("pom.xml"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
|
|
.unwrap();
|
|
let mut model = maven::load_with_repo(&root, None);
|
|
assert!(model.synced_at.is_none());
|
|
let cache = MavenCache {
|
|
hash: hash_files(&maven::build_files(&root, &model)),
|
|
at_ms: 5.0,
|
|
error: None,
|
|
projects: vec![CachedMavenProject {
|
|
key: "g:a".into(),
|
|
plugins: vec![maven::plugin(
|
|
"org.apache.maven.plugins",
|
|
"maven-clean-plugin",
|
|
Some("3.2.0".into()),
|
|
&[],
|
|
)],
|
|
dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }],
|
|
}],
|
|
};
|
|
write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap();
|
|
apply_maven_cache(&root, &cache_dir, &mut model);
|
|
assert_eq!(model.synced_at, Some(5.0));
|
|
assert!(!model.stale);
|
|
assert_eq!(model.projects[0].plugins.len(), 1);
|
|
assert_eq!(model.projects[0].dependencies[0].artifact_id, "dep");
|
|
|
|
std::fs::write(
|
|
root.join("pom.xml"),
|
|
"<project><groupId>g</groupId><artifactId>a</artifactId><version>2</version></project>",
|
|
)
|
|
.unwrap();
|
|
let mut model = maven::load_with_repo(&root, None);
|
|
apply_maven_cache(&root, &cache_dir, &mut model);
|
|
assert!(model.stale);
|
|
}
|
|
|
|
#[test]
|
|
fn gradle_cache_replaces_tasks_per_project() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path().join("p");
|
|
let cache_dir = tmp.path().join("cache");
|
|
std::fs::create_dir_all(root.join("app")).unwrap();
|
|
std::fs::write(root.join("settings.gradle"), "include 'app'").unwrap();
|
|
std::fs::write(root.join("app/build.gradle"), "apply plugin: 'java'").unwrap();
|
|
let mut model = gradle::load(&root).unwrap();
|
|
let cache = GradleCache {
|
|
hash: hash_files(&gradle::build_files(&root, &model)),
|
|
at_ms: 1.0,
|
|
error: None,
|
|
tasks: vec![(":app".into(), GradleTask { name: "hello".into(), group: "demo".into(), description: None })],
|
|
};
|
|
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
|
|
apply_gradle_cache(&root, &cache_dir, &mut model);
|
|
assert_eq!(model.projects[1].tasks.len(), 1);
|
|
assert!(model.projects[0].tasks.iter().any(|t| t.name == "wrapper"), "root keeps its defaults");
|
|
assert!(!model.stale);
|
|
}
|
|
|
|
#[test]
|
|
fn progress_and_error_lines() {
|
|
assert_eq!(
|
|
maven_building("[INFO] Building core 1.0-SNAPSHOT [2/3]"),
|
|
Some(("core 1.0-SNAPSHOT".into(), 2, 3))
|
|
);
|
|
assert_eq!(maven_building("[INFO] Building jar: x.jar"), None);
|
|
let text = "[INFO] x\n[ERROR] Failed to execute goal\n[ERROR] \n[ERROR] Re-run Maven using -X\n";
|
|
assert_eq!(error_summary(text), "Failed to execute goal");
|
|
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
|
|
}
|
|
}
|