use crate::build::{central_repos, extra_repos};
use crate::descriptor;
use crate::incremental::{
self, javac_version, needs_recompile, walk_files, write_javac_version_stamp, CompileStatus,
};
use crate::jar::classpath_string;
use crate::jpms;
use crate::kt_stale;
use anyhow::{bail, Context, Result};
use curie_deps::resolver::{resolve, DepEntry, ResolveOptions};
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(test)]
pub use crate::descriptor::DEFAULT_KOTLIN_VERSION as KOTLIN_VERSION;
pub const KOTLIN_COMPILER_COORD: &str = "org.jetbrains.kotlin:kotlin-compiler-embeddable";
pub const KOTLIN_STDLIB_COORD: &str = "org.jetbrains.kotlin:kotlin-stdlib";
pub const GROOVY_COORD: &str = "org.apache.groovy:groovy";
const SOURCE_SET_STAMP: &str = ".sources";
pub struct CompileOutput {
pub jar_path: PathBuf,
pub jar_name: String,
pub classes_dir: PathBuf,
pub src_roots: Vec<PathBuf>,
pub sources: Vec<PathBuf>,
pub dep_jars: Vec<PathBuf>,
pub kotlin_stdlib_jars: Vec<PathBuf>,
pub groovy_jars: Vec<PathBuf>,
pub resources_dir: Option<PathBuf>,
pub test_resources_dir: Option<PathBuf>,
pub is_modular: bool,
pub module_name: Option<String>,
pub module_path_jars: Vec<PathBuf>,
}
pub fn flat_package_src_dirs(project_root: &Path) -> Vec<PathBuf> {
flat_package_dirs_under(&project_root.join("src"))
}
pub fn flat_package_test_dirs(project_root: &Path) -> Vec<PathBuf> {
flat_package_dirs_under(&project_root.join("tests"))
}
fn flat_package_dirs_under(parent: &Path) -> Vec<PathBuf> {
if !parent.exists() {
return vec![];
}
let mut dirs: Vec<PathBuf> = std::fs::read_dir(parent)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let s = name.to_string_lossy();
s.contains('.') && e.path().is_dir()
})
.map(|e| e.path())
.collect();
dirs.sort();
dirs
}
pub fn pkg_prefix_for_src_root(src_root: &Path) -> String {
src_root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.filter(|n| n.contains('.'))
.unwrap_or_default()
}
fn kotlin_module_name(desc: &descriptor::Descriptor) -> &str {
desc.buildable_name()
}
fn parse_jdk_major_version(version_output: &str) -> Option<String> {
for line in version_output.lines() {
if let Some(rest) = line.trim().strip_prefix("javac ") {
if let Some(major) = rest.trim().split('.').next() {
if !major.is_empty() && major.chars().all(|c| c.is_ascii_digit()) {
return Some(major.to_string());
}
}
}
}
None
}
fn running_jdk_major_version() -> Result<String> {
let out = Command::new("javac")
.arg("-version")
.output()
.context("failed to invoke javac — is a JDK installed?")?;
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
parse_jdk_major_version(&stderr)
.or_else(|| parse_jdk_major_version(&stdout))
.with_context(|| {
format!("cannot parse JDK major version from javac -version output\nstderr: {stderr}\nstdout: {stdout}")
})
}
pub(crate) fn javac_release_arg(desc: &descriptor::Descriptor) -> Result<Option<String>> {
if let Some(release) = desc.java.effective() {
return Ok(Some(release.to_string()));
}
if desc.java.preview_enabled() {
return Ok(Some(running_jdk_major_version()?));
}
Ok(None)
}
pub(crate) fn groovy_target_bytecode_arg(desc: &descriptor::Descriptor) -> Option<String> {
desc.java.effective().map(|v| format!("-Dgroovy.target.bytecode={v}"))
}
pub(crate) fn groovyc_compiler_classpath(
shared_cp: &[PathBuf],
groovy_jars: &[PathBuf],
java_classes_dir: Option<&Path>,
) -> Vec<PathBuf> {
let mut gcp: Vec<PathBuf> = shared_cp.to_vec();
gcp.extend_from_slice(groovy_jars);
if let Some(dir) = java_classes_dir {
gcp.push(dir.to_path_buf());
}
gcp
}
pub fn compile(
project_root: &Path,
desc: &descriptor::Descriptor,
offline: bool,
extra_cp: &[PathBuf],
) -> Result<CompileOutput> {
let maven_java_src = project_root.join("src").join("main").join("java");
let maven_kotlin_src = project_root.join("src").join("main").join("kotlin");
let maven_groovy_src = project_root.join("src").join("main").join("groovy");
let flat_src_dirs = flat_package_src_dirs(project_root);
let flat_src_set: std::collections::HashSet<PathBuf> = flat_src_dirs.iter().cloned().collect();
let mut src_roots: Vec<PathBuf> = Vec::new();
if maven_java_src.exists() { src_roots.push(maven_java_src.clone()); }
if maven_kotlin_src.exists() { src_roots.push(maven_kotlin_src.clone()); }
if maven_groovy_src.exists() { src_roots.push(maven_groovy_src.clone()); }
src_roots.extend(flat_src_dirs);
let bare_src = project_root.join("src");
if bare_src.exists() && !src_roots.contains(&bare_src) {
let has_direct_sources = std::fs::read_dir(&bare_src)
.ok()
.map(|entries| {
entries
.filter_map(|e| e.ok())
.any(|e| {
e.file_type().map(|t| t.is_file()).unwrap_or(false)
&& matches!(
e.path().extension().and_then(|s| s.to_str()),
Some("java") | Some("kt") | Some("groovy")
)
})
})
.unwrap_or(false);
if has_direct_sources {
src_roots.push(bare_src);
}
}
if !desc.plugins.is_empty() {
for (plugin_name, plugin_config) in &desc.plugins {
let envelope = build_plugin_envelope(plugin_config)?;
let manifest = crate::plugin::fetch_manifest(plugin_name, &envelope, project_root)?;
let config_hash = crate::plugin::config_hash(&envelope, &manifest.version);
let plugins_dir = project_root.join("target").join(".curie-plugins");
let stamp_path = plugins_dir.join(format!("{plugin_name}.stamp"));
let output_set_stamp =
crate::plugin::plugin_output_set_stamp_path(&plugins_dir, plugin_name);
let prev_output_set = incremental::load_source_set(&output_set_stamp);
let pre_run_output_set =
crate::plugin::current_plugin_output_set(&manifest, project_root);
let outputs_intact = prev_output_set
.as_ref()
.map(|prev| pre_run_output_set.is_superset(prev))
.unwrap_or(true);
if !crate::plugin::is_up_to_date(&manifest, &stamp_path, project_root, &config_hash)
|| !outputs_intact
{
let input_summary = summarise_plugin_inputs(&manifest, project_root);
crate::parallel::emit(&crate::style::active(
&format!("Plugin {plugin_name}"),
&input_summary,
));
let plugin_repos: Vec<_> = {
let mut r = crate::build::central_repos();
r.extend(crate::build::extra_repos(desc));
r
};
let resolved = crate::plugin::download_artifacts(
&manifest.artifacts,
&plugin_repos,
offline,
)?;
crate::plugin::generate_sources(
plugin_name,
&envelope,
&resolved,
project_root,
offline,
)?;
crate::plugin::write_stamp(&manifest, &stamp_path, project_root, &config_hash)?;
let post_run_output_set =
crate::plugin::current_plugin_output_set(&manifest, project_root);
if let Some(prev) = &prev_output_set {
let wiped =
crate::plugin::wipe_orphaned_plugin_outputs(prev, &post_run_output_set);
if !wiped.is_empty() {
crate::parallel::emit(&crate::style::info(
&format!("Plugin {plugin_name}"),
&format!("removed {} orphaned generated file(s)", wiped.len()),
));
}
}
incremental::write_source_set(&output_set_stamp, &post_run_output_set)?;
} else {
crate::parallel::emit(&crate::style::up_to_date(&format!("Plugin {plugin_name}")));
}
for dir in &manifest.outputs.source_dirs {
src_roots.push(project_root.join(dir));
}
}
}
if src_roots.is_empty() {
bail!(
"no source directory found: expected src/main/java/, src/main/kotlin/, \
src/main/groovy/, src/<dot-named>/ (flat-package), or source files \
directly in src/ (unnamed classes)"
);
}
let classes_dir = project_root.join("target").join("classes");
let output_dir = project_root.join("target");
std::fs::create_dir_all(&classes_dir)
.context("failed to create target/classes")?;
let bom_gavs = desc.prod_bom_gavs()?;
let dep_jars = if desc.dependencies.is_empty() {
vec![]
} else {
let pairs: Vec<DepEntry> = desc
.dependencies
.iter()
.map(|(k, v)| DepEntry { key: k, version: v.version(), repo_id: v.repository(), exclusions: v.exclusions(), classifier: None, allow_version_conflict: v.allow_version_conflict() })
.collect();
let jars = resolve(
&pairs,
&ResolveOptions {
default_repos: central_repos(),
named_repos: extra_repos(desc),
progress: crate::parallel::try_get_sink().is_none(),
bom_imports: bom_gavs.clone(),
offline,
skip_version_ranges: false,
error_on_version_conflict: true,
},
)
.context("dependency resolution failed")?;
crate::parallel::emit(&crate::style::resolve("Resolve deps", &format!("{} JAR(s)", jars.len())));
jars
};
let ap_pairs = desc.ap_pairs();
let (ap_jars, ap_on_compile_classpath_jars) = if ap_pairs.is_empty() {
(Vec::new(), Vec::new())
} else {
let ap_entries: Vec<DepEntry> = ap_pairs
.iter()
.map(|(k, v)| DepEntry { key: k, version: v, repo_id: None, exclusions: vec![], classifier: None, allow_version_conflict: false })
.collect();
let jars = resolve(
&ap_entries,
&ResolveOptions {
default_repos: central_repos(),
named_repos: extra_repos(desc),
progress: crate::parallel::try_get_sink().is_none(),
bom_imports: bom_gavs.clone(),
offline,
skip_version_ranges: false, error_on_version_conflict: false,
},
)
.context("annotation-processor resolution failed")?;
crate::parallel::emit(&crate::style::resolve("Resolve APs", &format!("{} JAR(s)", jars.len())));
let on_cp_coords = desc.ap_on_compile_classpath_coords();
let mut on_cp_jars: Vec<PathBuf> = Vec::new();
for coord in on_cp_coords {
let version = ap_pairs
.iter()
.find(|(k, _)| *k == coord)
.map(|(_, v)| *v)
.expect("on-cp coord must be in ap_pairs");
let single = resolve(
&[DepEntry { key: coord, version, repo_id: None, exclusions: vec![], classifier: None, allow_version_conflict: false }],
&ResolveOptions {
default_repos: central_repos(),
named_repos: extra_repos(desc),
progress: false,
bom_imports: bom_gavs.clone(),
offline,
skip_version_ranges: false, error_on_version_conflict: false,
},
)
.with_context(|| format!("annotation-processor classpath resolution failed for {}", coord))?;
on_cp_jars.extend(single);
}
(jars, on_cp_jars)
};
let mut java_sources: Vec<PathBuf> = Vec::new();
let mut kotlin_sources: Vec<PathBuf> = Vec::new();
let mut groovy_sources: Vec<PathBuf> = Vec::new();
for src_root in &src_roots {
let colocated_layout = flat_src_set.contains(src_root);
let root_java: Vec<_> = walk_files(src_root)
.filter(|e| {
let name = e.file_name().to_string_lossy();
if !name.ends_with(".java") { return false; }
if colocated_layout {
!name.ends_with("Test.java")
&& !name.ends_with("Tests.java")
&& !name.ends_with("Spec.java")
} else {
true
}
})
.map(|e| e.into_path())
.collect();
java_sources.extend(root_java);
let root_kotlin: Vec<_> = walk_files(src_root)
.filter(|e| {
let name = e.file_name().to_string_lossy();
if !name.ends_with(".kt") { return false; }
if colocated_layout {
!name.ends_with("Test.kt")
&& !name.ends_with("Tests.kt")
&& !name.ends_with("Spec.kt")
} else {
true
}
})
.map(|e| e.into_path())
.collect();
kotlin_sources.extend(root_kotlin);
let root_groovy: Vec<_> = walk_files(src_root)
.filter(|e| {
let name = e.file_name().to_string_lossy();
if !name.ends_with(".groovy") { return false; }
if colocated_layout {
!name.ends_with("Test.groovy")
&& !name.ends_with("Tests.groovy")
&& !name.ends_with("Spec.groovy")
} else {
true
}
})
.map(|e| e.into_path())
.collect();
groovy_sources.extend(root_groovy);
}
java_sources.sort(); java_sources.dedup();
kotlin_sources.sort(); kotlin_sources.dedup();
groovy_sources.sort(); groovy_sources.dedup();
let has_kotlin = !kotlin_sources.is_empty();
let has_java = !java_sources.is_empty();
let has_groovy = !groovy_sources.is_empty();
if has_groovy && has_kotlin {
bail!(
"mixing Groovy and Kotlin sources in the same module is not supported; \
use separate modules for each language"
);
}
let mut sources: Vec<PathBuf> = Vec::new();
sources.extend(java_sources.iter().cloned());
sources.extend(kotlin_sources.iter().cloned());
sources.extend(groovy_sources.iter().cloned());
sources.sort();
sources.dedup();
if sources.is_empty() {
bail!(
"no Java, Kotlin, or Groovy source files found under {}",
src_roots.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
);
}
let resources_dir = {
let maven = project_root.join("src").join("main").join("resources");
let flat = project_root.join("resources");
if maven.exists() { Some(maven) } else if flat.exists() { Some(flat) } else { None }
};
let test_resources_dir = {
let maven = project_root.join("src").join("test").join("resources");
let flat = project_root.join("test-resources");
if maven.exists() { Some(maven) } else if flat.exists() { Some(flat) } else { None }
};
let kotlin_stdlib_jars: Vec<PathBuf>;
let kotlin_compiler_jars: Vec<PathBuf>;
if has_kotlin {
let kver = desc.kotlin.version();
let kotlin_jars = resolve(
&[
DepEntry { key: KOTLIN_COMPILER_COORD, version: kver, repo_id: None, exclusions: vec![], classifier: None, allow_version_conflict: false },
DepEntry { key: KOTLIN_STDLIB_COORD, version: kver, repo_id: None, exclusions: vec![], classifier: None, allow_version_conflict: false },
],
&ResolveOptions {
default_repos: central_repos(),
named_repos: extra_repos(desc),
progress: crate::parallel::try_get_sink().is_none(),
bom_imports: bom_gavs.clone(),
offline,
skip_version_ranges: false, error_on_version_conflict: false,
},
)
.context("Kotlin compiler/stdlib resolution failed")?;
crate::parallel::emit(&crate::style::resolve("Resolve Kotlin", &format!("{} JAR(s)", kotlin_jars.len())));
let stdlib: Vec<PathBuf> = kotlin_jars
.iter()
.filter(|p| {
p.file_name()
.map(|f| !f.to_string_lossy().starts_with("kotlin-compiler-embeddable"))
.unwrap_or(true)
})
.cloned()
.collect();
kotlin_compiler_jars = kotlin_jars;
kotlin_stdlib_jars = stdlib;
} else {
kotlin_compiler_jars = Vec::new();
kotlin_stdlib_jars = Vec::new();
}
let groovy_jars: Vec<PathBuf>;
if has_groovy {
let gver = desc.groovy.version();
let jars = resolve(
&[DepEntry { key: GROOVY_COORD, version: gver, repo_id: None, exclusions: vec![], classifier: None, allow_version_conflict: false }],
&ResolveOptions {
default_repos: central_repos(),
named_repos: extra_repos(desc),
progress: crate::parallel::try_get_sink().is_none(),
bom_imports: bom_gavs.clone(),
offline,
skip_version_ranges: false, error_on_version_conflict: false,
},
)
.context("Groovy compiler/runtime resolution failed")?;
crate::parallel::emit(&crate::style::resolve("Resolve Groovy", &format!("{} JAR(s)", jars.len())));
groovy_jars = jars;
} else {
groovy_jars = Vec::new();
}
let module_info_path = jpms::find_module_info_java(&src_roots);
let is_modular = module_info_path.is_some();
if is_modular && has_groovy {
bail!(
"JPMS modules (module-info.java) are not supported with Groovy sources; \
use separate modules for each language"
);
}
if is_modular {
if let Some(release) = desc.java.effective() {
let release_num: u32 = release.parse().unwrap_or(0);
if release_num < 9 {
bail!(
"JPMS modules (module-info.java) require Java 9 or later; \
project specifies releaseVersion = \"{release}\""
);
}
}
}
if is_modular {
if let crate::descriptor::DescriptorKind::Library(lib) = &desc.kind {
if lib.automatic_module_name.is_some() {
bail!(
"a project with module-info.java must not also declare \
automaticModuleName; remove one or the other"
);
}
}
}
let module_split: Option<(jpms::ParsedModuleInfo, jpms::ModuleSplit)> =
if let Some(ref mi_path) = module_info_path {
let content = std::fs::read_to_string(mi_path)
.with_context(|| format!("failed to read {}", mi_path.display()))?;
let parsed = jpms::parse_module_info_java(&content)
.with_context(|| format!("failed to parse {}", mi_path.display()))?;
let target_dir = project_root.join("target");
let split_jars: Vec<PathBuf> = {
let mut v = dep_jars.clone();
v.extend_from_slice(&kotlin_stdlib_jars);
v
};
let split = jpms::compute_module_path_split(&parsed, &split_jars, &target_dir)
.context("failed to compute module-path split")?;
Some((parsed, split))
} else {
None
};
let toml_path = project_root.join("Curie.toml");
let manifest_path = output_dir.join(".classes.toml");
let old_manifest = crate::class_manifest::load(&manifest_path)?;
let current_sources_set: std::collections::HashSet<String> = sources
.iter()
.filter_map(|p| p.canonicalize().ok())
.map(|p| p.to_string_lossy().into_owned())
.collect();
let canonical_target = output_dir
.canonicalize()
.ok()
.and_then(|p| p.to_str().map(String::from));
let pre_pruned: usize = match &old_manifest {
Some(old) => {
let stale = crate::class_manifest::stale_classes(
old,
None,
¤t_sources_set,
canonical_target.as_deref(),
);
crate::class_manifest::delete_classes(&classes_dir, &stale)?
}
None => 0,
};
let source_set = incremental::canonical_source_set(&sources);
let source_set_prev =
incremental::load_source_set(&incremental::source_set_stamp_path(&output_dir, SOURCE_SET_STAMP));
let source_set_changed =
incremental::source_set_changed(source_set_prev.as_ref(), &source_set);
let compile_status = if pre_pruned > 0 {
CompileStatus::StaleClasses
} else if source_set_changed {
CompileStatus::SourceSetChanged
} else if old_manifest
.as_ref()
.is_some_and(|m| crate::class_manifest::has_missing_classes(m, &classes_dir))
{
CompileStatus::MissingClasses
} else {
needs_recompile(&sources, &classes_dir, &toml_path, &output_dir, &[])
};
if compile_status.needs_recompile() {
crate::parallel::emit(&crate::style::active(
"Compile",
&format!("{} source file(s) [{}]", sources.len(), compile_status.reason()),
));
let wiped_kotlin_classes: Vec<PathBuf> = if has_kotlin || source_set_changed {
kt_stale::wipe_kotlin_derived_classes(&classes_dir)?
} else {
Vec::new()
};
let mut shared_cp: Vec<PathBuf> = Vec::new();
if let Some(ref rd) = resources_dir {
shared_cp.push(rd.clone());
}
shared_cp.extend_from_slice(&dep_jars);
shared_cp.extend_from_slice(extra_cp);
shared_cp.extend_from_slice(&ap_on_compile_classpath_jars);
shared_cp.extend_from_slice(&kotlin_stdlib_jars);
if has_kotlin {
let mut kotlinc = Command::new("java");
kotlinc.arg("--enable-native-access=ALL-UNNAMED");
kotlinc.arg("-cp").arg(classpath_string(&kotlin_compiler_jars));
kotlinc.arg("org.jetbrains.kotlin.cli.jvm.K2JVMCompiler");
kotlinc.arg("-no-stdlib");
kotlinc.arg("-no-reflect");
kotlinc.arg("-module-name").arg(kotlin_module_name(desc));
kotlinc.arg("-d").arg(&classes_dir);
if !shared_cp.is_empty() {
kotlinc.arg("-cp").arg(classpath_string(&shared_cp));
}
for src in &kotlin_sources {
kotlinc.arg(src);
}
for src in &java_sources {
if is_modular && src.file_name() == Some(std::ffi::OsStr::new("module-info.java")) {
continue;
}
kotlinc.arg(src);
}
let status = crate::proc::spawn_cmd(&mut kotlinc)
.context("failed to invoke kotlinc — is a JRE installed?")?;
if !status.success() {
bail!("Kotlin compilation failed");
}
let kotlin_orphans = wiped_kotlin_classes.iter().filter(|p| !p.exists()).count();
if kotlin_orphans > 0 {
crate::parallel::emit(&crate::style::stale(
"Stale (Kotlin)",
&format!("removed {} orphan class file{}", kotlin_orphans, if kotlin_orphans == 1 { "" } else { "s" }),
));
}
} else if !wiped_kotlin_classes.is_empty() {
crate::parallel::emit(&crate::style::stale(
"Stale (Kotlin)",
&format!("removed {} orphan class file{}", wiped_kotlin_classes.len(), if wiped_kotlin_classes.len() == 1 { "" } else { "s" }),
));
}
if has_java {
let wrapper_jar = crate::wrapper::ensure()?;
let mut javac = Command::new("java");
javac.arg("-jar").arg(&wrapper_jar);
javac.arg("--curie-manifest-out").arg(&manifest_path);
if let Some(release) = javac_release_arg(desc)? {
javac.arg("--release").arg(release);
}
if desc.java.preview_enabled() {
javac.arg("--enable-preview");
}
javac
.arg("-g")
.arg("-d")
.arg(&classes_dir);
if let Some((parsed_mi, split)) = &module_split {
if !split.module_path.is_empty() {
javac.arg("--module-path").arg(classpath_string(&split.module_path));
}
if has_kotlin {
let patch_arg = format!(
"{}={}",
parsed_mi.module_name,
classes_dir.display()
);
javac.arg("--patch-module").arg(patch_arg);
}
let mut cp_entries: Vec<PathBuf> = Vec::new();
cp_entries.extend_from_slice(&split.classpath);
for entry in &shared_cp {
if !split.module_path.contains(entry) && !cp_entries.contains(entry) {
cp_entries.push(entry.clone());
}
}
if !cp_entries.is_empty() {
javac.arg("-cp").arg(classpath_string(&cp_entries));
}
} else {
let mut cp_entries: Vec<PathBuf> = Vec::new();
if has_kotlin {
cp_entries.push(classes_dir.clone());
}
cp_entries.extend_from_slice(&shared_cp);
if !cp_entries.is_empty() {
javac.arg("-cp").arg(classpath_string(&cp_entries));
}
}
if !ap_jars.is_empty() {
let gen_dir = output_dir.join("generated-sources").join("annotations");
std::fs::create_dir_all(&gen_dir).with_context(|| {
format!("failed to create {}", gen_dir.display())
})?;
javac.arg("-processorpath").arg(classpath_string(&ap_jars));
javac.arg("-s").arg(&gen_dir);
}
for (key, value) in desc.flat_ap_options() {
javac.arg(format!("-A{}={}", key, value));
}
for src in &java_sources {
javac.arg(src);
}
let status = crate::proc::spawn_cmd(&mut javac)
.context("failed to invoke java — is a JRE installed?")?;
if !status.success() {
bail!("compilation failed");
}
if let Some(old) = &old_manifest {
if let Some(new) = crate::class_manifest::load(&manifest_path)? {
let stale = crate::class_manifest::stale_classes(
old,
Some(&new),
¤t_sources_set,
None, );
let n = crate::class_manifest::delete_classes(&classes_dir, &stale)?;
if n > 0 {
crate::parallel::emit(&crate::style::stale(
"Stale",
&format!("removed {} orphaned class file{}", n, if n == 1 { "" } else { "s" }),
));
}
}
}
}
if has_groovy {
let mut groovyc = Command::new("java");
if let Some(arg) = groovy_target_bytecode_arg(desc) {
groovyc.arg(arg);
}
groovyc.arg("-cp").arg(classpath_string(&groovy_jars));
groovyc.arg("org.codehaus.groovy.tools.FileSystemCompiler");
groovyc.arg("-d").arg(&classes_dir);
let gcp = groovyc_compiler_classpath(
&shared_cp,
&groovy_jars,
if has_java { Some(&classes_dir) } else { None },
);
if !gcp.is_empty() {
groovyc.arg("--classpath").arg(classpath_string(&gcp));
}
for src in &groovy_sources {
groovyc.arg(src);
}
let status = crate::proc::spawn_cmd(&mut groovyc)
.context("failed to invoke groovyc — is a JRE installed?")?;
if !status.success() {
bail!("Groovy compilation failed");
}
}
if let Ok(version) = javac_version() {
write_javac_version_stamp(&output_dir, &version)?;
}
incremental::write_source_set(
&incremental::source_set_stamp_path(&output_dir, SOURCE_SET_STAMP),
&source_set,
)?;
} else {
crate::parallel::emit(&crate::style::up_to_date("Compile"));
}
let jar_name = format!(
"{}-{}.jar",
desc.buildable_name().replace(':', "-"), desc.buildable_version()
);
let jar_path = output_dir.join(&jar_name);
let (module_name, module_path_jars) = match module_split {
Some((parsed_mi, split)) => (Some(parsed_mi.module_name), split.module_path),
None => (None, Vec::new()),
};
Ok(CompileOutput {
jar_path, jar_name, classes_dir, src_roots, sources, dep_jars,
kotlin_stdlib_jars, groovy_jars,
resources_dir, test_resources_dir,
is_modular,
module_name,
module_path_jars,
})
}
fn summarise_plugin_inputs(manifest: &crate::plugin::PluginManifest, project_root: &Path) -> String {
let from_dirs = manifest.inputs.dirs.iter().flat_map(|d| {
let full = project_root.join(d);
walkdir::WalkDir::new(&full)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>()
});
let from_files = manifest.inputs.files.iter().map(|f| {
Path::new(f)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| f.to_string_lossy().into_owned())
});
let all: Vec<_> = from_dirs.chain(from_files).collect();
if all.is_empty() {
"(no inputs)".to_string()
} else {
all.join(", ")
}
}
fn build_plugin_envelope(config: &toml::Value) -> Result<String> {
let envelope = serde_json::json!({
"curie_version": env!("CARGO_PKG_VERSION"),
"config": serde_json::to_value(config).context("failed to convert plugin config to JSON")?,
});
serde_json::to_string(&envelope).context("failed to serialize plugin envelope")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pkg_prefix_maven_style_is_empty() {
let p = Path::new("/some/path/src/main/java");
assert_eq!(pkg_prefix_for_src_root(p), "");
}
#[test]
fn pkg_prefix_flat_package_is_dir_name() {
let p = Path::new("/some/path/src/com.example.myapp");
assert_eq!(pkg_prefix_for_src_root(p), "com.example.myapp");
}
#[test]
fn pkg_prefix_kotlin_maven_style_is_empty() {
let p = Path::new("/some/path/src/main/kotlin");
assert_eq!(pkg_prefix_for_src_root(p), "");
}
#[test]
fn flat_package_src_dirs_finds_dot_named_dirs() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(src.join("com.example.foo")).unwrap();
std::fs::create_dir_all(src.join("com.example.bar")).unwrap();
std::fs::create_dir_all(src.join("main")).unwrap();
let mut found = flat_package_src_dirs(dir.path());
found.sort();
assert_eq!(found.len(), 2);
assert!(found[0].ends_with("com.example.bar"));
assert!(found[1].ends_with("com.example.foo"));
}
#[test]
fn flat_package_src_dirs_empty_when_no_src_dir() {
let dir = tempfile::tempdir().unwrap();
assert!(flat_package_src_dirs(dir.path()).is_empty());
}
#[test]
fn kotlin_version_constant_is_set() {
assert!(!KOTLIN_VERSION.is_empty());
let parts: Vec<&str> = KOTLIN_VERSION.split('.').collect();
assert!(parts.len() >= 2, "KOTLIN_VERSION should be at least major.minor");
}
#[test]
fn kotlin_module_name_matches_buildable_name() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"hello-kotlin\"\nversion = \"0.1.0\"\nmainClass = \"Main\"\n\
[java]\nreleaseVersion = \"21\"\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
assert_eq!(kotlin_module_name(&desc), "hello-kotlin");
}
#[test]
fn groovy_target_bytecode_uses_effective_release() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"g\"\nversion = \"0.1.0\"\nmainClass = \"Main\"\n\
[java]\nreleaseVersion = \"21\"\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
assert_eq!(groovy_target_bytecode_arg(&desc), Some("-Dgroovy.target.bytecode=21".to_string()));
}
#[test]
fn groovy_target_bytecode_absent_when_no_release_version() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"g\"\nversion = \"0.1.0\"\nmainClass = \"Main\"\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
assert_eq!(groovy_target_bytecode_arg(&desc), None);
}
#[test]
fn parse_jdk_major_version_plain_output() {
assert_eq!(parse_jdk_major_version("javac 21.0.3\n"), Some("21".to_string()));
assert_eq!(parse_jdk_major_version("javac 25.0.1"), Some("25".to_string()));
assert_eq!(parse_jdk_major_version("javac 11"), Some("11".to_string()));
}
#[test]
fn parse_jdk_major_version_with_java_tool_options_preamble() {
let output = "Picked up JAVA_TOOL_OPTIONS: -Dhttp.proxyHost=172.16.0.4 -Dhttp.proxyPort=2080\njavac 21.0.3\n";
assert_eq!(parse_jdk_major_version(output), Some("21".to_string()));
}
#[test]
fn parse_jdk_major_version_preamble_only_returns_none() {
let stderr = "Picked up JAVA_TOOL_OPTIONS: -Dhttp.proxyHost=172.16.0.4 -Dhttp.proxyPort=2080";
assert_eq!(parse_jdk_major_version(stderr), None);
}
#[test]
fn parse_jdk_major_version_unrecognised_output_returns_none() {
assert_eq!(parse_jdk_major_version(""), None);
assert_eq!(parse_jdk_major_version("Picked up JAVA_TOOL_OPTIONS: something"), None);
}
#[test]
fn javac_release_arg_uses_release_version_when_set() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"a\"\nversion = \"0.1.0\"\nmainClass = \"Main\"\n\
[java]\nreleaseVersion = \"21\"\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
assert_eq!(javac_release_arg(&desc).unwrap(), Some("21".to_string()));
}
#[test]
fn javac_release_arg_absent_without_preview_or_release_version() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"a\"\nversion = \"0.1.0\"\nmainClass = \"Main\"\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
assert_eq!(javac_release_arg(&desc).unwrap(), None);
}
#[test]
fn javac_release_arg_falls_back_to_running_jdk_when_preview_and_no_release_version() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"a\"\nversion = \"0.1.0\"\nmainClass = \"Main\"\n\
[java]\nenablePreview = true\n",
)
.unwrap();
let desc = descriptor::load(dir.path()).unwrap();
let release = javac_release_arg(&desc).unwrap().expect("should return running JDK version");
assert!(!release.is_empty(), "release should not be empty");
assert!(release.parse::<u32>().is_ok(), "release should be a number, got: {release}");
}
#[test]
fn groovy_sources_discovered_from_maven_layout_includes_test_named_files() {
let dir = tempfile::tempdir().unwrap();
let groovy_src = dir.path().join("src").join("main").join("groovy")
.join("com").join("example");
std::fs::create_dir_all(&groovy_src).unwrap();
std::fs::write(groovy_src.join("Greeter.groovy"), b"package com.example; class Greeter {}").unwrap();
std::fs::write(groovy_src.join("GreeterSpec.groovy"), b"package com.example; class GreeterSpec {}").unwrap();
use crate::incremental::walk_files;
let root = dir.path().join("src").join("main").join("groovy");
let found: Vec<_> = walk_files(&root)
.filter(|e| e.file_name().to_string_lossy().ends_with(".groovy"))
.collect();
assert_eq!(found.len(), 2, "both Greeter.groovy and GreeterSpec.groovy should be included; got: {:?}", found);
}
#[test]
fn groovy_sources_discovered_from_flat_package() {
let dir = tempfile::tempdir().unwrap();
let flat_dir = dir.path().join("src").join("com.example");
std::fs::create_dir_all(&flat_dir).unwrap();
std::fs::write(flat_dir.join("Hello.groovy"), b"package com.example; class Hello {}").unwrap();
std::fs::write(flat_dir.join("Hello.java"), b"package com.example; class Hello {}").unwrap();
let flat_dirs = flat_package_src_dirs(dir.path());
assert!(!flat_dirs.is_empty(), "should find com.example dir");
use crate::incremental::walk_files;
let groovy_files: Vec<_> = flat_dirs.iter()
.flat_map(|d| walk_files(d)
.filter(|e| e.file_name().to_string_lossy().ends_with(".groovy"))
.collect::<Vec<_>>()
)
.collect();
assert_eq!(groovy_files.len(), 1, "should find Hello.groovy; got: {:?}", groovy_files);
}
#[test]
fn groovyc_classpath_omits_classes_dir_when_no_java() {
let shared = vec![PathBuf::from("/deps/dep.jar")];
let groovy = vec![PathBuf::from("/groovy/groovy.jar")];
let classes = PathBuf::from("/target/classes");
let gcp = groovyc_compiler_classpath(&shared, &groovy, None);
assert_eq!(gcp, vec![
PathBuf::from("/deps/dep.jar"),
PathBuf::from("/groovy/groovy.jar"),
]);
assert!(!gcp.contains(&classes), "classes_dir must not appear when Java is absent");
}
#[test]
fn groovyc_classpath_appends_classes_dir_when_java_present() {
let shared = vec![PathBuf::from("/deps/dep.jar")];
let groovy = vec![PathBuf::from("/groovy/groovy.jar")];
let classes = PathBuf::from("/target/classes");
let gcp = groovyc_compiler_classpath(&shared, &groovy, Some(&classes));
assert_eq!(gcp, vec![
PathBuf::from("/deps/dep.jar"),
PathBuf::from("/groovy/groovy.jar"),
PathBuf::from("/target/classes"),
]);
}
}