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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 00:24:46 +08:00
1 джерело a7c970a6db
коміт e803d82202
9 files changed
+655 -29

No files matched your search

+239 -16
Переглянути файл
@@ -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<PathBuf> {
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<String>);
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<PathBuf> {
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<RunConfig>) {
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<RunConfig>) {
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<RunConfig>) {
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<RunConfig>) {
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} <class>` in the module, the classpath resolved at launch.
fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec<RunConfig>) {
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("<packaging>pom</packaging>");
}
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<String> {
let mut found = Vec::new();
let mut level: Vec<PathBuf> =
["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<String> {
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 {
+1
Переглянути файл
@@ -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;
+167
Переглянути файл
@@ -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<String>,
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<String> {
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<String, String>,
inherited: impl Fn(&str) -> Option<String>,
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<String> = args.iter().map(|a| (*a).to_owned()).collect();
let vars: BTreeMap<String, String> = 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())]);
}
}
+32 -1
Переглянути файл
@@ -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(&quote(a));
}
inv.display = display;
}
}
/// A launch's pathing jar / argfile, deleted when dropped (the process exited, or never started).
struct TempFile(Option<PathBuf>);
@@ -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);
+124
Переглянути файл
@@ -184,6 +184,130 @@ fn maven_projects() {
assert_eq!(c[0].command, "mvn");
}
const BOOT_POM: &str = "<project><artifactId>app</artifactId><build><plugins><plugin>\
<artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>";
const PARENT_POM: &str = "<project><packaging>pom</packaging><modules><module>app</module></modules>\
<build><pluginManagement><plugins><plugin>\
<artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></pluginManagement>\
</build></project>";
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", "<project><packaging>pom</packaging></project>"),
("RuoYi-Vue-Plus/ruoyi-common/ruoyi-common-core/pom.xml", "<project/>"),
("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<String>) {\n runApplication<Api>(*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", "<project/>"),
// Build output and sources are not searched.
("web/node_modules/x/pom.xml", "<project/>"),
("tool/src/pom.xml", "<project/>"),
]);
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", "<project/>"),
]);
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", "<project><dependencies>spring-boot-starter</dependencies></project>"),
("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(&[
+6 -2
Переглянути файл
@@ -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<Vec<RunConfig>> {
ide_host::ops::run::configs(&state.host, window.label())
pub async fn run_configs(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<RunConfig>> {
// 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]
+4 -3
Переглянути файл
@@ -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 `:<dir>` 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;
}
+69
Переглянути файл
@@ -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(
+13 -7
Переглянути файл
@@ -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 <project> res://<scene>` 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 };
}