From e803d82202b469e5d1f89f0eec26ce8e9dd480c7 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:24:46 +0800 Subject: [PATCH] fix(run): Detect Maven / Gradle builds nested in a folder of repositories (#157) Run configuration detection only looked for pom.xml / build.gradle(.kts) at the opened root, so a Maven or Gradle build in a subfolder produced no configurations, and Java files fell back to a single-file `java X.java` launch without classpath or build. Detection now takes the root build, or else the shallowest build on each branch up to three levels down, with the subfolder in the config's cwd, id and name; a multi-module parent pom counts as one build. Spring Boot applications (@SpringBootApplication with main in a module that applies the Spring Boot plugin) get spring-boot configurations that build the module and run with its classpath. Gutter and file runs pick the build the file belongs to (also for Go modules), and only files outside any build run as single files; the main config lookup takes the nearest bootRun / run / spring-boot:run. On Windows, piped JVM (java, Maven, Gradle) and Python processes now write in the console encoding instead of the ANSI code page, so their output is no longer garbled. run_configs runs on a blocking thread. Fixes #149 Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA --- crates/ide-run/src/detect.rs | 255 ++++++++++++++++++++++++-- crates/ide-run/src/lib.rs | 1 + crates/ide-run/src/output_encoding.rs | 167 +++++++++++++++++ crates/ide-run/src/process.rs | 33 +++- crates/ide-run/tests/detect.rs | 124 +++++++++++++ src-tauri/src/commands/run.rs | 8 +- src/features/run/context.ts | 7 +- src/features/run/producers.test.ts | 69 +++++++ src/features/run/producers.ts | 20 +- 9 files changed, 655 insertions(+), 29 deletions(-) create mode 100644 crates/ide-run/src/output_encoding.rs diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index d2ba05e16..c02e066b4 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -1,4 +1,5 @@ -//! Run configuration detection from build files found at the project root. +//! Run configuration detection from build files at the project root, or from the +//! builds nested in a folder that holds several repositories. //! //! Detection never fails: unreadable or malformed files simply contribute nothing. @@ -461,22 +462,89 @@ fn applies_application_plugin(text: &str) -> bool { }) } +/// How deep a build is looked for when the project root has none (a folder holding +/// several repositories: `backend/pom.xml`, `apps/server/settings.gradle`). +const NESTED_BUILD_DEPTH: usize = 3; + +/// The builds marked by any of `markers`: the root itself, else the shallowest marked +/// folder of each branch, up to [`NESTED_BUILD_DEPTH`] levels down. +fn build_dirs(root: &Path, markers: &[&str]) -> Vec { + let marked = |d: &Path| markers.iter().any(|m| d.join(m).is_file()); + if marked(root) { + return vec![root.to_path_buf()]; + } + let mut out = Vec::new(); + let mut level = vec![root.to_path_buf()]; + for _ in 0..NESTED_BUILD_DEPTH { + let mut next = Vec::new(); + for sub in level.iter().flat_map(|d| subdirs(d)).filter(|d| !d.ends_with("src") && !d.ends_with("out")) { + if marked(&sub) { + out.push(sub); + } else { + next.push(sub); + } + } + level = next; + } + out.sort(); + out +} + +/// Ids, names and working directory of the configurations of a build nested in +/// `rel` (`None`: the build at the project root). +struct Scope(Option); + +impl Scope { + fn new(root: &Path, dir: &Path) -> Self { + Self((dir != root).then(|| relative(root, dir).to_string_lossy().replace('\\', "/"))) + } + fn id(&self, base: &str) -> String { + match &self.0 { + Some(l) => format!("{base}:{l}"), + None => base.to_owned(), + } + } + fn name(&self, base: &str) -> String { + match &self.0 { + Some(l) => format!("{l}: {base}"), + None => base.to_owned(), + } + } + fn cwd(&self) -> Option { + self.0.as_ref().map(PathBuf::from) + } +} + +const GRADLE_SETTINGS: &[&str] = &["settings.gradle.kts", "settings.gradle"]; +const GRADLE_BUILDS: &[&str] = &["build.gradle.kts", "build.gradle"]; + fn detect_gradle(root: &Path, out: &mut Vec) { - let names = ["build.gradle.kts", "build.gradle"]; - let has_settings = first_existing(root, &["settings.gradle.kts", "settings.gradle"]).is_some(); - let text = match collect_texts(root, &names) { + let markers: Vec<&str> = GRADLE_SETTINGS.iter().chain(GRADLE_BUILDS).copied().collect(); + for dir in build_dirs(root, &markers) { + detect_gradle_build(root, &dir, out); + } +} + +fn detect_gradle_build(root: &Path, dir: &Path, out: &mut Vec) { + let has_settings = first_existing(dir, GRADLE_SETTINGS).is_some(); + let text = match collect_texts(dir, GRADLE_BUILDS) { Some(t) => t, - None if has_settings => collect_texts_from_subdirs(root, &names), + None if has_settings => collect_texts_from_subdirs(dir, GRADLE_BUILDS), None => return, }; - let cmd = wrapper(root, "gradlew", "gradlew.bat", "gradle"); - out.push(config("gradle:build", "gradle build", "gradle", &cmd, &["build"])); - out.push(config("gradle:test", "gradle test", "gradle", &cmd, &["test"]).test()); + let scope = Scope::new(root, dir); + let cmd = wrapper(dir, "gradlew", "gradlew.bat", "gradle"); + let gradle = |id: &str, task: &str| { + config(scope.id(id), scope.name(&format!("gradle {task}")), "gradle", &cmd, &[task]).in_dir(scope.cwd()) + }; + spring_boot_apps(root, dir, out); + out.push(gradle("gradle:build", "build")); + out.push(gradle("gradle:test", "test").test()); if applies_application_plugin(&text) { - out.push(config("gradle:run", "gradle run", "gradle", &cmd, &["run"])); + out.push(gradle("gradle:run", "run")); } if text.contains("org.springframework.boot") { - out.push(config("gradle:bootRun", "gradle bootRun", "gradle", &cmd, &["bootRun"])); + out.push(gradle("gradle:bootRun", "bootRun")); } } @@ -494,15 +562,170 @@ fn collect_texts_from_subdirs(root: &Path, names: &[&str]) -> String { } fn detect_maven(root: &Path, out: &mut Vec) { - let Some(text) = collect_texts(root, &["pom.xml"]) else { return }; - let cmd = wrapper(root, "mvnw", "mvnw.cmd", "mvn"); - out.push(config("maven:package", "mvn package", "maven", &cmd, &["package"])); - out.push(config("maven:test", "mvn test", "maven", &cmd, &["test"]).test()); - if text.contains("spring-boot-maven-plugin") { - out.push(config("maven:spring-boot:run", "mvn spring-boot:run", "maven", &cmd, &["spring-boot:run"])); + for dir in build_dirs(root, &["pom.xml"]) { + detect_maven_build(root, &dir, out); } } +fn detect_maven_build(root: &Path, dir: &Path, out: &mut Vec) { + let Some(text) = collect_texts(dir, &["pom.xml"]) else { return }; + let scope = Scope::new(root, dir); + let cmd = wrapper(dir, "mvnw", "mvnw.cmd", "mvn"); + let maven = |id: &str, goal: &str| { + config(scope.id(id), scope.name(&format!("mvn {goal}")), "maven", &cmd, &[goal]).in_dir(scope.cwd()) + }; + spring_boot_apps(root, dir, out); + out.push(maven("maven:package", "package")); + out.push(maven("maven:test", "test").test()); + if text.contains("spring-boot-maven-plugin") { + out.push(maven("maven:spring-boot:run", "spring-boot:run")); + } +} + +// ---------------------------------------------------------------- spring boot + +/// Source files read per build when looking for `@SpringBootApplication` classes. +const SPRING_SCAN_FILES: usize = 400; + +/// "Spring Boot" configurations for the `@SpringBootApplication` main classes of the +/// modules of the build in `build` that apply the Spring Boot plugin: +/// `java -cp ${classpath} ` in the module, the classpath resolved at launch. +fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec) { + let mut budget = SPRING_SCAN_FILES; + let mut modules = vec![build.to_path_buf()]; + let mut level = vec![build.to_path_buf()]; + for _ in 0..NESTED_BUILD_DEPTH { + level = level.iter().flat_map(|d| subdirs(d)).filter(|d| !d.ends_with("src")).collect(); + modules.extend(level.iter().cloned()); + } + for module in modules { + if budget == 0 { + break; + } + if !applies_spring_boot(&module) { + continue; + } + let module_rel = (module != root).then(|| relative(root, &module).to_string_lossy().replace('\\', "/")); + for class in spring_boot_classes(&module, &mut budget) { + let simple = class.rsplit(['.', '$']).next().unwrap_or(&class).to_owned(); + let id = match &module_rel { + Some(m) => format!("application:{m}:{class}"), + None => format!("application:{class}"), + }; + out.push( + config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class]) + .in_dir(module_rel.as_ref().map(PathBuf::from)) + .debug("java-debug"), + ); + } + } +} + +/// The module's own build applies the Spring Boot plugin, and it has main sources. +fn applies_spring_boot(module: &Path) -> bool { + if !["java", "kotlin"].iter().any(|l| module.join("src/main").join(l).is_dir()) { + return false; + } + if let Some(pom) = read(&module.join("pom.xml")) { + return pom.contains("spring-boot-maven-plugin") && !pom.contains("pom"); + } + GRADLE_BUILDS.iter().filter_map(|n| read(&module.join(n))).any(|text| { + text.lines().any(|l| { + let l = l.split("//").next().unwrap_or_default(); + // The plugin id (`id("org.springframework.boot")`), not a dependency (`org.springframework.boot:…`). + ["org.springframework.boot\"", "org.springframework.boot'"].iter().any(|p| l.contains(p)) + && !l.contains("apply false") + }) + }) +} + +/// Binary names of the `@SpringBootApplication` classes with a `main` under +/// `src/main/{java,kotlin}`, from the shallowest packages that declare one (the +/// application class sits in the root package of the code it scans). +fn spring_boot_classes(module: &Path, budget: &mut usize) -> Vec { + let mut found = Vec::new(); + let mut level: Vec = + ["java", "kotlin"].iter().map(|l| module.join("src/main").join(l)).filter(|d| d.is_dir()).collect(); + while !level.is_empty() && found.is_empty() && *budget > 0 { + let mut next = Vec::new(); + for dir in &level { + let Ok(entries) = fs::read_dir(dir) else { continue }; + let mut files = Vec::new(); + for e in entries.filter_map(Result::ok) { + let path = e.path(); + match e.file_type() { + Ok(t) if t.is_dir() => next.push(path), + Ok(t) if t.is_file() && path.extension().is_some_and(|x| x == "java" || x == "kt") => { + files.push(path) + } + _ => {} + } + } + files.sort(); + for file in files { + if *budget == 0 { + break; + } + *budget -= 1; + if fs::metadata(&file).is_ok_and(|m| m.len() > 256 * 1024) { + continue; + } + if let Some(class) = read(&file).and_then(|src| spring_boot_class(&src, &file)) { + found.push(class); + } + } + } + next.sort(); + level = next; + } + found +} + +/// The class to launch for a source file declaring a `@SpringBootApplication` with a `main`. +pub(crate) fn spring_boot_class(src: &str, file: &Path) -> Option { + let code: Vec<&str> = src.lines().map(|l| l.split("//").next().unwrap_or_default()).collect(); + let code = code.join("\n"); + let at = code.find("@SpringBootApplication")?; + let package = code + .lines() + .map(str::trim) + .find_map(|l| l.strip_prefix("package ")) + .map(|p| p.trim_end_matches(';').trim().to_owned()) + .filter(|p| !p.is_empty()); + let qualify = |name: &str| match &package { + Some(p) => format!("{p}.{name}"), + None => name.to_owned(), + }; + if file.extension().is_some_and(|x| x == "kt") { + // A top-level `fun main` compiles into the file facade (`AppKt`, or `@file:JvmName`). + if code.lines().any(|l| l.starts_with("fun main(")) { + let jvm_name = code + .lines() + .find_map(|l| l.trim().strip_prefix("@file:JvmName(")?.split('"').nth(1).map(str::to_owned)); + let facade = jvm_name.unwrap_or_else(|| { + let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let mut chars = stem.chars(); + let upper: String = chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default(); + format!("{upper}Kt") + }); + return Some(qualify(&facade)); + } + if !code.contains("fun main(") { + return None; + } + } else if !code.contains("void main(") { + return None; + } + let name: String = code[at..] + .split_whitespace() + .skip_while(|t| *t != "class") + .nth(1)? + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$') + .collect(); + (!name.is_empty()).then(|| qualify(&name)) +} + // ---------------------------------------------------------------- go fn is_go_main_package(dir: &Path) -> bool { diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index f4a698a70..4490b7f7c 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -5,6 +5,7 @@ mod detect; pub mod envfile; pub mod java_cmdline; pub mod junit; +mod output_encoding; mod process; mod utf8; pub mod which; diff --git a/crates/ide-run/src/output_encoding.rs b/crates/ide-run/src/output_encoding.rs new file mode 100644 index 000000000..76c6c4b50 --- /dev/null +++ b/crates/ide-run/src/output_encoding.rs @@ -0,0 +1,167 @@ +//! Makes the output of JVM and Python processes match the console's encoding. +//! +//! On Windows both write piped output in the ANSI code page (GBK on a Chinese +//! system: `javac` errors, Maven / Gradle logs, `print`), while the console decodes +//! the configured encoding (UTF-8 by default). They are told which one to use: +//! `java` through `stdout.encoding` / `stderr.encoding` (JDK 19+) and their +//! `sun.*` forerunners (JDK 8–18), Maven and Gradle through `MAVEN_OPTS` / +//! `GRADLE_OPTS`, Python through `PYTHONIOENCODING`. + +use std::collections::BTreeMap; + +const JAVA_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"]; + +/// What a launch needs: `-D` options to put first on a `java` command line, and +/// environment variables to set. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct EncodingFix { + pub java_args: Vec, + pub env: Vec<(String, String)>, +} + +fn is_utf8(encoding: &str) -> bool { + matches!(encoding.to_ascii_lowercase().replace('_', "-").as_str(), "utf-8" | "utf8") +} + +fn java_options(encoding: &str) -> Vec { + JAVA_PROPERTIES.iter().map(|p| format!("-D{p}={encoding}")).collect() +} + +fn sets_java_encoding(text: &str) -> bool { + JAVA_PROPERTIES.iter().any(|p| text.contains(&format!("-D{p}="))) +} + +/// The options for a launch of `program` (`args`, environment `vars` over the +/// `inherited` one) whose output is decoded as `encoding`; nothing when the +/// process already writes it (UTF-8 off Windows) or the user chose the options. +pub fn fix( + program: &str, + args: &[String], + vars: &BTreeMap, + inherited: impl Fn(&str) -> Option, + encoding: &str, + windows: bool, +) -> EncodingFix { + let encoding = encoding.trim(); + let mut out = EncodingFix::default(); + if encoding.is_empty() || (!windows && is_utf8(encoding)) { + return out; + } + let current = |key: &str| vars.get(key).cloned().or_else(|| inherited(key)); + let name = crate::wrap::program_name(program).to_ascii_lowercase(); + match name.as_str() { + "java" | "javaw" => { + if !args.iter().any(|a| sets_java_encoding(a)) + && !current("JDK_JAVA_OPTIONS").is_some_and(|o| sets_java_encoding(&o)) + { + out.java_args = java_options(encoding); + } + } + "mvn" | "mvnw" | "mvnd" | "gradle" | "gradlew" => { + let key = if name.starts_with("gradle") { "GRADLE_OPTS" } else { "MAVEN_OPTS" }; + let opts = current(key).unwrap_or_default(); + if !sets_java_encoding(&opts) { + let added = java_options(encoding).join(" "); + let value = if opts.trim().is_empty() { added } else { format!("{} {added}", opts.trim()) }; + out.env.push((key.to_owned(), value)); + } + } + _ if is_python(&name) && current("PYTHONIOENCODING").is_none_or(|v| v.trim().is_empty()) => { + out.env.push(("PYTHONIOENCODING".to_owned(), encoding.to_owned())); + } + _ => {} + } + out +} + +/// `python`, `python3`, `python3.12`, `py`, and the runners that start one. +fn is_python(name: &str) -> bool { + name == "python" + || name == "py" + || name.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.')) + || matches!(name, "uv" | "poetry" | "pdm" | "pytest" | "pipenv") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(program: &str, args: &[&str], vars: &[(&str, &str)], encoding: &str, windows: bool) -> EncodingFix { + let args: Vec = args.iter().map(|a| (*a).to_owned()).collect(); + let vars: BTreeMap = vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(); + fix(program, &args, &vars, |_| None, encoding, windows) + } + + #[test] + fn java_launches_write_the_console_encoding_on_windows() { + let expected = [ + "-Dstdout.encoding=UTF-8", + "-Dstderr.encoding=UTF-8", + "-Dsun.stdout.encoding=UTF-8", + "-Dsun.stderr.encoding=UTF-8", + ]; + // The reported case: a source-file launch through the Oracle `javapath` shim. + let java = "C:/Program Files/Common Files/Oracle/Java/javapath/java.exe"; + for program in [java, "java", "javaw.exe", r"C:\jdk\bin\java.exe"] { + let f = run(program, &["Main.java"], &[], "UTF-8", true); + assert_eq!(f.java_args, expected, "{program}"); + assert!(f.env.is_empty()); + } + assert_eq!(run("java", &["-cp", "x", "a.Main"], &[], "GBK", true).java_args[0], "-Dstdout.encoding=GBK"); + } + + #[test] + fn nothing_changes_where_output_already_matches() { + for (program, args) in [("java", vec!["Main.java"]), ("mvn", vec!["package"]), ("python3", vec!["a.py"])] { + assert_eq!(run(program, &args, &[], "UTF-8", false), EncodingFix::default(), "{program}"); + assert_eq!(run(program, &args, &[], "utf8", false), EncodingFix::default(), "{program}"); + assert_eq!(run(program, &args, &[], "", true), EncodingFix::default(), "{program}"); + } + for program in ["node", "cargo", "go", "npm.cmd", "cmd.exe"] { + assert_eq!(run(program, &[], &[], "UTF-8", true), EncodingFix::default(), "{program}"); + } + // A console set to another encoding elsewhere still gets it. + assert_eq!(run("java", &["Main"], &[], "GBK", false).java_args.len(), 4); + } + + #[test] + fn options_chosen_by_the_user_win() { + assert!(run("java", &["-Dstdout.encoding=GBK", "Main"], &[], "UTF-8", true).java_args.is_empty()); + assert!( + run("java", &["Main"], &[("JDK_JAVA_OPTIONS", "-Dsun.stdout.encoding=GBK")], "UTF-8", true) + .java_args + .is_empty() + ); + assert!(run("mvn.cmd", &[], &[("MAVEN_OPTS", "-Dstdout.encoding=GBK")], "UTF-8", true).env.is_empty()); + assert!(run("python.exe", &[], &[("PYTHONIOENCODING", "gbk")], "UTF-8", true).env.is_empty()); + } + + #[test] + fn build_tools_and_python_get_environment_variables() { + let opts = + "-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"; + for program in ["mvn.cmd", "mvnw.cmd", r"C:\maven\bin\mvn.cmd"] { + assert_eq!( + run(program, &["package"], &[], "UTF-8", true).env, + [("MAVEN_OPTS".to_owned(), opts.to_owned())] + ); + } + for program in ["gradlew.bat", "gradle.bat"] { + assert_eq!(run(program, &["build"], &[], "UTF-8", true).env, [("GRADLE_OPTS".to_owned(), opts.to_owned())]); + } + // Existing options are kept, from the configuration or the inherited environment. + let f = run("mvn.cmd", &[], &[("MAVEN_OPTS", "-Xmx1g")], "UTF-8", true); + assert_eq!(f.env, [("MAVEN_OPTS".to_owned(), format!("-Xmx1g {opts}"))]); + let inherited = + fix("gradlew.bat", &[], &BTreeMap::new(), |k| (k == "GRADLE_OPTS").then(|| "-Xmx2g".into()), "UTF-8", true); + assert_eq!(inherited.env, [("GRADLE_OPTS".to_owned(), format!("-Xmx2g {opts}"))]); + for program in ["python.exe", "python3", "python3.12", "py.exe", "uv.exe", "poetry"] { + assert_eq!( + run(program, &[], &[], "UTF-8", true).env, + [("PYTHONIOENCODING".to_owned(), "UTF-8".to_owned())], + "{program}" + ); + } + assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]); + } +} diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 4d326bc81..216e82455 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -171,6 +171,33 @@ fn shorten_java(inv: &mut Invocation, config: &RunConfig, cwd: &Path) -> IdeResu Ok(TempFile(Some(s.file))) } +/// Tells JVM and Python processes to write the encoding the console decodes (see +/// [`crate::output_encoding`]). +fn match_console_encoding(inv: &mut Invocation, env: &mut LaunchEnv, config: &RunConfig, options: &StartOptions) { + if inv.raw_args { + return; + } + let inherit = !env.clear; + let fix = crate::output_encoding::fix( + &inv.program.to_string_lossy(), + &inv.args, + &env.vars, + |key| if inherit { std::env::var(key).ok() } else { None }, + options.encoding.as_deref().unwrap_or("UTF-8"), + cfg!(windows), + ); + env.vars.extend(fix.env); + if !fix.java_args.is_empty() { + inv.args.splice(0..0, fix.java_args); + let mut display = quote(config.command.trim()); + for a in &inv.args { + display.push(' '); + display.push_str("e(a)); + } + inv.display = display; + } +} + /// A launch's pathing jar / argfile, deleted when dropped (the process exited, or never started). struct TempFile(Option); @@ -275,7 +302,7 @@ impl ProcessManager { if extras.emulate_terminal && extras.stdin_file.is_some() { return Err(IdeError::invalid("input cannot be redirected from a file when emulating a terminal")); } - let env = launch_env(root, config)?; + let mut env = launch_env(root, config)?; let on_output: OutputFn = Arc::from(on_output); let on_output = match extras.output_file.as_deref() { Some(file) => tee_output(&resolve_cwd(root, Some(Path::new(file))), on_output)?, @@ -286,6 +313,10 @@ impl ProcessManager { cwd: cwd.clone(), env: env.clone(), }); + if !extras.emulate_terminal { + // A terminal re-encodes output itself; pipes carry the process's bytes as they are. + match_console_encoding(&mut inv, &mut env, config, options); + } let temp = shorten_java(&mut inv, config, &cwd)?; if extras.emulate_terminal { return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output, temp); diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 9a69ddfe6..36fb5bf6b 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -184,6 +184,130 @@ fn maven_projects() { assert_eq!(c[0].command, "mvn"); } +const BOOT_POM: &str = "app\ + spring-boot-maven-plugin"; +const PARENT_POM: &str = "pomapp\ + \ + spring-boot-maven-plugin\ + "; +const BOOT_MAIN: &str = "package org.dromara;\n\nimport org.springframework.boot.SpringApplication;\n\ + import org.springframework.boot.autoconfigure.SpringBootApplication;\n\n\ + /** Entry. */\n@SpringBootApplication\npublic class DromaraApplication {\n\ + \x20 public static void main(String[] args) {\n\ + \x20 SpringApplication.run(DromaraApplication.class, args);\n }\n}\n"; + +/// A folder holding a Maven back end and an npm front end, each its own repository +/// (RuoYi-Vue-Plus + plus-ui): the Maven build is detected where it is. +#[test] +fn builds_nested_in_a_folder_of_repositories() { + let dir = project(&[ + ("RuoYi-Vue-Plus/pom.xml", PARENT_POM), + ("RuoYi-Vue-Plus/ruoyi-admin/pom.xml", BOOT_POM), + ("RuoYi-Vue-Plus/ruoyi-admin/src/main/java/org/dromara/DromaraApplication.java", BOOT_MAIN), + ("RuoYi-Vue-Plus/ruoyi-admin/src/main/java/org/dromara/web/Controller.java", "package org.dromara.web;"), + ("RuoYi-Vue-Plus/ruoyi-common/pom.xml", "pom"), + ("RuoYi-Vue-Plus/ruoyi-common/ruoyi-common-core/pom.xml", ""), + ("RuoYi-Vue-Plus/ruoyi-common/ruoyi-common-core/src/main/java/A.java", "class A {}"), + ("plus-ui/package.json", r#"{"scripts":{"dev":"vite"}}"#), + ]); + let c = detect(dir.path()); + let app = find(&c, "application:RuoYi-Vue-Plus/ruoyi-admin:org.dromara.DromaraApplication"); + assert_eq!(app.name, "DromaraApplication"); + assert_eq!(app.kind, "spring-boot"); + assert_eq!(cmdline(app), "java -cp ${classpath} org.dromara.DromaraApplication"); + assert_eq!(app.cwd.as_deref(), Some(Path::new("RuoYi-Vue-Plus/ruoyi-admin"))); + assert_eq!(app.debug_adapter.as_deref(), Some("java-debug")); + let package = find(&c, "maven:package:RuoYi-Vue-Plus"); + assert_eq!(package.name, "RuoYi-Vue-Plus: mvn package"); + assert_eq!(cmdline(package), "mvn package"); + assert_eq!(package.cwd.as_deref(), Some(Path::new("RuoYi-Vue-Plus"))); + assert!(find(&c, "maven:test:RuoYi-Vue-Plus").is_test); + assert!(ids(&c).contains(&"maven:spring-boot:run:RuoYi-Vue-Plus")); + assert!(ids(&c).contains(&"npm:plus-ui:dev")); + // Modules of the nested build are not builds of their own; only one application. + assert!(!ids(&c).iter().any(|id| id.contains("ruoyi-common"))); + assert_eq!(c.iter().filter(|c| c.kind == "spring-boot").count(), 1); +} + +#[test] +fn nested_builds_of_every_kind_and_depth() { + let dir = project(&[ + ("services/api/settings.gradle.kts", "include(\"app\")"), + ("services/api/gradlew", "#!/bin/sh"), + ("services/api/app/build.gradle.kts", "plugins { id(\"org.springframework.boot\") version \"3.3.0\" }\n"), + ( + "services/api/app/src/main/kotlin/com/acme/Api.kt", + "package com.acme\n\n@SpringBootApplication\nclass Api\n\nfun main(args: Array) {\n runApplication(*args)\n}\n", + ), + ("billing/pom.xml", BOOT_POM), + ("billing/mvnw", "#!/bin/sh"), + ( + "billing/src/main/java/Billing.java", + "@SpringBootApplication\nclass Billing { public static void main(String[] a) {} }", + ), + // Deeper than a build is looked for. + ("a/b/c/d/pom.xml", ""), + // Build output and sources are not searched. + ("web/node_modules/x/pom.xml", ""), + ("tool/src/pom.xml", ""), + ]); + let c = detect(dir.path()); + let boot = find(&c, "gradle:bootRun:services/api"); + assert_eq!(boot.name, "services/api: gradle bootRun"); + assert_eq!(boot.cwd.as_deref(), Some(Path::new("services/api"))); + if cfg!(unix) { + assert_eq!(cmdline(boot), "./gradlew bootRun"); + assert_eq!(cmdline(find(&c, "maven:package:billing")), "./mvnw package"); + } + assert!(find(&c, "gradle:test:services/api").is_test); + let api = find(&c, "application:services/api/app:com.acme.ApiKt"); + assert_eq!(api.cwd.as_deref(), Some(Path::new("services/api/app"))); + let billing = find(&c, "application:billing:Billing"); + assert_eq!(billing.cwd.as_deref(), Some(Path::new("billing"))); + let builds: Vec<&str> = + ids(&c).into_iter().filter(|id| id.starts_with("maven:package") || id.starts_with("gradle:build")).collect(); + assert_eq!(builds, ["gradle:build:services/api", "maven:package:billing"]); +} + +#[test] +fn a_root_build_disables_the_nested_scan() { + let dir = project(&[ + ("pom.xml", BOOT_POM), + ( + "src/main/java/demo/App.java", + "package demo;\n@SpringBootApplication public class App {\n public static void main(String[] args) {}\n}\n", + ), + ("tools/gen/pom.xml", ""), + ]); + let c = detect(dir.path()); + assert!(ids(&c).contains(&"maven:package")); + assert!(!ids(&c).iter().any(|id| id.contains("tools"))); + let app = find(&c, "application:demo.App"); + assert_eq!(app.cwd, None); +} + +#[test] +fn spring_boot_applications_need_the_plugin_and_a_main() { + let main = + "package demo;\n@SpringBootApplication\npublic class App {\n public static void main(String[] args) {}\n}\n"; + // A library using Spring Boot without the plugin, a configuration class without `main`, + // the annotation in a comment, a parent POM. + let dir = project(&[ + ("lib/pom.xml", "spring-boot-starter"), + ("lib/src/main/java/demo/App.java", main), + ("svc/pom.xml", BOOT_POM), + ("svc/src/main/java/demo/Config.java", "package demo;\n@SpringBootApplication\npublic class Config {}\n"), + ( + "svc/src/main/java/demo/Old.java", + "package demo;\n// @SpringBootApplication\nclass Old { public static void main(String[] a) {} }\n", + ), + ("gr/build.gradle", "dependencies { implementation 'org.springframework.boot:spring-boot-starter' }\n"), + ("gr/src/main/java/demo/App.java", main), + ]); + let c = detect(dir.path()); + assert!(!c.iter().any(|c| c.kind == "spring-boot"), "{:?}", ids(&c)); +} + #[test] fn go_projects() { let dir = project(&[ diff --git a/src-tauri/src/commands/run.rs b/src-tauri/src/commands/run.rs index eccc12bfe..800919d3c 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -11,8 +11,12 @@ use tauri::{State, Window}; /// Detected configs followed by user-defined ones (user ids win on conflict). #[tauri::command] #[specta::specta] -pub fn run_configs(window: Window, state: State<'_, AppState>) -> IdeResult> { - ide_host::ops::run::configs(&state.host, window.label()) +pub async fn run_configs(window: Window, state: State<'_, AppState>) -> IdeResult> { + // Detection reads build files (and Spring Boot sources): off the command thread. + let (host, label) = (state.host.clone(), window.label().to_owned()); + tauri::async_runtime::spawn_blocking(move || ide_host::ops::run::configs(&host, &label)) + .await + .map_err(|e| ide_core::IdeError::internal(e.to_string()))? } #[tauri::command] diff --git a/src/features/run/context.ts b/src/features/run/context.ts index a0691a544..c7969b1a4 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -185,10 +185,11 @@ function mainConfigFor(relPath: string, configs: RunConfig[]): RunConfig | null return derived(base, relPath, { args: ["run", target], name: `go run ${target}`, isTest: false }); } if (/\.(kt|java)$/.test(relPath)) { - return ( - configs.find((c) => c.id === "gradle:bootRun" || c.id === "gradle:run" || c.id === "maven:spring-boot:run") ?? - null + // The build containing the file (builds nested in a folder of repositories carry a `:` suffix). + const runs = configs.filter((c) => + ["gradle:bootRun", "gradle:run", "maven:spring-boot:run"].some((id) => c.id === id || c.id.startsWith(`${id}:`)), ); + return closest(runs, relPath) ?? null; } return null; } diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 3c75d02dc..5782b9060 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -298,6 +298,75 @@ describe("single-file producers (scratches)", () => { expect(p.config!.args.at(-1)).toBe("demo.Hi"); }); + describe("Java / Kotlin mains in a build nested in a folder of repositories", () => { + // RuoYi-Vue-Plus (Maven) next to plus-ui (npm), opened together. + const boot = + "package org.dromara;\n\nimport org.springframework.boot.SpringApplication;\n\n@SpringBootApplication\n" + + "public class DromaraApplication {\n public static void main(String[] args) {\n" + + " SpringApplication.run(DromaraApplication.class, args);\n }\n}\n"; + const nested = (kind: "maven" | "gradle", dir: string) => [ + cfg({ id: "npm:plus-ui:dev", kind: "npm", command: "npm", args: ["run", "dev"], cwd: "plus-ui" }), + cfg({ id: `${kind}:test:${dir}`, kind, args: ["test"], cwd: dir, isTest: true }), + ]; + const at = (path: string, configs: RunConfig[], content = boot, line = 6): ContextInput => ({ + path: `${ROOT}/${path}`, + root: ROOT, + configs, + content, + line, + }); + const cases = [ + { + kind: "maven", + dir: "RuoYi-Vue-Plus", + file: "RuoYi-Vue-Plus/ruoyi-admin/src/main/java/org/dromara/DromaraApplication.java", + }, + { + kind: "gradle", + dir: "services/api", + file: "services/api/app/src/main/java/org/dromara/DromaraApplication.java", + }, + ] as const; + for (const { kind, dir, file } of cases) { + it(`${kind}: the Spring Boot application in its module, from the gutter and from the file`, () => { + const module = file.slice(0, file.indexOf("/src/")); + for (const p of [ + produceContext(at(file, nested(kind, dir)), ALL), + produceFile(at(file, nested(kind, dir)), ALL), + ]) { + expect(p!.config).toMatchObject({ + id: `application:${module}:org.dromara.DromaraApplication`, + kind: "spring-boot", + name: "DromaraApplication", + command: "java", + cwd: module, + }); + expect(p!.config!.args).toEqual(["-cp", "${classpath}", "org.dromara.DromaraApplication"]); + } + }); + } + + it("a file outside every build still runs as a single source file", () => { + const src = "public class Hi {\n public static void main(String[] args) {}\n}\n"; + const p = produceContext(at("scripts/Hi.java", nested("maven", "RuoYi-Vue-Plus"), src, 1), ALL)!; + expect(p.config).toMatchObject({ kind: "java-file", args: ["scripts/Hi.java"] }); + }); + + it("Kotlin and test classes use the build that contains them", () => { + const kt = "package demo\n\nfun main() {\n println(1)\n}\n"; + const p = produceContext(at("backend/app/src/main/kotlin/demo/App.kt", nested("gradle", "backend"), kt, 3), ALL)!; + expect(p.config).toMatchObject({ kind: "application", cwd: "backend/app" }); + expect(p.config!.args.at(-1)).toBe("demo.AppKt"); + const test = "package demo;\nclass AppTest {\n @Test\n void works() {\n }\n}\n"; + const t = produceContext( + at("backend/app/src/test/java/demo/AppTest.java", nested("gradle", "backend"), test, 4), + ALL, + )!; + expect(t.config).toMatchObject({ kind: "gradle", cwd: "backend" }); + expect(t.config!.args).toEqual(["test", "--tests", "demo.AppTest.works"]); + }); + }); + it("C / C++: compile with debug info into the compile directory", () => { const c = produceContext(scratch("a.c", "int main(void) { return 0; }"), ALL)!; expect(c.config!.command).toMatch( diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index fe528a8f3..117cca4b0 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -515,16 +515,22 @@ const hasCMain = (content?: string) => content === undefined || /^\s*(?:int|void /** A top-level `fun main` (with or without `args`, `suspend` allowed). */ const hasKotlinMain = (content?: string) => content === undefined || /^(?:suspend\s+)?fun\s+main\s*\(/m.test(content); -/** The Cargo configs whose package contains the file, if any. */ -function cargoOwner(rel: string, configs: readonly RunConfig[]): RunConfig[] { +/** + * The configs of the given build tools whose build contains the file (working + * directory at or above it): a build nested in a folder of repositories owns only its files. + */ +function buildOwners(rel: string, configs: readonly RunConfig[], kinds: readonly string[]): RunConfig[] { return configs.filter((c) => { - if (c.kind !== "cargo") return false; + if (!kinds.includes(c.kind)) return false; const cwd = (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, ""); return !cwd || rel.startsWith(cwd + "/"); }); } -const hasBuildTool = (configs: readonly RunConfig[], kinds: string[]) => configs.some((c) => kinds.includes(c.kind)); +const cargoOwner = (rel: string, configs: readonly RunConfig[]) => buildOwners(rel, configs, ["cargo"]); +const hasBuildTool = (rel: string, configs: readonly RunConfig[], kinds: readonly string[]) => + buildOwners(rel, configs, kinds).length > 0; +const JVM_BUILDS = ["maven", "gradle"]; /** * A Godot scene: `godot --path res://` in the Godot project holding it @@ -589,12 +595,12 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null const main = contextConfig(rel, configs, input.content); return main ? { name: main.name, scope: "file", config: main } : null; } - if (lang === "go" && hasBuildTool(configs, ["go"])) { + if (lang === "go" && hasBuildTool(rel, configs, ["go"])) { if (!hasGoMain(input.content)) return null; const main = contextConfig(rel, configs, input.content ?? "package main"); if (main) return { name: main.name, scope: "file", config: main }; } - if (/\.(java|kt)$/.test(rel) && hasBuildTool(configs, ["maven", "gradle"])) { + if (/\.(java|kt)$/.test(rel) && hasBuildTool(rel, configs, JVM_BUILDS)) { if (input.content === undefined) return null; const targets = detectRunTargets(input.content, rel).filter((t) => t.className); const target = targets.find((t) => t.kind === "main") ?? targets.find((t) => t.kind === "suite"); @@ -796,7 +802,7 @@ function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget) const rel = s.idPath; if (target.kind === "main") { // Java / Kotlin main classes in a Maven / Gradle project: an Application configuration. - if (target.className && s.inProject && hasBuildTool(input.configs, ["maven", "gradle"])) { + if (target.className && s.inProject && hasBuildTool(rel, input.configs, JVM_BUILDS)) { const config = applicationConfig({ className: target.className, spring: target.spring }, rel, input.configs); return { name: config.name, scope: "element", config }; }