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 }; }