fix(run): import JetBrains Spring Boot and Application configurations (#1504)

The JetBrains importer skipped SpringBootApplicationConfigurationType and Application as unsupported types. Both now map to the spring-boot / application forms' command line with their module, working directory and environment; options without a field are listed as left out.
This commit is contained in:
l0ng-ai
2026-11-05 16:10:19 +08:00
committed by GitHub
parent 26e3ecc3a0
commit 2924824e7b
2 changed files with 198 additions and 10 deletions
+105 -2
View File
@@ -3,8 +3,9 @@
//! its templates (`default="true"`) and temporary configurations. A configuration found in more
//! than one place is taken from the first, in that order.
//!
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and
//! compound configurations become the matching kinds; other types are skipped with their type.
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script,
//! Application, Spring Boot and compound configurations become the matching kinds; other types
//! are skipped with their type.
//! `$PROJECT_DIR$` (and `$MODULE_DIR$`, `$MODULE_WORKING_DIR$`, `$ContentRoot$`,
//! `$ProjectFileDir$`, all the project directory here) and `$USER_HOME$` are replaced; another
//! path variable skips the configuration.
@@ -162,6 +163,8 @@ const VITEST: &str = "JavaScriptTestRunnerVitest";
const NODE: &str = "NodeJSConfigurationType";
const SHELL: &str = "ShConfigurationType";
const COMPOUND: &str = "CompoundRunConfigurationType";
const APPLICATION: &str = "Application";
const SPRING_BOOT: &str = "SpringBootApplicationConfigurationType";
type Res<T> = Result<T, SkipReason>;
@@ -329,6 +332,8 @@ impl Cx<'_> {
JEST | VITEST => self.js_tests(name, c, ty == VITEST, &mut ignored)?,
NODE => self.node(name, c, &mut ignored)?,
SHELL => self.shell(name, c)?,
APPLICATION => self.java_app(name, c, false, &mut ignored)?,
SPRING_BOOT => self.java_app(name, c, true, &mut ignored)?,
COMPOUND => {
members = c.children_named("toRun").filter_map(|t| t.attr("name")).map(str::to_owned).collect();
config(name, "compound", String::new(), Vec::new(), None)
@@ -695,6 +700,54 @@ impl Cx<'_> {
Ok(rc)
}
// ------------------------------------------------------------- Java
/// IDEA's Application and Spring Boot types, as the `application` / `spring-boot` forms run
/// them: `java <vm options> -cp ${classpath} <main> [--spring.profiles.active=…] <arguments>`
/// with the module's runtime classpath.
fn java_app(&self, name: &str, c: &El, spring: bool, ignored: &mut Vec<String>) -> Res<RunConfig> {
let main = if spring { "SPRING_BOOT_MAIN_CLASS" } else { "MAIN_CLASS_NAME" };
let Some(main) = self.text(c.option(main))? else { return Err(SkipReason::NothingToRun) };
let (_, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
let mut args = self.words(c.option("VM_PARAMETERS"))?;
args.extend(["-cp".to_owned(), "${classpath}".to_owned(), main]);
if spring && let Some(profiles) = self.text(c.option("ACTIVE_PROFILES"))? {
args.push(format!("--spring.profiles.active={profiles}"));
}
args.extend(self.words(c.option("PROGRAM_PARAMETERS"))?);
let mut rc =
config(name, if spring { "spring-boot" } else { "application" }, "java".into(), args, Some("java-debug"));
rc.cwd = cwd.map(Into::into);
self.envs(c, &mut rc);
if let Some(module) = c.child("module").and_then(|m| m.attr("name")).filter(|m| !m.is_empty()) {
match module_dir(self.root, module) {
Some(dir) => rc.extras.module = Some(dir),
None => ignored.push(format!("module {module}")),
}
}
// Options the forms have no field for.
if c.flag("ALTERNATIVE_JRE_PATH_ENABLED") {
ignored.push("JRE".into());
}
if c.flag("INCLUDE_PROVIDED_SCOPE") {
ignored.push("provided scope".into());
}
if c.child("classpathModifications").is_some_and(|m| !m.children.is_empty()) {
ignored.push("classpath modifications".into());
}
if spring {
for (option, label) in [("HIDE_BANNER", "hide banner"), ("DEBUG_MODE", "debug output")] {
if c.flag(option) {
ignored.push(label.into());
}
}
if c.child("additionalParameters").is_some_and(|p| !p.children.is_empty()) {
ignored.push("override configuration properties".into());
}
}
Ok(rc)
}
// ------------------------------------------------------------- Shell
/// A script file (with its interpreter) or script text, kept as the Shell Script form's fields.
@@ -835,6 +888,56 @@ fn module_count(root: &Path) -> usize {
.sum()
}
/// The folder (relative, `""` for the project's own) of IDEA module `name`: a Maven module is
/// named after its `artifactId` (usually its folder), a Gradle one `<project>.<path>.<source set>`.
fn module_dir(root: &Path, name: &str) -> Option<String> {
let folder = |dir: &Path| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let exact = |dir: &Path| folder(dir) == name || pom_artifact(dir).is_some_and(|a| a == name);
if let Some(dir) = find_module(root, &exact) {
return Some(dir);
}
let gradle = name.strip_suffix(".main").or_else(|| name.strip_suffix(".test")).unwrap_or(name);
let last = gradle.rsplit('.').next().filter(|last| *last != name)?;
find_module(root, &|dir: &Path| folder(dir) == last)
}
/// The shallowest build folder under `root` (itself included) that `names` accepts.
fn find_module(root: &Path, names: &dyn Fn(&Path) -> bool) -> Option<String> {
const BUILDS: [&str; 3] = ["pom.xml", "build.gradle", "build.gradle.kts"];
let mut level = vec![root.to_path_buf()];
for _ in 0..5 {
if let Some(found) = level.iter().find(|d| BUILDS.iter().any(|b| d.join(b).is_file()) && names(d)) {
let rel = found.strip_prefix(root).ok()?;
return Some(rel.to_string_lossy().replace('\\', "/"));
}
let mut next: Vec<PathBuf> = level
.iter()
.filter_map(|d| std::fs::read_dir(d).ok())
.flat_map(|entries| entries.flatten().map(|e| e.path()))
.filter(|p| p.is_dir())
.filter(|p| {
let n = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
!n.starts_with('.') && !["node_modules", "target", "build", "out", "src"].contains(&n.as_str())
})
.collect();
next.sort();
level = next;
}
None
}
/// The `artifactId` of the module's own `pom.xml` (not its parent's).
fn pom_artifact(dir: &Path) -> Option<String> {
let pom = std::fs::read_to_string(dir.join("pom.xml")).ok()?;
let own = match (pom.find("<parent>"), pom.find("</parent>")) {
(Some(start), Some(end)) if start < end => format!("{}{}", &pom[..start], &pom[end..]),
_ => pom,
};
let start = own.find("<artifactId>")? + "<artifactId>".len();
let end = start + own[start..].find("</artifactId>")?;
Some(own[start..end].trim().to_owned())
}
/// `--settings` from PyCharm's "Settings" field: a file (`mysite/settings/dev.py`, relative to
/// the project) becomes its module relative to the working directory; a module stays as it is.
fn settings_module(value: &str, root: &Path, dir: &Path) -> String {
+93 -8
View File
@@ -239,8 +239,8 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
<envs><env name="VITE_API" value="http://localhost:8000" /></envs>
<method v="2" />
</configuration>
<configuration name="Main" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="shop.Main" />
<configuration name="Main" type="JUnit" factoryName="JUnit">
<option name="MAIN_CLASS_NAME" value="shop.MainTest" />
<method v="2" />
</configuration>"#
)),
@@ -272,7 +272,7 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
assert_eq!(get(&r, "Custom").args, s(&["-m", "pytest", "tests/a.py", "--maxfail=2"]));
assert_eq!(reason(&r, "Unittests in tests"), &SkipReason::UnsupportedType { type_: "tests (Unittests)".into() });
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "Application".into() });
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
let dev = get(&r, "dev");
assert_eq!((dev.kind.as_str(), dev.command.as_str()), ("npm", "pnpm"));
@@ -526,14 +526,11 @@ fn sources_are_offered_when_their_files_exist() {
),
);
assert!(available(dir.path()).is_empty());
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="Application" />"#));
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="JUnit" />"#));
assert_eq!(available(dir.path()), [RunImportSource::Jetbrains]);
write(dir.path(), ".vscode/launch.json", "{}");
assert_eq!(available(dir.path()), [RunImportSource::Vscode, RunImportSource::Jetbrains]);
assert_eq!(
reason(&import_from(dir.path(), &[]), "App"),
&SkipReason::UnsupportedType { type_: "Application".into() }
);
assert_eq!(reason(&import_from(dir.path(), &[]), "App"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
}
#[test]
@@ -702,3 +699,91 @@ fn flags_defaults_inherited_variables_and_nested_django_tests() {
assert_eq!((tests.kind.as_str(), tests.is_test), ("python", true));
assert_eq!(tests.args[..3], s(&["backend/manage.py", "test", "orders"]));
}
#[test]
fn java_applications_and_spring_boot_keep_their_module() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
write(&root, "pom.xml", "<project><artifactId>shop</artifactId><packaging>pom</packaging></project>");
write(
&root,
"example-service/pom.xml",
"<project><parent><artifactId>shop</artifactId></parent><artifactId>example-service</artifactId></project>",
);
// IDEA names a Maven module after its artifactId, which needn't be its folder.
write(
&root,
"services/api/pom.xml",
"<project><parent><artifactId>shop</artifactId></parent><artifactId>api-service</artifactId></project>",
);
write(
&root,
".idea/workspace.xml",
&workspace(
r#" <configuration name="ExampleApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<module name="example-service" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.example.ExampleApplication" />
<option name="ACTIVE_PROFILES" value="dev,local" />
<option name="VM_PARAMETERS" value="-Xmx512m -Dfile.encoding=UTF-8" />
<option name="PROGRAM_PARAMETERS" value="--server.port=8081" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/example-service" />
<option name="HIDE_BANNER" value="true" />
<envs>
<env name="TZ" value="UTC" />
</envs>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
<configuration name="Api" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="com.example.api.Main" />
<module name="api-service" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
<option name="ALTERNATIVE_JRE_PATH" value="17" />
<method v="2" />
</configuration>
<configuration name="Gone" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="Gone" />
<module name="removed-module" />
</configuration>
<configuration name="NoMain" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<module name="example-service" />
</configuration>"#,
),
);
let r = import_from(&root, &[]);
let boot = get(&r, "ExampleApplication");
assert_eq!(boot.kind, "spring-boot");
assert_eq!(boot.command, "java");
assert_eq!(
boot.args,
s(&[
"-Xmx512m",
"-Dfile.encoding=UTF-8",
"-cp",
"${classpath}",
"com.example.ExampleApplication",
"--spring.profiles.active=dev,local",
"--server.port=8081",
])
);
assert_eq!(boot.extras.module.as_deref(), Some("example-service"));
assert_eq!(cwd(boot), Some(PathBuf::from("example-service")));
assert_eq!(boot.env.get("TZ").map(String::as_str), Some("UTC"));
assert_eq!(boot.debug_adapter.as_deref(), Some("java-debug"));
assert_eq!(ignored(&r, "ExampleApplication"), ["before launch", "hide banner"]);
let api = get(&r, "Api");
assert_eq!(api.kind, "application");
assert_eq!(api.args, s(&["-cp", "${classpath}", "com.example.api.Main"]));
assert_eq!(api.extras.module.as_deref(), Some("services/api"));
assert_eq!(ignored(&r, "Api"), ["JRE"]);
// A module that isn't here any more: the form finds it by the main class.
assert_eq!(get(&r, "Gone").extras.module, None);
assert_eq!(ignored(&r, "Gone"), ["module removed-module"]);
assert_eq!(reason(&r, "NoMain"), &SkipReason::NothingToRun);
}