Skip to content

Commit fd87398

Browse files
1lckclaude
andauthored
fix(run): keep module services listed when their working directory is overridden (#861) (#912)
Resolution checked `extensions.maven.module` against the effective `cwd`, so setting a multi-module service's working directory to its own module looked for `web/web`, reported `missingModule`, and dropped the configuration from the run list. The module is now checked against the Core-derived reactor (`extensions.maven.reactorPath`, or the root without detected ownership). `runConfig.updateOptions` also accepted directories that do not exist, including editor variables such as `${workspaceFolder}/web`, which resolution then disabled and hid. Working-directory overrides must now name an existing directory inside the project, and variables are rejected with a specific message the editor shows on save. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 04122aa commit fd87398

6 files changed

Lines changed: 269 additions & 29 deletions

File tree

‎docs/development/platform-parity-matrix.csv‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ lsp-lifecycle,Java,语言服务,LSP/JDTLS 启动与工作区准备,已实现,待
3434
lsp-completion-hover,Java,语言服务,补全与 Hover,已实现,待验证,已实现,待验证,Language Tooling,在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。,,macos/Sources/Lithe/Core/Language; shared/fixtures/lsp,windows/tauri/src/features; shared/fixtures/lsp
3535
lsp-diagnostics,Java,语言服务,诊断与编译错误定位,已实现,待验证,已实现,待验证,Language Tooling,制造语法和类型错误,比较诊断等级、消息、行列号和清理行为。,,macos/Sources/Lithe/Core/Language; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/diagnostics; shared/fixtures/lsp
3636
lsp-navigation-edits,Java,语言服务,跳转、引用、语义标记与编辑,已实现,待验证,已实现,待验证,Language Tooling,验证定义、引用、语义标记和 UTF-16 文本编辑的坐标转换。,,macos/Sources/Lithe/Core/Language; shared/contracts/rust-core-api.md,windows/tauri/src/features; shared/fixtures/lsp
37-
run-discovery,运行与调试,运行配置,入口点与运行配置发现,已实现,待验证,已实现,待验证,Run,验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别。,,macos/Sources/Lithe/Views/Run; shared/fixtures/run-configuration,windows/tauri/src/features/run; shared/fixtures/run-configuration
37+
run-discovery,运行与调试,运行配置,入口点与运行配置发现,已实现,待验证,已实现,待验证,Run,验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因。,,macos/Sources/Lithe/Views/Run; shared/fixtures/run-configuration,windows/tauri/src/features/run; shared/fixtures/run-configuration
3838
run-save-toolchain,运行与调试,运行配置,保存前同步与工具链解析,已实现,待验证,已实现,待验证,Run,修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java,windows/tauri/src/features/run; windows/tauri/src/features/maven
3939
run-java-test,运行与调试,运行配置,Java main 与测试运行,已实现,待验证,已实现,待验证,Run,运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。,,macos/Sources/Lithe/Views/Run; shared/fixtures/debug,windows/tauri/src/features/run; shared/fixtures/debug
4040
debug-breakpoints,运行与调试,调试器,启动调试与断点,已实现,待验证,部分实现,待验证,Debug,设置、命中、禁用和重新定位断点,确认调试会话生命周期。,Windows 真实调试产品链路仍在 #466 跟进。,macos/Sources/Lithe/Views/Debug; shared/fixtures/debug,windows/tauri/src/features/debugger; shared/fixtures/debug

‎docs/development/platform-parity-matrix.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@
136136

137137
| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 |
138138
| --- | --- | --- | --- | --- | --- | --- |
139-
| 运行配置 | **入口点与运行配置发现**<br><sub>run-discovery</sub> | ✅ 已实现<br><sub>🔍 待验证</sub><br><sub>`macos/Sources/Lithe/Views/Run`、`shared/fixtures/run-configuration`</sub> | ✅ 已实现<br><sub>🔍 待验证</sub><br><sub>`windows/tauri/src/features/run`、`shared/fixtures/run-configuration`</sub> | Run | 验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别。 | |
139+
| 运行配置 | **入口点与运行配置发现**<br><sub>run-discovery</sub> | ✅ 已实现<br><sub>🔍 待验证</sub><br><sub>`macos/Sources/Lithe/Views/Run`、`shared/fixtures/run-configuration`</sub> | ✅ 已实现<br><sub>🔍 待验证</sub><br><sub>`windows/tauri/src/features/run`、`shared/fixtures/run-configuration`</sub> | Run | 验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因。 | |
140140
| 运行配置 | **保存前同步与工具链解析**<br><sub>run-save-toolchain</sub> | ✅ 已实现<br><sub>🔍 待验证</sub><br><sub>`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java`</sub> | ✅ 已实现<br><sub>🔍 待验证</sub><br><sub>`windows/tauri/src/features/run`、`windows/tauri/src/features/maven`</sub> | Run | 修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断。 | |
141141
| 运行配置 | **Java main 与测试运行**<br><sub>run-java-test</sub> | ✅ 已实现<br><sub>🔍 待验证</sub><br><sub>`macos/Sources/Lithe/Views/Run`、`shared/fixtures/debug`</sub> | ✅ 已实现<br><sub>🔍 待验证</sub><br><sub>`windows/tauri/src/features/run`、`shared/fixtures/debug`</sub> | Run | 运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 | |
142142
| 调试器 | **启动调试与断点**<br><sub>debug-breakpoints</sub> | ✅ 已实现<br><sub>🔍 待验证</sub><br><sub>`macos/Sources/Lithe/Views/Debug`、`shared/fixtures/debug`</sub> | 🟡 部分实现<br><sub>🔍 待验证</sub><br><sub>`windows/tauri/src/features/debugger`、`shared/fixtures/debug`</sub> | Debug | 设置、命中、禁用和重新定位断点,确认调试会话生命周期。 | Windows 真实调试产品链路仍在 #466 跟进。 |

‎rust/lithe-core/src/execution/configuration.rs‎

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,7 +1124,14 @@ pub fn resolve(request: ResolveRequest) -> Result<Value, CoreError> {
11241124
continue;
11251125
}
11261126
if let Some(module) = configuration.module().filter(|value| value != ".") {
1127-
let workspace_module = workspace_maven_path(Some(&configuration.cwd), &module);
1127+
// `maven.module` is relative to the reactor that owns the entry, not
1128+
// to the effective `cwd`: a user-chosen working directory such as
1129+
// the module itself must not turn `web` into `web/web`. Entries
1130+
// without detected ownership were validated against the root.
1131+
let reactor = configuration
1132+
.extension_string("maven", "reactorPath")
1133+
.unwrap_or_else(dot);
1134+
let workspace_module = workspace_maven_path(Some(&reactor), &module);
11281135
if !project_directory_exists(&root, &workspace_module) {
11291136
configuration.disabled = true;
11301137
diagnostics.push(json!({
@@ -1385,10 +1392,9 @@ fn update_configuration_options(
13851392
let working_directory = if request.working_directory.trim().is_empty() {
13861393
None
13871394
} else {
1388-
Some(normalize_project_directory(
1395+
Some(normalize_working_directory(
13891396
&root,
13901397
request.working_directory.trim(),
1391-
false,
13921398
)?)
13931399
};
13941400
let mut document = if request.scope == "local" {
@@ -1562,7 +1568,6 @@ pub fn create_user_configuration(
15621568
} else {
15631569
request.module.trim()
15641570
},
1565-
true,
15661571
)?;
15671572
let main_class = request.main_class.trim();
15681573
if configuration_kind == "spring-boot.maven" {
@@ -2497,17 +2502,34 @@ fn normalize_scoped_toolchain_path(
24972502
return Ok(String::new());
24982503
}
24992504
if scope == "project" {
2500-
normalize_project_directory(root, value, true)
2505+
normalize_project_directory(root, value)
25012506
} else {
25022507
Ok(value.to_string())
25032508
}
25042509
}
25052510

2506-
fn normalize_project_directory(
2507-
root: &Path,
2508-
value: &str,
2509-
must_exist: bool,
2510-
) -> Result<String, CoreError> {
2511+
/// Validates the literal value of a `cwd` override before it is persisted.
2512+
///
2513+
/// Resolution disables a configuration whose `cwd` is missing and hides it from
2514+
/// the run list, so a directory that does not exist must fail here, where the
2515+
/// editor can show the error, instead of making the saved entry disappear.
2516+
/// Values are not expanded: `${workspaceFolder}` style variables from other
2517+
/// editors would otherwise be stored as a literal directory name.
2518+
fn normalize_working_directory(root: &Path, value: &str) -> Result<String, CoreError> {
2519+
normalize_project_directory(root, value).map_err(|error| {
2520+
if value.contains('$') && !root.join(value).is_dir() {
2521+
CoreError::new(
2522+
ErrorCode::InvalidRequest,
2523+
"Working directory variables are not supported; use a path relative to the project",
2524+
)
2525+
.with_details(value)
2526+
} else {
2527+
error
2528+
}
2529+
})
2530+
}
2531+
2532+
fn normalize_project_directory(root: &Path, value: &str) -> Result<String, CoreError> {
25112533
let candidate = Path::new(value);
25122534
if (!candidate.is_absolute() && invalid_relative_path(value))
25132535
|| candidate
@@ -2525,20 +2547,6 @@ fn normalize_project_directory(
25252547
} else {
25262548
root.join(candidate)
25272549
};
2528-
if !must_exist {
2529-
let normalized = target.components().collect::<PathBuf>();
2530-
let relative = normalized.strip_prefix(root).map_err(|_| {
2531-
CoreError::new(
2532-
ErrorCode::InvalidRequest,
2533-
"Project configuration paths must stay inside the project",
2534-
)
2535-
})?;
2536-
return if relative.as_os_str().is_empty() {
2537-
Ok(".".to_string())
2538-
} else {
2539-
Ok(relative.to_string_lossy().replace('\\', "/"))
2540-
};
2541-
}
25422550
let canonical_target = fs::canonicalize(&target).map_err(|error| {
25432551
CoreError::new(
25442552
ErrorCode::InvalidRequest,

‎rust/lithe-core/src/tests/run_configuration.rs‎

Lines changed: 225 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ fn run_configuration_generation_uses_a_maven_project_below_the_workspace() {
427427
.filter_map(Value::as_str)
428428
.any(|argument| argument == "-am" || argument == "spring-boot:run"));
429429

430-
fs::create_dir_all(root.join("custom-run/service")).unwrap();
430+
fs::create_dir_all(root.join("custom-run")).unwrap();
431431
fs::write(
432432
root.join(".lithe/run/configurations.json"),
433433
serde_json::json!({
@@ -3624,3 +3624,227 @@ fn an_unknown_java_entrypoint_schema_is_rejected() {
36243624
assert_eq!(response["ok"], false, "{response}");
36253625
fs::remove_dir_all(root).unwrap();
36263626
}
3627+
3628+
fn run_config_command(command: &str, payload: Value) -> Value {
3629+
serde_json::from_str(&execute_json(
3630+
&serde_json::json!({ "id": command, "command": command, "payload": payload }).to_string(),
3631+
))
3632+
.unwrap()
3633+
}
3634+
3635+
/// Issue #861: a multi-module Spring Boot service whose working directory is
3636+
/// set to its own module must stay in the run list. `maven.module` is relative
3637+
/// to the owning reactor, so resolving it against the overridden `cwd` looked
3638+
/// for `shop-web/shop-web` and silently dropped the entry.
3639+
#[test]
3640+
fn working_directory_override_keeps_reactor_module_configurations() {
3641+
let root = temporary_root("run-config-module-cwd");
3642+
// The guard also removes the workspace when an assertion fails.
3643+
struct Cleanup(PathBuf);
3644+
impl Drop for Cleanup {
3645+
fn drop(&mut self) {
3646+
let _ = fs::remove_dir_all(&self.0);
3647+
}
3648+
}
3649+
let _cleanup = Cleanup(root.clone());
3650+
let source = "shop-web/src/main/java/com/example/WebApp.java";
3651+
fs::create_dir_all(root.join("shop-web/src/main/java/com/example")).unwrap();
3652+
fs::create_dir_all(root.join("shop-api")).unwrap();
3653+
fs::write(
3654+
root.join("pom.xml"),
3655+
r#"<project><artifactId>shop</artifactId><packaging>pom</packaging><modules><module>shop-web</module><module>shop-api</module></modules></project>"#,
3656+
)
3657+
.unwrap();
3658+
fs::write(
3659+
root.join("shop-web/pom.xml"),
3660+
r#"<project><artifactId>shop-web</artifactId><build><plugins><plugin><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>"#,
3661+
)
3662+
.unwrap();
3663+
fs::write(
3664+
root.join("shop-api/pom.xml"),
3665+
r#"<project><artifactId>shop-api</artifactId></project>"#,
3666+
)
3667+
.unwrap();
3668+
fs::write(
3669+
root.join(source),
3670+
"package com.example; @SpringBootApplication class WebApp { public static void main(String[] args) {} }",
3671+
)
3672+
.unwrap();
3673+
3674+
let generated = generate_with(
3675+
&root,
3676+
&[source],
3677+
Some(jdt_entrypoints(&[(source, "com.example.WebApp")])),
3678+
);
3679+
assert_eq!(generated["ok"], true, "{generated}");
3680+
let service = generated["data"]["generated"]["configurations"]
3681+
.as_array()
3682+
.unwrap()
3683+
.iter()
3684+
.find(|value| value["provider"] == "spring-boot.maven")
3685+
.cloned()
3686+
.unwrap_or_else(|| panic!("missing Spring Boot service in {generated}"));
3687+
assert_eq!(service["cwd"], ".");
3688+
assert_eq!(service["extensions"]["maven"]["module"], "shop-web");
3689+
write_generated_run_document(&root, &generated["data"]["generated"]);
3690+
3691+
// A user-authored Maven Module entry has no detected reactor; its module was
3692+
// validated against the project root when it was created.
3693+
let created = run_config_command(
3694+
"runConfig.createUserConfiguration",
3695+
serde_json::json!({
3696+
"root": root, "scope": "project", "name": "API", "type": "mavenModule",
3697+
"module": "shop-api"
3698+
}),
3699+
);
3700+
assert_eq!(created["ok"], true, "{created}");
3701+
fs::write(
3702+
root.join(".lithe/run/configurations.json"),
3703+
created["data"]["document"].as_str().unwrap(),
3704+
)
3705+
.unwrap();
3706+
let user_module_id = created["data"]["id"].as_str().unwrap().to_string();
3707+
3708+
for (scope, id, directory) in [
3709+
("local", service["id"].as_str().unwrap(), "shop-web"),
3710+
("project", user_module_id.as_str(), "shop-api"),
3711+
] {
3712+
let updated = run_config_command(
3713+
"runConfig.updateOptions",
3714+
serde_json::json!({
3715+
"root": root, "scope": scope, "configurationId": id,
3716+
"workingDirectory": directory
3717+
}),
3718+
);
3719+
assert_eq!(updated["ok"], true, "{updated}");
3720+
let file = if scope == "local" {
3721+
"local.json"
3722+
} else {
3723+
"configurations.json"
3724+
};
3725+
fs::write(
3726+
root.join(".lithe/run").join(file),
3727+
updated["data"]["document"].as_str().unwrap(),
3728+
)
3729+
.unwrap();
3730+
}
3731+
3732+
let resolved = run_config_command("runConfig.resolve", serde_json::json!({ "root": root }));
3733+
assert_eq!(resolved["ok"], true, "{resolved}");
3734+
let configurations = resolved["data"]["configurations"].as_array().unwrap();
3735+
for (id, directory) in [
3736+
(service["id"].as_str().unwrap(), "shop-web"),
3737+
(user_module_id.as_str(), "shop-api"),
3738+
] {
3739+
let configuration = configurations
3740+
.iter()
3741+
.find(|value| value["id"] == id)
3742+
.unwrap_or_else(|| panic!("{id} was dropped: {resolved}"));
3743+
assert_eq!(configuration["cwd"], directory);
3744+
assert!(
3745+
!resolved["data"]["diagnostics"]
3746+
.as_array()
3747+
.unwrap()
3748+
.iter()
3749+
.any(|value| value["id"] == id),
3750+
"{resolved}"
3751+
);
3752+
}
3753+
let service_configuration = configurations
3754+
.iter()
3755+
.find(|value| value["id"] == service["id"])
3756+
.unwrap();
3757+
assert_eq!(
3758+
service_configuration["extensions"]["maven"]["reactorPath"],
3759+
"."
3760+
);
3761+
3762+
let plan = run_config_command(
3763+
"runConfig.createLaunchPlan",
3764+
serde_json::json!({
3765+
"root": root,
3766+
"configurationId": service["id"],
3767+
"javaLaunch": {
3768+
"mainClass": "com.example.WebApp",
3769+
"classPaths": ["/workspace/shop-web/target/classes"],
3770+
"modulePaths": []
3771+
},
3772+
"mavenContext": {"version": 1, "reactorPath": ".", "profiles": [], "skipTests": false}
3773+
}),
3774+
);
3775+
assert_eq!(plan["ok"], true, "{plan}");
3776+
assert_eq!(plan["data"]["workingDirectory"], "shop-web");
3777+
assert_eq!(
3778+
plan["data"]["arguments"],
3779+
serde_json::json!(["com.example.WebApp"])
3780+
);
3781+
}
3782+
3783+
/// Issue #861: resolution hides a configuration whose `cwd` is missing, so a
3784+
/// value that cannot name a project directory is rejected when saved, where
3785+
/// the editor shows the error, instead of making the entry vanish. Editor
3786+
/// variables are not expanded even when the directory they point at exists.
3787+
#[test]
3788+
fn working_directory_override_must_name_an_existing_project_directory() {
3789+
let root = temporary_root("run-config-invalid-cwd");
3790+
// The guard also removes the workspace when an assertion fails.
3791+
struct Cleanup(PathBuf);
3792+
impl Drop for Cleanup {
3793+
fn drop(&mut self) {
3794+
let _ = fs::remove_dir_all(&self.0);
3795+
}
3796+
}
3797+
let _cleanup = Cleanup(root.clone());
3798+
fs::create_dir_all(root.join(".lithe/run")).unwrap();
3799+
fs::create_dir_all(root.join("backend")).unwrap();
3800+
fs::write(
3801+
root.join(".lithe/run/generated.json"),
3802+
r#"{"version":2,"configurations":[{
3803+
"id":"python:api","name":"API","provider":"python.script",
3804+
"command":"python3","args":["app.py"],"cwd":".","toolchains":{}
3805+
}]}"#,
3806+
)
3807+
.unwrap();
3808+
3809+
for (directory, message) in [
3810+
(
3811+
"${workspaceFolder}/backend",
3812+
"Working directory variables are not supported; use a path relative to the project",
3813+
),
3814+
(
3815+
"$PROJECT_DIR$/backend",
3816+
"Working directory variables are not supported; use a path relative to the project",
3817+
),
3818+
("frontend", "Project configuration directory does not exist"),
3819+
(
3820+
"../outside",
3821+
"Project configuration paths must stay inside the project",
3822+
),
3823+
] {
3824+
for scope in ["local", "project"] {
3825+
let updated = run_config_command(
3826+
"runConfig.updateOptions",
3827+
serde_json::json!({
3828+
"root": root, "scope": scope, "configurationId": "python:api",
3829+
"workingDirectory": directory
3830+
}),
3831+
);
3832+
assert_eq!(updated["ok"], false, "{directory}: {updated}");
3833+
assert_eq!(updated["error"]["code"], "invalid_request");
3834+
assert_eq!(updated["error"]["message"], message, "{directory}");
3835+
}
3836+
}
3837+
3838+
let absolute = root.join("backend").canonicalize().unwrap();
3839+
let updated = run_config_command(
3840+
"runConfig.updateOptions",
3841+
serde_json::json!({
3842+
"root": root, "scope": "local", "configurationId": "python:api",
3843+
"workingDirectory": absolute
3844+
}),
3845+
);
3846+
assert_eq!(updated["ok"], true, "{updated}");
3847+
let document: Value =
3848+
serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap();
3849+
assert_eq!(document["configurations"][0]["cwd"], "backend");
3850+
}

0 commit comments

Comments
 (0)