diff --git a/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md b/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md index 58d4c2c4c..48f5c44b6 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md +++ b/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md @@ -48,13 +48,25 @@ Maven 自己的设置解析顺序是“用户级 `~/.m2/settings.xml` 叠加安 ## 决策 -**1. 安装级设置转成 `globalSettings`。** Core 从配置的 Maven 主目录或 -`/bin/mvn*` 启动器推出 `conf/settings.xml`,作为 -`java.configuration.maven.globalSettings` 交给 JDT LS。 - -推导失败时返回空,这是刻意的:项目 Wrapper(`mvnw`)不在任何安装目录里, -没有安装级设置;此时 JDT LS 停留在自身默认,而这正是直接敲 `mvnw` 会得到的 -结果。两边仍然一致。 +**1. 安装级设置转成 `globalSettings`。** Core 接受 Maven 安装根目录、`bin` +目录或已存在的 `mvn` / `mvnd` 启动器,根据实际选中的客户端推导全局设置,作为 +`java.configuration.maven.globalSettings` 交给 JDT LS。普通 Maven 使用 +`/conf/settings.xml`;Maven Daemon(mvnd,复用后台 JVM 的 Maven 客户端) +使用 `/mvn/conf/settings.xml`,不能把 daemon 自己的 `conf` 当成 Maven 配置。 + +目录必须含有可识别的客户端,仅存在 `conf/settings.xml` 不构成 Maven 安装。 +不存在的启动器、任意程序、Wrapper(包括放在 `bin` 下的 `mvnw`)和缺失的设置 +均返回空;选中 mvnd 后缺少内嵌设置时,也不能回退到旁边普通 Maven 的设置。 +这保留了 JDT 的默认设置行为,同时避免把无关文件送进语言服务。 + +显式目录里同时有两种客户端时优先 mvnd;显式指定 `bin/mvn` 仍保留普通 Maven +语义。比如选 `/tools/mvnd/bin/mvnd` 应读取 `/tools/mvnd/mvn/conf/settings.xml`, +不能悄悄改为执行 `/tools/mvnd/mvn/bin/mvn`,也不能读取 `/tools/mvnd/conf/settings.xml`。 +Core 只读文件系统,不探测版本、不启动 daemon,也不接管 JDT 内置 m2e 的项目导入。 + +为兼容 Homebrew 等安装器,直接选中的目录或客户端若是符号链接,会跟随到真实 +安装后定位设置;失效链接返回空。普通路径不统一做 canonicalize,避免 macOS +`/var` 被无条件改写为 `/private/var`,影响已有上下文的路径一致性。 **2. 设置随 `initialize` 一起送达。** `adapt_initialization_options()` 把 `settings.java` 注入 `initializationOptions`,赶在 m2e 配置和首次导入之前。 @@ -72,6 +84,17 @@ Maven 设置面板的显式配置 这样在任意一处填 `D:\apache-maven-3.9.16`,语言服务和命令行都会用它。 +两平台的自动选择保留可用 Wrapper、普通 Maven、mvnd 的顺序,新增 mvnd 不能 +抢走已有 Maven 安装。普通 Maven 候选保持宿主原有顺序;mvnd 从 `MVND_HOME` +和 PATH 补充,macOS 同时考虑 Homebrew 的标准位置。显式选择安装根目录、`bin` +或启动文件则优先服从用户选择,Windows 前端的候选匹配也必须接受相同形式。 + +工具链探测展示的是内嵌 **Apache Maven** 版本:`Apache Maven Daemon (mvnd)` +标题不构成版本结果,只读后面的数字开头 `Apache Maven` 版本,保留预发行后缀; +只有 daemon 标题时判为未获得 Maven 版本。两端使用 +`shared/fixtures/execution/maven-runtime-versions.json` 固定这些例子。macOS 探测 +缓存还观察内嵌 Maven 库,避免 mvnd 启动器未变但 Maven 更新后继续显示旧版本。 + **这条链上不允许启动进程。** 它跑在 Maven 工程加载里,而 Java 编辑器打开文件 时会先 `await` 这个加载再启动语言服务(`resolve-editor-lsp-launch.ts`)。 现成的 `run_discover_toolchains` 会对每个候选跑 `mvn -version` / `java -version`, @@ -115,6 +138,12 @@ Maven 设置面板的显式配置 - **把两个「Maven 主目录」输入框合并成一个。** 被否。运行工具链和项目导入的 作用域确实不同,合并会让“只想给某个运行配置换 JDK”的场景没法表达。改为共用 一条解析链,保留两个入口。 +- **先完整拆出 Maven 插件,再支持 mvnd。** 延后插件化。mvnd 是既有 Maven + 工具链的另一种启动器,复用宿主发现、执行和 Core 设置推导即可。完整插件化还 + 涉及运行配置、依赖树和 JDT 配置桥接;把它作为前提会扩大改动面。以后迁移归属 + 时连同这些选择规则和回归测试迁移,不能借插件化改变命令行与语言服务的一致性。 +- **选中 mvnd 后改跑内嵌 mvn,或使用 daemon 的 conf 兜底。** 被否。前者失去用户 + 选择 daemon 的意义,后者使 JDT 与实际构建使用不同配置;缺失设置应明确返回空。 - **Core 直接读 `.lithe/run/local.json` 拿运行工具链。** 被否。运行配置的本地 层由宿主读取后传入 Core(`local_layer_document`),Core 自己读盘会破坏既有的 持久化归属。解析链因此留在平台侧。 @@ -375,8 +404,39 @@ profile 变化、失败或超时期间变更补跑、握手前无设置变化的 ## 验证 +mvnd 的 Core 定向验证应包含整个 `tests::languages::`:除 mvnd 根目录、`bin`、 +四种启动文件、同目录普通 Maven、缺失内嵌设置与符号链接外,还覆盖普通 Maven +目录必须含有启动器、Wrapper 拒绝以及原有 Maven/JDT 设置回归。Apple Silicon +宿主可复用 macOS Core 构建目录运行以下单测试计时命令;其他宿主应使用自己的 +原生 target,不能把 macOS 结果当作 Windows 宿主验证。计时器生成 HTML、JUnit、 +JSON 和日志,报告目录独立以免覆盖并行 Swift 验证的首页。 + +```bash +CARGO_TARGET_DIR="$PWD/rust/target/macos" CARGO_BUILD_JOBS=2 node --input-type=module <<'NODE' +import { run, parseArguments } from './.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs'; +import { runProcess } from './.agents/skills/write-stable-tests/scripts/test-timing-lib.mjs'; +await run(parseArguments([ + '--manifest', 'rust/lithe-core/Cargo.toml', '--package', 'lithe-core', '--keep-going', + '--report', '.artifacts/test-stability/core-mvnd/core-mvnd-languages.json', +]), { runProcessImpl: request => runProcess({ + ...request, + args: request.command === 'cargo' + ? [...request.args, '--target', 'aarch64-apple-darwin', '--jobs', '2', '--lib'] + : request.args.includes('--list') ? [...request.args, 'tests::languages::'] : request.args, +}) }); +NODE +``` + +宿主验收还需在各自原生系统覆盖:仅 `MVND_HOME`、仅 PATH、与普通 Maven 共存、 +有效 Wrapper 优先、无效 `MVND_HOME` 回退、三种显式选择形式,以及版本共享 fixture。 +macOS 使用 `MacRuntimeDiscoveryTests`、`MacRuntimeProbeCacheTests` 和 +`MavenRuntimeTests`;Windows 宿主使用 `run` 中的 Maven 发现与解析测试,前端 +使用 `run-configuration.test.ts`。真实 mvnd 验收须用独立临时项目、本地仓库和 +本次拥有的 daemon 状态目录,验证构建与 JDT 全局设置,并在有界结束后清理进程。 +单元测试或跨平台编译不能替代这一步,功能矩阵在原生验收完成前保持待验证。 + - Rust Core:`cargo test --manifest-path rust/lithe-core/Cargo.toml` - 覆盖主目录与启动器两种写法都能推出 `conf/settings.xml`、Wrapper 与无 `conf` + 覆盖主目录、bin 目录与启动器写法都能推出正确的全局设置、Wrapper 与无 `conf` 的安装正确返回空、`initializationOptions` 注入且不吃掉扩展目录的键、 Profile 指纹随安装变化,以及生成文档保留镜像/注释、插入缺失节点、展开空元素、 转义路径中的 `&`、不误伤 profile 内的同名元素。完整校验运行 diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index e1e87ab69..4d3db9be8 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -248,7 +248,7 @@ "Maven Home" = "Maven 主目录"; "Maven JDK" = "Maven JDK"; "Detected Maven" = "检测到的 Maven"; -"Automatic uses a project mvnw first, then the system Maven on PATH." = "自动模式会优先使用项目 mvnw,然后使用 PATH 中的系统 Maven。"; +"Automatic uses a project mvnw first, then detected Maven, then mvnd." = "自动模式会优先使用项目 mvnw,其次使用检测到的 Maven,最后使用 mvnd。"; "Project runtime settings are saved locally for this project." = "项目运行时设置会保存在本机的项目配置中。"; "Open a project to configure its JDK and Maven runtime." = "打开项目后即可配置 JDK 和 Maven 运行时。"; "Application-wide editor and terminal settings remain available in the other categories." = "应用级编辑器和终端设置仍可在其他分类中使用。"; @@ -259,7 +259,7 @@ "Maven Wrapper is not executable" = "Maven Wrapper 不可执行"; "Maven executable not resolved" = "未解析出 Maven 可执行文件"; "Choose a JDK directory" = "选择 JDK 目录"; -"Choose Maven home or bin/mvn" = "选择 Maven 主目录或 bin/mvn"; +"Choose Maven or mvnd home, bin, or executable" = "选择 Maven 或 mvnd 的主目录、bin 目录或可执行文件"; "Detected JDK" = "检测到的 JDK"; "Inherit project JDK" = "继承项目 JDK"; "Use Project JDK" = "使用项目 JDK"; diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift index b8e76b5f6..8891731ca 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift @@ -26,18 +26,16 @@ enum MacRuntimeDiscovery { guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { return nil } - let executable = isDirectory.boolValue - ? url.appendingPathComponent("bin/mvn") - : url - var executableIsDirectory: ObjCBool = false - guard FileManager.default.fileExists( - atPath: executable.path, - isDirectory: &executableIsDirectory - ), !executableIsDirectory.boolValue, - FileManager.default.isExecutableFile(atPath: executable.path) else { - return nil + let candidates: [URL] + if isDirectory.boolValue { + // A daemon distribution must launch its client, not its embedded Maven. + candidates = [url.appendingPathComponent("bin"), url].flatMap { bin in + ["mvnd", "mvn"].map { bin.appendingPathComponent($0) } + } + } else { + candidates = [url] } - return executable.standardizedFileURL + return candidates.first { MacRuntimeLocator().isExecutable(at: $0) } } static func validJavaHome(_ path: String) -> URL? { @@ -133,21 +131,27 @@ enum MacRuntimeDiscovery { return paths.values.sorted { $0.path < $1.path } } - private static func discoverMavenExecutables(environment: [String: String]) -> [URL] { - var paths = Set() - func add(_ path: String?) { - guard let path else { return } + static func discoverMavenExecutables( + environment: [String: String], + isExecutable: (URL) -> Bool = { MacRuntimeLocator().isExecutable(at: $0) } + ) -> [URL] { + var paths: [URL] = [] + var seen = Set() + func add(_ path: String) { let expanded = (path as NSString).expandingTildeInPath let url = URL(fileURLWithPath: expanded).standardizedFileURL - guard FileManager.default.isExecutableFile(atPath: url.path) else { return } - paths.insert(url.path) + guard isExecutable(url), seen.insert(url.path).inserted else { return } + paths.append(url) } - if let mavenHome = environment["MAVEN_HOME"] { - add(URL(fileURLWithPath: mavenHome).appendingPathComponent("bin/mvn").path) + for key in ["MAVEN_HOME", "M2_HOME"] { + if let home = environment[key], !home.isEmpty { + add(URL(fileURLWithPath: home).appendingPathComponent("bin/mvn").path) + } } - for component in (environment["PATH"] ?? "").split(separator: ":").map(String.init) { - add(URL(fileURLWithPath: component).appendingPathComponent("mvn").path) + let pathDirectories = (environment["PATH"] ?? "").split(separator: ":").map(String.init) + for directory in pathDirectories { + add(URL(fileURLWithPath: directory).appendingPathComponent("mvn").path) } for path in [ "/opt/homebrew/opt/maven/bin/mvn", @@ -158,7 +162,23 @@ enum MacRuntimeDiscovery { ] { add(path) } - return paths.sorted().map(URL.init(fileURLWithPath:)) + // Preserve ordinary Maven preference even when mvnd appears earlier on PATH. + // Discovery only inspects files; importing Java must not start a daemon. + if let home = environment["MVND_HOME"], !home.isEmpty { + add(URL(fileURLWithPath: home).appendingPathComponent("bin/mvnd").path) + } + for directory in pathDirectories { + add(URL(fileURLWithPath: directory).appendingPathComponent("mvnd").path) + } + for path in [ + "/opt/homebrew/opt/mvnd/bin/mvnd", + "/opt/homebrew/bin/mvnd", + "/usr/local/opt/mvnd/bin/mvnd", + "/usr/local/bin/mvnd" + ] { + add(path) + } + return paths } static func probeJavaHome(_ home: URL) -> JavaRuntimeCandidate? { @@ -180,7 +200,9 @@ enum MacRuntimeDiscovery { static func probeMaven(_ executable: URL) -> MavenRuntimeCandidate? { let resolvedExecutable = executable.resolvingSymlinksInPath() - let homeURL = resolvedExecutable.deletingLastPathComponent().deletingLastPathComponent() + let distribution = resolvedExecutable.deletingLastPathComponent().deletingLastPathComponent() + let homeURL = resolvedExecutable.lastPathComponent == "mvnd" + ? distribution.appendingPathComponent("mvn") : distribution let library = homeURL.appendingPathComponent("lib") let libraries = directoryNames(at: library.path).filter { $0.hasPrefix("maven-core-") }.sorted() let wrapper = resolvedExecutable.deletingLastPathComponent().appendingPathComponent(".mvn/wrapper") @@ -188,11 +210,15 @@ enum MacRuntimeDiscovery { executable: executable, arguments: ["-version"], dependencies: [library] + libraries.map { library.appendingPathComponent($0) } + [wrapper.appendingPathComponent("maven-wrapper.properties"), wrapper.appendingPathComponent("maven-wrapper.jar")], - accepts: { firstCapture(pattern: #"Apache Maven\s+([^\s]+)"#, in: $0) != nil } + accepts: { mavenVersion(in: $0) != nil } ) - let version = firstCapture(pattern: #"Apache Maven\s+([^\s]+)"#, in: output) ?? "" - let home = executable.deletingLastPathComponent().deletingLastPathComponent().path - return MavenRuntimeCandidate(homePath: home, executablePath: executable.path, version: version) + guard let version = mavenVersion(in: output) else { return nil } + return MavenRuntimeCandidate(homePath: distribution.path, executablePath: executable.path, version: version) + } + + static func mavenVersion(in output: String) -> String? { + // mvnd prints its own banner before the embedded Apache Maven version. + firstCapture(pattern: #"Apache Maven\s+([0-9][^\s\x1B]*)"#, in: output) } private static func javaHomeOutput() -> [String] { diff --git a/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift b/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift index c6faf99e4..089fc9d93 100644 --- a/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift @@ -207,12 +207,12 @@ struct ProjectRuntimeSettingsView: View { pathField( title: "Maven Home", value: mavenHomePathBinding, - placeholder: "Choose Maven home or bin/mvn", - chooseTitle: "Choose Maven home or bin/mvn", + placeholder: "Choose Maven or mvnd home, bin, or executable", + chooseTitle: "Choose Maven or mvnd home, bin, or executable", fileOrDirectory: true ) } - Text("Automatic uses a project mvnw first, then the system Maven on PATH.") + Text("Automatic uses a project mvnw first, then detected Maven, then mvnd.") .font(LitheTheme.smallFont) .foregroundStyle(LitheTheme.secondaryText) if let workspaceURL = model.workspaceURL { @@ -311,7 +311,7 @@ struct ProjectRuntimeSettingsView: View { title: "Maven Home", value: moduleMavenHomeBinding(for: subproject), placeholder: "Inherit project Maven", - chooseTitle: "Choose Maven home or bin/mvn", + chooseTitle: "Choose Maven or mvnd home, bin, or executable", fileOrDirectory: true ) row("Maven JDK") { diff --git a/macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift b/macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift index 0e96f6594..3f4d0345b 100644 --- a/macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift +++ b/macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift @@ -65,6 +65,65 @@ struct MacRuntimeDiscoveryTests { #expect(homes == [javaHome.standardizedFileURL]) } + @Test + func discoversMvndWithoutProbingAndPreservesMavenPriority() { + func discover(_ environment: [String: String], files: Set) -> [String] { + MacRuntimeDiscovery.discoverMavenExecutables( + environment: environment, isExecutable: { files.contains($0.path) } + ).map(\.path) + } + let daemon = "/fixture/daemon/bin/mvnd" + let pathDaemon = "/fixture/path/mvnd" + let maven = "/fixture/maven/bin/mvn" + #expect(discover(["MVND_HOME": "/fixture/daemon"], files: [daemon]) == [daemon]) + #expect(discover(["PATH": "/fixture/path"], files: [pathDaemon]) == [pathDaemon]) + #expect(discover(["MVND_HOME": "/missing", "PATH": "/fixture/path"], files: [pathDaemon]) == [pathDaemon]) + #expect(discover( + ["MAVEN_HOME": "/fixture/maven", "MVND_HOME": "/fixture/daemon", "PATH": "/fixture/path"], + files: [daemon, pathDaemon, maven] + ) == [maven, daemon, pathDaemon]) + #expect(discover( + ["PATH": "/fixture/path:/fixture/maven/bin:/fixture/path"], files: [pathDaemon, maven] + ) == [maven, pathDaemon]) + #expect(discover(["M2_HOME": "/fixture/maven"], files: [maven]) == [maven]) + #expect(discover(["MVND_HOME": "/missing"], files: []) == []) + #expect(discover([:], files: ["/opt/homebrew/bin/mvnd"]) == ["/opt/homebrew/bin/mvnd"]) + #expect(discover([:], files: ["/usr/local/bin/mvn", "/opt/homebrew/bin/mvnd"]) + == ["/usr/local/bin/mvn", "/opt/homebrew/bin/mvnd"]) + } + + @Test + func explicitDaemonHomeNeverFallsBackToEmbeddedMaven() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let embedded = root.appendingPathComponent("mvn/bin/mvn") + try FileManager.default.createDirectory(at: embedded.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: embedded) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: embedded.path) + #expect(MacRuntimeDiscovery.mavenExecutable(forHomePath: root.path) == nil) + let bin = root.appendingPathComponent("bin") + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + // Traversable directories named like launchers are not executable files. + try FileManager.default.createDirectory(at: bin.appendingPathComponent("mvnd"), withIntermediateDirectories: true) + #expect(MacRuntimeDiscovery.mavenExecutable(forHomePath: root.path) == nil) + } + + @Test + func mavenVersionsMatchSharedContract() throws { + struct Fixture: Decodable { + struct Case: Decodable { let output: String; let version: String? } + let cases: [Case] + } + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: + root.appendingPathComponent("shared/fixtures/execution/maven-runtime-versions.json"))) + for example in fixture.cases { + #expect(MacRuntimeDiscovery.mavenVersion(in: example.output) == example.version) + } + } + private func createExecutableJava(at home: URL) throws { let executable = home.appendingPathComponent("bin/java") try FileManager.default.createDirectory( diff --git a/macos/Tests/LitheTests/MacRuntimeProbeCacheTests.swift b/macos/Tests/LitheTests/MacRuntimeProbeCacheTests.swift index 1b3bc3977..e6b530de9 100644 --- a/macos/Tests/LitheTests/MacRuntimeProbeCacheTests.swift +++ b/macos/Tests/LitheTests/MacRuntimeProbeCacheTests.swift @@ -63,6 +63,29 @@ struct MacRuntimeProbeCacheTests { } } + @Test + func mvndProbeUsesEmbeddedMavenAndInvalidatesItsLibraries() async throws { + try await onThrowingWorker { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("daemon") + let executable = home.appendingPathComponent("bin/mvnd") + let library = home.appendingPathComponent("mvn/lib/maven-core-fixture.jar") + let log = home.appendingPathComponent("probes") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: library.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("first".utf8).write(to: library) + try Data("#!/bin/sh\necho probe >> \"$(dirname \"$0\")/../probes\"\necho 'Apache Maven Daemon (mvnd) 1.0.6'\necho 'Apache Maven 3.9.11 (fixture)'\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + #expect(MacRuntimeDiscovery.probeMaven(executable)?.version == "3.9.11") + #expect(MacRuntimeDiscovery.probeMaven(executable)?.version == "3.9.11") + #expect(try String(contentsOf: log, encoding: .utf8).split(separator: "\n").count == 1) + try Data("changed Maven library".utf8).write(to: library) + #expect(MacRuntimeDiscovery.probeMaven(executable)?.version == "3.9.11") + #expect(try String(contentsOf: log, encoding: .utf8).split(separator: "\n").count == 2) + } + } + @Test func mavenWrapperReusesVersionUntilDistributionChanges() async throws { try await onThrowingWorker { diff --git a/macos/Tests/LitheTests/MavenRuntimeTests.swift b/macos/Tests/LitheTests/MavenRuntimeTests.swift index 269dda7bd..4cb50e929 100644 --- a/macos/Tests/LitheTests/MavenRuntimeTests.swift +++ b/macos/Tests/LitheTests/MavenRuntimeTests.swift @@ -488,14 +488,14 @@ struct MavenRuntimeTests { #expect(service.mavenExecutable(at: root, overridePath: "toolchains/missing") == nil) } - @Test + @Test(arguments: ["mvn", "mvnd"]) @MainActor - func macRuntimeDiscoveryDistinguishesMavenHomeFromExecutable() throws { + func macRuntimeDiscoveryDistinguishesMavenHomeFromExecutable(launcher: String) throws { let testRoot = FileManager.default.temporaryDirectory .appendingPathComponent("lithe-maven-runtime-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: testRoot) } let home = testRoot.appendingPathComponent("apache-maven", isDirectory: true) - let homeExecutable = home.appendingPathComponent("bin/mvn") + let homeExecutable = home.appendingPathComponent("bin/\(launcher)") let directExecutable = testRoot.appendingPathComponent("custom-mvn") let invalidHome = testRoot.appendingPathComponent("invalid-maven", isDirectory: true) try FileManager.default.createDirectory( @@ -529,12 +529,23 @@ struct MavenRuntimeTests { #expect(service.mavenExecutable(at: testRoot, overridePath: directExecutable.path) == directExecutable) #expect(service.mavenExecutable(at: testRoot, overridePath: invalidHome.path) == nil) + if launcher == "mvnd" { + let ordinaryMaven = home.appendingPathComponent("bin/mvn") + try Data().write(to: ordinaryMaven) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: ordinaryMaven.path) + #expect(MacRuntimeDiscovery.mavenExecutable(forHomePath: home.path) == homeExecutable) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: ordinaryMaven.path) + } + let binLink = testRoot.appendingPathComponent("bin-alias", isDirectory: true) + try FileManager.default.createSymbolicLink(at: binLink, withDestinationURL: home.appendingPathComponent("bin")) + #expect(service.mavenExecutable(at: testRoot, overridePath: binLink.path) == binLink.appendingPathComponent(launcher)) let homeLink = testRoot.appendingPathComponent("maven-link", isDirectory: true) let executableLink = testRoot.appendingPathComponent("mvn-link") try FileManager.default.createSymbolicLink(at: homeLink, withDestinationURL: home) try FileManager.default.createSymbolicLink(at: executableLink, withDestinationURL: homeExecutable) #expect(!locator.isExecutable(at: homeLink)) - #expect(service.mavenExecutable(at: testRoot, overridePath: homeLink.path) == homeLink.appendingPathComponent("bin/mvn")) + #expect(service.mavenExecutable(at: testRoot, overridePath: homeLink.path) == homeLink.appendingPathComponent("bin/\(launcher)")) + #expect(service.mavenExecutable(at: testRoot, overridePath: home.appendingPathComponent("bin").path) == homeExecutable) #expect(service.mavenExecutable(at: testRoot, overridePath: executableLink.path) == executableLink) try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: homeExecutable.path) diff --git a/rust/lithe-core/src/project/maven.rs b/rust/lithe-core/src/project/maven.rs index e5ba6bae3..aacf3313b 100644 --- a/rust/lithe-core/src/project/maven.rs +++ b/rust/lithe-core/src/project/maven.rs @@ -455,28 +455,90 @@ fn settings_parse_error(error: quick_xml::Error) -> CoreError { .with_details(error.to_string()) } -/// Resolves the `conf/settings.xml` of the configured Maven installation. +/// Resolves the global settings read by the selected Maven or mvnd launcher. /// -/// The stored value may be either a Maven home or a launcher inside its `bin` -/// directory, because the platform settings accept both. A project wrapper such -/// as `mvnw` sits outside an installation and therefore resolves to `None`, -/// which matches the settings Maven itself would read when the wrapper runs. -/// Returning `None` also covers installations that ship no global settings, so -/// callers keep JDT LS on its own defaults instead of pointing it at a missing -/// file. +/// Accepts an installation home, its `bin` directory, or a recognized launcher. +/// Explicit directories prefer mvnd when both clients are installed; automatic +/// discovery precedence belongs to the host. Wrappers, missing clients and +/// installations without global settings return `None` without running a probe. fn installation_settings_path(maven_executable_path: Option<&str>) -> Option { let configured = Path::new(maven_executable_path?); - let home = if configured.is_dir() { - configured.to_path_buf() - } else { - // Only the `/bin/mvn*` layout identifies an installation root. - let bin = configured.parent()?; - if !bin.file_name()?.to_str()?.eq_ignore_ascii_case("bin") { - return None; + let maven_launchers = ["mvn.cmd", "mvn.bat", "mvn.exe", "mvn"]; + let mvnd_launchers = ["mvnd.exe", "mvnd.cmd", "mvnd.bat", "mvnd"]; + let is_launcher = |path: &Path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + mvnd_launchers + .iter() + .chain(maven_launchers.iter()) + .any(|launcher| name.eq_ignore_ascii_case(launcher)) + }) + }; + if !configured.is_dir() && (!configured.is_file() || !is_launcher(configured)) { + return None; + } + // Homebrew exposes client and directory aliases outside the installation. + // Only canonicalize an actual alias: canonicalizing every ordinary path + // would also rewrite macOS /var paths to /private/var in existing contexts. + let resolve_alias = |path: &Path| { + if path.symlink_metadata().ok()?.file_type().is_symlink() { + path.canonicalize().ok() + } else { + Some(path.to_path_buf()) } - bin.parent()?.to_path_buf() }; - let settings = home.join("conf").join("settings.xml"); + let configured = resolve_alias(configured)?; + let launcher = if configured.is_dir() { + [configured.join("bin"), configured.clone()] + .into_iter() + .flat_map(|bin| { + mvnd_launchers + .iter() + .chain(maven_launchers.iter()) + .map(move |name| bin.join(name)) + }) + .find(|candidate| candidate.is_file())? + } else { + configured + }; + let launcher = resolve_alias(&launcher)?; + // Hosts may return alias-to-bin/mvnd after resolving a selected bin directory. + // Resolve that parent alias as well, without rewriting ordinary /var paths. + let launcher = if launcher + .parent()? + .symlink_metadata() + .ok()? + .file_type() + .is_symlink() + { + launcher.canonicalize().ok()? + } else { + launcher + }; + if !is_launcher(&launcher) { + return None; + } + let bin = launcher.parent()?; + if !bin.file_name()?.to_str()?.eq_ignore_ascii_case("bin") { + return None; + } + let home = bin.parent()?; + let is_mvnd = mvnd_launchers.iter().any(|name| { + launcher + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case(name)) + }); + // The daemon's own conf is not Maven's conf. An explicitly selected mvn + // beside mvnd still keeps its own settings; missing embedded settings must + // never fall back to the daemon directory or another installation. + let maven_home = if is_mvnd { + home.join("mvn") + } else { + home.to_path_buf() + }; + let settings = maven_home.join("conf").join("settings.xml"); settings .is_file() .then(|| settings.to_string_lossy().into_owned()) diff --git a/rust/lithe-core/src/tests/languages.rs b/rust/lithe-core/src/tests/languages.rs index 81adaa230..ba7817b60 100644 --- a/rust/lithe-core/src/tests/languages.rs +++ b/rust/lithe-core/src/tests/languages.rs @@ -1519,6 +1519,7 @@ fn maven_installation_settings_reach_jdt_for_a_home_or_a_launcher() { for configured in [ maven_home.to_string_lossy().into_owned(), + maven_home.join("bin").to_string_lossy().into_owned(), launcher.to_string_lossy().into_owned(), ] { let configuration = jdt_configuration( @@ -1567,6 +1568,173 @@ fn maven_installation_settings_are_absent_for_wrappers_and_bare_installations() fs::remove_dir_all(root).expect("Maven fixture should be removable"); } +/// Removes this test's installation and symlinks even when an assertion fails. +struct MavenSettingsFixture { + root: PathBuf, +} + +impl MavenSettingsFixture { + fn new(label: &str) -> Self { + Self { + root: maven_reactor_root(label), + } + } + + fn settings_for(&self, configured: &Path) -> Option { + jdt_configuration( + self.root.to_str().expect("fixture path should be UTF-8"), + maven_jdt_context(Some(configured.to_string_lossy().into_owned())), + ) + .expect("Maven configuration should parse") + .global_settings_path + } +} + +impl Drop for MavenSettingsFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +#[test] +fn mvnd_installation_settings_reach_jdt_for_every_client_home_and_bin() { + // Every platform launcher must read the embedded Maven configuration, never + // the daemon's unrelated conf directory, even when ordinary Maven coexists. + let fixture = MavenSettingsFixture::new("mvnd-jdt-settings"); + for name in ["mvnd.exe", "mvnd.cmd", "mvnd.bat", "mvnd"] { + let home = fixture + .root + .join(format!("installation with spaces-{name}")); + let bin = home.join("bin"); + let embedded_conf = home.join("mvn/conf"); + fs::create_dir_all(&bin).unwrap(); + fs::create_dir_all(&embedded_conf).unwrap(); + fs::create_dir_all(home.join("conf")).unwrap(); + let settings = embedded_conf.join("settings.xml"); + fs::write(&settings, "").unwrap(); + fs::write(home.join("conf/settings.xml"), "").unwrap(); + let launcher = bin.join(name); + fs::write(&launcher, "").unwrap(); + let ordinary_launcher = bin.join("mvn.cmd"); + fs::write(&ordinary_launcher, "").unwrap(); + for configured in [&home, &bin, &launcher] { + assert_eq!( + fixture.settings_for(configured), + Some(settings.to_string_lossy().into_owned()), + "embedded settings for {}", + configured.display() + ); + } + assert_eq!( + fixture.settings_for(&ordinary_launcher), + Some( + home.join("conf/settings.xml") + .to_string_lossy() + .into_owned() + ), + "an explicit ordinary Maven client retains its own settings" + ); + } +} + +#[test] +fn mvnd_missing_embedded_settings_never_fall_back_to_distribution_conf() { + // A missing embedded file must not silently select the daemon's conf or + // ordinary Maven beside it, whether the selected path is home, bin or client. + let fixture = MavenSettingsFixture::new("mvnd-jdt-missing-settings"); + let home = fixture.root.join("mvnd"); + let bin = home.join("bin"); + fs::create_dir_all(&bin).unwrap(); + fs::create_dir_all(home.join("conf")).unwrap(); + fs::write(home.join("conf/settings.xml"), "").unwrap(); + fs::write(bin.join("mvn"), "").unwrap(); + let launcher = bin.join("mvnd"); + fs::write(&launcher, "").unwrap(); + for configured in [&home, &bin, &launcher] { + assert_eq!( + fixture.settings_for(configured), + None, + "{}", + configured.display() + ); + } +} + +#[test] +fn maven_installation_settings_reject_missing_clients_and_wrappers_in_bin() { + // Nearby conf files do not make a nonexistent executable, arbitrary program + // or project wrapper a Maven installation. Even a wrapper under bin is not mvn. + let fixture = MavenSettingsFixture::new("maven-jdt-invalid-client"); + let home = fixture.root.join("installation"); + let bin = home.join("bin"); + fs::create_dir_all(&bin).unwrap(); + for conf in [home.join("conf"), home.join("mvn/conf")] { + fs::create_dir_all(&conf).unwrap(); + fs::write(conf.join("settings.xml"), "").unwrap(); + } + for name in ["mvnw", "mvnw.cmd", "mvnw.bat", "unrelated"] { + let path = bin.join(name); + fs::write(&path, "").unwrap(); + assert_eq!(fixture.settings_for(&path), None); + } + for name in ["mvn", "mvnd", "mvnd.exe", "mvnd.cmd", "mvnd.bat"] { + assert_eq!(fixture.settings_for(&bin.join(name)), None); + } + assert_eq!(fixture.settings_for(&home), None); + assert_eq!(fixture.settings_for(&bin), None); +} + +#[cfg(unix)] +#[test] +fn mvnd_installation_settings_follow_client_and_directory_aliases() { + // Homebrew exposes /prefix/bin/mvnd as a symlink into its versioned package; + // the global settings belong to that package, not /prefix/conf. + use std::os::unix::fs::symlink; + + let fixture = MavenSettingsFixture::new("mvnd-jdt-symlinks"); + let home = fixture.root.join("cellar/mvnd/version/libexec"); + fs::create_dir_all(home.join("bin")).unwrap(); + fs::create_dir_all(home.join("mvn/conf")).unwrap(); + let launcher = home.join("bin/mvnd"); + fs::write(&launcher, "").unwrap(); + let settings = home.join("mvn/conf/settings.xml"); + fs::write(&settings, "").unwrap(); + let prefix = fixture.root.join("prefix"); + fs::create_dir_all(prefix.join("bin")).unwrap(); + fs::create_dir_all(prefix.join("conf")).unwrap(); + fs::write(prefix.join("conf/settings.xml"), "").unwrap(); + let client_alias = prefix.join("bin/mvnd"); + symlink("../../cellar/mvnd/version/libexec/bin/mvnd", &client_alias).unwrap(); + let home_alias = fixture.root.join("current-installation"); + symlink(&home, &home_alias).unwrap(); + let bin_alias = fixture.root.join("current-launchers"); + symlink(home.join("bin"), &bin_alias).unwrap(); + let expected = settings + .canonicalize() + .unwrap() + .to_string_lossy() + .into_owned(); + for configured in [ + &client_alias, + &home_alias, + &bin_alias, + &bin_alias.join("mvnd"), + &prefix, + &prefix.join("bin"), + ] { + assert_eq!( + fixture.settings_for(configured), + Some(expected.clone()), + "{}", + configured.display() + ); + } + // Existing ancestors and settings must not rescue dangling explicit aliases. + let broken = prefix.join("bin/mvn"); + symlink("missing-client", &broken).unwrap(); + assert_eq!(fixture.settings_for(&broken), None); +} + /// Mirrors the shape of a real installation settings file: a documented /// `` plus the mirror a workspace actually resolves through. const SETTINGS_WITH_MIRROR: &str = r#" diff --git a/shared/fixtures/execution/maven-runtime-versions.json b/shared/fixtures/execution/maven-runtime-versions.json new file mode 100644 index 000000000..96166679f --- /dev/null +++ b/shared/fixtures/execution/maven-runtime-versions.json @@ -0,0 +1,10 @@ +{ + "description": "Maven and mvnd expose the embedded Apache Maven version, never the daemon banner.", + "cases": [ + { "output": "Apache Maven 3.9.11 (fixture)\nMaven home: /fixture/maven\n", "version": "3.9.11" }, + { "output": "Apache Maven Daemon (mvnd) 1.0.6 darwin-aarch64\nApache Maven 3.9.11 (fixture)\n", "version": "3.9.11" }, + { "output": "Apache Maven Daemon (mvnd) 1.0.6 windows-amd64\r\nApache Maven 4.0.0-rc-3 (fixture)\r\n", "version": "4.0.0-rc-3" }, + { "output": "Apache Maven Daemon (mvnd) 1.0.6\n", "version": null }, + { "output": "Java home is invalid\n", "version": null } + ] +} diff --git a/shared/platform-feature-matrix/features/java-runtime-discovery.json b/shared/platform-feature-matrix/features/java-runtime-discovery.json index 950a6b483..cee7cde2b 100644 --- a/shared/platform-feature-matrix/features/java-runtime-discovery.json +++ b/shared/platform-feature-matrix/features/java-runtime-discovery.json @@ -2,7 +2,7 @@ "id": "java-runtime-discovery", "area": "Java", "group": "Java/Maven", - "capability": "JDK 与 Maven 工具链发现及按项目要求自动选择 JDK", + "capability": "JDK、Maven / mvnd 工具链发现、安装设置同步及按项目要求自动选择 JDK", "macos": { "evidence": [ "macos/Sources/Lithe/Services/Java", @@ -13,7 +13,13 @@ "macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeProbeCache.swift", "macos/Tests/LitheTests/MacRuntimeProbeCacheTests.swift", "macos/Tests/LitheTests/ProjectRuntimeLoadingTests.swift", - "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift" + "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift", + "macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift", + "macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift", + "rust/lithe-core/src/project/maven.rs", + "rust/lithe-core/src/tests/languages.rs", + "shared/fixtures/execution/maven-runtime-versions.json", + ".agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -23,11 +29,17 @@ "windows/tauri/src/features/maven", "windows/tauri/src/features/workspace", "windows/tauri/src-tauri/src/run.rs", - "shared/fixtures/run-configuration/automatic-java-selection.json" + "shared/fixtures/run-configuration/automatic-java-selection.json", + "windows/tauri/src/features/run/utils/run-configuration.ts", + "windows/tauri/src/features/run/utils/run-configuration.test.ts", + "rust/lithe-core/src/project/maven.rs", + "rust/lithe-core/src/tests/languages.rs", + "shared/fixtures/execution/maven-runtime-versions.json", + ".agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Java / Maven", - "verification": "两端安装 JDK 8/17/21:项目要求 17 且 JAVA_HOME 为 8 时验证自动选择兼容版本;核对设置、Run、Maven 目标、测试使用同一 JDK。验证显式选择不变、没有兼容版本时回退并提示,以及无要求时的平台默认顺序。 macOS 恢复已有运行配置时检查主队列仍能处理交互;同一工具与符号链接别名只探测一次,并发请求共用结果;工具文件变化或手动刷新后重新探测,失败结果可重试。验证切换项目和取消打开后旧探测结果不再应用。" + "verification": "两端安装 JDK 8/17/21:项目要求 17 且 JAVA_HOME 为 8 时验证自动选择兼容版本;核对设置、Run、Maven 目标、测试使用同一 JDK。验证显式选择不变、没有兼容版本时回退并提示,以及无要求时的平台默认顺序。 macOS 恢复已有运行配置时检查主队列仍能处理交互;同一工具与符号链接别名只探测一次,并发请求共用结果;工具文件变化或手动刷新后重新探测,失败结果可重试。验证切换项目和取消打开后旧探测结果不再应用。 两端验证 mvnd 根目录、bin 目录和启动器三种显式选择,执行仍使用 mvnd 客户端;同目录显式 mvn 保持普通 Maven 设置。自动选择保留有效 Wrapper、普通 Maven、mvnd 顺序,覆盖仅 MVND_HOME、仅 PATH、无效 MVND_HOME 回退。版本读取内嵌 Apache Maven 行,忽略 daemon 标题,使用共享版本 fixture。核对 JDT 全局设置读取 mvnd 的 mvn/conf/settings.xml,缺失时不回退 daemon conf;普通 Maven 目录缺启动器、Wrapper 和失效链接均不冒充安装,macOS 目录与客户端符号链接跟随真实安装。Core languages 测试仅验证共享设置规则;各原生系统还需以独立临时项目、仓库和 daemon 状态目录实际构建、验证 JDT 设置并清理进程,完成前两端保持待验证。" } diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 8def554c5..634ecac8f 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -21,6 +21,9 @@ use tauri::{AppHandle, Emitter, Manager}; mod launch_arguments; +const MAVEN_LAUNCHERS: &[&str] = &["mvn.cmd", "mvn.bat", "mvn.exe", "mvn"]; +const MVND_LAUNCHERS: &[&str] = &["mvnd.exe", "mvnd.cmd", "mvnd.bat", "mvnd"]; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; const TOOLCHAIN_PROBE_TIMEOUT: Duration = Duration::from_secs(3); const PRE_LAUNCH_TIMEOUT: Duration = Duration::from_secs(600); @@ -342,6 +345,16 @@ pub fn maven_resolve_installation(root: PathBuf, override_path: Option) } fn maven_executable_without_probing(root: &Path, override_path: Option<&str>) -> Option { + maven_executable_without_probing_with_candidates(root, override_path, || { + maven_executable_candidates(None) + }) +} + +fn maven_executable_without_probing_with_candidates( + root: &Path, + override_path: Option<&str>, + candidates: impl FnOnce() -> Vec, +) -> Option { if let Some(configured) = override_path .map(str::trim) .filter(|value| !value.is_empty()) @@ -364,7 +377,7 @@ fn maven_executable_without_probing(root: &Path, override_path: Option<&str>) -> } // Wrappers were already considered above, and an unusable one must not // shadow a machine installation here. - maven_executable_candidates(Some(root)) + candidates() .into_iter() .filter(|candidate| !is_maven_wrapper(candidate)) .find(|candidate| candidate.is_file()) @@ -1437,25 +1450,48 @@ fn well_known_java_roots() -> Vec { } fn maven_executable_candidates(project_root: Option<&Path>) -> Vec { + maven_executable_candidates_with_sources( + project_root, + |key| { + std::env::var_os(key) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + }, + lookup_on_path, + ) +} + +fn maven_executable_candidates_with_sources( + project_root: Option<&Path>, + mut environment: impl FnMut(&str) -> Option, + mut path_lookup: impl FnMut(&str) -> Option, +) -> Vec { let mut executables = Vec::new(); if let Some(root) = project_root { executables.push(root.join("mvnw.cmd")); executables.push(root.join("mvnw.bat")); executables.push(root.join("mvnw")); } - if let Ok(home) = std::env::var("MAVEN_HOME") { - let home = PathBuf::from(home); - executables.push(home.join("bin").join("mvn.cmd")); - executables.push(home.join("bin").join("mvn.bat")); - executables.push(home.join("bin").join("mvn")); + for key in ["MAVEN_HOME", "M2_HOME"] { + if let Some(home) = environment(key) { + executables.extend( + MAVEN_LAUNCHERS + .iter() + .map(|name| home.join("bin").join(name)), + ); + } + } + // An installed mvnd must not replace an ordinary Maven or project wrapper. + for name in MAVEN_LAUNCHERS { + if let Some(path) = path_lookup(name) { + executables.push(path); + } } - if let Ok(home) = std::env::var("M2_HOME") { - let home = PathBuf::from(home); - executables.push(home.join("bin").join("mvn.cmd")); - executables.push(home.join("bin").join("mvn")); + if let Some(home) = environment("MVND_HOME") { + executables.extend(custom_maven_executable_candidates(&home)); } - for name in ["mvn.cmd", "mvn.bat", "mvn.exe", "mvn"] { - if let Some(path) = lookup_on_path(name) { + for name in MVND_LAUNCHERS { + if let Some(path) = path_lookup(name) { executables.push(path); } } @@ -1466,13 +1502,17 @@ fn custom_maven_executable_candidates(path: &Path) -> Vec { if path.is_file() { return vec![path.to_path_buf()]; } - let bin = path.join("bin"); - let mut candidates = ["mvn.cmd", "mvn.bat", "mvn.exe", "mvn"] + // Accept an installation root or bin directory. Never substitute mvnd's + // embedded mvn: selecting mvnd must keep the daemon client as the executable. + [path.join("bin"), path.to_path_buf()] .into_iter() - .map(|name| bin.join(name)) - .collect::>(); - candidates.push(path.to_path_buf()); - candidates + .flat_map(|bin| { + MVND_LAUNCHERS + .iter() + .chain(MAVEN_LAUNCHERS) + .map(move |name| bin.join(name)) + }) + .collect() } pub(crate) fn probe_java_home(home: &Path) -> Option { @@ -1742,6 +1782,17 @@ pub(crate) fn resolve_maven_executable( root: &Path, working_directory: &Path, override_path: &str, +) -> Result { + resolve_maven_executable_with_candidates(root, working_directory, override_path, || { + maven_executable_candidates(None) + }) +} + +fn resolve_maven_executable_with_candidates( + root: &Path, + working_directory: &Path, + override_path: &str, + candidates: impl FnOnce() -> Vec, ) -> Result { let configured = override_path.trim(); if !configured.is_empty() { @@ -1750,14 +1801,10 @@ pub(crate) fn resolve_maven_executable( } else { root.join(configured) }; - let candidates = [ - path.clone(), - path.join("bin").join("mvn.cmd"), - path.join("bin").join("mvn.bat"), - path.join("bin").join("mvn.exe"), - path.join("bin").join("mvn"), - ]; - if let Some(found) = candidates.into_iter().find(|candidate| candidate.is_file()) { + if let Some(found) = custom_maven_executable_candidates(&path) + .into_iter() + .find(|candidate| candidate.is_file()) + { return Ok(normalize_path(&found).to_string_lossy().into_owned()); } return Err("Maven executable path does not exist.".into()); @@ -1777,7 +1824,7 @@ pub(crate) fn resolve_maven_executable( } // Match project import's filesystem-only fallback. Discovering a launcher // must never execute a wrapper or download its Maven distribution. - maven_executable_without_probing(root, None) + maven_executable_without_probing_with_candidates(root, None, candidates) .ok_or_else(|| { if saw_incomplete_wrapper { "Maven wrapper is incomplete (.mvn/wrapper/maven-wrapper.properties is missing) and no system Maven was found. Install Maven or restore the wrapper files.".into() @@ -2079,8 +2126,14 @@ fn java_version(output: &str) -> Option { fn maven_version(output: &str) -> Option { output.lines().find_map(|line| { - let rest = line.trim().strip_prefix("Apache Maven")?; - rest.split_whitespace().next().map(str::to_string) + // mvnd prints its daemon banner before the embedded Apache Maven line. + let rest = line.trim().strip_prefix("Apache Maven ")?; + let version = rest.split_whitespace().next()?; + version + .chars() + .next()? + .is_ascii_digit() + .then(|| version.to_string()) }) } @@ -2745,6 +2798,220 @@ mod tests { } } + mod mvnd { + use super::*; + + struct Fixture(PathBuf); + + impl Fixture { + fn new() -> Self { + Self(temp_project()) + } + + fn file(&self, relative: &str) -> PathBuf { + let path = self.0.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "fixture; never execute").unwrap(); + path + } + + fn candidates( + &self, + homes: &[(&str, &str)], + clients: &[(&str, &Path)], + ) -> Vec { + maven_executable_candidates_with_sources( + Some(&self.0), + |key| { + homes + .iter() + .find(|(name, _)| *name == key) + .map(|(_, home)| self.0.join(home)) + }, + |key| { + clients + .iter() + .find(|(name, _)| *name == key) + .map(|(_, path)| path.to_path_buf()) + }, + ) + } + + fn assert_resolution(&self, candidates: Vec, expected: &Path) { + let import = + maven_executable_without_probing_with_candidates(&self.0, None, || { + candidates.clone() + }); + let execution = + resolve_maven_executable_with_candidates(&self.0, &self.0, "", || candidates); + let expected = normalize_path(expected).to_string_lossy().into_owned(); + assert_eq!(import.as_deref(), Some(expected.as_str())); + assert_eq!(execution, Ok(expected)); + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.0) { + eprintln!("Could not clean mvnd fixture: {error}"); + } + } + } + + #[test] + fn explicit_home_bin_and_clients_keep_mvnd_instead_of_embedded_maven() { + let fixture = Fixture::new(); + for name in MVND_LAUNCHERS { + let home = fixture.0.join(name); + let client = fixture.file(&format!("{name}/bin/{name}")); + fixture.file(&format!("{name}/mvn/bin/mvn.cmd")); + for selected in [home.clone(), home.join("bin"), client.clone()] { + let selected = selected.to_string_lossy(); + let expected = normalize_path(&client).to_string_lossy().into_owned(); + assert_eq!( + maven_executable_without_probing_with_candidates( + &fixture.0, + Some(&selected), + Vec::new + ), + Some(expected.clone()) + ); + assert_eq!( + resolve_maven_executable_with_candidates( + &fixture.0, + &fixture.0, + &selected, + Vec::new + ), + Ok(expected) + ); + let discovered = + discover_maven_candidates(vec![client.clone()], Some(&selected), |path| { + path.is_file().then(|| MavenRuntime { + executable_path: normalize_path(path) + .to_string_lossy() + .into_owned(), + version: "3.9.9".into(), + }) + }); + assert_eq!(discovered.len(), 1, "deduplicate explicitly selected mvnd"); + assert_eq!( + Path::new(&discovered[0].executable_path), + normalize_path(&client) + ); + } + } + } + + #[test] + fn invalid_explicit_mvnd_does_not_fall_back_to_another_installation() { + let fixture = Fixture::new(); + let client = fixture.file("maven/bin/mvn.cmd"); + // Even a distribution containing only the embedded Maven is invalid. + fixture.file("broken-mvnd/mvn/bin/mvn.cmd"); + for selected in ["missing", "broken-mvnd"] { + assert_eq!( + maven_executable_without_probing_with_candidates( + &fixture.0, + Some(selected), + || vec![client.clone()] + ), + None + ); + assert!(resolve_maven_executable_with_candidates( + &fixture.0, + &fixture.0, + selected, + || vec![client.clone()] + ) + .is_err()); + } + } + + #[test] + fn mvnd_home_only_resolves_the_daemon_client() { + let fixture = Fixture::new(); + let client = fixture.file("mvnd/bin/mvnd.exe"); + fixture.file("mvnd/mvn/bin/mvn.cmd"); + fixture.assert_resolution(fixture.candidates(&[("MVND_HOME", "mvnd")], &[]), &client); + } + + #[test] + fn path_only_resolves_each_mvnd_launcher() { + let fixture = Fixture::new(); + for name in MVND_LAUNCHERS { + let client = fixture.file(&format!("path/{name}")); + fixture.assert_resolution(fixture.candidates(&[], &[(name, &client)]), &client); + } + } + + #[test] + fn ordinary_maven_precedes_mvnd_from_home_or_path() { + let fixture = Fixture::new(); + let ordinary = fixture.file("maven/bin/mvn.cmd"); + let daemon = fixture.file("mvnd/bin/mvnd.exe"); + for key in ["MAVEN_HOME", "M2_HOME"] { + fixture.assert_resolution( + fixture.candidates( + &[(key, "maven"), ("MVND_HOME", "mvnd")], + &[("mvnd.exe", &daemon)], + ), + &ordinary, + ); + } + fixture.assert_resolution( + fixture.candidates( + &[("MVND_HOME", "mvnd")], + &[("mvn.cmd", &ordinary), ("mvnd.exe", &daemon)], + ), + &ordinary, + ); + } + + #[test] + fn usable_wrapper_precedes_maven_and_mvnd() { + let fixture = Fixture::new(); + let wrapper = fixture.file("mvnw.cmd"); + fixture.file(".mvn/wrapper/maven-wrapper.properties"); + fixture.file("maven/bin/mvn.cmd"); + fixture.file("mvnd/bin/mvnd.exe"); + fixture.assert_resolution( + fixture.candidates(&[("MAVEN_HOME", "maven"), ("MVND_HOME", "mvnd")], &[]), + &wrapper, + ); + } + + #[test] + fn invalid_mvnd_home_and_incomplete_wrapper_fall_back_to_path() { + let fixture = Fixture::new(); + fixture.file("mvnw.cmd"); + let client = fixture.file("path/mvnd.cmd"); + fixture.assert_resolution( + fixture.candidates(&[("MVND_HOME", "missing")], &[("mvnd.cmd", &client)]), + &client, + ); + assert_eq!( + maven_executable_without_probing_with_candidates(&fixture.0, None, || fixture + .candidates(&[("MVND_HOME", "missing")], &[])), + None + ); + } + + #[test] + fn version_uses_embedded_maven_instead_of_daemon_banner() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/execution/maven-runtime-versions.json" + )) + .expect("valid Maven version fixture"); + for case in fixture["cases"].as_array().expect("version cases") { + assert_eq!( + maven_version(case["output"].as_str().expect("version output")).as_deref(), + case["version"].as_str() + ); + } + } + } + #[test] fn java_listing_skips_maven_target() { let root = temp_project(); diff --git a/windows/tauri/src/features/run/utils/run-configuration.test.ts b/windows/tauri/src/features/run/utils/run-configuration.test.ts index a5094715d..d9c4018ba 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.test.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.test.ts @@ -184,6 +184,48 @@ describe("run configuration mapping", () => { ]); }); + for (const launcher of ["mvnd.exe", "mvnd.cmd", "mvnd.bat", "mvnd", "mvn.cmd", "mvn.bat", "mvn.exe", "mvn"]) { + for (const selection of ["d:/tools/maven/", "d:/tools/maven/BIN/", `d:/tools/maven/bin/${launcher.toUpperCase()}`]) { + test(`matches Maven selection ${selection} to ${launcher}`, () => { + expect(selectedToolchainCandidates( + { + java: [], + maven: [ + { executablePath: "C:/Other/bin/mvn.cmd", version: "3.8.8" }, + { executablePath: `D:\\Tools\\Maven\\bin\\${launcher}`, version: "3.9.9" }, + ], + runtimes: [], + }, + { + javaHomePath: "", + mavenExecutablePath: selection, + mavenJavaHomePath: "", + runtimeExecutablePaths: {}, + }, + )).toEqual([{ id: "project-maven", type: "maven", version: "3.9.9", vendor: "" }]); + }); + } + } + + test("does not match a selected mvnd home to its embedded Maven or an unrelated client", () => { + expect(selectedToolchainCandidates( + { + java: [], + maven: [ + { executablePath: "D:/Tools/mvnd/mvn/bin/mvn.cmd", version: "3.9.9" }, + { executablePath: "D:/Other/bin/mvnd.exe", version: "3.9.9" }, + ], + runtimes: [], + }, + { + javaHomePath: "", + mavenExecutablePath: "D:/Tools/mvnd", + mavenJavaHomePath: "", + runtimeExecutablePaths: {}, + }, + )).toEqual([]); + }); + test("shows Maven settings from the configuration requirement without discovery", () => { expect(configurationUsesMaven({ toolchains: { java: "project-jdk", maven: "project-maven" } })).toBe(true); expect(configurationUsesMaven({ toolchains: { java: "project-jdk" } })).toBe(false); diff --git a/windows/tauri/src/features/run/utils/run-configuration.ts b/windows/tauri/src/features/run/utils/run-configuration.ts index b99a5146a..34ec62462 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.ts @@ -282,8 +282,9 @@ export function effectiveRuntimeExecutablePaths( function mavenSelectionMatchesRuntime(selection: string, executable: string): boolean { if (sameWindowsPath(selection, executable)) return true; const home = selection.replace(/[\\/]+$/, ""); - return ["mvn.cmd", "mvn.bat", "mvn.exe", "mvn"].some((name) => - sameWindowsPath(`${home}/bin/${name}`, executable)); + return ["mvnd.exe", "mvnd.cmd", "mvnd.bat", "mvnd", "mvn.cmd", "mvn.bat", "mvn.exe", "mvn"].some((name) => + sameWindowsPath(`${home}/bin/${name}`, executable) + || sameWindowsPath(`${home}/${name}`, executable)); } function sameWindowsPath(left: string, right: string): boolean { diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 10f6217e2..745418775 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1602,7 +1602,7 @@ const catalogs = { "run.jdkHomeHint": "Leave empty to use the detected JDK.", "run.mavenExecutable": "Maven home or executable", "run.mavenExecutableHint": - "Choose a Maven home directory, or leave empty to use the project wrapper or detected Maven.", + "Choose a Maven or mvnd home, bin directory, or executable. Leave empty to use the project wrapper, detected Maven, then mvnd.", "run.nodeExecutable": "Node.js executable", "run.nodeExecutableHint": "Choose node.exe, or leave empty to use the detected Node.js runtime.", @@ -1838,7 +1838,7 @@ const catalogs = { "settings.run.description": "Select a service or task to configure its arguments, environment and project-environment overrides. Changes apply after Save.", "settings.project.openProject": "Open a project to configure its JDK and Maven.", "settings.project.scope": "Defaults for this project on this computer. Run configurations inherit these values unless explicitly overridden. Leave a path empty to use automatic selection.", - "settings.project.mavenAutomatic": "Automatic: project Maven Wrapper, then detected Maven. You can also enter a Maven home or executable path.", + "settings.project.mavenAutomatic": "Automatic: project Maven Wrapper, detected Maven, then mvnd. You can also enter a Maven or mvnd home, bin directory, or executable path.", "settings.project.useProjectJdk": "Use project JDK", "settings.project.detected": "Detected installations", "settings.project.saved": "Project environment saved.", @@ -6166,7 +6166,7 @@ const catalogs = { "run.jdkHome": "JDK 主目录", "run.jdkHomeHint": "留空则使用自动检测到的 JDK。", "run.mavenExecutable": "Maven 主目录 / 可执行文件", - "run.mavenExecutableHint": "可选择 Maven 主目录;留空则使用项目 Wrapper 或系统 Maven。", + "run.mavenExecutableHint": "可选择 Maven 或 mvnd 的主目录、bin 目录或可执行文件;留空则依次使用项目 Wrapper、检测到的 Maven、mvnd。", "run.nodeExecutable": "Node.js 可执行文件", "run.nodeExecutableHint": "可选择 node.exe;留空则使用自动检测到的 Node.js 运行时。", "run.mavenJdkHome": "Maven JDK 主目录", @@ -6392,7 +6392,7 @@ const catalogs = { "settings.run.description": "选择服务或任务,配置启动参数、环境变量和项目环境覆盖项;点击保存后生效。", "settings.project.openProject": "打开项目后,可在这里配置项目 JDK 和 Maven。", "settings.project.scope": "仅保存在当前电脑,作用于当前项目。运行配置默认继承这些值,单独设置的覆盖值保持不变。路径留空时使用自动选择。", - "settings.project.mavenAutomatic": "自动:优先使用项目 Maven Wrapper,其次使用检测到的 Maven。也可以填写 Maven 主目录或可执行文件路径。", + "settings.project.mavenAutomatic": "自动:依次使用项目 Maven Wrapper、检测到的 Maven、mvnd。也可以填写 Maven 或 mvnd 的主目录、bin 目录或可执行文件路径。", "settings.project.useProjectJdk": "使用项目 JDK", "settings.project.detected": "检测到的安装", "settings.project.saved": "项目环境已保存。",