From 144468f38e2d283f3de7113df8b03401cf8455ec Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Wed, 12 Aug 2026 20:35:00 -0700 Subject: [PATCH 1/2] fix(maven): detect projects below workspace root --- .../Lithe/Application/UIFeatureModels.swift | 6 +- Sources/Lithe/Core/RustCoreBridge.swift | 17 ++- .../Lithe/Core/RustJavaMavenOperations.swift | 16 ++- Sources/Lithe/Services/MavenService.swift | 4 +- .../Services/RunExecutableResolver.swift | 5 +- Sources/Lithe/Views/MavenView.swift | 2 +- Tests/LitheTests/MavenRuntimeTests.swift | 9 +- .../RunConfigurationIntegrationTests.swift | 49 +++++++- .../lithe-core/src/execution/configuration.rs | 95 +++++++++++---- .../src/execution/detectors/maven.rs | 8 +- .../lithe-core/src/execution/detectors/mod.rs | 15 ++- .../src/execution/detectors/scan.rs | 19 +++ rust/lithe-core/src/project/maven.rs | 63 +++++++++- rust/lithe-core/src/protocol/contracts.rs | 1 + rust/lithe-core/src/tests/languages.rs | 48 +++++++- .../lithe-core/src/tests/run_configuration.rs | 109 ++++++++++++++++++ shared/contracts/application-boundary.md | 2 +- shared/contracts/rust-core-api.md | 13 ++- shared/fixtures/maven/basic.json | 4 +- 19 files changed, 426 insertions(+), 59 deletions(-) diff --git a/Sources/Lithe/Application/UIFeatureModels.swift b/Sources/Lithe/Application/UIFeatureModels.swift index ecafa55d5..5ebf3e05a 100644 --- a/Sources/Lithe/Application/UIFeatureModels.swift +++ b/Sources/Lithe/Application/UIFeatureModels.swift @@ -23,8 +23,8 @@ final class MavenFeatureModel: ObservableObject { var issues: [MavenBuildIssue] { service.issues } var lastExitCode: Int32? { service.lastExitCode } - func loadProject(at workspaceURL: URL) async { - await service.loadProject(at: workspaceURL) + func loadProject(at workspaceURL: URL, files: [URL]) async { + await service.loadProject(at: workspaceURL, files: files) } func run(phase: MavenLifecyclePhase, module: MavenModule?, profiles: Set) { @@ -197,7 +197,7 @@ final class ProjectDevelopmentFeatureModel { file.lastPathComponent.lowercased() == "pom.xml" } if hasMavenDescriptor { - await mavenFeature.loadProject(at: workspaceURL) + await mavenFeature.loadProject(at: workspaceURL, files: files) } else { mavenFeature.reset() } diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index 98690c865..758a60b13 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -227,6 +227,7 @@ struct RustCoreBridge: Sendable { } } + let relativePath: String let groupID: String? let artifactID: String let version: String? @@ -237,6 +238,7 @@ struct RustCoreBridge: Sendable { enum CodingKeys: String, CodingKey { case groupID = "groupId" + case relativePath case artifactID = "artifactId" case version case packaging @@ -245,8 +247,11 @@ struct RustCoreBridge: Sendable { case hasWrapper } - func makeProject(rootURL: URL) -> MavenProject { - MavenProject( + func makeProject(workspaceRootURL: URL) -> MavenProject { + let rootURL = relativePath == "." + ? workspaceRootURL + : workspaceRootURL.appendingPathComponent(relativePath, isDirectory: true) + return MavenProject( rootURL: rootURL, pomURL: rootURL.appendingPathComponent("pom.xml"), groupID: groupID, @@ -1041,6 +1046,7 @@ struct RustCoreBridge: Sendable { private struct MavenScanRequest: Encodable { let root: String + let paths: [String] } private struct MarkdownRenderRequest: Encodable { @@ -1773,10 +1779,13 @@ struct RustCoreBridge: Sendable { return response?.relocated == true } - func scanMaven(at rootURL: URL) -> MavenScanPayload? { + func scanMaven(at rootURL: URL, paths: [String] = []) -> MavenScanPayload? { execute( command: "maven.scan", - payload: MavenScanRequest(root: rootURL.standardizedFileURL.path) + payload: MavenScanRequest( + root: rootURL.standardizedFileURL.path, + paths: paths + ) ) } diff --git a/Sources/Lithe/Core/RustJavaMavenOperations.swift b/Sources/Lithe/Core/RustJavaMavenOperations.swift index 6192bae29..4984cbc4c 100644 --- a/Sources/Lithe/Core/RustJavaMavenOperations.swift +++ b/Sources/Lithe/Core/RustJavaMavenOperations.swift @@ -1,7 +1,7 @@ import Foundation protocol JavaMavenOperations: Sendable { - func scanMavenProject(at rootURL: URL) -> MavenProject? + func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] func codeVision( at rootURL: URL, @@ -42,8 +42,18 @@ struct JavaCodeVisionValue: Sendable { struct RustJavaMavenOperations: JavaMavenOperations, Sendable { let core: RustCoreBridge - func scanMavenProject(at rootURL: URL) -> MavenProject? { - core.scanMaven(at: rootURL)?.makeProject(rootURL: rootURL.standardizedFileURL) + func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { + let root = rootURL.standardizedFileURL + let rootComponents = root.pathComponents + let paths = files.compactMap { fileURL -> String? in + let file = fileURL.standardizedFileURL + guard file.lastPathComponent.lowercased() == "pom.xml", + file.pathComponents.starts(with: rootComponents) else { return nil } + return file.pathComponents + .dropFirst(rootComponents.count) + .joined(separator: "/") + } + return core.scanMaven(at: root, paths: paths)?.makeProject(workspaceRootURL: root) } func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { diff --git a/Sources/Lithe/Services/MavenService.swift b/Sources/Lithe/Services/MavenService.swift index d3fc66d64..b223df483 100644 --- a/Sources/Lithe/Services/MavenService.swift +++ b/Sources/Lithe/Services/MavenService.swift @@ -42,14 +42,14 @@ final class MavenService: ObservableObject { } } - func loadProject(at workspaceURL: URL) async { + func loadProject(at workspaceURL: URL, files: [URL]) async { let loadID = UUID() projectLoadID = loadID isLoadingProject = true let rootURL = workspaceURL.standardizedFileURL let javaMavenOperations = javaMavenOperations let scannedProject = await Task.detached(priority: .utility) { - javaMavenOperations.scanMavenProject(at: rootURL) + javaMavenOperations.scanMavenProject(at: rootURL, files: files) }.value guard !Task.isCancelled, projectLoadID == loadID else { return } project = scannedProject diff --git a/Sources/Lithe/Services/RunExecutableResolver.swift b/Sources/Lithe/Services/RunExecutableResolver.swift index e437691d5..60ac44815 100644 --- a/Sources/Lithe/Services/RunExecutableResolver.swift +++ b/Sources/Lithe/Services/RunExecutableResolver.swift @@ -26,9 +26,12 @@ final class RunExecutableResolver: RunExecutableResolving { switch plan.executable { case .toolchain(let id): + let toolchainProjectURL = id == "project-maven" && plan.workingDirectory != "." + ? projectURL.appendingPathComponent(plan.workingDirectory, isDirectory: true) + : projectURL let resolved = try toolchainRegistry.resolve( identifier: id, - projectURL: projectURL, + projectURL: toolchainProjectURL.standardizedFileURL, options: options, runtimeService: runtimeService ) diff --git a/Sources/Lithe/Views/MavenView.swift b/Sources/Lithe/Views/MavenView.swift index 9e19263f9..f0203fab5 100644 --- a/Sources/Lithe/Views/MavenView.swift +++ b/Sources/Lithe/Views/MavenView.swift @@ -87,7 +87,7 @@ struct MavenView: View { private func refreshProject() { guard let workspaceURL = model.workspaceURL else { return } - Task { await feature.loadProject(at: workspaceURL) } + Task { await feature.loadProject(at: workspaceURL, files: model.projectFiles) } } private func projectPane(_ project: MavenProject) -> some View { diff --git a/Tests/LitheTests/MavenRuntimeTests.swift b/Tests/LitheTests/MavenRuntimeTests.swift index 904575367..87710b1d3 100644 --- a/Tests/LitheTests/MavenRuntimeTests.swift +++ b/Tests/LitheTests/MavenRuntimeTests.swift @@ -8,6 +8,7 @@ struct MavenRuntimeTests { func mavenScanPayloadDecodesRustCamelCaseIdentifiers() throws { let json = #""" { + "relativePath": "services/api", "groupId": "com.example", "artifactId": "root", "version": "1.0", @@ -29,12 +30,18 @@ struct MavenRuntimeTests { from: Data(json.utf8) ) - let project = payload.makeProject(rootURL: URL(fileURLWithPath: "/tmp/maven")) + let workspaceRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-maven-payload", isDirectory: true) + let project = payload.makeProject(workspaceRootURL: workspaceRoot) + let expectedRoot = workspaceRoot.appendingPathComponent("services/api", isDirectory: true) + #expect(project.rootURL == expectedRoot) + #expect(project.pomURL == expectedRoot.appendingPathComponent("pom.xml")) #expect(project.groupID == "com.example") #expect(project.artifactID == "root") #expect(project.modules.count == 1) #expect(project.modules[0].groupID == "com.example") #expect(project.modules[0].artifactID == "child") + #expect(project.modules[0].url == expectedRoot.appendingPathComponent("module-a")) #expect(project.profiles == [MavenProfile(id: "dev", isActiveByDefault: true)]) #expect(project.hasWrapper) } diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 66e58278f..765c22faf 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -893,6 +893,29 @@ struct RunConfigurationIntegrationTests { } } + @Test + func mavenToolchainResolvesTheWrapperFromTheLaunchWorkingDirectory() throws { + let workspaceRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-nested-maven-wrapper", isDirectory: true) + let mavenRoot = workspaceRoot.appendingPathComponent("projects/demo", isDirectory: true) + let runtime = ProjectRuntimeService( + runtimeLocator: NestedMavenWrapperRuntimeLocator(wrapperRoot: mavenRoot), + store: RunTestKeyValueStore() + ) + let resolver = RunExecutableResolver(runtimeService: runtime) + let resolved = try resolver.resolve( + SharedLaunchPlan( + executable: .toolchain("project-maven"), + arguments: ["verify"], + workingDirectory: "projects/demo" + ), + projectURL: workspaceRoot, + options: RunOptions() + ) + + #expect(resolved.executableURL == mavenRoot.appendingPathComponent("mvnw")) + } + @Test func commandResolverLayersGenericEnvironmentWithoutJavaInjection() throws { let runtime = ProjectRuntimeService( @@ -4074,6 +4097,24 @@ private struct RunTestRuntimeLocator: RuntimeLocator { func systemJDBExecutable() -> URL? { URL(fileURLWithPath: "/toolchains/jdk/bin/jdb") } } +private struct NestedMavenWrapperRuntimeLocator: RuntimeLocator { + let wrapperRoot: URL + + func environment() -> [String: String] { [:] } + func discover() -> RuntimeDiscoveryResult { + RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) + } + func validJavaHome(path: String) -> URL? { nil } + func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? { nil } + func isExecutable(at url: URL) -> Bool { + url.standardizedFileURL == wrapperRoot.appendingPathComponent("mvnw").standardizedFileURL + } + func systemMavenExecutable() -> URL? { nil } + func mavenExecutable(forHomePath path: String) -> URL? { nil } + func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } + func systemJDBExecutable() -> URL? { nil } +} + private struct MissingJavaRuntimeLocator: RuntimeLocator { func environment() -> [String: String] { ["PATH": "/usr/bin"] } func discover() -> RuntimeDiscoveryResult { @@ -4381,7 +4422,7 @@ private final class RunTestKeyValueStore: KeyValueStore, @unchecked Sendable { } private struct RunTestJavaMavenOperations: JavaMavenOperations { - func scanMavenProject(at rootURL: URL) -> MavenProject? { nil } + func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil } func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } func codeVision(at rootURL: URL, targetPath: String, paths: [String]) -> [JavaCodeVisionValue] { [] } func className(source: String, simpleName: String) -> String? { nil } @@ -4402,6 +4443,7 @@ struct CorePayloadDecodingTests { func mavenScanDecodesTheCoordinateSpellingTheCoreEmits() throws { let json = """ { + "relativePath": ".", "groupId": "com.lithe.demo", "artifactId": "full-stack-demo", "version": "1.0.0-SNAPSHOT", @@ -4425,7 +4467,10 @@ struct CorePayloadDecodingTests { RustCoreBridge.MavenScanPayload.self, from: Data(json.utf8) ) - let project = payload.makeProject(rootURL: URL(fileURLWithPath: "/tmp/demo")) + let project = payload.makeProject( + workspaceRootURL: FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-maven-core-payload", isDirectory: true) + ) #expect(project.artifactID == "full-stack-demo") #expect(project.modules.map(\.artifactID) == ["backend-api"]) diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index f6debfe09..e9a16d2f5 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -349,18 +349,29 @@ pub fn inspect(request: InspectRequest) -> Result { pub fn generate(request: GenerateRequest) -> Result { let root = existing_root(&request.root)?; + let maven_root = crate::project::maven_root(&root, &request.paths); + let maven_relative_path = maven_root + .as_ref() + .map(|(_, relative_path)| relative_path.as_str()); let has_java_sources = request .paths .iter() .any(|path| path.to_lowercase().ends_with(".java")); - let has_maven_project = root.join("pom.xml").is_file() || root.join("mvnw").is_file(); + let has_maven_project = maven_relative_path.is_some() + || root.join("mvnw").is_file() + || root.join("mvnw.cmd").is_file(); // A Gradle build needs the same JDK requirement as Maven, and its sources may // be Kotlin or Groovy rather than Java, so the build files count on their own. let has_gradle_project = ["build.gradle", "build.gradle.kts", "gradlew"] .iter() .any(|name| root.join(name).is_file()); let has_java_ecosystem = has_java_sources || has_maven_project || has_gradle_project; - let module_paths = inferred_maven_module_paths(&root, &request.paths, request.module_paths); + let configured_module_paths = request + .module_paths + .into_iter() + .map(|path| workspace_maven_path(maven_relative_path, &path)) + .collect(); + let module_paths = inferred_maven_module_paths(&root, &request.paths, configured_module_paths); let scanned = crate::languages::run_configurations(JavaRunConfigurationsRequest { root: request.root.clone(), paths: request.paths, @@ -384,10 +395,12 @@ pub fn generate(request: GenerateRequest) -> Result { }; let id = java_configuration_id(&value); let mut maven = serde_json::Map::new(); - maven.insert( - "module".to_string(), - json!(value.module_path.unwrap_or_else(|| ".".to_string())), - ); + let module_path = value + .module_path + .as_deref() + .map(|path| maven_module_path(maven_relative_path, path)) + .unwrap_or_else(|| ".".to_string()); + maven.insert("module".to_string(), json!(module_path)); if let Some(main_class) = value.main_class { maven.insert("mainClass".to_string(), json!(main_class)); } @@ -401,7 +414,11 @@ pub fn generate(request: GenerateRequest) -> Result { }, command: None, args: Vec::new(), - cwd: ".".to_string(), + cwd: if provider == "java.current-file" { + ".".to_string() + } else { + maven_relative_path.unwrap_or(".").to_string() + }, env: BTreeMap::new(), confidence: Confidence::Native, toolchains: if provider == "java.current-file" { @@ -461,14 +478,23 @@ pub fn generate(request: GenerateRequest) -> Result { .iter() .map(|item| item.id.clone()) .collect::>(); - let mut detected = detected_configurations(&root, &claimed)?; - adopt_annotated_main_classes(&mut detected, &annotated_main_classes); + let mut detected = detected_configurations( + &root, + maven_root.as_ref().map(|(path, _)| path.as_path()), + &claimed, + )?; + adopt_annotated_main_classes(&mut detected, &annotated_main_classes, maven_relative_path); // Counts real entry points, not documents: the always-present "Current File" // fallback is excluded so an empty project still reports zero and the UI can // say so, while a detected npm service correctly reports one. let entry_count = java_entry_count + detected.len(); configurations.extend(detected); - let requirements = detect_requirements(&root, has_java_ecosystem, &configurations)?; + let requirements = detect_requirements( + &root, + maven_root.as_ref().map(|(path, _)| path.as_path()), + has_java_ecosystem, + &configurations, + )?; let generated = RunConfigurationDocument { version: VERSION, generator: Some(GeneratorMetadata { @@ -482,6 +508,27 @@ pub fn generate(request: GenerateRequest) -> Result { ) } +fn workspace_maven_path(maven_root: Option<&str>, path: &str) -> String { + match (maven_root, path) { + (Some(root), ".") => root.to_string(), + (Some(root), path) if root != "." => format!("{root}/{path}"), + _ => path.to_string(), + } +} + +fn maven_module_path(maven_root: Option<&str>, path: &str) -> String { + let Some(root) = maven_root.filter(|root| *root != ".") else { + return path.to_string(); + }; + if path == root { + ".".to_string() + } else { + path.strip_prefix(&(root.to_string() + "/")) + .unwrap_or(path) + .to_string() + } +} + /// Whether a service is a Spring Boot service is decided by the build, not by an /// annotation: `spring-boot-maven-plugin` is what makes `spring-boot:run` work at /// all, and the Maven detector reads it from the declared module graph. The scan @@ -509,6 +556,7 @@ fn java_configuration_id(value: &crate::protocol::JavaRunConfigurationResponse) fn adopt_annotated_main_classes( configurations: &mut [RunConfiguration], annotated: &[(String, String)], + maven_root: Option<&str>, ) { for configuration in configurations .iter_mut() @@ -521,7 +569,7 @@ fn adopt_annotated_main_classes( // module is ambiguous, and guessing would start the wrong service. let mut matches = annotated .iter() - .filter(|(path, _)| within_module(path, &module)) + .filter(|(path, _)| within_maven_module(path, &module, maven_root)) .map(|(_, qualified_name)| qualified_name); let Some(main_class) = matches.next() else { continue; @@ -539,8 +587,9 @@ fn adopt_annotated_main_classes( } } -fn within_module(path: &str, module: &str) -> bool { - module == "." || path.starts_with(&format!("{module}/")) +fn within_maven_module(path: &str, module: &str, maven_root: Option<&str>) -> bool { + let workspace_module = workspace_maven_path(maven_root, module); + workspace_module == "." || path.starts_with(&format!("{workspace_module}/")) } /// Translates detector output into the run-configuration contract. @@ -552,9 +601,10 @@ fn within_module(path: &str, module: &str) -> bool { /// provider `create_launch_plan` already handles. fn detected_configurations( root: &Path, + maven_root: Option<&Path>, claimed: &BTreeSet, ) -> Result, CoreError> { - Ok(super::detectors::detect_all(root)? + Ok(super::detectors::detect_all(root, maven_root)? .into_iter() .map(|item| RunConfiguration { id: item.id(), @@ -668,7 +718,8 @@ pub fn resolve(request: ResolveRequest) -> Result { continue; } if let Some(module) = configuration.module().filter(|value| value != ".") { - if !project_directory_exists(&root, &module) { + let workspace_module = workspace_maven_path(Some(&configuration.cwd), &module); + if !project_directory_exists(&root, &workspace_module) { configuration.disabled = true; diagnostics.push(json!({ "id": configuration.id, @@ -1705,6 +1756,7 @@ fn read_requirements(root: &Path) -> Result, has_java_ecosystem: bool, configurations: &[RunConfiguration], ) -> Result { @@ -1724,26 +1776,29 @@ fn detect_requirements( version: None, java: Some("project-jdk".to_string()), }; - let pom = root.join("pom.xml"); + let maven_root = maven_root.unwrap_or(root); + let pom = maven_root.join("pom.xml"); if let Ok(text) = fs::read_to_string(pom) { let re = regex::Regex::new(r"(?:maven.compiler.release|maven.compiler.source|maven.compiler.target|java.version)\s*>?\s*[:=]?\s*([0-9]+)").unwrap(); jdk.minimum_version = re .captures(&text) .and_then(|c| c.get(1).map(|m| m.as_str().to_string())); } - if let Some((version, vendor)) = declared_java_version(root) { + if let Some((version, vendor)) = + declared_java_version(maven_root).or_else(|| declared_java_version(root)) + { jdk.minimum_version = Some(version); jdk.preferred_vendor = vendor; } - if root.join("mvnw").exists() { + if maven_root.join("mvnw").exists() { maven.wrapper = Some("./mvnw".to_string()); } - maven.version = maven_wrapper_version(root); + maven.version = maven_wrapper_version(maven_root); let mut toolchains = BTreeMap::new(); if has_java_ecosystem { toolchains.insert("project-jdk".to_string(), jdk); } - if root.join("pom.xml").is_file() || root.join("mvnw").is_file() { + if maven_root.join("pom.xml").is_file() || maven_root.join("mvnw").is_file() { toolchains.insert("project-maven".to_string(), maven); } let providers = configurations diff --git a/rust/lithe-core/src/execution/detectors/maven.rs b/rust/lithe-core/src/execution/detectors/maven.rs index ebb24875f..8f06acde9 100644 --- a/rust/lithe-core/src/execution/detectors/maven.rs +++ b/rust/lithe-core/src/execution/detectors/maven.rs @@ -18,8 +18,8 @@ const SERVICE_PLUGINS: &[(&str, &str)] = &[ ]; /// Maven modules are *declared*, not discovered, so this detector reads the -/// module graph from the root `pom.xml` instead of judging each directory the -/// shared walk visits. +/// module graph from the selected Maven root instead of judging each directory +/// the shared walk visits. /// /// A directory-driven scan gets Maven wrong in both directions: `` may /// name a directory the walk prunes (one called `build` or `out` is invisible), @@ -27,10 +27,10 @@ const SERVICE_PLUGINS: &[(&str, &str)] = &[ /// Reading the graph once at the root is also cheaper than parsing every pom the /// walk happens to pass. pub fn detect(ctx: &DirectoryContext) -> Vec { - if ctx.relative != "." || !ctx.has("pom.xml") { + if !ctx.has("pom.xml") { return Vec::new(); } - let Ok(modules) = declared_modules(&ctx.root) else { + let Ok(modules) = declared_modules(&ctx.path) else { return Vec::new(); }; let names = service_names(&modules); diff --git a/rust/lithe-core/src/execution/detectors/mod.rs b/rust/lithe-core/src/execution/detectors/mod.rs index 80a91aa92..51c9bab2e 100644 --- a/rust/lithe-core/src/execution/detectors/mod.rs +++ b/rust/lithe-core/src/execution/detectors/mod.rs @@ -6,9 +6,10 @@ //! ecosystems are added. //! //! An ecosystem whose layout is *declared* rather than discovered reads its own -//! manifest graph instead, from the root directory only -- see `maven`. That is -//! not a shortcut around the shared walk: a declared graph names directories the -//! walk prunes, and omits ones it would visit. +//! manifest graph instead -- see `maven`. The caller selects the Maven root +//! because it may sit below the opened workspace. That is not a shortcut around +//! the shared walk: a declared graph names directories the walk prunes, and +//! omits ones it would visit. mod cargo; mod compose; @@ -207,7 +208,6 @@ const DETECTORS: &[DetectFn] = &[ cargo::detect, go::detect, gradle::detect, - maven::detect, make::detect, shell::detect_just, ]; @@ -215,7 +215,7 @@ const DETECTORS: &[DetectFn] = &[ /// Runs every detector over the project and returns deduplicated results /// ordered shallowest-directory-first, so the top-level service of a monorepo /// reads before its packages. -pub fn detect_all(root: &Path) -> Result, CoreError> { +pub fn detect_all(root: &Path, maven_root: Option<&Path>) -> Result, CoreError> { let directories = scan(root)?; let mut found = Vec::new(); for context in &directories { @@ -223,6 +223,11 @@ pub fn detect_all(root: &Path) -> Result, CoreError> { found.extend(detector(context)); } } + if let Some(maven_root) = maven_root { + if let Some(context) = DirectoryContext::at(root, maven_root)? { + found.extend(maven::detect(&context)); + } + } Ok(dedupe(found)) } diff --git a/rust/lithe-core/src/execution/detectors/scan.rs b/rust/lithe-core/src/execution/detectors/scan.rs index 6d1fdf5dc..11b5e4e5f 100644 --- a/rust/lithe-core/src/execution/detectors/scan.rs +++ b/rust/lithe-core/src/execution/detectors/scan.rs @@ -62,6 +62,25 @@ pub struct DirectoryContext { } impl DirectoryContext { + pub fn at(root: &Path, path: &Path) -> Result, CoreError> { + let Ok(entries) = fs::read_dir(path) else { + return Ok(None); + }; + let files = entries + .flatten() + .filter_map(|entry| { + let kind = entry.file_type().ok()?; + (!kind.is_dir()).then(|| entry.file_name().to_str().map(str::to_string))? + }) + .collect(); + Ok(Some(Self { + root: root.to_path_buf(), + path: path.to_path_buf(), + relative: relative_path(root, path)?, + files, + })) + } + pub fn has(&self, name: &str) -> bool { self.files.contains(name) } diff --git a/rust/lithe-core/src/project/maven.rs b/rust/lithe-core/src/project/maven.rs index c6c5c3274..2dcf00b9d 100644 --- a/rust/lithe-core/src/project/maven.rs +++ b/rust/lithe-core/src/project/maven.rs @@ -15,6 +15,8 @@ use std::path::{Component, Path, PathBuf}; #[serde(rename_all = "camelCase")] pub struct MavenScanRequest { pub root: String, + #[serde(default)] + pub paths: Vec, } #[derive(Debug, Deserialize)] @@ -122,7 +124,10 @@ fn collect_modules( } pub fn scan(request: MavenScanRequest) -> Result, CoreError> { - let root = existing_root(&request.root)?; + let workspace_root = existing_root(&request.root)?; + let Some((root, relative_path)) = maven_root(&workspace_root, &request.paths) else { + return Ok(None); + }; let pom = root.join("pom.xml"); let Some(root_descriptor) = descriptor(&pom)? else { return Ok(None); @@ -136,6 +141,7 @@ pub fn scan(request: MavenScanRequest) -> Result, Core .collect(); Ok(Some(MavenScanResponse { + relative_path, group_id: root_descriptor.group_id, artifact_id: root_descriptor.artifact_id.unwrap_or_else(|| { root.file_name() @@ -151,6 +157,51 @@ pub fn scan(request: MavenScanRequest) -> Result, Core })) } +/// Selects one Maven root from visible workspace paths. The application model +/// currently represents one Maven reactor, so the shallowest descriptor wins; +/// lexical ordering makes independent candidates deterministic. +pub(crate) fn maven_root(root: &Path, paths: &[String]) -> Option<(PathBuf, String)> { + let canonical_root = root.canonicalize().ok()?; + if canonical_root.join("pom.xml").is_file() { + return Some((root.to_path_buf(), ".".to_string())); + } + + let mut candidates = paths + .iter() + .filter_map(|path| normalize_relative_path(path)) + .filter(|path| { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("pom.xml")) + }) + .filter_map(|path| { + let directory = Path::new(&path).parent()?; + let relative_path = directory.to_string_lossy().replace('\\', "/"); + let candidate = root.join(directory); + let canonical_candidate = candidate.canonicalize().ok()?; + if !canonical_candidate.starts_with(&canonical_root) { + return None; + } + canonical_candidate.join("pom.xml").is_file().then_some(( + candidate, + relative_path, + directory.components().count(), + )) + }) + .collect::>(); + candidates.sort_by(|left, right| { + left.2 + .cmp(&right.2) + .then_with(|| left.1.to_lowercase().cmp(&right.1.to_lowercase())) + .then_with(|| left.1.cmp(&right.1)) + }); + candidates + .into_iter() + .next() + .map(|(path, relative_path, _)| (path, relative_path)) +} + pub fn diagnostics( request: MavenDiagnosticsRequest, ) -> Result { @@ -374,10 +425,12 @@ fn normalize_relative_path(value: &str) -> Option { if path.as_os_str().is_empty() || path.is_absolute() { return None; } - if path - .components() - .any(|component| matches!(component, Component::ParentDir)) - { + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { return None; } let value = path.to_string_lossy().replace('\\', "/"); diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index 80c77678c..f92820aea 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -152,6 +152,7 @@ pub struct MavenModuleResponse { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MavenScanResponse { + pub relative_path: String, pub group_id: Option, pub artifact_id: String, pub version: Option, diff --git a/rust/lithe-core/src/tests/languages.rs b/rust/lithe-core/src/tests/languages.rs index 94db4b73d..680cb1365 100644 --- a/rust/lithe-core/src/tests/languages.rs +++ b/rust/lithe-core/src/tests/languages.rs @@ -27,13 +27,14 @@ fn maven_scan_returns_recursive_shared_project_model() { let request = serde_json::json!({ "id": "maven", "command": "maven.scan", - "payload": {"root": root} + "payload": {"root": root, "paths": ["module-a/pom.xml", "pom.xml"]} }); let response: Value = serde_json::from_str(&execute_json( &serde_json::to_string(&request).expect("Maven request should encode"), )) .expect("Maven response should be JSON"); assert_eq!(response["ok"], true); + assert_eq!(response["data"]["relativePath"], "."); assert_eq!(response["data"]["artifactId"], "demo"); assert_eq!(response["data"]["packaging"], "pom"); assert_eq!(response["data"]["profiles"][0]["id"], "dev"); @@ -70,6 +71,51 @@ fn maven_scan_returns_recursive_shared_project_model() { fs::remove_dir_all(root).expect("Maven fixture should be removable"); } +#[test] +fn maven_scan_discovers_a_deterministic_project_below_the_workspace() { + let root = temporary_root("nested-maven"); + fs::create_dir_all(root.join("apps-a/module-a")).expect("first project should be creatable"); + fs::create_dir_all(root.join("apps-z")).expect("second project should be creatable"); + fs::write( + root.join("apps-a/pom.xml"), + r#"selectedpommodule-a"#, + ) + .expect("first pom should be writable"); + fs::write( + root.join("apps-a/module-a/pom.xml"), + r#"child"#, + ) + .expect("module pom should be writable"); + fs::write( + root.join("apps-z/pom.xml"), + r#"other"#, + ) + .expect("second pom should be writable"); + + let request = serde_json::json!({ + "id": "nested-maven", + "command": "maven.scan", + "payload": { + "root": root, + "paths": [ + "apps-z/pom.xml", + "apps-a/module-a/pom.xml", + "apps-a/pom.xml" + ] + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Maven request should encode"), + )) + .expect("Maven response should be JSON"); + + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["relativePath"], "apps-a"); + assert_eq!(response["data"]["artifactId"], "selected"); + assert_eq!(response["data"]["modules"][0]["relativePath"], "module-a"); + fs::remove_dir_all(root).expect("Maven fixture should be removable"); +} + #[test] fn java_core_commands_return_shared_runtime_and_structure_data() { let root = temporary_root("java"); diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index 5727f2af7..16917420a 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -127,6 +127,115 @@ fn run_configuration_generation_infers_maven_modules_from_nearest_pom() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn run_configuration_generation_uses_a_maven_project_below_the_workspace() { + let root = temporary_root("run-config-nested-maven-root"); + let source = "projects/demo/service/src/main/java/com/example/App.java"; + fs::create_dir_all(root.join("projects/demo/service/src/main/java/com/example")).unwrap(); + fs::create_dir_all(root.join("projects/demo/.mvn/wrapper")).unwrap(); + fs::write( + root.join("projects/demo/pom.xml"), + r#"demopomservice21"#, + ) + .unwrap(); + fs::write( + root.join("projects/demo/service/pom.xml"), + r#"servicespring-boot-maven-plugin"#, + ) + .unwrap(); + fs::write(root.join("projects/demo/mvnw"), "#!/bin/sh\n").unwrap(); + fs::write(root.join("projects/demo/.sdkmanrc"), "java=21.0.5-tem\n").unwrap(); + fs::write( + root.join("projects/demo/.mvn/wrapper/maven-wrapper.properties"), + "distributionUrl=https://example.invalid/apache-maven-3.9.9-bin.zip\n", + ) + .unwrap(); + fs::write( + root.join(source), + "package com.example; @SpringBootApplication class App { public static void main(String[] args) {} }", + ) + .unwrap(); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-nested-maven-root", + "command": "runConfig.generate", + "payload": { + "root": root, + "paths": ["projects/demo/pom.xml", "projects/demo/service/pom.xml", source], + "modulePaths": ["service"] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + let configurations = response["data"]["generated"]["configurations"] + .as_array() + .unwrap(); + let service = configurations + .iter() + .find(|value| value["provider"] == "spring-boot.maven") + .unwrap_or_else(|| panic!("missing nested Maven service in {configurations:?}")); + assert_eq!(service["cwd"], "projects/demo"); + assert_eq!(service["source"], "projects/demo/service/pom.xml"); + assert_eq!(service["extensions"]["maven"]["module"], "service"); + assert_eq!( + service["extensions"]["maven"]["mainClass"], + "com.example.App" + ); + let java_main = configurations + .iter() + .find(|value| value["provider"] == "java.main") + .unwrap(); + assert_eq!(java_main["cwd"], "projects/demo"); + assert_eq!(java_main["extensions"]["maven"]["module"], "service"); + assert_eq!( + response["data"]["toolchainRequirements"]["toolchains"]["project-jdk"]["minimumVersion"], + "21" + ); + assert_eq!( + response["data"]["toolchainRequirements"]["toolchains"]["project-jdk"]["preferredVendor"], + "temurin" + ); + assert_eq!( + response["data"]["toolchainRequirements"]["toolchains"]["project-maven"]["wrapper"], + "./mvnw" + ); + assert_eq!( + response["data"]["toolchainRequirements"]["toolchains"]["project-maven"]["version"], + "3.9.9" + ); + + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(&response["data"]["generated"]).unwrap(), + ) + .unwrap(); + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan-nested-maven-root", + "command": "runConfig.createLaunchPlan", + "payload": { + "root": root, + "configurationId": service["id"] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!(plan["data"]["workingDirectory"], "projects/demo"); + assert!(plan["data"]["arguments"] + .as_array() + .unwrap() + .windows(2) + .any(|arguments| arguments == ["-pl", "service"])); + + fs::remove_dir_all(root).unwrap(); +} + #[test] fn ordinary_java_main_uses_an_application_launch_plan() { let root = temporary_root("run-config-java-main"); diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 83e58bfa3..b936c8473 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -28,7 +28,7 @@ verification scripts are the executable source of boundary checks. | Git | changes, commits, branches, diffs, history, validation, and mutation results | Git executable discovery, credentials, process environment | | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | | Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | -| Java/Maven | Maven project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, and JDTLS adapter policy | JDK/Maven discovery, Java/Maven child processes, sockets, and JDB transport | +| Java/Maven | deterministic Maven-root selection, project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, and JDTLS adapter policy | JDK/Maven discovery, Java/Maven child processes, sockets, and JDB transport | | Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, and platform-neutral launch plans | project file persistence, child processes, sockets, and JDB transport | | Terminal | input bytes, output bytes, lifecycle | PTY/ConPTY, shell and environment | | Local History | revision metadata, text content, restore result | persistence location and file operations | diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index dd85644c4..9545b4a15 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -264,11 +264,14 @@ are ignored. `history.entries` returns Unix-second timestamps and relative `contentPath` values. `history.content` rejects traversal, and `history.relocate` updates metadata and storage paths at the command boundary. -`maven.scan` accepts `{ "root": string }` and returns `null` when the root does -not contain a readable `pom.xml`. A project response contains `groupId`, -`artifactId`, `version`, `packaging`, recursive `modules`, `profiles`, and -`hasWrapper`. Module paths are workspace-relative and use `/` separators. -Malformed XML returns `parse_failed`. +`maven.scan` accepts `{ "root": string, "paths"?: string[] }` and returns +`null` when neither the root nor the supplied visible workspace-relative paths +contain a readable `pom.xml`. The root descriptor wins when present; otherwise +the shallowest descriptor is selected, with `/`-normalized lexical path order +breaking ties. A project response contains its workspace `relativePath`, +`groupId`, `artifactId`, `version`, `packaging`, recursive `modules`, `profiles`, +and `hasWrapper`. Module paths are relative to the selected Maven root and use +`/` separators. Malformed XML returns `parse_failed`. `maven.diagnostics` accepts `{ "root": string, "output": string }` and returns `{ "issues": [] }`. Diagnostic paths may be absolute or workspace-relative; diff --git a/shared/fixtures/maven/basic.json b/shared/fixtures/maven/basic.json index c05a4b1b7..77699b50a 100644 --- a/shared/fixtures/maven/basic.json +++ b/shared/fixtures/maven/basic.json @@ -3,10 +3,12 @@ "request": { "command": "maven.scan", "payload": { - "root": "/workspace" + "root": "/workspace", + "paths": ["pom.xml", "module-a/pom.xml", "module-a/module-b/pom.xml"] } }, "expected": { + "relativePath": ".", "artifactId": "demo", "packaging": "pom", "modules": [ From 8bddee247e5b84da9bc25ecfe6b618d93593aeb3 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Wed, 12 Aug 2026 21:46:03 -0700 Subject: [PATCH 2/2] fix(maven): address nested project review feedback --- Sources/Lithe/Core/RustCoreBridge.swift | 2 +- .../Lithe/Core/RustJavaMavenOperations.swift | 39 +++++--- .../Services/RunExecutableResolver.swift | 2 +- Sources/Lithe/Theme/LitheTheme.swift | 2 +- Tests/LitheTests/MavenRuntimeTests.swift | 44 ++++++++- .../RunConfigurationIntegrationTests.swift | 6 +- .../lithe-core/src/execution/configuration.rs | 2 +- rust/lithe-core/src/project/maven.rs | 91 ++++++++++++------- rust/lithe-core/src/tests/languages.rs | 65 +++++++++++++ shared/contracts/rust-core-api.md | 9 +- 10 files changed, 203 insertions(+), 59 deletions(-) diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index 758a60b13..f7ae19f9c 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -250,7 +250,7 @@ struct RustCoreBridge: Sendable { func makeProject(workspaceRootURL: URL) -> MavenProject { let rootURL = relativePath == "." ? workspaceRootURL - : workspaceRootURL.appendingPathComponent(relativePath, isDirectory: true) + : workspaceRootURL.appending(path: relativePath, directoryHint: .isDirectory) return MavenProject( rootURL: rootURL, pomURL: rootURL.appendingPathComponent("pom.xml"), diff --git a/Sources/Lithe/Core/RustJavaMavenOperations.swift b/Sources/Lithe/Core/RustJavaMavenOperations.swift index 4984cbc4c..b5e8796ce 100644 --- a/Sources/Lithe/Core/RustJavaMavenOperations.swift +++ b/Sources/Lithe/Core/RustJavaMavenOperations.swift @@ -115,12 +115,11 @@ struct RustJavaMavenOperations: JavaMavenOperations, Sendable { mavenProject: MavenProject? ) -> [JavaRunConfiguration] { let root = rootURL.standardizedFileURL - let paths = files.compactMap { fileURL -> String? in - let file = fileURL.standardizedFileURL - guard file.path.hasPrefix(root.path + "/") else { return nil } - return String(file.path.dropFirst(root.path.count + 1)) + let paths = files.compactMap { + workspaceRelativePath(for: $0, root: root) } - let modulePaths = mavenProject?.allModules.map(\.relativePath) ?? [] + let workspaceModules = workspaceMavenModules(in: mavenProject, relativeTo: root) + let modulePaths = workspaceModules.map(\.0) guard let payload = core.scanJavaRunConfigurations( at: root, paths: paths, @@ -129,24 +128,36 @@ struct RustJavaMavenOperations: JavaMavenOperations, Sendable { return payload.configurations.compactMap { value in guard let kind = JavaRunConfigurationKind(rawValue: value.kind) else { return nil } - let name: String - if kind == .mavenModule, - let modulePath = value.modulePath, - let module = mavenProject?.allModules.first(where: { $0.relativePath == modulePath }) { - name = module.displayName - } else { - name = value.name + let module = value.modulePath.flatMap { modulePath in + workspaceModules.first(where: { $0.0 == modulePath })?.1 } return JavaRunConfiguration( id: value.id, - name: name, + name: kind == .mavenModule ? module?.displayName ?? value.name : value.name, kind: kind, - modulePath: value.modulePath, + modulePath: module?.relativePath ?? value.modulePath, mainClass: value.mainClass ) } } + func workspaceMavenModules( + in project: MavenProject?, + relativeTo root: URL + ) -> [(path: String, module: MavenModule)] { + project?.allModules.compactMap { module in + workspaceRelativePath(for: module.url, root: root).map { ($0, module) } + } ?? [] + } + + private func workspaceRelativePath(for url: URL, root: URL) -> String? { + let path = url.standardizedFileURL.path + let rootPath = root.standardizedFileURL.path + let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/" + guard path.hasPrefix(prefix) else { return nil } + return String(path.dropFirst(prefix.count)) + } + func structure( source: String, declarationSources: [String] diff --git a/Sources/Lithe/Services/RunExecutableResolver.swift b/Sources/Lithe/Services/RunExecutableResolver.swift index 60ac44815..bf0f16f6a 100644 --- a/Sources/Lithe/Services/RunExecutableResolver.swift +++ b/Sources/Lithe/Services/RunExecutableResolver.swift @@ -27,7 +27,7 @@ final class RunExecutableResolver: RunExecutableResolving { switch plan.executable { case .toolchain(let id): let toolchainProjectURL = id == "project-maven" && plan.workingDirectory != "." - ? projectURL.appendingPathComponent(plan.workingDirectory, isDirectory: true) + ? projectURL.appending(path: plan.workingDirectory, directoryHint: .isDirectory) : projectURL let resolved = try toolchainRegistry.resolve( identifier: id, diff --git a/Sources/Lithe/Theme/LitheTheme.swift b/Sources/Lithe/Theme/LitheTheme.swift index af64eb084..c69d34aaf 100644 --- a/Sources/Lithe/Theme/LitheTheme.swift +++ b/Sources/Lithe/Theme/LitheTheme.swift @@ -489,12 +489,12 @@ private struct LithePointerModifier: ViewModifier { /// in the view body depends on it. Storing it as view state would invalidate /// every hovered control, which is costly when the pointer sweeps across many /// rows during a scroll. -@MainActor private final class LithePointerCursor { var isHovered = false private var isPointing = false /// The push/pop pair is balanced even when a view disappears. + @MainActor func update(isPointing newValue: Bool) { guard newValue != isPointing else { return } isPointing = newValue diff --git a/Tests/LitheTests/MavenRuntimeTests.swift b/Tests/LitheTests/MavenRuntimeTests.swift index 87710b1d3..e99e3eec2 100644 --- a/Tests/LitheTests/MavenRuntimeTests.swift +++ b/Tests/LitheTests/MavenRuntimeTests.swift @@ -33,8 +33,12 @@ struct MavenRuntimeTests { let workspaceRoot = FileManager.default.temporaryDirectory .appendingPathComponent("lithe-maven-payload", isDirectory: true) let project = payload.makeProject(workspaceRootURL: workspaceRoot) - let expectedRoot = workspaceRoot.appendingPathComponent("services/api", isDirectory: true) + let expectedRoot = URL( + fileURLWithPath: workspaceRoot.path + "/services/api", + isDirectory: true + ) #expect(project.rootURL == expectedRoot) + #expect(!project.rootURL.absoluteString.contains("%2F")) #expect(project.pomURL == expectedRoot.appendingPathComponent("pom.xml")) #expect(project.groupID == "com.example") #expect(project.artifactID == "root") @@ -46,6 +50,44 @@ struct MavenRuntimeTests { #expect(project.hasWrapper) } + @Test + func nestedMavenRunConfigurationsUseWorkspaceRelativeModulePaths() { + let workspaceRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-nested-maven-run-\(UUID().uuidString)", isDirectory: true) + let mavenRoot = URL( + fileURLWithPath: workspaceRoot.path + "/projects/demo", + isDirectory: true + ) + let moduleRoot = mavenRoot.appendingPathComponent("service", isDirectory: true) + let module = MavenModule( + relativePath: "service", + url: moduleRoot, + groupID: "com.example", + artifactID: "service-api", + version: "1.0", + packaging: "jar", + modules: [] + ) + let project = MavenProject( + rootURL: mavenRoot, + pomURL: mavenRoot.appendingPathComponent("pom.xml"), + groupID: "com.example", + artifactID: "demo", + version: "1.0", + packaging: "pom", + modules: [module], + profiles: [], + hasWrapper: false + ) + + let modules = RustJavaMavenOperations(core: RustCoreBridge()).workspaceMavenModules( + in: project, + relativeTo: workspaceRoot + ) + #expect(modules.map { $0.path } == ["projects/demo/service"]) + #expect(modules.map { $0.module.relativePath } == ["service"]) + } + @Test @MainActor func canceledRuntimeDiscoveryClearsDiscoveringState() async throws { diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 007c2b9fe..671a8f889 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -897,7 +897,10 @@ struct RunConfigurationIntegrationTests { func mavenToolchainResolvesTheWrapperFromTheLaunchWorkingDirectory() throws { let workspaceRoot = FileManager.default.temporaryDirectory .appendingPathComponent("lithe-nested-maven-wrapper", isDirectory: true) - let mavenRoot = workspaceRoot.appendingPathComponent("projects/demo", isDirectory: true) + let mavenRoot = URL( + fileURLWithPath: workspaceRoot.path + "/projects/demo", + isDirectory: true + ) let runtime = ProjectRuntimeService( runtimeLocator: NestedMavenWrapperRuntimeLocator(wrapperRoot: mavenRoot), store: RunTestKeyValueStore() @@ -914,6 +917,7 @@ struct RunConfigurationIntegrationTests { ) #expect(resolved.executableURL == mavenRoot.appendingPathComponent("mvnw")) + #expect(!resolved.executableURL.absoluteString.contains("%2F")) } @Test diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 1b9856484..dd26ac22a 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -349,7 +349,7 @@ pub fn inspect(request: InspectRequest) -> Result { pub fn generate(request: GenerateRequest) -> Result { let root = existing_root(&request.root)?; - let maven_root = crate::project::maven_root(&root, &request.paths); + let maven_root = crate::project::maven_root(&root, &request.paths)?; let maven_relative_path = maven_root .as_ref() .map(|(_, relative_path)| relative_path.as_str()); diff --git a/rust/lithe-core/src/project/maven.rs b/rust/lithe-core/src/project/maven.rs index 2dcf00b9d..750e9a9da 100644 --- a/rust/lithe-core/src/project/maven.rs +++ b/rust/lithe-core/src/project/maven.rs @@ -125,7 +125,7 @@ fn collect_modules( pub fn scan(request: MavenScanRequest) -> Result, CoreError> { let workspace_root = existing_root(&request.root)?; - let Some((root, relative_path)) = maven_root(&workspace_root, &request.paths) else { + let Some((root, relative_path)) = maven_root(&workspace_root, &request.paths)? else { return Ok(None); }; let pom = root.join("pom.xml"); @@ -157,49 +157,70 @@ pub fn scan(request: MavenScanRequest) -> Result, Core })) } -/// Selects one Maven root from visible workspace paths. The application model -/// currently represents one Maven reactor, so the shallowest descriptor wins; -/// lexical ordering makes independent candidates deterministic. -pub(crate) fn maven_root(root: &Path, paths: &[String]) -> Option<(PathBuf, String)> { - let canonical_root = root.canonicalize().ok()?; +/// Selects one parseable Maven root from visible workspace paths. The +/// application model currently represents one Maven reactor, so the shallowest +/// valid descriptor wins; lexical ordering makes independent candidates +/// deterministic. Parse failures are retained only when no candidate is valid. +pub(crate) fn maven_root( + root: &Path, + paths: &[String], +) -> Result, CoreError> { + let canonical_root = root.canonicalize().map_err(CoreError::from)?; + let mut candidates = Vec::new(); if canonical_root.join("pom.xml").is_file() { - return Some((root.to_path_buf(), ".".to_string())); + candidates.push((root.to_path_buf(), ".".to_string(), 0)); } - let mut candidates = paths - .iter() - .filter_map(|path| normalize_relative_path(path)) - .filter(|path| { - Path::new(path) - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.eq_ignore_ascii_case("pom.xml")) - }) - .filter_map(|path| { - let directory = Path::new(&path).parent()?; - let relative_path = directory.to_string_lossy().replace('\\', "/"); - let candidate = root.join(directory); - let canonical_candidate = candidate.canonicalize().ok()?; - if !canonical_candidate.starts_with(&canonical_root) { - return None; - } - canonical_candidate.join("pom.xml").is_file().then_some(( - candidate, - relative_path, - directory.components().count(), - )) - }) - .collect::>(); + candidates.extend( + paths + .iter() + .filter_map(|path| normalize_relative_path(path)) + .filter(|path| { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("pom.xml")) + }) + .filter_map(|path| { + let directory = Path::new(&path).parent()?; + let relative_path = if directory.as_os_str().is_empty() { + ".".to_string() + } else { + directory.to_string_lossy().replace('\\', "/") + }; + let candidate = root.join(directory); + let canonical_candidate = candidate.canonicalize().ok()?; + if !canonical_candidate.starts_with(&canonical_root) { + return None; + } + canonical_candidate.join("pom.xml").is_file().then_some(( + candidate, + relative_path, + directory.components().count(), + )) + }), + ); candidates.sort_by(|left, right| { left.2 .cmp(&right.2) .then_with(|| left.1.to_lowercase().cmp(&right.1.to_lowercase())) .then_with(|| left.1.cmp(&right.1)) }); - candidates - .into_iter() - .next() - .map(|(path, relative_path, _)| (path, relative_path)) + candidates.dedup_by(|left, right| left.0 == right.0); + + let mut first_parse_error = None; + for (path, relative_path, _) in candidates { + match descriptor(&path.join("pom.xml")) { + Ok(Some(_)) => return Ok(Some((path, relative_path))), + Ok(None) => {} + Err(error) if first_parse_error.is_none() => first_parse_error = Some(error), + Err(_) => {} + } + } + match first_parse_error { + Some(error) => Err(error), + None => Ok(None), + } } pub fn diagnostics( diff --git a/rust/lithe-core/src/tests/languages.rs b/rust/lithe-core/src/tests/languages.rs index 680cb1365..81714bea3 100644 --- a/rust/lithe-core/src/tests/languages.rs +++ b/rust/lithe-core/src/tests/languages.rs @@ -116,6 +116,71 @@ fn maven_scan_discovers_a_deterministic_project_below_the_workspace() { fs::remove_dir_all(root).expect("Maven fixture should be removable"); } +#[test] +fn maven_scan_skips_a_malformed_root_descriptor_for_a_valid_nested_project() { + let root = temporary_root("nested-maven-malformed-root"); + fs::create_dir_all(root.join("projects/demo")).expect("nested project should be creatable"); + fs::write(root.join("pom.xml"), "broken") + .expect("malformed root pom should be writable"); + fs::write( + root.join("projects/demo/pom.xml"), + r#"selected"#, + ) + .expect("nested pom should be writable"); + + let request = serde_json::json!({ + "id": "nested-maven-malformed-root", + "command": "maven.scan", + "payload": { + "root": root, + "paths": ["pom.xml", "projects/demo/pom.xml"] + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Maven request should encode"), + )) + .expect("Maven response should be JSON"); + + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["relativePath"], "projects/demo"); + assert_eq!(response["data"]["artifactId"], "selected"); + fs::remove_dir_all(root).expect("Maven fixture should be removable"); +} + +#[test] +fn java_run_configurations_match_workspace_relative_nested_maven_modules() { + let root = temporary_root("java-nested-maven-module"); + let source = "projects/demo/service/src/main/java/com/example/App.java"; + fs::create_dir_all(root.join("projects/demo/service/src/main/java/com/example")) + .expect("nested Java source directory should be creatable"); + fs::write( + root.join(source), + "package com.example; @SpringBootApplication class App { public static void main(String[] args) {} }", + ) + .expect("nested Java source should be writable"); + + let request = serde_json::json!({ + "id": "java-nested-maven-module", + "command": "java.runConfigurations", + "payload": { + "root": root, + "paths": [source], + "modulePaths": ["projects/demo/service"] + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Java request should encode"), + )) + .expect("Java response should be JSON"); + + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["configurations"][0]["modulePath"], + "projects/demo/service" + ); + fs::remove_dir_all(root).expect("Java fixture should be removable"); +} + #[test] fn java_core_commands_return_shared_runtime_and_structure_data() { let root = temporary_root("java"); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 9545b4a15..4434e8248 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -266,9 +266,10 @@ are ignored. `history.entries` returns Unix-second timestamps and relative `maven.scan` accepts `{ "root": string, "paths"?: string[] }` and returns `null` when neither the root nor the supplied visible workspace-relative paths -contain a readable `pom.xml`. The root descriptor wins when present; otherwise -the shallowest descriptor is selected, with `/`-normalized lexical path order -breaking ties. A project response contains its workspace `relativePath`, +contain a readable `pom.xml`. Candidates are tried in shallowest-first order, +with `/`-normalized lexical paths breaking ties, until one parses successfully; +a malformed candidate does not hide a valid nested project. A project response +contains its workspace `relativePath`, `groupId`, `artifactId`, `version`, `packaging`, recursive `modules`, `profiles`, and `hasWrapper`. Module paths are relative to the selected Maven root and use `/` separators. Malformed XML returns `parse_failed`. @@ -280,7 +281,7 @@ and normalizes severity to `error` or `warning`. Duplicate issue lines are removed deterministically. `java.runConfigurations` accepts `{ "root": string, "paths": string[], -"modulePaths": string[] }`. Paths are relative Java files. The response +"modulePaths": string[] }`. Java and module paths are workspace-relative. The response contains detected `mainClasses` and deterministic `configurations`; process launching remains a platform adapter responsibility.