Files
openidea/crates/ide-build/src/lib.rs
l0ng-ai 43c85ce8f3 fix(gradle): unlink a build by the path the UI sends
Linked builds are saved normalised (canonical, forward slashes), but unlinking
compared the paths it was given verbatim, so a build outside the project could
not be unlinked on Windows (C:\… vs C:/…), nor through a symlinked path. Unlink
now normalises each path the way linking saves it. The Gradle linkage tests also
create the platform's wrapper (gradlew.bat on Windows) and compare canonical
paths without the \\?\ prefix.
2026-11-05 15:04:23 +08:00

292 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Build tool models for the Maven and Gradle tool windows, and Build › Build
//! Project ([`compile`]) for Maven, Gradle, Cargo, Go and TypeScript projects.
//!
//! [`load`] reads the project's build files (instant, no build tool involved)
//! and merges what the last sync ([`sync_maven`] / [`sync_gradle`], which run
//! Maven / Gradle in the background) cached for the project.
pub mod application_update;
pub mod compile;
pub mod gradle;
mod gradle_deps;
pub mod kotlin_bridge;
pub mod maven;
pub mod pom_edit;
mod process;
mod sync;
pub mod web;
mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
pub use gradle_deps::{GradleDependency, gradle_dependencies};
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
pub use sync::{
SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_gradle_model, sync_maven,
};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum BuildTool {
Maven,
Gradle,
}
/// The build systems of a project; `None` when the project does not use one.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct BuildModel {
pub maven: Option<MavenModel>,
#[serde(default)]
pub gradle: Vec<GradleModel>,
}
impl BuildModel {
/// Prefer the deepest linked build owning the module over a composite including it.
pub fn gradle_for_module(&self, root: &Path, module: &Path) -> Option<&GradleModel> {
let module = root.join(module);
self.gradle.iter().filter(|g| g.projects.iter().any(|p| root.join(&p.dir) == module)).min_by_key(|g| {
let dir = root.join(&g.dir);
std::cmp::Reverse(if module.starts_with(&dir) { dir.components().count() } else { 0 })
})
}
}
/// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`.
/// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one.
pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> BuildModel {
load_linked(
root,
cache_dir,
maven_repo,
&ide_core::settings::MavenSettings::default(),
&ide_core::settings::GradleSettings::default(),
)
}
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
pub fn load_linked(
root: &Path,
cache_dir: &Path,
maven_repo: Option<PathBuf>,
settings: &ide_core::settings::MavenSettings,
gradle_settings: &ide_core::settings::GradleSettings,
) -> BuildModel {
let maven = maven::has_projects(root, settings).then(|| {
let profiles = sync::maven_profiles(cache_dir);
let repo = maven_repo.or_else(maven::default_local_repo);
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
// The user settings' profiles can be chosen too (`-P` applies to them as well).
for p in maven::user_settings_file(root, settings).map(|f| maven::settings_profiles(&f)).unwrap_or_default() {
if !m.profiles.iter().any(|x| x.id == p.id) {
m.profiles.push(p);
}
}
sync::apply_maven_cache(root, cache_dir, &mut m);
m
});
let gradle = gradle::load_linked(root, gradle_settings)
.into_iter()
.map(|mut g| {
sync::apply_gradle_cache(root, &gradle::cache_dir(cache_dir, &g.dir), &mut g);
g
})
.collect();
BuildModel { maven, gradle }
}
/// Gradle project folders outside `root` as of the last sync (`includeFlat` siblings):
/// extra content roots, as IDEA lists module roots beside the project folder.
/// Reads only the sync's cached projects (no build script), so it is cheap enough for hot paths.
pub fn gradle_external_roots(
root: &Path,
cache_dir: &Path,
settings: &ide_core::settings::GradleSettings,
) -> Vec<PathBuf> {
let builds = gradle::linked_dirs(root, settings);
let dirs: Vec<String> = builds
.iter()
.flat_map(|dir| {
std::iter::once(root.join(dir).to_string_lossy().into_owned()).chain(
sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir))
.iter()
.map(|p| p.dir.clone())
.collect::<Vec<_>>(),
)
})
.collect();
gradle::external_roots(root, dirs.iter().map(String::as_str))
}
/// Source, resource and build folders of the last Gradle sync, absolute.
pub fn gradle_source_roots(
root: &Path,
cache_dir: &Path,
settings: &ide_core::settings::GradleSettings,
) -> Vec<(PathBuf, gradle::ReportedRoot)> {
let reported = gradle::linked_dirs(root, settings)
.iter()
.flat_map(|dir| sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir)).as_ref().clone())
.collect::<Vec<_>>();
gradle::reported_source_roots(&reported)
}
/// A library the project depends on, as `groupId:artifactId:version`.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DependencyCoordinate {
/// Gradle builds resolve into the Gradle cache, Maven ones into the local repository.
pub gradle: bool,
pub group_id: String,
pub artifact_id: String,
pub version: String,
}
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
/// (with the transitive ones the last sync read), except the build's own modules,
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
let mut out = Vec::new();
if let Some(m) = &model.maven {
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;
};
if d.kind == "pom" || matches!(d.scope.as_str(), "import" | "system") {
continue;
}
if modules.contains(&format!("{}:{}", d.group_id, d.artifact_id)) {
continue;
}
out.push(DependencyCoordinate {
gradle: false,
group_id: d.group_id.clone(),
artifact_id: d.artifact_id.clone(),
version: version.to_owned(),
});
}
}
for g in &model.gradle {
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
}));
}
}
out.sort();
out.dedup();
out
}
/// Whether a created / changed / removed project-relative path can change the build model.
pub fn affects_build(rel: &Path) -> bool {
let Some(name) = rel.file_name().and_then(|n| n.to_str()) else { return false };
name == "pom.xml"
|| gradle::SETTINGS_FILES.contains(&name)
|| gradle::BUILD_FILES.contains(&name)
|| matches!(name, "mvnw" | "mvnw.cmd" | "gradlew" | "gradlew.bat")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn external_linked_build_is_indexed_before_sync_and_disappears_when_unlinked() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("workspace");
let external = temp.path().join("external");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(&external).unwrap();
std::fs::write(external.join("settings.gradle"), "").unwrap();
let mut settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
..Default::default()
};
assert_eq!(
gradle_external_roots(&root, &temp.path().join("cache"), &settings),
[dunce::canonicalize(&external).unwrap()]
);
settings.linked_projects = Some(vec![]);
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
}
#[test]
fn detects_build_systems_and_build_files() {
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join("cache");
let root = tmp.path().join("p");
std::fs::create_dir_all(root.join("svc")).unwrap();
assert_eq!(load(&root, &cache, None), BuildModel::default());
std::fs::write(root.join("svc/pom.xml"), "<project><artifactId>svc</artifactId></project>").unwrap();
std::fs::write(root.join("build.gradle"), "").unwrap();
let m = load(&root, &cache, None);
assert_eq!(m.maven.unwrap().projects[0].pom, "svc/pom.xml");
assert_eq!(m.gradle[0].projects[0].path, ":");
assert!(affects_build(Path::new("a/pom.xml")) && affects_build(Path::new("settings.gradle.kts")));
assert!(!affects_build(Path::new("src/Main.java")));
}
#[test]
fn dependency_coordinates_flatten_maven_trees_and_read_gradle_scripts() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "dependencies { implementation 'org.a:x:1.0' }\n").unwrap();
let dep = |g: &str, a: &str, v: Option<&str>, children: Vec<MavenDependency>| MavenDependency {
group_id: g.into(),
artifact_id: a.into(),
version: v.map(Into::into),
kind: "jar".into(),
scope: "compile".into(),
children,
..Default::default()
};
let maven = MavenModel {
projects: vec![
MavenProject {
group_id: "me".into(),
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![]),
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()
},
MavenProject { group_id: "me".into(), artifact_id: "core".into(), ..Default::default() },
],
..Default::default()
};
let gradle = gradle::load(root).into_iter().collect();
let model = BuildModel { maven: Some(maven), gradle };
let names: Vec<String> = dependency_coordinates(root, &model)
.into_iter()
.map(|c| format!("{}{}:{}:{}", if c.gradle { "G " } else { "M " }, c.group_id, c.artifact_id, c.version))
.collect();
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
}
}