Файли
openidea/crates/ide-build/src/compile/mod.rs
T
l0ng-ai ba9ce1d842
CI / Changes (push) Successful in 1s
Language smoke / Language smoke (go) (push) Failing after 6s
Language smoke / Language smoke (java) (push) Failing after 5s
Language smoke / Language smoke (kotlin) (push) Failing after 5s
Language smoke / Language smoke (python) (push) Failing after 4s
Language smoke / Language smoke (typescript) (push) Failing after 4s
Language smoke / Language smoke (rust) (push) Failing after 5s
CI / Rust (ubuntu-22.04) (push) Failing after 43s
CI / Plugins (SDK, official, fixtures) (push) Failing after 1m19s
CI / Remote server (linux musl) (push) Failing after 1m32s
CI / Cargo deny (push) Successful in 54s
CI / E2E (push) Successful in 15m57s
CI / Frontend (push) Successful in 24m49s
CI / Rust (macos-latest) (push) Canceled after 0s
CI / Rust (windows-latest) (push) Canceled after 0s
fix(workspace): unify nested project discovery and run ownership (#1716)
* fix(build): scope Kotlin bridge to linked Maven modules

* fix(run): resolve nested Go modules for context runs

* fix(gradle): discover nested builds and select compatible JVMs

* fix(workspace): share project discovery and module ownership

* fix(run): keep root toolchains for nested modules and resolve them once per module

- Python run configurations of a nested module without its own virtualenv use the closest one up to the project root.
- Gradle builds and runs fall back to the JDK matching the project's Java version when the Gradle version is unknown.
- Module-local interpreters and toolchains are resolved lazily, once per owning module.
- Tests build expected paths per segment so they hold on Windows.
2026-11-06 15:51:41 +08:00

1280 рядки
54 KiB
Rust
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
//! Build › Build Project / Rebuild / Build Module / Build Artifacts: picks the
//! build system of the project (or of the file's module), runs it in the
//! background with cancellation and parses the compiler output into messages
//! for the Build tool window, the Problems view and the editor.
mod fingerprint;
mod parse;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
use crate::process::{self, Stream};
use crate::{BuildModel, maven};
use ide_core::settings::CMakeProfile;
use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity};
use parse::{ColUnit, Format, OutputParser, RawMessage};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Instant;
/// Diagnostics source of build messages in the Problems view and the editor.
pub const DIAGNOSTICS_SOURCE: &str = "build";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum BuildKind {
/// Incremental compile of sources and tests.
Build,
/// Clean, then build.
Rebuild,
/// Package the outputs (`package`, `assemble`, `cargo build --release`, `npm run build`).
Artifacts,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum BuildSystem {
Maven,
Gradle,
Cargo,
Go,
#[serde(rename = "typescript")]
TypeScript,
#[serde(rename = "cmake")]
CMake,
}
/// One command of a build.
#[derive(Clone, Debug, PartialEq)]
pub struct BuildStep {
pub program: String,
pub args: Vec<String>,
pub cwd: PathBuf,
pub env: BTreeMap<String, String>,
format: Format,
}
impl BuildStep {
fn new(program: impl Into<String>, args: &[&str], cwd: &Path, format: Format) -> Self {
BuildStep {
program: program.into(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
cwd: cwd.to_path_buf(),
env: BTreeMap::new(),
format,
}
}
/// The command line as echoed in the console.
pub fn display(&self) -> String {
let program = Path::new(&self.program).file_name().map_or(self.program.clone(), |n| n.to_string_lossy().into());
std::iter::once(program)
.chain(self.args.iter().map(|a| if a.contains(' ') { format!("\"{a}\"") } else { a.clone() }))
.collect::<Vec<_>>()
.join(" ")
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct BuildPlan {
pub system: BuildSystem,
/// Tree root title: `demo` / `Build module 'core'`.
pub title: String,
pub steps: Vec<BuildStep>,
}
/// A compiler message in IDE coordinates (0-based line, UTF-16 column).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct BuildMessage {
pub severity: Severity,
pub text: String,
/// Absolute path; `None` for messages about the build itself.
pub path: Option<ide_core::IpcPath>,
pub range: Option<Range>,
pub code: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum BuildEvent {
/// The build starts: its tree root.
Started {
system: BuildSystem,
title: String,
},
/// A command starts (echoed in the console).
Step {
command: String,
cwd: ide_core::IpcPath,
},
/// Console output (one or more lines, each ending in `\n`).
Output {
text: String,
stderr: bool,
},
Message {
message: BuildMessage,
},
/// Status bar progress; `fraction` 0..1 or `None` when indeterminate.
Progress {
message: String,
fraction: Option<f32>,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct BuildResult {
pub system: BuildSystem,
pub title: String,
pub success: bool,
pub errors: u32,
pub warnings: u32,
pub duration_ms: f64,
pub messages: Vec<BuildMessage>,
}
// ---------------------------------------------------------------- planning
fn has(dir: &Path, names: &[&str]) -> bool {
names.iter().any(|n| dir.join(n).is_file())
}
fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem> {
let rel = rel_str(root, dir);
if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| p.dir == rel)) || has(dir, &["pom.xml"]) {
Some(BuildSystem::Maven)
} else if model.gradle_for_module(root, dir).is_some()
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
{
Some(BuildSystem::Gradle)
} else if has(dir, &["Cargo.toml"]) {
Some(BuildSystem::Cargo)
} else if has(dir, &["go.mod"]) {
Some(BuildSystem::Go)
} else if has(dir, &["tsconfig.json", "package.json"]) {
Some(BuildSystem::TypeScript)
} else if has(dir, &["CMakeLists.txt"]) {
Some(BuildSystem::CMake)
} else {
None
}
}
/// Resolves a workspace-relative or absolute directory with native path separators.
fn subdir(root: &Path, rel: &str) -> PathBuf {
root.join(rel).components().collect()
}
fn rel_str(root: &Path, dir: &Path) -> String {
dir.strip_prefix(root).unwrap_or(dir).to_string_lossy().replace('\\', "/")
}
/// Directories from `file`'s folder up to `root` (inclusive).
fn ancestors_within<'a>(root: &'a Path, file: &'a Path) -> impl Iterator<Item = &'a Path> {
let start = if file.is_dir() { Some(file) } else { file.parent() };
start.into_iter().flat_map(Path::ancestors).take_while(move |d| d.starts_with(root))
}
/// The build of `root`, or of the module containing `file` (Build Module / Recompile).
/// `cmake`: the CMake profiles of the settings (the first one is built), if any.
pub fn plan(
root: &Path,
model: &BuildModel,
kind: BuildKind,
file: Option<&Path>,
cmake: Option<&[CMakeProfile]>,
) -> IdeResult<BuildPlan> {
let project_name = root.file_name().map_or_else(|| "project".to_owned(), |n| n.to_string_lossy().into_owned());
let module_dir = match file {
Some(f) => {
let scope = model
.gradle
.iter()
.flat_map(|g| std::iter::once(root.join(&g.dir)).chain(g.projects.iter().map(|p| root.join(&p.dir))))
.filter(|dir| f.starts_with(dir))
.max_by_key(|dir| dir.components().count())
.unwrap_or_else(|| root.to_path_buf());
let dir = ancestors_within(&scope, f)
.find(|d| detect_dir(d, model, root).is_some())
.ok_or_else(|| IdeError::not_found(format!("{} is not in a module", f.display())))?;
Some(dir.to_path_buf())
}
None => None,
};
let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf());
// A folder of sibling projects has no build file itself; its Maven projects are built one by one.
let system = detect_dir(&dir, model, root)
.or((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.ok_or_else(|| {
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
})?;
let module = module_dir.as_deref().filter(|d| *d != root);
let title = match module {
Some(d) => format!(
"{}: build module '{}'",
project_name,
d.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned())
),
None => project_name,
};
let steps = match system {
BuildSystem::Maven => maven_steps(root, model, kind, module_dir.as_deref())?,
BuildSystem::Gradle => gradle_steps(root, model, kind, module_dir.as_deref())?,
BuildSystem::Cargo => cargo_steps(&dir, kind),
// Go builds the file's package (a module holds many).
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind),
BuildSystem::TypeScript => ts_steps(&dir, kind),
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
};
Ok(BuildPlan { system, title, steps })
}
pub(super) fn maven_steps(
root: &Path,
model: &BuildModel,
kind: BuildKind,
module: Option<&Path>,
) -> IdeResult<Vec<BuildStep>> {
let fallback;
let m = match &model.maven {
Some(m) => m,
None => {
fallback = maven::load(root);
&fallback
}
};
let goals: &[&str] = match kind {
BuildKind::Build => &["test-compile"],
BuildKind::Rebuild => &["clean", "test-compile"],
BuildKind::Artifacts => &["package", "-DskipTests"],
};
let tops: Vec<&maven::MavenProject> = m.projects.iter().filter(|p| !p.aggregated).collect();
if tops.is_empty() {
return Err(IdeError::not_found("no Maven project found"));
}
let step = |p: &maven::MavenProject, extra: &[String]| {
let mut s = BuildStep::new(&p.command, &["-B", "-Dstyle.color=never"], &subdir(root, &p.dir), Format::Maven);
s.args.extend(p.options.iter().cloned());
s.args.extend(extra.iter().cloned());
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
s.env = p.env.clone();
s
};
let Some(module) = module else { return Ok(tops.iter().map(|p| step(p, &[])).collect()) };
// The reactor root containing the module builds it with what it depends on (`-pl … -am`).
let rel = rel_str(root, module);
if !m.projects.iter().any(|p| p.dir == rel) {
return Err(IdeError::not_found("no linked Maven project found for module"));
}
let top = maven_reactor(m, &rel).ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
let top_dir = subdir(root, &top.dir);
if module == top_dir {
return Ok(vec![step(top, &[])]);
}
let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or(rel);
Ok(vec![step(top, &["-pl".into(), pl, "-am".into()])])
}
fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult<Vec<BuildStep>> {
if module.is_none() && !model.gradle.is_empty() {
return Ok(model.gradle.iter().flat_map(|g| gradle_step(root, g, kind, None)).collect());
}
let g = module
.and_then(|m| model.gradle_for_module(root, m))
.ok_or_else(|| IdeError::not_found("module does not belong to a linked Gradle build"))?;
Ok(gradle_step(root, g, kind, module))
}
fn gradle_step(root: &Path, g: &crate::GradleModel, kind: BuildKind, module: Option<&Path>) -> Vec<BuildStep> {
let prefix = module
.and_then(|m| {
let rel = rel_str(root, m);
g.projects.iter().filter(|p| p.dir == rel).map(|p| p.path.clone()).next()
})
.filter(|p| p != ":")
.map(|p| format!("{p}:"))
.unwrap_or_default();
let tasks: Vec<String> = match kind {
BuildKind::Build => vec![format!("{prefix}classes"), format!("{prefix}testClasses")],
BuildKind::Rebuild => {
vec![format!("{prefix}clean"), format!("{prefix}classes"), format!("{prefix}testClasses")]
}
BuildKind::Artifacts => vec![format!("{prefix}assemble")],
};
let mut s = BuildStep::new(&g.command, &["--console=plain"], &subdir(root, &g.dir), Format::Gradle);
s.args.extend(tasks);
s.env = g.env.clone();
vec![s]
}
fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
let build = |release: bool| {
let mut args = vec!["build", "--all-targets", "--message-format=json"];
if release {
args = vec!["build", "--release", "--message-format=json"];
}
args.extend(["--manifest-path", "Cargo.toml"]);
BuildStep::new("cargo", &args, dir, Format::Cargo)
};
match kind {
BuildKind::Build => vec![build(false)],
BuildKind::Rebuild => {
vec![BuildStep::new("cargo", &["clean", "--manifest-path", "Cargo.toml"], dir, Format::Cargo), build(false)]
}
BuildKind::Artifacts => vec![build(true)],
}
}
/// CLion's Build Project for a CMake project: configures the build directory when it has no
/// `CMakeCache.txt` yet, then `cmake --build` (`--clean-first` to rebuild). The directory and
/// options come from the CMake profile; without one, a configured directory of the project
/// (the first by name), else `build`, as the generated run configurations use.
fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> Vec<BuildStep> {
let build_dir = match profile {
Some(p) if !p.build_dir.trim().is_empty() => p.build_dir.trim().to_owned(),
Some(p) => format!("cmake-build-{}", p.build_type.to_lowercase()),
None => {
let mut configured: Vec<String> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().join("CMakeCache.txt").is_file())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
configured.sort();
configured.into_iter().next().unwrap_or_else(|| "build".into())
}
};
let build_type = profile.map_or("Debug", |p| p.build_type.as_str());
let env = profile.map(|p| p.env.clone()).unwrap_or_default();
let mut steps = Vec::new();
if !dir.join(&build_dir).join("CMakeCache.txt").is_file() {
let mut configure = BuildStep::new("cmake", &["-S", ".", "-B", &build_dir], dir, Format::Gcc);
if let Some(p) = profile.filter(|p| !p.generator.trim().is_empty()) {
configure.args.extend(["-G".into(), p.generator.clone()]);
}
configure.args.push(format!("-DCMAKE_BUILD_TYPE={build_type}"));
configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into());
configure.args.extend(profile.map(|p| p.cmake_args.clone()).unwrap_or_default());
configure.env = env.clone();
steps.push(configure);
}
let mut build = BuildStep::new("cmake", &["--build", &build_dir, "--config", build_type], dir, Format::Gcc);
if kind == BuildKind::Rebuild {
build.args.push("--clean-first".into());
}
build.args.extend(profile.map(|p| p.build_args.clone()).unwrap_or_default());
build.env = env;
steps.push(build);
steps
}
fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
let module_root = dir.ancestors().find(|d| d.join("go.mod").is_file()).unwrap_or(dir);
let target = if module_root == dir { "./...".to_owned() } else { format!("./{}/...", rel_str(module_root, dir)) };
let mut args = vec!["build"];
if kind == BuildKind::Rebuild {
args.push("-a");
}
let mut s = BuildStep::new("go", &args, module_root, Format::Go);
s.args.push(target);
vec![s]
}
/// `pnpm` / `yarn` / `bun` / `npm`, from the lock file next to `package.json` or above it.
fn package_manager(dir: &Path) -> &'static str {
ide_core::project_roots::package_manager(dir)
}
fn has_build_script(dir: &Path) -> bool {
std::fs::read(dir.join("package.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.is_some_and(|v| v.pointer("/scripts/build").is_some_and(serde_json::Value::is_string))
}
fn ts_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
let script = has_build_script(dir);
if script && (kind == BuildKind::Artifacts || !dir.join("tsconfig.json").is_file()) {
return vec![BuildStep::new(package_manager(dir), &["run", "build"], dir, Format::Tsc)];
}
let local = dir.ancestors().map(|d| d.join("node_modules/.bin/tsc")).find(|p| p.is_file());
let program = local.map_or_else(|| "tsc".to_owned(), |p| p.to_string_lossy().into_owned());
vec![BuildStep::new(program, &["--noEmit", "--pretty", "false", "-p", "."], dir, Format::Tsc)]
}
// ---------------------------------------------------------------- running
/// Project paths as the UI knows them: compilers may print the canonical path
/// (`/private/tmp/…` for `/tmp/…`).
struct PathMap {
root: PathBuf,
canonical: Option<PathBuf>,
}
impl PathMap {
fn new(root: &Path) -> Self {
let canonical = dunce::canonicalize(root).ok().filter(|c| c != root);
PathMap { root: root.to_path_buf(), canonical }
}
fn resolve(&self, printed: &str, cwd: &Path, base: Option<&str>) -> PathBuf {
let p = Path::new(printed);
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
let in_base = base.map(|b| Path::new(b).join(p)).filter(|c| c.exists());
in_base.unwrap_or_else(|| cwd.join(p))
};
let abs = normalize(&abs);
match &self.canonical {
Some(c) => abs.strip_prefix(c).map(|r| self.root.join(r)).unwrap_or(abs),
None => abs,
}
}
}
/// Removes `.` and resolvable `..` components without touching the file system.
fn normalize(p: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir if out.file_name().is_some() => {
out.pop();
}
other => out.push(other),
}
}
out
}
/// Line texts of files, for converting printed columns to UTF-16.
#[derive(Default)]
struct Lines {
files: HashMap<PathBuf, Option<Vec<String>>>,
}
impl Lines {
fn line(&mut self, path: &Path, line: u32) -> Option<&str> {
let lines = self.files.entry(path.to_path_buf()).or_insert_with(|| {
std::fs::read(path).ok().map(|b| String::from_utf8_lossy(&b).lines().map(str::to_owned).collect())
});
lines.as_ref()?.get(line as usize).map(String::as_str)
}
}
/// 1-based column in `unit` → 0-based UTF-16 column of `text`.
fn utf16_col(text: Option<&str>, col: u32, unit: ColUnit) -> u32 {
let n = col.saturating_sub(1);
let Some(text) = text else { return n };
match unit {
ColUnit::Utf16 => n,
ColUnit::Char => {
text.chars().take(n as usize).map(|c| c.len_utf16() as u32).sum::<u32>()
+ n.saturating_sub(text.chars().count() as u32)
}
ColUnit::Byte => {
let mut b = n as usize;
while b > 0 && b < text.len() && !text.is_char_boundary(b) {
b -= 1;
}
let b = b.min(text.len());
text[..b].encode_utf16().count() as u32 + (n as usize).saturating_sub(text.len()) as u32
}
}
}
/// UTF-16 length of the identifier starting at UTF-16 column `col` of `text`.
fn identifier_len(text: &str, col: u32) -> u32 {
let mut at = 0;
let mut len = 0;
for c in text.chars() {
if at >= col {
if !(c.is_alphanumeric() || c == '_' || c == '$') {
break;
}
len += c.len_utf16() as u32;
}
at += c.len_utf16() as u32;
}
len
}
fn resolve(raw: RawMessage, cwd: &Path, paths: &PathMap, lines: &mut Lines) -> BuildMessage {
let path = raw.path.as_deref().map(|p| paths.resolve(p, cwd, raw.base.as_deref()));
let range = match (&path, raw.line) {
(Some(p), Some(line)) => {
let l = line - 1;
let start_col = raw.col.map_or(0, |c| utf16_col(lines.line(p, l), c, raw.unit));
let end = match (raw.end_line, raw.end_col) {
(Some(el), Some(ec)) if el >= line => {
let el = el - 1;
Pos::new(el, utf16_col(lines.line(p, el), ec, raw.unit))
}
// A point: underline the identifier there, like the compilers' carets.
_ => {
let word = lines.line(p, l).map_or(0, |t| identifier_len(t, start_col));
Pos::new(l, start_col + word)
}
};
Some(Range::new(Pos::new(l, start_col), end))
}
_ => None,
};
BuildMessage { severity: raw.severity, text: raw.text, path: path.map(Into::into), range, code: raw.code }
}
fn progress_of(line: &str) -> Option<(String, Option<f32>)> {
let line = line.trim();
if let Some((name, i, total)) = crate::sync::maven_building(line) {
return Some((format!("Building {name}"), Some((i.saturating_sub(1) as f32 / total.max(1) as f32).min(1.0))));
}
if let Some(task) = line.strip_prefix("> Task ") {
return Some((format!("Running {}", task.split_whitespace().next().unwrap_or(task)), None));
}
if let Some(krate) = line.strip_prefix("Compiling ") {
return Some((format!("Compiling {}", krate.split_whitespace().next().unwrap_or(krate)), None));
}
None
}
/// Runs `plan`, streaming console output, messages and progress to `on_event`.
/// Returns `cancelled` when `cancel` fires (the processes are killed).
pub fn run(
root: &Path,
plan: &BuildPlan,
cancel: &CancellationToken,
on_event: &(dyn Fn(BuildEvent) + Sync),
) -> IdeResult<BuildResult> {
let started = Instant::now();
on_event(BuildEvent::Started { system: plan.system, title: plan.title.clone() });
let paths = PathMap::new(root);
let mut lines = Lines::default();
let mut messages: Vec<BuildMessage> = Vec::new();
let mut seen: HashSet<(Option<PathBuf>, Option<Range>, String)> = HashSet::new();
let mut success = true;
let mut last_error_line = String::new();
for step in &plan.steps {
let command = step.display();
on_event(BuildEvent::Step { command: command.clone(), cwd: step.cwd.clone().into() });
on_event(BuildEvent::Progress { message: command.clone(), fraction: None });
let mut parser = OutputParser::new(step.format);
let mut emit = |raws: Vec<RawMessage>, messages: &mut Vec<BuildMessage>| {
for raw in raws {
let m = resolve(raw, &step.cwd, &paths, &mut lines);
if seen.insert((m.path.clone().map(Into::into), m.range, m.text.clone())) {
on_event(BuildEvent::Message { message: m.clone() });
messages.push(m);
}
}
};
let out = process::run(&step.program, &step.args, &step.cwd, &step.env, cancel, |stream, line| {
let parsed = parser.feed(line);
match parsed.console {
Some(text) if text.is_empty() => {}
Some(text) => on_event(BuildEvent::Output { text, stderr: stream == Stream::Stderr }),
None => {
on_event(BuildEvent::Output { text: format!("{line}\n"), stderr: stream == Stream::Stderr });
if let Some((message, fraction)) = progress_of(line) {
on_event(BuildEvent::Progress { message, fraction });
}
}
}
let stripped = parse::strip_ansi(line);
if !stripped.trim().is_empty() && !stripped.starts_with('{') {
last_error_line = stripped;
}
emit(parsed.messages, &mut messages);
});
let out = match out {
Ok(out) => out,
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
Err(e) => {
let m = BuildMessage {
severity: Severity::Error,
text: e.message.clone(),
path: None,
range: None,
code: None,
};
on_event(BuildEvent::Output { text: format!("{}\n", e.message), stderr: true });
on_event(BuildEvent::Message { message: m.clone() });
messages.push(m);
success = false;
break;
}
};
emit(parser.finish(), &mut messages);
let code = if out.success { "0" } else { "non-zero" };
on_event(BuildEvent::Output { text: format!("\nProcess finished with exit code {code}\n"), stderr: false });
if !out.success {
success = false;
if !messages.iter().any(|m| m.severity == Severity::Error) {
let summary = crate::sync::error_summary(&out.text);
let text = if summary.is_empty() { last_error_line.clone() } else { summary };
let m = BuildMessage { severity: Severity::Error, text, path: None, range: None, code: None };
on_event(BuildEvent::Message { message: m.clone() });
messages.push(m);
}
break;
}
}
let count = |s: Severity| messages.iter().filter(|m| m.severity == s).count() as u32;
Ok(BuildResult {
system: plan.system,
title: plan.title.clone(),
success,
errors: count(Severity::Error),
warnings: count(Severity::Warning),
duration_ms: started.elapsed().as_secs_f64() * 1000.0,
messages,
})
}
/// Per-file editor / Problems diagnostics of the messages that have a position.
pub fn diagnostics(messages: &[BuildMessage]) -> BTreeMap<PathBuf, Vec<Diagnostic>> {
let mut out: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
for m in messages {
let (Some(path), Some(range)) = (&m.path, m.range) else { continue };
out.entry(path.to_path_buf()).or_default().push(Diagnostic {
range,
severity: m.severity,
message: m.text.clone(),
source: Some(DIAGNOSTICS_SOURCE.to_owned()),
code: m.code.clone(),
unnecessary: false,
deprecated: false,
server: Some(DIAGNOSTICS_SOURCE.to_owned()),
});
}
out
}
// ---------------------------------------------------------------- JVM launches of non-Java modules
/// Whether the JVM module at `module` has Kotlin, Groovy or Scala sources, which jdtls does not compile.
pub fn has_non_java_sources(module: &Path) -> bool {
fn walk(dir: &Path, depth: u32) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else { return false };
entries.flatten().any(|e| {
let p = e.path();
if p.is_dir() {
depth > 0 && walk(&p, depth - 1)
} else {
p.extension().is_some_and(|x| x == "kt" || x == "groovy" || x == "scala")
}
})
}
walk(&module.join("src"), 12)
}
fn reactor_projects<'a>(m: &'a maven::MavenModel, top: &'a maven::MavenProject) -> Vec<&'a maven::MavenProject> {
let mut projects = Vec::new();
let mut pending = vec![top];
let mut seen = HashSet::new();
while let Some(p) = pending.pop() {
if !seen.insert(&p.pom) {
continue;
}
projects.push(p);
pending.extend(m.projects.iter().filter(|child| p.modules.contains(&child.pom)));
}
projects
}
fn maven_reactor<'a>(m: &'a maven::MavenModel, module: &str) -> Option<&'a maven::MavenProject> {
m.projects.iter().filter(|p| !p.aggregated).find(|top| reactor_projects(m, top).iter().any(|p| p.dir == module))
}
/// Launch module first, followed by its upstream modules in the same Maven reactor.
/// External artifacts and unrelated linked reactors are never rebuilt for this launch.
fn maven_launch_projects<'a>(
root: &Path,
m: &'a maven::MavenModel,
module: &Path,
test: bool,
) -> Vec<&'a maven::MavenProject> {
let rel = rel_str(root, module);
let Some(top) = maven_reactor(m, &rel) else { return Vec::new() };
let reactor = reactor_projects(m, top);
let Some(start) = reactor.iter().find(|p| p.dir == rel) else { return Vec::new() };
let mut pending = vec![*start];
let mut seen = HashSet::new();
let mut projects = Vec::new();
while let Some(p) = pending.pop() {
if !seen.insert(&p.pom) {
continue;
}
projects.push(p);
for dep in &p.dependencies {
if !dep.resolved() || (dep.scope == "test" && (!test || p.pom != start.pom)) {
continue;
}
if let Some(upstream) = reactor.iter().find(|u| {
u.group_id == dep.group_id
&& u.artifact_id == dep.artifact_id
&& dep.version.as_ref().is_none_or(|v| *v == u.version)
}) {
pending.push(*upstream);
}
}
}
projects
}
/// Whether a launch requires the external JVM compiler. Maven includes upstream
/// reactor dependencies; adapter selection remains a separate, module-local decision.
pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Path, test: bool) -> bool {
if let Some(m) = &model.maven {
let projects = maven_launch_projects(root, m, module, test);
if !projects.is_empty() {
return projects.iter().any(|p| has_non_java_sources(&subdir(root, &p.dir)));
}
}
has_non_java_sources(module)
}
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
// JDT may have left newer error stubs in Maven's output directories.
BuildKind::Rebuild
} else {
BuildKind::Build
};
let mut plan = plan(root, model, kind, Some(module), None)?;
if plan.system == BuildSystem::Maven && !test {
for step in &mut plan.steps {
if let Some(goal) = step.args.last_mut() {
*goal = "compile".into();
}
}
}
Ok(plan)
}
/// Compiles the module at `module` with its build system before a JVM launch and
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
pub fn build_for_launch(
root: &Path,
model: &BuildModel,
module: &Path,
test: bool,
cancel: &CancellationToken,
) -> IdeResult<Vec<PathBuf>> {
let plan = launch_plan(root, model, module, test)?;
let result = run(root, &plan, cancel, &|_| {})?;
if !result.success {
let errors: Vec<String> = result
.messages
.iter()
.filter(|m| m.severity == Severity::Error)
.take(5)
.map(|m| match (&m.path, m.range) {
(Some(p), Some(r)) => format!("{}:{}: {}", p.display(), r.start.line + 1, m.text),
_ => m.text.clone(),
})
.collect();
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
}
if plan.system == BuildSystem::Maven
&& let Some(m) = &model.maven
{
return Ok(maven_launch_projects(root, m, module, test)
.iter()
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
.collect());
}
Ok(launch_class_dirs(plan.system, module, test))
}
fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<PathBuf> {
let sets: &[&str] = if test { &["test", "main"] } else { &["main"] };
let dirs: Vec<PathBuf> = match system {
BuildSystem::Gradle => sets
.iter()
.flat_map(|s| {
["classes/kotlin", "classes/groovy", "classes/scala", "classes/java", "resources"]
.map(|d| subdir(module, &format!("build/{d}/{s}")))
})
.collect(),
BuildSystem::Maven if test => vec![subdir(module, "target/test-classes"), subdir(module, "target/classes")],
BuildSystem::Maven => vec![subdir(module, "target/classes")],
_ => Vec::new(),
};
dirs.into_iter().filter(|d| d.is_dir()).collect()
}
#[cfg(test)]
mod tests {
#[test]
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for dir in ["a", "b"] {
std::fs::create_dir_all(root.join(dir).join("app/src/main/java")).unwrap();
std::fs::write(root.join(dir).join("settings.gradle"), "include 'app'").unwrap();
std::fs::write(root.join(dir).join("app/build.gradle"), "plugins { id 'java' }").unwrap();
std::fs::write(root.join(dir).join(wrapper), "").unwrap();
}
let settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec!["a".into(), "b".into()]),
..Default::default()
};
let mut model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
model.gradle[0].env.insert("JAVA_HOME".into(), "/jdk-a".into());
model.gradle[1].env.insert("JAVA_HOME".into(), "/jdk-b".into());
let all = super::plan(root, &model, super::BuildKind::Build, None, None).unwrap();
assert_eq!(all.steps.len(), 2);
assert_eq!(all.steps[0].cwd, root.join("a"));
assert_eq!(all.steps[1].cwd, root.join("b"));
let module = super::plan(root, &model, super::BuildKind::Rebuild, Some(&root.join("b/app")), None).unwrap();
assert_eq!(module.steps.len(), 1);
assert_eq!(module.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
assert_eq!(module.steps[0].cwd, root.join("b"));
assert_eq!(module.steps[0].env["JAVA_HOME"], "/jdk-b");
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
// A directly linked build owns its module even when another composite reports it too.
let direct = model.gradle[1].projects[0].clone();
model.gradle[0].projects.push(direct);
assert_eq!(model.gradle_for_module(root, &root.join("b")).unwrap().dir, "b");
let mut nested = model.gradle[1].projects[1].clone();
nested.path = ":b:app".into();
model.gradle[0].projects.push(nested);
let nested = super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).unwrap();
assert_eq!(nested.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
assert_eq!(nested.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
model.gradle.clear();
assert!(super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).is_err());
}
#[test]
fn absolute_linked_roots_build_the_project_and_external_source_module() {
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("workspace");
let external = base.join("external");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir_all(external.join("app/src/main/java")).unwrap();
std::fs::write(external.join("settings.gradle"), "include 'app'").unwrap();
let source = external.join("app/src/main/java/App.java");
std::fs::write(&source, "class App {}").unwrap();
std::fs::write(external.join(wrapper), "").unwrap();
let settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
..Default::default()
};
let mut model = crate::load_linked(&root, &base.join("cache"), None, &Default::default(), &settings);
model.gradle[0].env.insert("JAVA_HOME".into(), "/external-jdk".into());
let all = super::plan(&root, &model, super::BuildKind::Build, None, None).unwrap();
assert_eq!(all.steps[0].cwd, external);
// Linked roots are saved with forward slashes, so on Windows the program mixes separators.
assert_eq!(std::path::Path::new(&all.steps[0].program), external.join(wrapper));
let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap();
assert_eq!(module.steps[0].cwd, external);
assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk");
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
let other = base.join("unlinked");
std::fs::create_dir(&other).unwrap();
std::fs::write(other.join("build.gradle"), "").unwrap();
assert!(super::plan(&root, &model, super::BuildKind::Build, Some(&other), None).is_err());
}
use super::*;
/// Launch outputs use the platform separator throughout, so they compare equal to
/// the same directories as JDT LS reports them.
#[test]
fn launch_dirs_use_one_separator() {
let root = tempfile::tempdir().unwrap();
let module = subdir(root.path(), "parent/app");
for dir in ["target/classes", "target/test-classes", "build/classes/java/main", "build/resources/test"] {
std::fs::create_dir_all(subdir(&module, dir)).unwrap();
}
let foreign = if std::path::MAIN_SEPARATOR == '\\' { '/' } else { '\\' };
let dirs = [
launch_class_dirs(BuildSystem::Maven, &module, true),
launch_class_dirs(BuildSystem::Gradle, &module, true),
]
.concat();
assert_eq!(dirs.len(), 4, "{dirs:?}");
for dir in &dirs {
let suffix = dir.strip_prefix(root.path()).unwrap().to_string_lossy().into_owned();
assert!(!suffix.contains(foreign), "{suffix}");
}
assert_eq!(subdir(root.path(), ""), root.path());
assert_eq!(subdir(root.path(), "a//b/"), root.path().join("a").join("b"));
}
fn write(root: &Path, rel: &str, text: &str) {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
}
#[test]
fn plans_per_build_system() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("demo");
write(
&root,
"pom.xml",
"<project><artifactId>demo</artifactId><modules><module>core</module></modules></project>",
);
write(&root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(&root, "core/src/main/java/A.java", "class A {}");
let model = crate::load(&root, &tmp.path().join("cache"), None);
let p = plan(&root, &model, BuildKind::Build, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(p.title, "demo");
assert_eq!(p.steps.len(), 1);
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "test-compile"]);
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "-pl", "core", "-am", "clean", "test-compile"]);
assert_eq!(p.steps[0].cwd, root);
assert_eq!(p.title, "demo: build module 'core'");
let g = tmp.path().join("g");
write(&g, "settings.gradle", "include 'app'");
write(&g, "app/build.gradle", "apply plugin: 'java'");
let model = crate::load(&g, &tmp.path().join("cache2"), None);
let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java")), None).unwrap();
assert_eq!(p.system, BuildSystem::Gradle);
assert_eq!(p.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
assert_eq!(
plan(&g, &model, BuildKind::Artifacts, None, None).unwrap().steps[0].args,
["--console=plain", "assemble"]
);
let c = tmp.path().join("c");
write(&c, "Cargo.toml", "[package]\nname='c'");
let p = plan(&c, &BuildModel::default(), BuildKind::Rebuild, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Cargo);
assert_eq!(p.steps.len(), 2);
assert_eq!(p.steps[0].args[0], "clean");
let go = tmp.path().join("go");
write(&go, "go.mod", "module x");
let p = plan(&go, &BuildModel::default(), BuildKind::Build, Some(&go.join("pkg/a/a.go")), None).unwrap();
assert_eq!(p.steps[0].args, ["build", "./pkg/a/..."]);
assert_eq!(p.steps[0].cwd, go);
let ts = tmp.path().join("ts");
write(&ts, "package.json", r#"{"scripts":{"build":"vite build"}}"#);
write(&ts, "tsconfig.json", "{}");
write(&ts, "pnpm-lock.yaml", "");
let p = plan(&ts, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
assert_eq!(p.steps[0].args, ["--noEmit", "--pretty", "false", "-p", "."]);
let p = plan(&ts, &BuildModel::default(), BuildKind::Artifacts, None, None).unwrap();
assert_eq!(
(p.steps[0].program.as_str(), p.steps[0].args.as_slice()),
("pnpm", &["run".to_owned(), "build".to_owned()][..])
);
let cm = tmp.path().join("cm");
write(&cm, "CMakeLists.txt", "project(app)");
let args = |p: &BuildPlan| p.steps.iter().map(|s| s.args.join(" ")).collect::<Vec<_>>();
let p = plan(&cm, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
assert_eq!(p.system, BuildSystem::CMake);
assert_eq!(
args(&p),
[
"-S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
"--build build --config Debug"
],
"an unconfigured project is configured first"
);
write(&cm, "cmake-build-release/CMakeCache.txt", "");
let p = plan(&cm, &BuildModel::default(), BuildKind::Rebuild, None, None).unwrap();
assert_eq!(
args(&p),
["--build cmake-build-release --config Debug --clean-first"],
"a configured directory is reused"
);
let profile = CMakeProfile {
build_type: "Release".into(),
generator: "Ninja".into(),
build_args: vec!["-j".into(), "8".into()],
..CMakeProfile::default()
};
let p =
plan(&cm, &BuildModel::default(), BuildKind::Build, None, Some(std::slice::from_ref(&profile))).unwrap();
assert_eq!(args(&p), ["--build cmake-build-release --config Release -j 8"]);
let p = plan(&cm, &BuildModel::default(), BuildKind::Build, None, Some(&[CMakeProfile::default()])).unwrap();
assert_eq!(p.steps[0].args[..4], ["-S", ".", "-B", "cmake-build-debug"]);
let siblings = tmp.path().join("siblings");
write(&siblings, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(&siblings, "system/pom.xml", "<project><artifactId>system</artifactId></project>");
let model = crate::load(&siblings, &tmp.path().join("cache3"), None);
let p = plan(&siblings, &model, BuildKind::Build, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(
p.steps.iter().map(|s| s.cwd.clone()).collect::<Vec<_>>(),
[siblings.join("core"), siblings.join("system")]
);
let none = tmp.path().join("none");
std::fs::create_dir_all(&none).unwrap();
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None, None).is_err());
}
#[test]
fn columns_become_utf16() {
assert_eq!(utf16_col(Some("a😀b"), 3, ColUnit::Char), 3);
assert_eq!(utf16_col(Some("a😀b"), 6, ColUnit::Byte), 3);
assert_eq!(utf16_col(Some("a😀b"), 4, ColUnit::Utf16), 3);
assert_eq!(utf16_col(None, 5, ColUnit::Char), 4);
assert_eq!(utf16_col(Some("ab"), 5, ColUnit::Char), 4);
assert_eq!(identifier_len("x 😀ab_1 c", 2), 0);
assert_eq!(identifier_len("x é_1 c", 2), 3);
}
// Uses Unix absolute paths for the canonical root.
#[cfg(unix)]
#[test]
fn resolves_paths_against_cwd_and_canonical_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("p");
write(&root, "src/a.ts", "let é = 1;\nlet x: number = 'a';\n");
let map = PathMap { root: root.clone(), canonical: Some(PathBuf::from("/canon/p")) };
assert_eq!(map.resolve("/canon/p/src/a.ts", &root, None), root.join("src/a.ts"));
assert_eq!(map.resolve("./src/../src/a.ts", &root, None), root.join("src/a.ts"));
let mut lines = Lines::default();
let raw = RawMessage {
severity: Severity::Error,
text: "bad".into(),
path: Some("src/a.ts".into()),
line: Some(2),
col: Some(5),
end_line: None,
end_col: None,
unit: ColUnit::Utf16,
code: Some("TS2322".into()),
base: None,
};
let m = resolve(raw, &root, &map, &mut lines);
assert_eq!(m.path.as_deref(), Some(root.join("src/a.ts").as_path()));
assert_eq!(m.range, Some(Range::new(Pos::new(1, 4), Pos::new(1, 5))), "the identifier at the column");
let d = diagnostics(&[m]);
assert_eq!(d[&root.join("src/a.ts")][0].source.as_deref(), Some("build"));
}
#[test]
fn runs_a_failing_build_and_reports_messages() {
if cfg!(windows) {
return;
}
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("p");
write(&root, "main.go", "package main\n");
let script = "echo '# demo'; echo './main.go:1:9: undefined: x' >&2; exit 1";
let mut step = BuildStep::new("sh", &["-c", script], &root, Format::Go);
step.env.insert("A".into(), "1".into());
let plan = BuildPlan { system: BuildSystem::Go, title: "p".into(), steps: vec![step] };
let events = parking_lot_free::Events::default();
let result = run(&root, &plan, &CancellationToken::new(), &|e| events.push(e)).unwrap();
assert!(!result.success);
assert_eq!((result.errors, result.warnings), (1, 0));
let m = &result.messages[0];
assert_eq!(m.path.as_deref(), Some(root.join("main.go").as_path()));
assert_eq!(m.range, Some(Range::new(Pos::new(0, 8), Pos::new(0, 12))));
let kinds = events.kinds();
assert_eq!(kinds.first().map(String::as_str), Some("started"));
assert!(kinds.iter().any(|k| k == "message") && kinds.iter().any(|k| k == "output"));
let cancel = CancellationToken::new();
cancel.cancel();
let slow = BuildPlan {
system: BuildSystem::Go,
title: "p".into(),
steps: vec![BuildStep::new("sh", &["-c", "sleep 5"], &root, Format::Go)],
};
assert_eq!(run(&root, &slow, &cancel, &|_| {}).err().unwrap().kind, ide_core::error::ErrorKind::Cancelled);
}
mod parking_lot_free {
use super::super::BuildEvent;
use std::sync::Mutex;
#[derive(Default)]
pub struct Events(Mutex<Vec<String>>);
impl Events {
pub fn push(&self, e: BuildEvent) {
let kind = serde_json::to_value(&e).unwrap()["kind"].as_str().unwrap().to_owned();
self.0.lock().unwrap().push(kind);
}
pub fn kinds(&self) -> Vec<String> {
self.0.lock().unwrap().clone()
}
}
}
fn reactor_model() -> BuildModel {
let project = |dir: &str, deps: &[&str]| maven::MavenProject {
pom: format!("{dir}/pom.xml"),
dir: dir.into(),
group_id: "probe".into(),
artifact_id: dir.into(),
version: "1".into(),
aggregated: true,
dependencies: deps
.iter()
.map(|d| maven::MavenDependency {
group_id: "probe".into(),
artifact_id: (*d).into(),
version: Some("1".into()),
scope: "compile".into(),
..Default::default()
})
.collect(),
..Default::default()
};
let mut projects = vec![
project("app", &["middle"]),
project("middle", &["lib"]),
project("lib", &["app"]),
project("unrelated", &[]),
];
projects.insert(
0,
maven::MavenProject {
pom: "pom.xml".into(),
artifact_id: "reactor".into(),
command: "mvnw".into(),
modules: projects.iter().map(|p| p.pom.clone()).collect(),
options: vec!["-s".into(), "settings.xml".into()],
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
..Default::default()
},
);
BuildModel { maven: Some(maven::MavenModel { projects, ..Default::default() }), ..Default::default() }
}
#[test]
fn mixed_launch_detection_follows_only_reactor_dependencies() {
for extension in ["kt", "groovy", "scala"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut model = reactor_model();
write(root, "app/src/main/java/Application.java", "class Application {}");
write(root, &format!("unrelated/src/main/java/Other.{extension}"), "");
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
write(root, &format!("lib/src/main/java/Channel.{extension}"), "");
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), false));
assert_eq!(
maven_launch_projects(root, model.maven.as_ref().unwrap(), &root.join("app"), false).len(),
3,
"cycles terminate"
);
let m = model.maven.as_mut().unwrap();
m.projects[2].dependencies[0].scope = "test".into();
assert!(
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
"upstream test dependencies are not transitive"
);
let m = model.maven.as_mut().unwrap();
m.projects[1].dependencies[0].scope = "test".into();
m.projects[2].dependencies[0].scope = "compile".into();
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), true));
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("external".into());
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), true));
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("1".into());
model.maven.as_mut().unwrap().projects[3].aggregated = false;
model.maven.as_mut().unwrap().projects[0].modules.retain(|p| p != "lib/pom.xml");
assert!(
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
"another linked reactor is external"
);
}
}
#[test]
fn maven_launch_steps_use_reactor_settings_and_launch_scope() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "app/pom.xml", "<project/>");
let model = reactor_model();
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
let step = &plan.steps[0];
assert_eq!(step.cwd, root);
assert_eq!(step.program, "mvnw");
assert_eq!(step.env["JAVA_HOME"], "/jdk");
assert_eq!(
step.args,
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
);
}
}
#[cfg(unix)]
#[test]
fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "app/pom.xml", "<project/>");
write(
root,
"mvnw",
"#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n",
);
let wrapper = root.join("mvnw");
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut model = reactor_model();
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
assert_eq!(
dirs,
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
.map(|p| root.join(p))
);
write(
root,
"mvnw",
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
);
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
ide_core::error::ErrorKind::Cancelled
);
}
#[test]
fn non_java_launch_class_dirs() {
let tmp = tempfile::tempdir().unwrap();
let m = tmp.path();
assert!(!has_non_java_sources(m));
write(m, "src/main/java/A.java", "class A {}");
assert!(!has_non_java_sources(m));
write(m, "src/main/kotlin/a/App.kt", "fun main() {}");
assert!(has_non_java_sources(m));
let g = tmp.path().join("g");
write(&g, "src/test/groovy/ASpec.groovy", "class ASpec {}");
assert!(has_non_java_sources(&g));
for d in ["build/classes/kotlin/main", "build/classes/kotlin/test", "build/resources/main", "target/classes"] {
std::fs::create_dir_all(m.join(d)).unwrap();
}
assert_eq!(
launch_class_dirs(BuildSystem::Gradle, m, false),
[m.join("build/classes/kotlin/main"), m.join("build/resources/main")]
);
assert_eq!(launch_class_dirs(BuildSystem::Gradle, m, true)[0], m.join("build/classes/kotlin/test"));
assert_eq!(launch_class_dirs(BuildSystem::Maven, m, true), [m.join("target/classes")]);
}
}