Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,25 @@ Maven 自己的设置解析顺序是“用户级 `~/.m2/settings.xml` 叠加安

## 决策

**1. 安装级设置转成 `globalSettings`。** Core 从配置的 Maven 主目录或
`<home>/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 使用
`<home>/conf/settings.xml`;Maven Daemon(mvnd,复用后台 JVM 的 Maven 客户端)
使用 `<home>/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 配置和首次导入之前。
Expand All @@ -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`,
Expand Down Expand Up @@ -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 自己读盘会破坏既有的
持久化归属。解析链因此留在平台侧。
Expand Down Expand Up @@ -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 内的同名元素。完整校验运行
Expand Down
4 changes: 2 additions & 2 deletions macos/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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." = "应用级编辑器和终端设置仍可在其他分类中使用。";
Expand All @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -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<String>()
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<String>()
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",
Expand All @@ -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? {
Expand All @@ -180,19 +200,25 @@ 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")
let output = cachedCommandOutput(
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] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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") {
Expand Down
59 changes: 59 additions & 0 deletions macos/Tests/LitheTests/MacRuntimeDiscoveryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,65 @@ struct MacRuntimeDiscoveryTests {
#expect(homes == [javaHome.standardizedFileURL])
}

@Test
func discoversMvndWithoutProbingAndPreservesMavenPriority() {
func discover(_ environment: [String: String], files: Set<String>) -> [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(
Expand Down
Loading
Loading