Files
openidea/crates/ide-build/src/sync.rs
T

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");
}
}