use anyhow::{bail, Result};
use console::style;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::config;
use crate::hotreload;
use crate::resources;
use crate::scripts;
use crate::watcher::FileWatcher;
use crate::workspace::graph::WorkspaceGraph;
pub fn execute(
target: Option<String>,
no_reload: bool,
debug: bool,
debug_port: Option<u16>,
suspend: bool,
jvm_extra_args: Vec<String>,
) -> Result<()> {
let (config_path, cfg) = config::load_or_find_config()?;
let project = config::project_dir(&config_path);
super::build::ensure_jdk_for_config(&cfg)?;
scripts::run_script(&cfg, "predev", &project)?;
if cfg.workspaces.is_some() {
let target = target.as_deref().unwrap_or_else(|| {
eprintln!(" In workspace mode, specify a target: ymc dev <module>");
std::process::exit(1);
});
let result = dev_workspace(&project, target);
scripts::run_script(&cfg, "postdev", &project)?;
return result;
}
let start = Instant::now();
let _all_jars = super::build::resolve_deps(&project, &cfg)?;
let compile_jars = super::build::resolve_deps_with_scopes(&project, &cfg, &["compile", "provided"])?;
let runtime_jars = super::build::resolve_deps_with_scopes(&project, &cfg, &["compile", "runtime"])?;
let dep_count = runtime_jars.len();
let resolve_time = start.elapsed();
let ws_count = cfg.workspace_module_deps().len();
println!(
"{} dependencies ({} workspace + {} maven) {:>4}ms",
style(format!("{:>12}", "Resolving")).green().bold(),
ws_count,
dep_count,
resolve_time.as_millis()
);
let compile_start = Instant::now();
let result = super::build::compile_project(&project, &cfg, &compile_jars)?;
let compile_time = compile_start.elapsed();
if !result.success {
eprint!("{}", crate::compiler::colorize_errors(&result.errors));
bail!("Compilation failed");
}
let src = config::source_dir(&project);
let out = config::output_classes_dir(&project);
let custom_res_ext = cfg.compiler.as_ref().and_then(|c| c.resource_extensions.as_ref());
let res_exclude = cfg.compiler.as_ref().and_then(|c| c.resource_exclude.as_ref());
resources::copy_resources_with_extensions(&src, &out, custom_res_ext.map(|v| v.as_slice()), res_exclude.map(|v| v.as_slice()))?;
let resources_dir = project.join("src").join("main").join("resources");
if resources_dir.exists() {
resources::copy_resources_with_extensions(&resources_dir, &out, custom_res_ext.map(|v| v.as_slice()), res_exclude.map(|v| v.as_slice()))?;
}
println!(
"{} {} ({} files) {:>27}ms",
style(format!("{:>12}", "Compiling")).green().bold(),
&cfg.name,
result.outcome.files_compiled(),
compile_time.as_millis()
);
let main_class = resolve_main_class(&cfg, &project, target.as_deref())?;
let out_dir = config::output_classes_dir(&project);
let mut classpath = vec![out_dir.clone()];
classpath.extend(runtime_jars.clone());
let mut jvm_args: Vec<String> = cfg.jvm_args.clone().unwrap_or_default();
jvm_args.extend(jvm_extra_args.clone());
if debug || suspend {
let port = debug_port.unwrap_or(5005);
if is_port_in_use(port) {
eprintln!(
" {} Debug port {} is already in use. Use --debug-port to specify another port.",
style("!").yellow(),
style(port).bold()
);
}
let suspend_flag = if suspend { "y" } else { "n" };
jvm_args.push(format!(
"-agentlib:jdwp=transport=dt_socket,server=y,suspend={},address=*:{}",
suspend_flag, port
));
if suspend {
println!(
" {} Waiting for debugger on port {}...",
style("!").yellow(),
style(port).bold()
);
} else {
println!(
" {} Debug mode: listening on port {}",
style("✓").green(),
port
);
}
}
if !jvm_args.iter().any(|a| a.contains("AllowEnhancedClassRedefinition")) && detect_dcevm() {
jvm_args.push("-XX:+AllowEnhancedClassRedefinition".to_string());
println!(
" {} DCEVM enabled (enhanced hot reload)",
style("✓").green(),
);
}
if runtime_jars.iter().any(|p| p.to_string_lossy().contains("spring-boot-devtools")) {
if !jvm_args.iter().any(|a| a.contains("spring.devtools")) {
jvm_args.push("-Dspring.devtools.restart.enabled=true".to_string());
jvm_args.push("-Dspring.devtools.livereload.enabled=true".to_string());
println!(
" {} Spring Boot DevTools detected (restart + livereload)",
style("✓").green(),
);
}
}
let hot_reload_enabled = !no_reload
&& cfg
.hot_reload
.as_ref()
.and_then(|h| h.enabled)
.unwrap_or(true);
let agent_port = if hot_reload_enabled {
if let Some(agent_jar) = hotreload::find_agent_jar() {
let port = hotreload::find_free_port()?;
let agent_args = hotreload::agent_jvm_args(&agent_jar, port);
jvm_args.extend(agent_args);
println!(
" {} Hot reload agent on port {}",
style("✓").green(),
port
);
Some(port)
} else {
None
}
} else {
None
};
let dotenv = load_dotenv(&project);
let run_start = Instant::now();
let mut child = start_java_process(&main_class, &classpath, &jvm_args, &dotenv)?;
let run_time = run_start.elapsed();
println!(
" {} Started {} {:>4.1}s",
style("✓").green(),
style(&main_class).bold(),
run_time.as_secs_f64()
);
println!();
let src_dir = config::source_dir(&project);
let watch_extensions = cfg
.hot_reload
.as_ref()
.and_then(|h| h.watch_extensions.clone())
.unwrap_or_else(|| vec![".java".to_string()]);
let file_count = count_source_files(&src_dir, &watch_extensions);
println!(
" {} watching {} source files...",
style("➜").green(),
style(file_count).cyan()
);
println!();
let watcher = FileWatcher::new(&[src_dir], watch_extensions)?;
let result = dev_watch_loop(watcher, &mut child, &main_class, &classpath, &jvm_args, &dotenv, &project, &cfg, &compile_jars, agent_port);
scripts::run_script(&cfg, "postdev", &project)?;
result
}
fn dev_workspace(root: &std::path::Path, target: &str) -> Result<()> {
let ws = WorkspaceGraph::build(root)?;
let packages = ws.transitive_closure(target)?;
let start = Instant::now();
super::build::compile_only(Some(target.to_string()))?;
let _build_time = start.elapsed();
let mut classpath = Vec::new();
let mut watch_dirs = Vec::new();
let mut all_jars = Vec::new();
let mut src_to_module: Vec<(std::path::PathBuf, String)> = Vec::new();
for pkg_name in &packages {
let pkg = ws.get_package(pkg_name).unwrap();
classpath.push(config::output_classes_dir(&pkg.path));
let jars = super::build::resolve_deps(&pkg.path, &pkg.config)?;
all_jars.extend(jars);
let src = config::source_dir(&pkg.path);
if src.exists() {
watch_dirs.push(src.clone());
src_to_module.push((src, pkg_name.clone()));
}
}
classpath.extend(all_jars);
let target_pkg = ws.get_package(target).unwrap();
let main_class = resolve_main_class(&target_pkg.config, &target_pkg.path, None)?;
let jvm_args = target_pkg.config.jvm_args.clone().unwrap_or_default();
let dotenv = load_dotenv(&target_pkg.path);
let run_start = Instant::now();
let mut child = start_java_process(&main_class, &classpath, &jvm_args, &dotenv)?;
let run_time = run_start.elapsed();
println!(
" {} Started {} {:>4.1}s",
style("✓").green(),
style(&main_class).bold(),
run_time.as_secs_f64()
);
println!();
let watch_extensions = vec![".java".to_string()];
let file_count: usize = watch_dirs
.iter()
.map(|d| count_source_files(d, &watch_extensions))
.sum();
println!(
" {} watching {} source files...",
style("➜").green(),
style(file_count).cyan()
);
println!();
let watcher = FileWatcher::new(&watch_dirs, watch_extensions)?;
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
})?;
while running.load(Ordering::SeqCst) {
let changed = watcher.wait_for_changes(Duration::from_millis(100));
if !running.load(Ordering::SeqCst) {
break;
}
if changed.is_empty() {
continue;
}
let now = chrono_time();
for path in &changed {
if let Some(name) = path.file_name() {
println!(
" {} Changed: {}",
style(&now).dim(),
style(name.to_string_lossy()).yellow()
);
}
}
let changed_modules = identify_changed_modules(&changed, &src_to_module);
let compile_start = Instant::now();
let build_ok = if changed_modules.is_empty() {
super::build::compile_only(Some(target.to_string())).is_ok()
} else {
recompile_affected_modules(&changed_modules, &packages, &ws, &classpath)
};
let compile_time = compile_start.elapsed();
if build_ok {
graceful_stop(&mut child);
child = start_java_process(&main_class, &classpath, &jvm_args, &dotenv)?;
let module_info = if changed_modules.is_empty() {
"all".to_string()
} else {
changed_modules.join(", ")
};
println!(
" {} recompiled [{}] ({}ms) -> restarted {}",
style(&now).dim(),
module_info,
compile_time.as_millis(),
style("✓").green()
);
} else {
eprintln!(
" {} Compilation failed ({}ms)",
style(&now).dim(),
compile_time.as_millis()
);
}
}
println!();
println!(" Stopping...");
graceful_stop(&mut child);
Ok(())
}
fn identify_changed_modules(
changed_files: &[std::path::PathBuf],
src_to_module: &[(std::path::PathBuf, String)],
) -> Vec<String> {
let mut modules = Vec::new();
for file in changed_files {
for (src_dir, module_name) in src_to_module {
if file.starts_with(src_dir) && !modules.contains(module_name) {
modules.push(module_name.clone());
break;
}
}
}
modules
}
fn recompile_affected_modules(
changed_modules: &[String],
all_packages: &[String],
ws: &WorkspaceGraph,
full_classpath: &[std::path::PathBuf],
) -> bool {
let mut affected: std::collections::HashSet<String> = changed_modules.iter().cloned().collect();
for pkg_name in all_packages {
if affected.contains(pkg_name) {
continue;
}
if let Some(pkg) = ws.get_package(pkg_name) {
let ws_deps = pkg.config.workspace_module_deps();
if ws_deps.iter().any(|dep| affected.contains(dep)) {
affected.insert(pkg_name.clone());
}
}
}
for pkg_name in all_packages {
if !affected.contains(pkg_name) {
continue;
}
if let Some(pkg) = ws.get_package(pkg_name) {
let result = super::build::compile_project(&pkg.path, &pkg.config, full_classpath);
match result {
Ok(r) if r.success => {}
Ok(r) => {
eprint!("{}", crate::compiler::colorize_errors(&r.errors));
return false;
}
Err(e) => {
eprintln!(" {} Error compiling {}: {}", style("✗").red(), pkg_name, e);
return false;
}
}
}
}
true
}
fn dev_watch_loop(
watcher: FileWatcher,
child: &mut std::process::Child,
main_class: &str,
classpath: &[std::path::PathBuf],
jvm_args: &[String],
dotenv: &HashMap<String, String>,
project: &std::path::Path,
cfg: &config::schema::YmConfig,
jars: &[std::path::PathBuf],
agent_port: Option<u16>,
) -> Result<()> {
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
})?;
let agent_client = agent_port.map(hotreload::AgentClient::new);
while running.load(Ordering::SeqCst) {
let changed = watcher.wait_for_changes(Duration::from_millis(100));
if !running.load(Ordering::SeqCst) {
break;
}
if changed.is_empty() {
continue;
}
let now = chrono_time();
for path in &changed {
if let Some(name) = path.file_name() {
println!(
" {} Changed: {}",
style(&now).dim(),
style(name.to_string_lossy()).yellow()
);
}
}
let compile_start = Instant::now();
let result = super::build::compile_project(project, cfg, jars)?;
let compile_time = compile_start.elapsed();
if !result.success {
eprintln!(
" {} Compilation failed ({}ms)",
style(&now).dim(),
compile_time.as_millis()
);
eprint!("{}", crate::compiler::colorize_errors(&result.errors));
continue;
}
let process_alive = child.try_wait().ok().flatten().is_none();
if process_alive {
if let Some(ref client) = agent_client {
let class_names = extract_class_names(&changed, project);
if !class_names.is_empty() {
let out_dir = config::output_classes_dir(project);
match client.reload(&out_dir, &class_names) {
Ok(reload_result) if reload_result.success => {
println!(
" {} compiled {} file(s) ({}ms) -> {} {}",
style(&now).dim(),
result.outcome.files_compiled(),
compile_time.as_millis(),
reload_result.strategy,
style("✓").green()
);
continue;
}
Ok(reload_result) => {
eprintln!(
" {} Hot reload failed: {} (falling back to restart)",
style("!").yellow(),
reload_result.error.as_deref().unwrap_or("unknown")
);
}
Err(e) => {
eprintln!(
" {} Agent unreachable: {} (falling back to restart)",
style("!").yellow(),
e
);
}
}
}
}
}
graceful_stop(child);
*child = start_java_process(main_class, classpath, jvm_args, dotenv)?;
println!(
" {} compiled {} file(s) ({}ms) -> restarted {}",
style(&now).dim(),
result.outcome.files_compiled(),
compile_time.as_millis(),
style("✓").green()
);
}
println!();
println!(" Stopping...");
graceful_stop(child);
Ok(())
}
pub fn resolve_main_class(cfg: &config::schema::YmConfig, project: &std::path::Path, _target: Option<&str>) -> Result<String> {
if let Some(ref main) = cfg.main {
return Ok(main.clone());
}
let src_dir = config::source_dir_for(project, cfg);
let main_classes = find_main_classes(&src_dir);
match main_classes.len() {
0 => bail!("No main class found. Set 'main' in package.toml or add a class with 'public static void main(String[] args)'"),
1 => Ok(main_classes[0].clone()),
_ => {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
bail!("Multiple main classes found: {}. Set 'main' in package.toml", main_classes.join(", "));
}
let selection = dialoguer::Select::new()
.with_prompt("Select main class")
.items(&main_classes)
.default(0)
.interact()?;
Ok(main_classes[selection].clone())
}
}
}
fn find_main_classes(src_dir: &std::path::Path) -> Vec<String> {
let mut result = Vec::new();
if !src_dir.exists() {
return result;
}
for entry in walkdir::WalkDir::new(src_dir) {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if entry.path().extension().and_then(|e| e.to_str()) != Some("java") {
continue;
}
if let Ok(content) = std::fs::read_to_string(entry.path()) {
if content.contains("public static void main(String") ||
content.contains("public static void main( String") {
if let Ok(rel) = entry.path().strip_prefix(src_dir) {
let class_name = rel
.to_string_lossy()
.replace(['/', '\\'], ".")
.trim_end_matches(".java")
.to_string();
result.push(class_name);
}
}
}
}
result
}
fn load_dotenv(dir: &std::path::Path) -> HashMap<String, String> {
let mut env = HashMap::new();
for name in &[".env", ".env.development"] {
let path = dir.join(name);
if let Ok(content) = std::fs::read_to_string(&path) {
let mut count = 0;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
env.insert(key.trim().to_string(), value.trim().to_string());
count += 1;
}
}
println!(
" {} Loaded {} ({} vars)",
style("✓").green(),
name,
count
);
}
}
env
}
fn start_java_process(
main_class: &str,
classpath: &[std::path::PathBuf],
jvm_args: &[String],
env: &HashMap<String, String>,
) -> Result<std::process::Child> {
let cp = classpath
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(if cfg!(windows) { ";" } else { ":" });
let mut cmd = std::process::Command::new("java");
cmd.envs(env);
for arg in jvm_args {
cmd.arg(arg);
}
let argfile = std::env::temp_dir().join(format!("ym-cp-{}.txt", std::process::id()));
std::fs::write(&argfile, format!("-cp\n{}\n{}", cp, main_class))?;
cmd.arg(format!("@{}", argfile.display()));
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
let child = cmd.spawn()
.map_err(|e| anyhow::anyhow!("Failed to start Java process: {}", e))?;
Ok(child)
}
fn graceful_stop(child: &mut std::process::Child) {
if child.try_wait().ok().flatten().is_some() {
return;
}
#[cfg(unix)]
{
let pid = child.id() as i32;
unsafe { libc::kill(-pid, libc::SIGTERM); }
for _ in 0..50 {
if child.try_wait().ok().flatten().is_some() {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
unsafe { libc::kill(-pid, libc::SIGKILL); }
let _ = child.wait();
}
#[cfg(not(unix))]
{
let pid = child.id();
let _ = std::process::Command::new("taskkill")
.args(["/F", "/T", "/PID", &pid.to_string()])
.status();
let _ = child.wait();
}
}
fn extract_class_names(changed_files: &[std::path::PathBuf], project: &std::path::Path) -> Vec<String> {
let src_dir = config::source_dir(project);
changed_files
.iter()
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("java"))
.filter_map(|p| {
p.strip_prefix(&src_dir).ok().map(|rel| {
rel.to_string_lossy()
.replace(['/', '\\'], ".")
.trim_end_matches(".java")
.to_string()
})
})
.collect()
}
fn chrono_time() -> String {
use std::time::SystemTime;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap();
let secs = now.as_secs() % 86400;
let hours = secs / 3600;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
format!("[{:02}:{:02}:{:02}]", hours, minutes, seconds)
}
fn count_source_files(dir: &std::path::Path, extensions: &[String]) -> usize {
if !dir.exists() {
return 0;
}
walkdir::WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
if let Some(ext) = e.path().extension().and_then(|e| e.to_str()) {
let dot_ext = format!(".{}", ext);
extensions.iter().any(|x| x == &dot_ext || x == ext)
} else {
false
}
})
.count()
}
fn detect_dcevm() -> bool {
if let Ok(java_home) = std::env::var("JAVA_HOME") {
let home_lower = java_home.to_lowercase();
if home_lower.contains("jbr") || home_lower.contains("jetbrains") {
return true;
}
}
if let Ok(output) = std::process::Command::new("java")
.arg("-version")
.output()
{
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
if stderr.contains("jetbrains") || stderr.contains("jbr") || stderr.contains("dcevm") {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_identify_changed_modules() {
let src_to_module = vec![
(std::path::PathBuf::from("/project/core/src"), "core".to_string()),
(std::path::PathBuf::from("/project/web/src"), "web".to_string()),
];
let changed = vec![std::path::PathBuf::from("/project/core/src/Main.java")];
let modules = identify_changed_modules(&changed, &src_to_module);
assert_eq!(modules, vec!["core"]);
let changed = vec![
std::path::PathBuf::from("/project/core/src/Foo.java"),
std::path::PathBuf::from("/project/web/src/Bar.java"),
];
let modules = identify_changed_modules(&changed, &src_to_module);
assert_eq!(modules.len(), 2);
}
}
fn is_port_in_use(port: u16) -> bool {
std::net::TcpListener::bind(("127.0.0.1", port)).is_err()
}