From 0929e61ebdef053122d1430e680b74e1f2137899 Mon Sep 17 00:00:00 2001
From: Yao Jingxi <23722032@bjtu.edu.cn>
Date: Wed, 4 Nov 2026 21:58:33 +0800
Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E7=A7=BB=E6=A4=8D=20Java=20Support?=
=?UTF-8?q?=20=E6=8F=92=E4=BB=B6=E5=8C=85=E5=92=8C=E6=89=93=E5=8C=85?=
=?UTF-8?q?=E8=84=9A=E6=9C=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
从 Lithe 主仓库 1daaeb525 移入插件包的静态部分和构建脚本:
- dev.lithe.plugin.java-support:plugin.json(身份、权限、设置、语言服务器、
三个项目生成器不变)、图标和项目模板。清单暂不声明 backend,等宿主插件
接口就位后再加。
- scripts/assemble-package.sh、build-package.sh、verify-package.sh、
archive-package.sh:组装、校验、归档签名包。运行时包只读,JDT LS、Lombok、
java-debug、测试运行器和 JDK 都在构建阶段固定来源后放进包。
- scripts/prepare-jdtls.sh、prepare-jdk.sh 和 third_party 清单:来源地址与
SHA-256 固定在清单里。
- scripts/host-tools.sh 与 host-tools.lock.json:签名器和校验器按固定的 Lithe
提交构建。
---
.../icons/LICENSE-APACHE-2.0.txt | 171 +++++
.../icons/NOTICE.txt | 6 +
dev.lithe.plugin.java-support/icons/java.svg | 6 +
.../icons/java_dark.svg | 6 +
dev.lithe.plugin.java-support/icons/maven.svg | 4 +
.../icons/maven_dark.svg | 4 +
.../icons/spring-boot.svg | 4 +
.../icons/spring-boot_dark.svg | 4 +
dev.lithe.plugin.java-support/plugin.json | 670 ++++++++++++++++++
.../templates/java/gradle-groovy/build.gradle | 26 +
.../java/gradle-groovy/dot-gitignore | 23 +
.../java/gradle-groovy/settings.gradle | 1 +
.../java/gradle-kotlin/build.gradle.kts | 26 +
.../java/gradle-kotlin/dot-gitignore | 23 +
.../java/gradle-kotlin/settings.gradle.kts | 1 +
.../templates/java/maven/dot-gitignore | 19 +
.../templates/java/maven/pom.xml | 43 ++
.../java/plain-empty/src/dot-gitkeep | 0
.../templates/java/plain-sample/src/Main.java | 5 +
.../templates/java/plain/dot-gitignore | 16 +
.../standard-empty/src/main/java/dot-gitkeep | 0
.../src/main/resources/dot-gitkeep | 0
.../standard-empty/src/test/java/dot-gitkeep | 0
.../java/standard-sample-main/Main.java | 7 +
.../src/main/resources/dot-gitkeep | 0
.../standard-sample/src/test/java/dot-gitkeep | 0
host-tools.lock.json | 6 +
scripts/archive-package.sh | 94 +++
scripts/assemble-package.sh | 91 +++
scripts/build-package.sh | 42 ++
scripts/host-tools.sh | 83 +++
scripts/prepare-jdk.sh | 135 ++++
scripts/prepare-jdtls.sh | 360 ++++++++++
scripts/verify-package.sh | 105 +++
third_party/README.md | 22 +
third_party/jdk/manifest.json | 26 +
third_party/jdtls/manifest.json | 28 +
37 files changed, 2057 insertions(+)
create mode 100644 dev.lithe.plugin.java-support/icons/LICENSE-APACHE-2.0.txt
create mode 100644 dev.lithe.plugin.java-support/icons/NOTICE.txt
create mode 100644 dev.lithe.plugin.java-support/icons/java.svg
create mode 100644 dev.lithe.plugin.java-support/icons/java_dark.svg
create mode 100644 dev.lithe.plugin.java-support/icons/maven.svg
create mode 100644 dev.lithe.plugin.java-support/icons/maven_dark.svg
create mode 100644 dev.lithe.plugin.java-support/icons/spring-boot.svg
create mode 100644 dev.lithe.plugin.java-support/icons/spring-boot_dark.svg
create mode 100644 dev.lithe.plugin.java-support/plugin.json
create mode 100644 dev.lithe.plugin.java-support/templates/java/gradle-groovy/build.gradle
create mode 100644 dev.lithe.plugin.java-support/templates/java/gradle-groovy/dot-gitignore
create mode 100644 dev.lithe.plugin.java-support/templates/java/gradle-groovy/settings.gradle
create mode 100644 dev.lithe.plugin.java-support/templates/java/gradle-kotlin/build.gradle.kts
create mode 100644 dev.lithe.plugin.java-support/templates/java/gradle-kotlin/dot-gitignore
create mode 100644 dev.lithe.plugin.java-support/templates/java/gradle-kotlin/settings.gradle.kts
create mode 100644 dev.lithe.plugin.java-support/templates/java/maven/dot-gitignore
create mode 100644 dev.lithe.plugin.java-support/templates/java/maven/pom.xml
create mode 100644 dev.lithe.plugin.java-support/templates/java/plain-empty/src/dot-gitkeep
create mode 100644 dev.lithe.plugin.java-support/templates/java/plain-sample/src/Main.java
create mode 100644 dev.lithe.plugin.java-support/templates/java/plain/dot-gitignore
create mode 100644 dev.lithe.plugin.java-support/templates/java/standard-empty/src/main/java/dot-gitkeep
create mode 100644 dev.lithe.plugin.java-support/templates/java/standard-empty/src/main/resources/dot-gitkeep
create mode 100644 dev.lithe.plugin.java-support/templates/java/standard-empty/src/test/java/dot-gitkeep
create mode 100644 dev.lithe.plugin.java-support/templates/java/standard-sample-main/Main.java
create mode 100644 dev.lithe.plugin.java-support/templates/java/standard-sample/src/main/resources/dot-gitkeep
create mode 100644 dev.lithe.plugin.java-support/templates/java/standard-sample/src/test/java/dot-gitkeep
create mode 100644 host-tools.lock.json
create mode 100755 scripts/archive-package.sh
create mode 100755 scripts/assemble-package.sh
create mode 100755 scripts/build-package.sh
create mode 100755 scripts/host-tools.sh
create mode 100755 scripts/prepare-jdk.sh
create mode 100755 scripts/prepare-jdtls.sh
create mode 100755 scripts/verify-package.sh
create mode 100644 third_party/README.md
create mode 100644 third_party/jdk/manifest.json
create mode 100644 third_party/jdtls/manifest.json
diff --git a/dev.lithe.plugin.java-support/icons/LICENSE-APACHE-2.0.txt b/dev.lithe.plugin.java-support/icons/LICENSE-APACHE-2.0.txt
new file mode 100644
index 0000000..acb68f5
--- /dev/null
+++ b/dev.lithe.plugin.java-support/icons/LICENSE-APACHE-2.0.txt
@@ -0,0 +1,171 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work.
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or Derivative Works
+ a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works that
+ You distribute, all copyright, patent, trademark, and attribution
+ notices from the Source form of the Work, excluding those notices
+ that do not pertain to any part of the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one of
+ the following places: within a NOTICE text file distributed as
+ part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/dev.lithe.plugin.java-support/icons/NOTICE.txt b/dev.lithe.plugin.java-support/icons/NOTICE.txt
new file mode 100644
index 0000000..90eda25
--- /dev/null
+++ b/dev.lithe.plugin.java-support/icons/NOTICE.txt
@@ -0,0 +1,6 @@
+java.svg, java_dark.svg, maven.svg, and maven_dark.svg are copied from the
+JetBrains IntelliJ Platform Community repository
+(https://github.com/JetBrains/intellij-community), platform/icons/src/fileTypes
+and plugins/maven resources, under the Apache License 2.0 in
+LICENSE-APACHE-2.0.txt. spring-boot.svg and spring-boot_dark.svg are original
+Lithe artwork.
diff --git a/dev.lithe.plugin.java-support/icons/java.svg b/dev.lithe.plugin.java-support/icons/java.svg
new file mode 100644
index 0000000..00e1a4c
--- /dev/null
+++ b/dev.lithe.plugin.java-support/icons/java.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/dev.lithe.plugin.java-support/icons/java_dark.svg b/dev.lithe.plugin.java-support/icons/java_dark.svg
new file mode 100644
index 0000000..155295f
--- /dev/null
+++ b/dev.lithe.plugin.java-support/icons/java_dark.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/dev.lithe.plugin.java-support/icons/maven.svg b/dev.lithe.plugin.java-support/icons/maven.svg
new file mode 100644
index 0000000..bb88b7a
--- /dev/null
+++ b/dev.lithe.plugin.java-support/icons/maven.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/dev.lithe.plugin.java-support/icons/maven_dark.svg b/dev.lithe.plugin.java-support/icons/maven_dark.svg
new file mode 100644
index 0000000..e26ebc1
--- /dev/null
+++ b/dev.lithe.plugin.java-support/icons/maven_dark.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/dev.lithe.plugin.java-support/icons/spring-boot.svg b/dev.lithe.plugin.java-support/icons/spring-boot.svg
new file mode 100644
index 0000000..52020b0
--- /dev/null
+++ b/dev.lithe.plugin.java-support/icons/spring-boot.svg
@@ -0,0 +1,4 @@
+
diff --git a/dev.lithe.plugin.java-support/icons/spring-boot_dark.svg b/dev.lithe.plugin.java-support/icons/spring-boot_dark.svg
new file mode 100644
index 0000000..ffbc5b2
--- /dev/null
+++ b/dev.lithe.plugin.java-support/icons/spring-boot_dark.svg
@@ -0,0 +1,4 @@
+
diff --git a/dev.lithe.plugin.java-support/plugin.json b/dev.lithe.plugin.java-support/plugin.json
new file mode 100644
index 0000000..7bcd18b
--- /dev/null
+++ b/dev.lithe.plugin.java-support/plugin.json
@@ -0,0 +1,670 @@
+{
+ "schemaVersion": 2,
+ "id": "dev.lithe.plugin.java-support",
+ "displayName": "Java Support",
+ "version": "1.1.0",
+ "pluginAPIVersion": 4,
+ "requires": {"debugAdapters": 1, "languages": 1, "languageServers": 1, "serverProcess": 1, "settings": 2, "projectGenerators": 1, "packageTrust": 1, "toolDefinitions": 1, "executableResolution": 2},
+ "vendor": {
+ "id": "dev.lithe",
+ "displayName": "Lithe",
+ "signatureRequirement": "publisherPackage",
+ "bundledSignatureRequirement": "sameTeamAsHost"
+ },
+ "permissions": ["languageServer.launch", "project.generate", "debug.launch", "debug.attach"],
+ "activationEvents": ["onLanguage:java", "onDebug:java"],
+ "toolDefinitions": [
+ {
+ "id": "java",
+ "displayName": "JDK",
+ "icon": {
+ "host": "plugin"
+ },
+ "executableLayouts": [
+ {
+ "path": "bin/java",
+ "platforms": [
+ "macos",
+ "linux"
+ ]
+ },
+ {
+ "path": "bin/java.exe",
+ "platforms": [
+ "windows"
+ ]
+ }
+ ],
+ "executables": [
+ {
+ "id": "pinned",
+ "kind": "setting",
+ "settingID": "executable",
+ "layer": "project"
+ },
+ {
+ "id": "project-request",
+ "kind": "toolchain",
+ "scope": "project"
+ },
+ {
+ "id": "default",
+ "kind": "setting",
+ "settingID": "executable",
+ "layer": "global"
+ },
+ {
+ "id": "global-request",
+ "kind": "toolchain",
+ "scope": "global"
+ },
+ {
+ "id": "home",
+ "kind": "environment",
+ "variable": "JAVA_HOME",
+ "path": "bin/java",
+ "platforms": [
+ "macos",
+ "linux"
+ ]
+ },
+ {
+ "id": "home-windows",
+ "kind": "environment",
+ "variable": "JAVA_HOME",
+ "path": "bin/java.exe",
+ "platforms": [
+ "windows"
+ ]
+ },
+ {
+ "id": "shell",
+ "kind": "command",
+ "command": "java"
+ },
+ {
+ "id": "detected",
+ "kind": "toolchain",
+ "scope": "installed"
+ }
+ ],
+ "version": {
+ "arguments": [
+ "-version"
+ ],
+ "pattern": "version \\\"(?:1\\.)?(\\d+(?:\\.\\d+)*)"
+ },
+ "homeLayouts": [
+ {
+ "match": "*.jdk",
+ "home": "Contents/Home",
+ "platforms": [
+ "macos"
+ ]
+ },
+ {
+ "match": "*",
+ "home": "."
+ }
+ ],
+ "discovery": [
+ {
+ "root": {
+ "kind": "absolute",
+ "path": "/Library/Java/JavaVirtualMachines"
+ },
+ "pattern": "*.jdk",
+ "platforms": [
+ "macos"
+ ]
+ },
+ {
+ "root": {
+ "kind": "home"
+ },
+ "pattern": "Library/Java/JavaVirtualMachines/*.jdk",
+ "platforms": [
+ "macos"
+ ]
+ }
+ ],
+ "homeVariable": "JAVA_HOME",
+ "commands": [
+ "java"
+ ],
+ "wrappers": [],
+ "exports": [
+ {
+ "name": "JAVA_HOME",
+ "value": "${home}"
+ },
+ {
+ "name": "LITHE_JAVA_PATH",
+ "value": "${executable}"
+ }
+ ],
+ "pathPrepend": [
+ "${home}/bin"
+ ],
+ "launch": [
+ "${executable}",
+ "${runtimeArguments}",
+ "-jar",
+ "${entry}",
+ "${arguments}"
+ ]
+ }
+ ],
+ "languages": [
+ {
+ "id": "java",
+ "displayName": "Java",
+ "fileExtensions": ["java"],
+ "projectFileNames": ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]
+ }
+ ],
+ "debugAdapters": [
+ {
+ "id": "java",
+ "displayName": "Java Debugger",
+ "adapterID": "java",
+ "configurationTypes": ["java"],
+ "requests": ["attach"],
+ "selectors": [{"targetKinds": ["connection"], "languageIDs": ["java"]}],
+ "launch": {
+ "transport": "tcpLanguageServer",
+ "endpoint": {"languageID": "java", "command": "vscode.java.startDebugSession"}
+ },
+ "configurations": {
+ "attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}
+ }
+ }
+ ],
+ "settings": [
+ {
+ "id": "heapMaxMB",
+ "type": "integer",
+ "title": "Maximum memory",
+ "description": "Upper bound of the Java language server's heap. Raise it for large multi-module projects.",
+ "default": 2048,
+ "minimum": 2048,
+ "maximum": 16384,
+ "unit": "MB",
+ "page": {"languageID": "java"},
+ "section": "Language server"
+ },
+ {
+ "id": "logLevel",
+ "type": "enum",
+ "title": "Language server log level",
+ "description": "How much the Java language server writes to its own log. Quieter levels reduce disk writes.",
+ "default": "WARNING",
+ "options": [
+ { "value": "ALL", "title": "All messages" },
+ { "value": "INFO", "title": "Information" },
+ { "value": "WARNING", "title": "Warnings" },
+ { "value": "SEVERE", "title": "Errors only" }
+ ],
+ "page": {"languageID": "java"},
+ "section": "Language server"
+ }
+ ],
+ "languageServers": [
+ {
+ "languageID": "java",
+ "languageIdentifier": "java",
+ "launch": {
+ "kind": "runtime",
+ "tool": "java",
+ "packagedRuntime": "runtime/${arch}/bin/java",
+ "runtimeArguments": [
+ "-Xmx${setting:heapMaxMB}m",
+ "-Dlog.level=${setting:logLevel}",
+ "-Dosgi.logfile=${logDir}/jdt.log",
+ "-Declipse.log.include.commandline=false",
+ "-Declipse.log.size.max=1024",
+ "-Declipse.log.backup.max=2",
+ "-Declipse.log.level=WARNING"
+ ],
+ "entry": "language-server/plugins/org.eclipse.equinox.launcher_1.8.0.v20260804-1928.jar"
+ },
+ "hostIntegration": {
+ "id": "jdt",
+ "capabilities": ["debug", "run", "testing"],
+ "resources": {
+ "configuration": "language-server/configuration/${arch}",
+ "lombokAgent": "language-server/lombok/lombok.jar",
+ "extensionBundleDirectories": [
+ "language-server/java-debug",
+ "language-server/java-test/extensions"
+ ],
+ "testRunner": "language-server/java-test/runner/com.microsoft.java.test.runner-jar-with-dependencies.jar"
+ }
+ }
+ }
+ ],
+ "projectGenerators": [
+ {
+ "id": "java",
+ "displayName": "Java",
+ "description": "A Java project with no build tool, Maven, or Gradle.",
+ "section": "project",
+ "order": 10,
+ "icon": {"path": "icons/java.svg", "darkPath": "icons/java_dark.svg", "mode": "original"},
+ "fields": [
+ {
+ "id": "buildSystem",
+ "type": "enum",
+ "title": "Build system",
+ "presentation": "segmented",
+ "default": "maven",
+ "options": [
+ {"value": "none", "title": "Lithe"},
+ {"value": "maven", "title": "Maven"},
+ {"value": "gradle", "title": "Gradle"}
+ ]
+ },
+ {
+ "id": "gradleDsl",
+ "type": "enum",
+ "title": "Gradle DSL",
+ "presentation": "segmented",
+ "visibleWhen": {"field": "buildSystem", "equals": "gradle"},
+ "options": [
+ {"value": "kotlin", "title": "Kotlin"},
+ {"value": "groovy", "title": "Groovy"}
+ ]
+ },
+ {
+ "id": "jdk",
+ "type": "runtime",
+ "title": "JDK",
+ "runtime": "java",
+ "minimumVersion": "11"
+ },
+ {
+ "id": "addSampleCode",
+ "type": "boolean",
+ "title": "Add sample code",
+ "default": true
+ },
+ {
+ "id": "groupId",
+ "type": "text",
+ "title": "GroupId",
+ "description": "Usually the reversed domain name of your organization, such as com.example.",
+ "advanced": true,
+ "default": "org.example",
+ "pattern": "[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*",
+ "maxLength": 200,
+ "visibleWhen": {"field": "buildSystem", "oneOf": ["maven", "gradle"]}
+ },
+ {
+ "id": "artifactId",
+ "type": "text",
+ "title": "ArtifactId",
+ "description": "The name of the artifact the build produces.",
+ "advanced": true,
+ "default": "${project.name}",
+ "pattern": "[A-Za-z0-9_][A-Za-z0-9_.-]*",
+ "maxLength": 200,
+ "visibleWhen": {"field": "buildSystem", "oneOf": ["maven", "gradle"]}
+ }
+ ],
+ "requirements": [
+ {"languageID": "java"},
+ {"buildSystemID": "maven", "when": {"field": "buildSystem", "equals": "maven"}},
+ {"buildSystemID": "gradle", "when": {"field": "buildSystem", "equals": "gradle"}}
+ ],
+ "steps": [
+ {"kind": "files", "source": "templates/java/plain", "when": {"field": "buildSystem", "equals": "none"}},
+ {
+ "kind": "files",
+ "source": "templates/java/plain-sample",
+ "when": [{"field": "buildSystem", "equals": "none"}, {"field": "addSampleCode", "equals": true}]
+ },
+ {
+ "kind": "files",
+ "source": "templates/java/plain-empty",
+ "when": [{"field": "buildSystem", "equals": "none"}, {"field": "addSampleCode", "equals": false}]
+ },
+ {"kind": "files", "source": "templates/java/maven", "when": {"field": "buildSystem", "equals": "maven"}},
+ {"kind": "files", "source": "templates/java/gradle-kotlin", "when": [{"field": "buildSystem", "equals": "gradle"}, {"field": "gradleDsl", "equals": "kotlin"}]},
+ {"kind": "files", "source": "templates/java/gradle-groovy", "when": [{"field": "buildSystem", "equals": "gradle"}, {"field": "gradleDsl", "equals": "groovy"}]},
+ {
+ "kind": "files",
+ "source": "templates/java/standard-sample",
+ "when": [{"field": "buildSystem", "oneOf": ["maven", "gradle"]}, {"field": "addSampleCode", "equals": true}]
+ },
+ {
+ "kind": "files",
+ "source": "templates/java/standard-sample-main",
+ "target": "src/main/java/${field.groupId|package|path}",
+ "when": [{"field": "buildSystem", "oneOf": ["maven", "gradle"]}, {"field": "addSampleCode", "equals": true}]
+ },
+ {
+ "kind": "files",
+ "source": "templates/java/standard-empty",
+ "when": [{"field": "buildSystem", "oneOf": ["maven", "gradle"]}, {"field": "addSampleCode", "equals": false}]
+ }
+ ],
+ "openFiles": [
+ {"path": "src/Main.java", "when": [{"field": "buildSystem", "equals": "none"}, {"field": "addSampleCode", "equals": true}]},
+ {"path": "src/main/java/${field.groupId|package|path}/Main.java", "when": [{"field": "buildSystem", "oneOf": ["maven", "gradle"]}, {"field": "addSampleCode", "equals": true}]}
+ ]
+ },
+ {
+ "id": "maven-archetype",
+ "displayName": "Maven Archetype",
+ "description": "A project generated by Maven from an archetype.",
+ "section": "generator",
+ "order": 10,
+ "icon": {"path": "icons/maven.svg", "darkPath": "icons/maven_dark.svg", "mode": "original"},
+ "fields": [
+ {
+ "id": "jdk",
+ "type": "runtime",
+ "title": "JDK",
+ "description": "Maven runs with this JDK, and the new project uses it.",
+ "runtime": "java",
+ "minimumVersion": "11"
+ },
+ {
+ "id": "archetype",
+ "type": "enum",
+ "title": "Archetype",
+ "presentation": "menu",
+ "default": "maven-archetype-quickstart",
+ "options": [
+ {"value": "maven-archetype-quickstart", "title": "maven-archetype-quickstart"},
+ {"value": "maven-archetype-webapp", "title": "maven-archetype-webapp"},
+ {"value": "maven-archetype-simple", "title": "maven-archetype-simple"},
+ {"value": "maven-archetype-j2ee-simple", "title": "maven-archetype-j2ee-simple"},
+ {"value": "maven-archetype-plugin", "title": "maven-archetype-plugin"},
+ {"value": "maven-archetype-archetype", "title": "maven-archetype-archetype"},
+ {"value": "maven-archetype-site-simple", "title": "maven-archetype-site-simple"},
+ {"value": "custom", "title": "Custom archetype…"}
+ ]
+ },
+ {
+ "id": "archetypeGroupId",
+ "type": "text",
+ "title": "Archetype GroupId",
+ "pattern": "[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_-]*)*",
+ "maxLength": 200,
+ "placeholder": "org.example.archetypes",
+ "visibleWhen": {"field": "archetype", "equals": "custom"}
+ },
+ {
+ "id": "archetypeArtifactId",
+ "type": "text",
+ "title": "Archetype ArtifactId",
+ "pattern": "[A-Za-z0-9_][A-Za-z0-9_.-]*",
+ "maxLength": 200,
+ "placeholder": "my-archetype",
+ "visibleWhen": {"field": "archetype", "equals": "custom"}
+ },
+ {
+ "id": "archetypeVersion",
+ "type": "text",
+ "title": "Archetype version",
+ "description": "Leave empty to use the latest version Maven finds in its catalog.",
+ "optional": true,
+ "pattern": "[A-Za-z0-9_.-]+",
+ "maxLength": 64,
+ "placeholder": "Latest"
+ },
+ {
+ "id": "groupId",
+ "type": "text",
+ "title": "GroupId",
+ "advanced": true,
+ "default": "org.example",
+ "pattern": "[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*",
+ "maxLength": 200
+ },
+ {
+ "id": "artifactId",
+ "type": "text",
+ "title": "ArtifactId",
+ "advanced": true,
+ "default": "${project.name}",
+ "pattern": "[A-Za-z0-9_][A-Za-z0-9_.-]*",
+ "maxLength": 200
+ },
+ {
+ "id": "version",
+ "type": "text",
+ "title": "Version",
+ "advanced": true,
+ "default": "1.0-SNAPSHOT",
+ "pattern": "[A-Za-z0-9_.-]+",
+ "maxLength": 64
+ },
+ {
+ "id": "packageName",
+ "type": "text",
+ "title": "Package",
+ "advanced": true,
+ "default": "${field.groupId}.${field.artifactId|package}",
+ "pattern": "[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*",
+ "maxLength": 200
+ }
+ ],
+ "requirements": [
+ {"languageID": "java"},
+ {"buildSystemID": "maven"}
+ ],
+ "steps": [
+ {
+ "kind": "command",
+ "command": "mvn",
+ "arguments": [
+ "-B",
+ "org.apache.maven.plugins:maven-archetype-plugin:3.3.1:generate",
+ "-DinteractiveMode=false",
+ {"value": "-DarchetypeGroupId=org.apache.maven.archetypes", "when": {"field": "archetype", "notEquals": "custom"}},
+ {"value": "-DarchetypeArtifactId=${field.archetype}", "when": {"field": "archetype", "notEquals": "custom"}},
+ {"value": "-DarchetypeGroupId=${field.archetypeGroupId}", "when": {"field": "archetype", "equals": "custom"}},
+ {"value": "-DarchetypeArtifactId=${field.archetypeArtifactId}", "when": {"field": "archetype", "equals": "custom"}},
+ {"value": "-DarchetypeVersion=${field.archetypeVersion}", "when": {"field": "archetypeVersion", "notEquals": ""}},
+ "-DgroupId=${field.groupId}",
+ "-DartifactId=${field.artifactId}",
+ "-Dversion=${field.version}",
+ "-Dpackage=${field.packageName}"
+ ],
+ "environment": {"JAVA_HOME": "${field.jdk}"},
+ "collect": "${field.artifactId}",
+ "mayUseNetwork": true,
+ "timeoutSeconds": 900
+ }
+ ],
+ "openFiles": ["pom.xml"]
+ },
+ {
+ "id": "spring-boot",
+ "displayName": "Spring Boot",
+ "description": "A Spring Boot project from Spring Initializr.",
+ "section": "generator",
+ "order": 20,
+ "icon": {"path": "icons/spring-boot.svg", "darkPath": "icons/spring-boot_dark.svg", "mode": "original"},
+ "fields": [
+ {
+ "id": "serverUrl",
+ "type": "text",
+ "title": "Server URL",
+ "description": "The Spring Initializr server that generates the project. Change it to use a mirror or your company's own server.",
+ "default": "https://start.spring.io",
+ "pattern": "(https://[A-Za-z0-9.-]+|http://(localhost|127\\.0\\.0\\.1))(:[0-9]+)?(/[A-Za-z0-9._~-]+)*",
+ "maxLength": 256
+ },
+ {
+ "id": "language",
+ "type": "enum",
+ "title": "Language",
+ "presentation": "segmented",
+ "options": [
+ {"value": "java", "title": "Java"},
+ {"value": "kotlin", "title": "Kotlin"},
+ {"value": "groovy", "title": "Groovy"}
+ ]
+ },
+ {
+ "id": "type",
+ "type": "enum",
+ "title": "Type",
+ "presentation": "segmented",
+ "default": "maven-project",
+ "options": [
+ {"value": "gradle-project", "title": "Gradle - Groovy"},
+ {"value": "gradle-project-kotlin", "title": "Gradle - Kotlin"},
+ {"value": "maven-project", "title": "Maven"}
+ ]
+ },
+ {
+ "id": "groupId",
+ "type": "text",
+ "title": "Group",
+ "description": "Usually the reversed domain name of your organization, such as com.example.",
+ "default": "com.example",
+ "pattern": "[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*",
+ "maxLength": 200
+ },
+ {
+ "id": "artifactId",
+ "type": "text",
+ "title": "Artifact",
+ "description": "The name of the artifact the build produces.",
+ "default": "${project.name}",
+ "pattern": "[A-Za-z0-9_][A-Za-z0-9_.-]*",
+ "maxLength": 200
+ },
+ {
+ "id": "packageName",
+ "type": "text",
+ "title": "Package name",
+ "default": "${field.groupId}.${field.artifactId|package}",
+ "pattern": "[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*",
+ "maxLength": 200
+ },
+ {
+ "id": "jdk",
+ "type": "runtime",
+ "title": "JDK",
+ "description": "The JDK the new project uses. It must be at least the Java version below.",
+ "runtime": "java",
+ "minimumVersion": "17"
+ },
+ {
+ "id": "javaVersion",
+ "type": "enum",
+ "title": "Java",
+ "maximumVersion": "${field.jdk.major}",
+ "presentation": "menu",
+ "default": "17",
+ "options": [
+ {"value": "17", "title": "17"},
+ {"value": "21", "title": "21"},
+ {"value": "25", "title": "25"}
+ ]
+ },
+ {
+ "id": "packaging",
+ "type": "enum",
+ "title": "Packaging",
+ "presentation": "segmented",
+ "options": [
+ {"value": "jar", "title": "Jar"},
+ {"value": "war", "title": "War"}
+ ]
+ },
+ {
+ "id": "configurationFileFormat",
+ "type": "enum",
+ "title": "Configuration",
+ "presentation": "segmented",
+ "options": [
+ {"value": "properties", "title": "Properties"},
+ {"value": "yaml", "title": "YAML"}
+ ]
+ },
+ {
+ "id": "bootVersion",
+ "type": "text",
+ "title": "Spring Boot",
+ "description": "Leave empty to use the version the server recommends.",
+ "optional": true,
+ "pattern": "[0-9]+\\.[0-9]+\\.[0-9]+(-[A-Za-z0-9.]+)?",
+ "maxLength": 64,
+ "placeholder": "Server default"
+ },
+ {
+ "id": "dependencies",
+ "type": "multiSelect",
+ "title": "Dependencies",
+ "options": [
+ {"value": "devtools", "title": "Spring Boot DevTools", "group": "Developer Tools"},
+ {"value": "lombok", "title": "Lombok", "group": "Developer Tools"},
+ {"value": "configuration-processor", "title": "Configuration Processor", "group": "Developer Tools"},
+ {"value": "web", "title": "Spring Web", "group": "Web"},
+ {"value": "webflux", "title": "Spring Reactive Web", "group": "Web"},
+ {"value": "thymeleaf", "title": "Thymeleaf", "group": "Template Engines"},
+ {"value": "security", "title": "Spring Security", "group": "Security"},
+ {"value": "oauth2-client", "title": "OAuth2 Client", "group": "Security"},
+ {"value": "oauth2-resource-server", "title": "OAuth2 Resource Server", "group": "Security"},
+ {"value": "data-jpa", "title": "Spring Data JPA", "group": "SQL"},
+ {"value": "jdbc", "title": "JDBC API", "group": "SQL"},
+ {"value": "mybatis", "title": "MyBatis Framework", "group": "SQL"},
+ {"value": "flyway", "title": "Flyway Migration", "group": "SQL"},
+ {"value": "liquibase", "title": "Liquibase Migration", "group": "SQL"},
+ {"value": "mysql", "title": "MySQL Driver", "group": "SQL"},
+ {"value": "postgresql", "title": "PostgreSQL Driver", "group": "SQL"},
+ {"value": "h2", "title": "H2 Database", "group": "SQL"},
+ {"value": "data-redis", "title": "Spring Data Redis", "group": "NoSQL"},
+ {"value": "data-mongodb", "title": "Spring Data MongoDB", "group": "NoSQL"},
+ {"value": "data-elasticsearch", "title": "Spring Data Elasticsearch", "group": "NoSQL"},
+ {"value": "amqp", "title": "Spring for RabbitMQ", "group": "Messaging"},
+ {"value": "kafka", "title": "Spring for Apache Kafka", "group": "Messaging"},
+ {"value": "validation", "title": "Validation", "group": "I/O"},
+ {"value": "mail", "title": "Java Mail Sender", "group": "I/O"},
+ {"value": "cache", "title": "Spring Cache Abstraction", "group": "I/O"},
+ {"value": "quartz", "title": "Quartz Scheduler", "group": "I/O"},
+ {"value": "actuator", "title": "Spring Boot Actuator", "group": "Ops"},
+ {"value": "testcontainers", "title": "Testcontainers", "group": "Testing"}
+ ]
+ }
+ ],
+ "requirements": [
+ {"languageID": "java", "when": {"field": "language", "equals": "java"}},
+ {"languageID": "kotlin", "when": {"field": "language", "equals": "kotlin"}},
+ {"languageID": "groovy", "when": {"field": "language", "equals": "groovy"}},
+ {"buildSystemID": "maven", "when": {"field": "type", "equals": "maven-project"}},
+ {"buildSystemID": "gradle", "when": {"field": "type", "oneOf": ["gradle-project", "gradle-project-kotlin"]}}
+ ],
+ "steps": [
+ {
+ "kind": "archive",
+ "url": "${field.serverUrl}/starter.zip",
+ "query": [
+ {"name": "type", "value": "${field.type}"},
+ {"name": "language", "value": "${field.language}"},
+ {"name": "bootVersion", "value": "${field.bootVersion}", "omitEmpty": true},
+ {"name": "groupId", "value": "${field.groupId}"},
+ {"name": "artifactId", "value": "${field.artifactId}"},
+ {"name": "name", "value": "${project.name}"},
+ {"name": "packageName", "value": "${field.packageName}"},
+ {"name": "packaging", "value": "${field.packaging}"},
+ {"name": "javaVersion", "value": "${field.javaVersion}"},
+ {"name": "configurationFileFormat", "value": "${field.configurationFileFormat}"},
+ {"name": "dependencies", "value": "${field.dependencies}", "omitEmpty": true}
+ ]
+ }
+ ],
+ "openFiles": [
+ {"path": "pom.xml", "when": {"field": "type", "equals": "maven-project"}},
+ {"path": "build.gradle", "when": {"field": "type", "equals": "gradle-project"}},
+ {"path": "build.gradle.kts", "when": {"field": "type", "equals": "gradle-project-kotlin"}}
+ ]
+ }
+ ]
+}
diff --git a/dev.lithe.plugin.java-support/templates/java/gradle-groovy/build.gradle b/dev.lithe.plugin.java-support/templates/java/gradle-groovy/build.gradle
new file mode 100644
index 0000000..8c4bd09
--- /dev/null
+++ b/dev.lithe.plugin.java-support/templates/java/gradle-groovy/build.gradle
@@ -0,0 +1,26 @@
+plugins {
+ id 'java'
+}
+
+group = '{{ field.groupId }}'
+version = '1.0-SNAPSHOT'
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of({{ field.jdk.major }})
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ testImplementation platform('org.junit:junit-bom:5.13.4')
+ testImplementation 'org.junit.jupiter:junit-jupiter'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+test {
+ useJUnitPlatform()
+}
diff --git a/dev.lithe.plugin.java-support/templates/java/gradle-groovy/dot-gitignore b/dev.lithe.plugin.java-support/templates/java/gradle-groovy/dot-gitignore
new file mode 100644
index 0000000..2246277
--- /dev/null
+++ b/dev.lithe.plugin.java-support/templates/java/gradle-groovy/dot-gitignore
@@ -0,0 +1,23 @@
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### Lithe ###
+.lithe/workspace/
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### macOS ###
+.DS_Store
diff --git a/dev.lithe.plugin.java-support/templates/java/gradle-groovy/settings.gradle b/dev.lithe.plugin.java-support/templates/java/gradle-groovy/settings.gradle
new file mode 100644
index 0000000..19318ab
--- /dev/null
+++ b/dev.lithe.plugin.java-support/templates/java/gradle-groovy/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = '{{ field.artifactId }}'
diff --git a/dev.lithe.plugin.java-support/templates/java/gradle-kotlin/build.gradle.kts b/dev.lithe.plugin.java-support/templates/java/gradle-kotlin/build.gradle.kts
new file mode 100644
index 0000000..578317c
--- /dev/null
+++ b/dev.lithe.plugin.java-support/templates/java/gradle-kotlin/build.gradle.kts
@@ -0,0 +1,26 @@
+plugins {
+ java
+}
+
+group = "{{ field.groupId }}"
+version = "1.0-SNAPSHOT"
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of({{ field.jdk.major }})
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ testImplementation(platform("org.junit:junit-bom:5.13.4"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
diff --git a/dev.lithe.plugin.java-support/templates/java/gradle-kotlin/dot-gitignore b/dev.lithe.plugin.java-support/templates/java/gradle-kotlin/dot-gitignore
new file mode 100644
index 0000000..2246277
--- /dev/null
+++ b/dev.lithe.plugin.java-support/templates/java/gradle-kotlin/dot-gitignore
@@ -0,0 +1,23 @@
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### Lithe ###
+.lithe/workspace/
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### macOS ###
+.DS_Store
diff --git a/dev.lithe.plugin.java-support/templates/java/gradle-kotlin/settings.gradle.kts b/dev.lithe.plugin.java-support/templates/java/gradle-kotlin/settings.gradle.kts
new file mode 100644
index 0000000..e95732b
--- /dev/null
+++ b/dev.lithe.plugin.java-support/templates/java/gradle-kotlin/settings.gradle.kts
@@ -0,0 +1 @@
+rootProject.name = "{{ field.artifactId }}"
diff --git a/dev.lithe.plugin.java-support/templates/java/maven/dot-gitignore b/dev.lithe.plugin.java-support/templates/java/maven/dot-gitignore
new file mode 100644
index 0000000..f5ed2ce
--- /dev/null
+++ b/dev.lithe.plugin.java-support/templates/java/maven/dot-gitignore
@@ -0,0 +1,19 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### Lithe ###
+.lithe/workspace/
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### macOS ###
+.DS_Store
diff --git a/dev.lithe.plugin.java-support/templates/java/maven/pom.xml b/dev.lithe.plugin.java-support/templates/java/maven/pom.xml
new file mode 100644
index 0000000..3fa0e0e
--- /dev/null
+++ b/dev.lithe.plugin.java-support/templates/java/maven/pom.xml
@@ -0,0 +1,43 @@
+
+
+ 4.0.0
+
+ {{ field.groupId }}
+ {{ field.artifactId }}
+ 1.0-SNAPSHOT
+
+
+ {{ field.jdk.major }}
+ UTF-8
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.13.4
+ test
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.14.0
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.4
+
+
+
+
+
diff --git a/dev.lithe.plugin.java-support/templates/java/plain-empty/src/dot-gitkeep b/dev.lithe.plugin.java-support/templates/java/plain-empty/src/dot-gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/dev.lithe.plugin.java-support/templates/java/plain-sample/src/Main.java b/dev.lithe.plugin.java-support/templates/java/plain-sample/src/Main.java
new file mode 100644
index 0000000..5a5f303
--- /dev/null
+++ b/dev.lithe.plugin.java-support/templates/java/plain-sample/src/Main.java
@@ -0,0 +1,5 @@
+public class Main {
+ public static void main(String[] args) {
+ System.out.println("Hello, World!");
+ }
+}
diff --git a/dev.lithe.plugin.java-support/templates/java/plain/dot-gitignore b/dev.lithe.plugin.java-support/templates/java/plain/dot-gitignore
new file mode 100644
index 0000000..813b139
--- /dev/null
+++ b/dev.lithe.plugin.java-support/templates/java/plain/dot-gitignore
@@ -0,0 +1,16 @@
+### Lithe ###
+.lithe/workspace/
+out/
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+
+### macOS ###
+.DS_Store
diff --git a/dev.lithe.plugin.java-support/templates/java/standard-empty/src/main/java/dot-gitkeep b/dev.lithe.plugin.java-support/templates/java/standard-empty/src/main/java/dot-gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/dev.lithe.plugin.java-support/templates/java/standard-empty/src/main/resources/dot-gitkeep b/dev.lithe.plugin.java-support/templates/java/standard-empty/src/main/resources/dot-gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/dev.lithe.plugin.java-support/templates/java/standard-empty/src/test/java/dot-gitkeep b/dev.lithe.plugin.java-support/templates/java/standard-empty/src/test/java/dot-gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/dev.lithe.plugin.java-support/templates/java/standard-sample-main/Main.java b/dev.lithe.plugin.java-support/templates/java/standard-sample-main/Main.java
new file mode 100644
index 0000000..0550c62
--- /dev/null
+++ b/dev.lithe.plugin.java-support/templates/java/standard-sample-main/Main.java
@@ -0,0 +1,7 @@
+package {{ field.groupId | package }};
+
+public class Main {
+ public static void main(String[] args) {
+ System.out.println("Hello, World!");
+ }
+}
diff --git a/dev.lithe.plugin.java-support/templates/java/standard-sample/src/main/resources/dot-gitkeep b/dev.lithe.plugin.java-support/templates/java/standard-sample/src/main/resources/dot-gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/dev.lithe.plugin.java-support/templates/java/standard-sample/src/test/java/dot-gitkeep b/dev.lithe.plugin.java-support/templates/java/standard-sample/src/test/java/dot-gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/host-tools.lock.json b/host-tools.lock.json
new file mode 100644
index 0000000..7ea6db2
--- /dev/null
+++ b/host-tools.lock.json
@@ -0,0 +1,6 @@
+{
+ "comment": "Lithe revision whose package tools sign and verify this plugin. LithePluginPackageSigner holds the trusted publisher keys and LitheOfficialPluginVerifier checks a package against the host's plugin contract, so both must come from Lithe itself. scripts/host-tools.sh builds them from this exact commit; update it when the plugin needs a newer contract.",
+ "repository": "https://github.com/1lck/Lithe-IDEA",
+ "revision": "1daaeb525831999ad518b4c6fd3b11b5b1b64b5a",
+ "products": ["LithePluginPackageSigner", "LitheOfficialPluginVerifier"]
+}
diff --git a/scripts/archive-package.sh b/scripts/archive-package.sh
new file mode 100755
index 0000000..c6425d2
--- /dev/null
+++ b/scripts/archive-package.sh
@@ -0,0 +1,94 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+# Signs and archives a package directory built by scripts/build-package.sh.
+#
+# --sign reads the publisher key from LITHE_PLUGIN_PACKAGE_PRIVATE_KEY and
+# signs with the Lithe signer from scripts/host-tools.sh; only the release
+# workflow's publishing step has that key. --development-unsigned archives
+# the package without a signature; only development builds of Lithe install
+# such a package, so its file name says so.
+#
+# The archive is dist/Lithe-Java-Support--darwin-.zip
+# (with -unsigned before .zip for development archives), created with
+# `ditto --keepParent` so it holds the package directory itself. The archive
+# is extracted again and re-verified, and dist/.sha256 records its
+# SHA-256. Prints " dist/".
+
+ROOT_DIR="${0:A:h:h}"
+PLUGIN_ID="dev.lithe.plugin.java-support"
+PACKAGE=""
+ARCH=""
+HOST_TOOLS=""
+MODE=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --package) PACKAGE="$2"; shift 2 ;;
+ --arch) ARCH="$2"; shift 2 ;;
+ --host-tools) HOST_TOOLS="$2"; shift 2 ;;
+ --sign) MODE="sign"; shift ;;
+ --development-unsigned) MODE="unsigned"; shift ;;
+ *) print -u2 -- "Usage: $0 --package directory --arch arm64|x86_64 (--sign --host-tools bin-directory | --development-unsigned)"; exit 2 ;;
+ esac
+done
+[[ -d "$PACKAGE" && "${PACKAGE:A:t}" == "$PLUGIN_ID" ]] || {
+ print -u2 -- "--package must name a $PLUGIN_ID package directory"
+ exit 2
+}
+case "$ARCH" in
+ arm64) platform="darwin-arm64" ;;
+ x86_64) platform="darwin-x64" ;;
+ *) print -u2 -- "Unsupported architecture: $ARCH"; exit 2 ;;
+esac
+
+version=$(/usr/bin/plutil -extract version raw -o - "$PACKAGE/plugin.json")
+case "$MODE" in
+ sign)
+ [[ -n "$HOST_TOOLS" ]] || { print -u2 -- "--sign needs --host-tools"; exit 2; }
+ [[ -n "${LITHE_PLUGIN_PACKAGE_PRIVATE_KEY:-}" ]] || {
+ print -u2 -- "Configure LITHE_PLUGIN_PACKAGE_PRIVATE_KEY to sign the Java Support package"
+ exit 1
+ }
+ rm -f "$PACKAGE/lithe-plugin-signature.json"
+ print -rn -- "$LITHE_PLUGIN_PACKAGE_PRIVATE_KEY" | "$HOST_TOOLS/LithePluginPackageSigner" "$PACKAGE"
+ asset="Lithe-Java-Support-$version-$platform.zip"
+ verify_args=(--require-signature)
+ ;;
+ unsigned)
+ [[ ! -f "$PACKAGE/lithe-plugin-signature.json" ]] || {
+ print -u2 -- "The package is already signed; rebuild it before a development archive"
+ exit 1
+ }
+ asset="Lithe-Java-Support-$version-$platform-unsigned.zip"
+ verify_args=()
+ ;;
+ *) print -u2 -- "Choose --sign or --development-unsigned"; exit 2 ;;
+esac
+[[ -z "$HOST_TOOLS" ]] || verify_args+=(--host-tools "$HOST_TOOLS")
+
+dist="$ROOT_DIR/dist"
+mkdir -p "$dist"
+rm -f "$dist/$asset" "$dist/$asset.sha256"
+/usr/bin/ditto -c -k --keepParent "$PACKAGE" "$dist/$asset"
+
+# Verify what users download, not the directory it was made from.
+# The check directory stays inside the repository: the official verifier
+# rejects packages under a symbolically linked path such as $TMPDIR
+# (/var is /private/var on macOS).
+extracted="$ROOT_DIR/.artifacts/archive-check/$platform"
+rm -rf "$extracted"
+mkdir -p "$extracted"
+trap 'rm -rf "$extracted"' EXIT
+/usr/bin/ditto -x -k "$dist/$asset" "$extracted"
+entries=("$extracted"/*(DN:t))
+[[ "${entries[*]}" == "$PLUGIN_ID" ]] || {
+ print -u2 -- "$asset must contain only $PLUGIN_ID/, found: ${entries[*]}"
+ exit 1
+}
+"$ROOT_DIR/scripts/verify-package.sh" --package "$extracted/$PLUGIN_ID" --arch "$ARCH" "${verify_args[@]}"
+
+checksum=$(/usr/bin/shasum -a 256 "$dist/$asset" | cut -d' ' -f1)
+print -r -- "$checksum" > "$dist/$asset.sha256"
+print -r -- "$checksum dist/$asset"
diff --git a/scripts/assemble-package.sh b/scripts/assemble-package.sh
new file mode 100755
index 0000000..689377b
--- /dev/null
+++ b/scripts/assemble-package.sh
@@ -0,0 +1,91 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+# Assembles the resources of the Java Support plugin package: the pinned
+# JDT LS distribution with its Lombok, Debug, and Test extensions, and the
+# JDK it runs on. Everything is fetched and checksum-verified by the shared
+# prepare scripts; this script only arranges the result in the layout
+# plugin.json declares, plus the icons and template directories of the New
+# Project entries (Java, Maven Archetype, Spring Boot). The assembled package
+# is read-only at runtime; projects are generated in the user's chosen folder.
+# Moved from Plugins/mac/Official/JavaSupport/assemble-package.sh in the Lithe
+# repository; see MIGRATION.md.
+
+ROOT_DIR="${0:A:h:h}"
+PLUGIN_DIR="$ROOT_DIR/dev.lithe.plugin.java-support"
+OUTPUT_DIR=""
+ARCH=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --output) OUTPUT_DIR="$2"; shift 2 ;;
+ --arch) ARCH="$2"; shift 2 ;;
+ *) print -u2 -- "Usage: $0 --output package-directory --arch arm64|x86_64|universal"; exit 2 ;;
+ esac
+done
+[[ -n "$OUTPUT_DIR" ]] || { print -u2 -- "--output is required"; exit 2; }
+case "$ARCH" in
+ arm64|x86_64) runtime_architectures=("$ARCH") ;;
+ universal) runtime_architectures=(arm64 x86_64) ;;
+ *) print -u2 -- "Unsupported architecture: $ARCH"; exit 2 ;;
+esac
+
+jdtls_root=$(LITHE_ARCH="$ARCH" "$ROOT_DIR/scripts/prepare-jdtls.sh")
+language_server="$OUTPUT_DIR/language-server"
+rm -rf "$language_server" "$OUTPUT_DIR/runtime" "$OUTPUT_DIR/icons" "$OUTPUT_DIR/templates"
+cp -R "$PLUGIN_DIR/icons" "$OUTPUT_DIR/icons"
+cp -R "$PLUGIN_DIR/templates" "$OUTPUT_DIR/templates"
+mkdir -p "$language_server/configuration"
+
+# Only what a macOS launch reads is packaged. The wrapper scripts and the
+# configuration areas of other platforms are left out.
+for entry in plugins features lombok java-debug java-test manifest.json LICENSE-EPL-2.0.txt; do
+ [[ -e "$jdtls_root/$entry" ]] || {
+ print -u2 -- "JDT LS distribution is missing $entry: $jdtls_root"
+ exit 1
+ }
+ cp -R "$jdtls_root/$entry" "$language_server/$entry"
+done
+
+# Equinox reads one configuration per architecture. The packaged copy stays
+# read-only; the host gives each launch a writable area in its cache.
+for runtime_arch in "${runtime_architectures[@]}"; do
+ case "$runtime_arch" in
+ arm64) configuration_name="config_mac_arm" ;;
+ x86_64) configuration_name="config_mac" ;;
+ esac
+ [[ -f "$jdtls_root/$configuration_name/config.ini" ]] || {
+ print -u2 -- "JDT LS configuration is missing: $jdtls_root/$configuration_name/config.ini"
+ exit 1
+ }
+ mkdir -p "$language_server/configuration/$runtime_arch"
+ cp "$jdtls_root/$configuration_name/config.ini" "$language_server/configuration/$runtime_arch/config.ini"
+
+ if [[ "$ARCH" == "universal" ]]; then
+ override_variable="LITHE_JDK_${runtime_arch:u}_ROOT"
+ jdk_root=$(LITHE_JDK_ROOT="${(P)override_variable:-}" LITHE_JDK_TARGET_ARCH="$runtime_arch" \
+ "$ROOT_DIR/scripts/prepare-jdk.sh")
+ else
+ jdk_root=$(LITHE_JDK_TARGET_ARCH="$runtime_arch" "$ROOT_DIR/scripts/prepare-jdk.sh")
+ fi
+ mkdir -p "$OUTPUT_DIR/runtime"
+ cp -R "$jdk_root" "$OUTPUT_DIR/runtime/$runtime_arch"
+done
+
+# plugin.json names these files exactly, so a JDT LS upgrade that renames one
+# fails here instead of at the user's first Java file.
+launcher=$(/usr/bin/plutil -extract languageServers.0.launch.entry raw "$PLUGIN_DIR/plugin.json")
+test_runner=$(/usr/bin/plutil -extract languageServers.0.hostIntegration.resources.testRunner raw "$PLUGIN_DIR/plugin.json")
+for declared in "$launcher" "$test_runner" language-server/lombok/lombok.jar; do
+ [[ -f "$OUTPUT_DIR/$declared" ]] || {
+ print -u2 -- "Java Support declares $declared, which the assembled package does not contain. Update dev.lithe.plugin.java-support/plugin.json for the pinned JDT LS version."
+ exit 1
+ }
+done
+
+# The contract forbids symbolic links inside a package.
+if [[ -n "$(find "$OUTPUT_DIR" -type l -print -quit)" ]]; then
+ print -u2 -- "The Java Support package contains symbolic links: $(find "$OUTPUT_DIR" -type l -print -quit)"
+ exit 1
+fi
diff --git a/scripts/build-package.sh b/scripts/build-package.sh
new file mode 100755
index 0000000..0ce5756
--- /dev/null
+++ b/scripts/build-package.sh
@@ -0,0 +1,42 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+# Builds the unsigned Java Support package directory for one architecture:
+# copies plugin.json, assembles the pinned third-party resources next to it
+# (scripts/assemble-package.sh) and verifies the result
+# (scripts/verify-package.sh). Prints the package directory,
+# .artifacts/package//dev.lithe.plugin.java-support.
+#
+# Signing and archiving are a separate step (scripts/archive-package.sh) so
+# that the release workflow only exposes the signing key to that step.
+
+ROOT_DIR="${0:A:h:h}"
+PLUGIN_ID="dev.lithe.plugin.java-support"
+ARCH=""
+HOST_TOOLS=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --arch) ARCH="$2"; shift 2 ;;
+ --host-tools) HOST_TOOLS="$2"; shift 2 ;;
+ *) print -u2 -- "Usage: $0 --arch arm64|x86_64 [--host-tools bin-directory]"; exit 2 ;;
+ esac
+done
+# Each release asset targets one macOS architecture; the store lists them as
+# darwin-arm64 and darwin-x64.
+case "$ARCH" in
+ arm64|x86_64) ;;
+ *) print -u2 -- "Unsupported architecture: $ARCH"; exit 2 ;;
+esac
+
+package_dir="$ROOT_DIR/.artifacts/package/$ARCH/$PLUGIN_ID"
+rm -rf "$package_dir"
+mkdir -p "$package_dir"
+cp "$ROOT_DIR/$PLUGIN_ID/plugin.json" "$package_dir/plugin.json"
+"$ROOT_DIR/scripts/assemble-package.sh" --output "$package_dir" --arch "$ARCH" >&2
+
+verify_args=(--package "$package_dir" --arch "$ARCH")
+[[ -z "$HOST_TOOLS" ]] || verify_args+=(--host-tools "$HOST_TOOLS")
+"$ROOT_DIR/scripts/verify-package.sh" "${verify_args[@]}"
+print -r -- "$package_dir"
diff --git a/scripts/host-tools.sh b/scripts/host-tools.sh
new file mode 100755
index 0000000..a44a8aa
--- /dev/null
+++ b/scripts/host-tools.sh
@@ -0,0 +1,83 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+# Builds the Lithe package tools this plugin is signed and verified with:
+# LithePluginPackageSigner (it holds the trusted publisher keys) and
+# LitheOfficialPluginVerifier (it applies the host's plugin contract). Both
+# come from the Lithe revision pinned in host-tools.lock.json, fetched into
+# .artifacts/host-tools//source; nothing outside this repository is
+# written. Prints the directory that holds the two executables.
+#
+# LITHE_HOST_SOURCE may name a local Lithe clone to fetch the revision from
+# instead of the network; the clone is only read.
+#
+# The pinned revision names its Swift compiler in .swift-version. A different
+# compiler only prints a warning, unless --require-pinned-swift is given (CI
+# and releases use it).
+
+ROOT_DIR="${0:A:h:h}"
+LOCK="$ROOT_DIR/host-tools.lock.json"
+REQUIRE_PINNED_SWIFT=false
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --require-pinned-swift) REQUIRE_PINNED_SWIFT=true; shift ;;
+ *) print -u2 -- "Usage: $0 [--require-pinned-swift]"; exit 2 ;;
+ esac
+done
+
+repository=$(/usr/bin/plutil -extract repository raw -o - "$LOCK")
+revision=$(/usr/bin/plutil -extract revision raw -o - "$LOCK")
+[[ "$revision" =~ '^[0-9a-f]{40}$' ]] || {
+ print -u2 -- "host-tools.lock.json must pin a full commit: $revision"
+ exit 1
+}
+work="$ROOT_DIR/.artifacts/host-tools/$revision"
+source_dir="$work/source"
+
+if [[ "$(git -C "$source_dir" rev-parse HEAD 2>/dev/null || true)" != "$revision" ]]; then
+ rm -rf "$source_dir"
+ mkdir -p "$source_dir"
+ git -C "$source_dir" init --quiet
+ git -C "$source_dir" fetch --quiet --depth 1 "${LITHE_HOST_SOURCE:-$repository}" "$revision"
+ git -C "$source_dir" checkout --quiet --detach FETCH_HEAD
+fi
+[[ "$(git -C "$source_dir" rev-parse HEAD)" == "$revision" ]] || {
+ print -u2 -- "Fetched Lithe source is not $revision"
+ exit 1
+}
+
+expected_swift=$(<"$source_dir/.swift-version")
+actual_swift=$(swift --version 2>/dev/null | sed -nE 's/.*Swift version ([0-9]+\.[0-9]+(\.[0-9]+)?).*/\1/p' | head -n 1)
+if [[ "$actual_swift" != "$expected_swift" ]]; then
+ message="Lithe $revision builds with Swift $expected_swift, found ${actual_swift:-no Swift}"
+ [[ "$REQUIRE_PINNED_SWIFT" == false ]] || { print -u2 -- "$message"; exit 1; }
+ print -u2 -- "warning: $message"
+fi
+
+case "$(uname -m)" in
+ arm64) triple="arm64-apple-macosx" ;;
+ x86_64) triple="x86_64-apple-macosx" ;;
+ *) print -u2 -- "Unsupported host architecture: $(uname -m)"; exit 1 ;;
+esac
+# Same flags Lithe uses for these products; building them alone keeps the
+# application and its Rust Core out of the build.
+swift_args=(
+ --package-path "$source_dir"
+ --scratch-path "$work/build"
+ --triple "$triple"
+ -Xswiftc -Xfrontend
+ -Xswiftc -disable-round-trip-debug-types
+)
+for product in LithePluginPackageSigner LitheOfficialPluginVerifier; do
+ swift build "${swift_args[@]}" --product "$product" >&2
+done
+bin_dir=$(swift build "${swift_args[@]}" --show-bin-path)
+for product in LithePluginPackageSigner LitheOfficialPluginVerifier; do
+ [[ -x "$bin_dir/$product" ]] || {
+ print -u2 -- "Lithe host tool was not built: $bin_dir/$product"
+ exit 1
+ }
+done
+print -r -- "$bin_dir"
diff --git a/scripts/prepare-jdk.sh b/scripts/prepare-jdk.sh
new file mode 100755
index 0000000..86c0807
--- /dev/null
+++ b/scripts/prepare-jdk.sh
@@ -0,0 +1,135 @@
+#!/bin/zsh
+
+# Stages the bundled JDTLS runtime JDK into an architecture-specific artifact directory.
+# This JDK only runs the Java language server. Project SDKs stay user-owned.
+
+set -euo pipefail
+
+ROOT_DIR="${0:A:h:h}"
+MANIFEST="$ROOT_DIR/third_party/jdk/manifest.json"
+CACHE_DIR="$ROOT_DIR/.artifacts/jdk-downloads"
+TARGET_ARCH="${LITHE_JDK_TARGET_ARCH:-$(uname -m)}"
+OUTPUT_DIR="${LITHE_JDK_ROOT:-$ROOT_DIR/.artifacts/jdk-$TARGET_ARCH}"
+
+manifest_value() {
+ /usr/bin/plutil -extract "$1" raw -o - "$MANIFEST"
+}
+
+case "$TARGET_ARCH" in
+ arm64) platform="macos-aarch64" ;;
+ x86_64) platform="macos-x86_64" ;;
+ *) print -u2 -- "Unsupported macOS JDK target architecture: $TARGET_ARCH"; exit 1 ;;
+esac
+
+jdk_version="$(manifest_value version)"
+archive_url="$(manifest_value "platforms.$platform.url")"
+archive_sha256="$(manifest_value "platforms.$platform.sha256")"
+safe_jdk_version="${jdk_version//[^A-Za-z0-9._-]/_}"
+archive_path="$CACHE_DIR/jdk-$safe_jdk_version-$platform-$archive_sha256.tar.gz"
+identity="$jdk_version|$platform|$archive_sha256"
+identity_path="$OUTPUT_DIR/.lithe-jdk"
+
+file_sha256() {
+ shasum -a 256 "$1" | awk '{print tolower($1)}'
+}
+
+download_verified_file() {
+ local url="$1"
+ local expected_sha256="$2"
+ local destination="$3"
+ local description="$4"
+ local actual_sha256
+ local temporary_path="$destination.download.$$"
+
+ if [[ -f "$destination" ]]; then
+ actual_sha256="$(file_sha256 "$destination")"
+ if [[ "$actual_sha256" == "$expected_sha256" ]]; then
+ return 0
+ fi
+ print -u2 -- "$description cache checksum mismatch; removing it before retrying the download"
+ rm -f -- "$destination"
+ fi
+
+ rm -f -- "$temporary_path"
+ print -u2 -- "Downloading $description: $url"
+ if ! curl \
+ --fail \
+ --location \
+ --retry 3 \
+ --retry-all-errors \
+ --connect-timeout 15 \
+ --max-time 180 \
+ --output "$temporary_path" \
+ "$url"; then
+ rm -f -- "$temporary_path"
+ return 1
+ fi
+ actual_sha256="$(file_sha256 "$temporary_path")"
+ if [[ "$actual_sha256" != "$expected_sha256" ]]; then
+ print -u2 -- "$description checksum mismatch: expected $expected_sha256, got $actual_sha256"
+ rm -f -- "$temporary_path"
+ return 1
+ fi
+ mv -f -- "$temporary_path" "$destination"
+}
+
+validate_output() {
+ [[ -x "$OUTPUT_DIR/bin/java" ]] || { print -u2 -- "Bundled JDK java executable is missing: $OUTPUT_DIR/bin/java"; exit 1; }
+ [[ -d "$OUTPUT_DIR/lib" ]] || { print -u2 -- "Bundled JDK lib directory is missing: $OUTPUT_DIR"; exit 1; }
+ /usr/bin/lipo "$OUTPUT_DIR/bin/java" -verify_arch "$TARGET_ARCH" >/dev/null 2>&1 || {
+ print -u2 -- "Bundled JDK java executable does not contain $TARGET_ARCH: $OUTPUT_DIR/bin/java"
+ exit 1
+ }
+ [[ -f "$OUTPUT_DIR/release" ]] || { print -u2 -- "Bundled JDK release metadata is missing: $OUTPUT_DIR/release"; exit 1; }
+ grep -Eq '^JAVA_VERSION="21\.' "$OUTPUT_DIR/release" || {
+ print -u2 -- "Bundled JDK release metadata does not report Java 21: $OUTPUT_DIR/release"
+ exit 1
+ }
+}
+
+output_is_valid() {
+ [[ -x "$OUTPUT_DIR/bin/java" ]] &&
+ [[ -d "$OUTPUT_DIR/lib" ]] &&
+ [[ -f "$OUTPUT_DIR/release" ]] &&
+ /usr/bin/lipo "$OUTPUT_DIR/bin/java" -verify_arch "$TARGET_ARCH" >/dev/null 2>&1 &&
+ grep -Eq '^JAVA_VERSION="21\.' "$OUTPUT_DIR/release"
+}
+
+if [[ -n "${LITHE_JDK_ROOT:-}" ]]; then
+ validate_output
+ print -r -- "$OUTPUT_DIR"
+ exit 0
+fi
+
+if [[ -f "$identity_path" && "$(<"$identity_path")" == "$identity" ]]; then
+ if output_is_valid; then
+ print -r -- "$OUTPUT_DIR"
+ exit 0
+ fi
+ print -u2 -- "Prepared bundled JDK is invalid and will be rebuilt: $OUTPUT_DIR"
+fi
+
+mkdir -p "$CACHE_DIR"
+download_verified_file "$archive_url" "$archive_sha256" "$archive_path" "Temurin JDK archive"
+
+rm -rf "$OUTPUT_DIR"
+staging="$CACHE_DIR/staging.$$"
+rm -rf "$staging"
+mkdir -p "$staging"
+tar -xzf "$archive_path" -C "$staging"
+
+# macOS Temurin archives nest the runtime under /Contents/Home.
+# Flatten it so both platforms expose the same bin/java layout.
+home_directory="$(find "$staging" -maxdepth 3 -type d -name Home -path '*/Contents/Home' -print | head -n 1)"
+if [[ -z "$home_directory" ]]; then
+ print -u2 -- "Could not locate Contents/Home in the Temurin archive"
+ rm -rf "$staging"
+ exit 1
+fi
+mkdir -p "$(dirname "$OUTPUT_DIR")"
+mv "$home_directory" "$OUTPUT_DIR"
+print -r -- "$identity" > "$identity_path"
+rm -rf "$staging"
+
+validate_output
+print -r -- "$OUTPUT_DIR"
diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh
new file mode 100755
index 0000000..b6020b4
--- /dev/null
+++ b/scripts/prepare-jdtls.sh
@@ -0,0 +1,360 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+ROOT_DIR="${0:A:h:h}"
+MANIFEST="$ROOT_DIR/third_party/jdtls/manifest.json"
+OUTPUT_DIR="${LITHE_JDTLS_ROOT:-$ROOT_DIR/.artifacts/jdtls}"
+CACHE_DIR="$ROOT_DIR/.artifacts/jdtls-downloads"
+
+manifest_value() {
+ /usr/bin/plutil -extract "$1" raw -o - "$MANIFEST"
+}
+
+archive_url="$(manifest_value archiveURL)"
+archive_sha256="$(manifest_value archiveSHA256)"
+license_url="$(manifest_value licenseURL)"
+license_sha256="$(manifest_value licenseSHA256)"
+lombok_url="$(manifest_value lombokURL)"
+lombok_sha256="$(manifest_value lombokSHA256)"
+lombok_license_url="$(manifest_value lombokLicenseURL)"
+lombok_license_sha256="$(manifest_value lombokLicenseSHA256)"
+java_debug_archive_url="$(manifest_value javaDebugArchiveURL)"
+java_debug_archive_sha256="$(manifest_value javaDebugArchiveSHA256)"
+java_debug_plugin_sha256="$(manifest_value javaDebugPluginSHA256)"
+java_debug_license_url="$(manifest_value javaDebugLicenseURL)"
+java_debug_license_sha256="$(manifest_value javaDebugLicenseSHA256)"
+java_test_archive_url="$(manifest_value javaTestArchiveURL)"
+java_test_archive_sha256="$(manifest_value javaTestArchiveSHA256)"
+java_test_plugin_sha256="$(manifest_value javaTestPluginSHA256)"
+java_test_runner_sha256="$(manifest_value javaTestRunnerSHA256)"
+java_test_license_url="$(manifest_value javaTestLicenseURL)"
+java_test_license_sha256="$(manifest_value javaTestLicenseSHA256)"
+jdtls_version="$(manifest_value version)"
+lombok_version="$(manifest_value lombokVersion)"
+java_debug_extension_version="$(manifest_value javaDebugExtensionVersion)"
+java_debug_server_version="$(manifest_value javaDebugServerVersion)"
+java_test_extension_version="$(manifest_value javaTestExtensionVersion)"
+java_test_plugin_version="$(manifest_value javaTestPluginVersion)"
+minimum_java_version="$(manifest_value minimumJavaVersion)"
+bundled_jdk_version="$(/usr/bin/plutil -extract version raw -o - "$ROOT_DIR/third_party/jdk/manifest.json")"
+archive_path="${LITHE_JDTLS_ARCHIVE:-$CACHE_DIR/jdtls-$jdtls_version-$archive_sha256.tar.gz}"
+license_path="$CACHE_DIR/EPL-2.0-$license_sha256.txt"
+lombok_path="$CACHE_DIR/lombok-$lombok_version-$lombok_sha256.jar"
+lombok_license_path="$CACHE_DIR/lombok-MIT-$lombok_version-$lombok_license_sha256.txt"
+java_debug_archive_path="$CACHE_DIR/vscode-java-debug-$java_debug_extension_version-$java_debug_archive_sha256.vsix"
+java_debug_license_path="$CACHE_DIR/java-debug-EPL-1.0-$java_debug_server_version-$java_debug_license_sha256.txt"
+java_debug_plugin_name="com.microsoft.java.debug.plugin-$java_debug_server_version.jar"
+java_test_archive_path="$CACHE_DIR/vscode-java-test-$java_test_extension_version-$java_test_archive_sha256.vsix"
+java_test_license_path="$CACHE_DIR/java-test-MIT-$java_test_extension_version-$java_test_license_sha256.txt"
+java_test_plugin_name="com.microsoft.java.test.plugin-$java_test_plugin_version.jar"
+java_test_runner_name="com.microsoft.java.test.runner-jar-with-dependencies.jar"
+# Records the Java Test bundle set declared by the extension so validation
+# checks the exact upstream list instead of a hard-coded count.
+java_test_bundle_list="java-test/extensions.txt"
+
+# JDT LS refuses to start on a Java runtime older than it requires, and the
+# bundled JDK is the runtime Lithe launches it with.
+(( ${bundled_jdk_version%%.*} >= minimum_java_version )) || {
+ print -u2 -- "Bundled JDK $bundled_jdk_version is older than the Java $minimum_java_version that JDTLS $jdtls_version requires"
+ exit 1
+}
+
+file_sha256() {
+ shasum -a 256 "$1" | awk '{print tolower($1)}'
+}
+
+cache_warning() {
+ local message="$1"
+ print -u2 -- "warning: $message"
+ if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then
+ message="${message//'%'/'%25'}"
+ message="${message//$'\r'/'%0D'}"
+ message="${message//$'\n'/'%0A'}"
+ print -- "::warning title=JDTLS cache fallback::$message"
+ fi
+}
+
+download_verified_file() {
+ local url="$1"
+ local expected_sha256="$2"
+ local destination="$3"
+ local description="$4"
+ local actual_sha256
+ local temporary_path="$destination.download.$$"
+
+ if [[ -f "$destination" ]]; then
+ actual_sha256="$(file_sha256 "$destination")"
+ if [[ "$actual_sha256" == "$expected_sha256" ]]; then
+ return 0
+ fi
+ cache_warning "$description cache checksum mismatch; removing it before retrying the download"
+ rm -f -- "$destination"
+ fi
+
+ rm -f -- "$temporary_path"
+ print -u2 -- "Downloading $description: $url"
+ if ! curl \
+ --fail \
+ --location \
+ --retry 3 \
+ --retry-all-errors \
+ --connect-timeout 15 \
+ --max-time 180 \
+ --output "$temporary_path" \
+ "$url"; then
+ rm -f -- "$temporary_path"
+ return 1
+ fi
+ actual_sha256="$(file_sha256 "$temporary_path")"
+ if [[ "$actual_sha256" != "$expected_sha256" ]]; then
+ print -u2 -- "$description checksum mismatch: expected $expected_sha256, got $actual_sha256"
+ rm -f -- "$temporary_path"
+ return 1
+ fi
+ mv -f -- "$temporary_path" "$destination"
+}
+
+validate_output() {
+ [[ -d "$OUTPUT_DIR/plugins" ]] || { print -u2 -- "JDTLS plugins directory is missing: $OUTPUT_DIR"; exit 1; }
+ local launcher_jars=("$OUTPUT_DIR"/plugins/org.eclipse.equinox.launcher_*.jar(N))
+ (( ${#launcher_jars[@]} > 0 )) || { print -u2 -- "JDTLS Equinox launcher is missing: $OUTPUT_DIR/plugins"; exit 1; }
+ local target_arch="${LITHE_ARCH:-$(uname -m)}"
+ local required_mac_configurations
+ case "$target_arch" in
+ arm64) required_mac_configurations=(config_mac_arm) ;;
+ x86_64) required_mac_configurations=(config_mac) ;;
+ universal) required_mac_configurations=(config_mac_arm config_mac) ;;
+ *) print -u2 -- "Unsupported JDTLS target architecture: $target_arch"; exit 1 ;;
+ esac
+ local configuration_name
+ for configuration_name in "${required_mac_configurations[@]}"; do
+ [[ -d "$OUTPUT_DIR/$configuration_name" ]] || {
+ print -u2 -- "JDTLS $configuration_name configuration is missing: $OUTPUT_DIR"
+ exit 1
+ }
+ done
+ [[ -d "$OUTPUT_DIR/config_win" ]] || { print -u2 -- "JDTLS Windows configuration is missing: $OUTPUT_DIR"; exit 1; }
+ [[ -x "$OUTPUT_DIR/bin/jdtls" ]] || { print -u2 -- "JDTLS launcher is missing: $OUTPUT_DIR/bin/jdtls"; exit 1; }
+ [[ -f "$OUTPUT_DIR/bin/jdtls.ps1" ]] || { print -u2 -- "JDTLS Windows launcher is missing: $OUTPUT_DIR"; exit 1; }
+ [[ -f "$OUTPUT_DIR/lombok/lombok.jar" ]] || { print -u2 -- "JDTLS Lombok agent is missing: $OUTPUT_DIR"; exit 1; }
+ [[ -f "$OUTPUT_DIR/lombok/LICENSE-MIT.txt" ]] || { print -u2 -- "JDTLS Lombok license is missing: $OUTPUT_DIR"; exit 1; }
+ [[ -f "$OUTPUT_DIR/java-debug/$java_debug_plugin_name" ]] || { print -u2 -- "Java Debug Server plugin is missing: $OUTPUT_DIR"; exit 1; }
+ [[ -f "$OUTPUT_DIR/java-debug/LICENSE-EPL-1.0.txt" ]] || { print -u2 -- "Java Debug Server license is missing: $OUTPUT_DIR"; exit 1; }
+ [[ -f "$OUTPUT_DIR/java-test/extensions/$java_test_plugin_name" ]] || { print -u2 -- "Java Test extension plugin is missing: $OUTPUT_DIR"; exit 1; }
+ [[ -f "$OUTPUT_DIR/$java_test_bundle_list" ]] || { print -u2 -- "Java Test bundle list is missing: $OUTPUT_DIR/$java_test_bundle_list"; exit 1; }
+ local declared_bundles=("${(@f)$(<"$OUTPUT_DIR/$java_test_bundle_list")}")
+ local declared_bundle
+ for declared_bundle in "${declared_bundles[@]}"; do
+ [[ -f "$OUTPUT_DIR/java-test/extensions/$declared_bundle" ]] || { print -u2 -- "Java Test bundle $declared_bundle is missing: $OUTPUT_DIR/java-test/extensions"; exit 1; }
+ [[ ! -e "$OUTPUT_DIR/plugins/$declared_bundle" ]] || { print -u2 -- "Java Test bundle $declared_bundle duplicates a JDTLS plugin: $OUTPUT_DIR/plugins"; exit 1; }
+ done
+ local java_test_extension_bundles=("$OUTPUT_DIR"/java-test/extensions/*.jar(N))
+ (( ${#java_test_extension_bundles[@]} == ${#declared_bundles[@]} )) || { print -u2 -- "Java Test extension bundles do not match $java_test_bundle_list: $OUTPUT_DIR/java-test/extensions"; exit 1; }
+ [[ -f "$OUTPUT_DIR/java-test/runner/$java_test_runner_name" ]] || { print -u2 -- "Java Test runner is missing: $OUTPUT_DIR"; exit 1; }
+ [[ -f "$OUTPUT_DIR/java-test/LICENSE-MIT.txt" ]] || { print -u2 -- "Java Test license is missing: $OUTPUT_DIR"; exit 1; }
+ # Wrapper scripts remain available for external/legacy launch plans. The
+ # packaged product launches bundled Java directly with the resources above.
+ grep -Fq -- '-javaagent:' "$OUTPUT_DIR/bin/jdtls" || { print -u2 -- "JDTLS launcher does not load the Lombok agent: $OUTPUT_DIR"; exit 1; }
+ grep -Fq -- '-javaagent:' "$OUTPUT_DIR/bin/jdtls.ps1" || { print -u2 -- "JDTLS Windows launcher does not load the Lombok agent: $OUTPUT_DIR"; exit 1; }
+}
+
+if [[ -n "${LITHE_JDTLS_ROOT:-}" ]]; then
+ validate_output
+ print -r -- "$OUTPUT_DIR"
+ exit 0
+fi
+
+mkdir -p "$CACHE_DIR"
+if [[ -n "${LITHE_JDTLS_ARCHIVE:-}" ]]; then
+ [[ -f "$archive_path" ]] || { print -u2 -- "JDTLS archive was not found: $archive_path"; exit 1; }
+ actual_archive_sha256="$(file_sha256 "$archive_path")"
+ if [[ "$actual_archive_sha256" != "$archive_sha256" ]]; then
+ print -u2 -- "JDTLS archive checksum mismatch: expected $archive_sha256, got $actual_archive_sha256"
+ exit 1
+ fi
+else
+ download_verified_file "$archive_url" "$archive_sha256" "$archive_path" "JDTLS archive"
+fi
+download_verified_file "$license_url" "$license_sha256" "$license_path" "EPL-2.0 license"
+download_verified_file "$lombok_url" "$lombok_sha256" "$lombok_path" "Lombok agent"
+download_verified_file "$lombok_license_url" "$lombok_license_sha256" "$lombok_license_path" "Lombok MIT license"
+download_verified_file "$java_debug_archive_url" "$java_debug_archive_sha256" "$java_debug_archive_path" "Java Debug extension"
+download_verified_file "$java_debug_license_url" "$java_debug_license_sha256" "$java_debug_license_path" "Java Debug EPL-1.0 license"
+download_verified_file "$java_test_archive_url" "$java_test_archive_sha256" "$java_test_archive_path" "Java Test extension"
+download_verified_file "$java_test_license_url" "$java_test_license_sha256" "$java_test_license_path" "Java Test MIT license"
+
+rm -rf "$OUTPUT_DIR"
+mkdir -p "$OUTPUT_DIR"
+tar -xzf "$archive_path" -C "$OUTPUT_DIR"
+cp "$license_path" "$OUTPUT_DIR/LICENSE-EPL-2.0.txt"
+cp "$MANIFEST" "$OUTPUT_DIR/manifest.json"
+mkdir -p "$OUTPUT_DIR/lombok"
+cp "$lombok_path" "$OUTPUT_DIR/lombok/lombok.jar"
+cp "$lombok_license_path" "$OUTPUT_DIR/lombok/LICENSE-MIT.txt"
+mkdir -p "$OUTPUT_DIR/java-debug"
+unzip -p \
+ "$java_debug_archive_path" \
+ "extension/server/$java_debug_plugin_name" \
+ > "$OUTPUT_DIR/java-debug/$java_debug_plugin_name"
+actual_java_debug_plugin_sha256="$(file_sha256 "$OUTPUT_DIR/java-debug/$java_debug_plugin_name")"
+if [[ "$actual_java_debug_plugin_sha256" != "$java_debug_plugin_sha256" ]]; then
+ print -u2 -- "Java Debug Server plugin checksum mismatch: expected $java_debug_plugin_sha256, got $actual_java_debug_plugin_sha256"
+ exit 1
+fi
+cp "$java_debug_license_path" "$OUTPUT_DIR/java-debug/LICENSE-EPL-1.0.txt"
+java_test_extraction="$(mktemp -d "$CACHE_DIR/java-test-extract.XXXXXX")"
+unzip -q \
+ "$java_test_archive_path" \
+ "extension/package.json" \
+ "extension/server/*.jar" \
+ -d "$java_test_extraction"
+mkdir -p "$OUTPUT_DIR/java-test/extensions" "$OUTPUT_DIR/java-test/runner"
+# The extension's `contributes.javaExtensions` is the upstream list of bundles
+# JDT LS must load; copying that list keeps the runner and coverage agent out of
+# OSGi and follows upstream when the bundle set changes. Bundles JDT LS already
+# ships (Eclipse names them `_.jar`) are skipped: JDT LS
+# cannot replace its own copy, so loading them only fails with "A bundle is
+# already installed" at every start.
+: > "$OUTPUT_DIR/$java_test_bundle_list"
+java_test_bundle_index=0
+while java_test_bundle="$(/usr/bin/plutil -extract "contributes.javaExtensions.$java_test_bundle_index" raw -o - "$java_test_extraction/extension/package.json" 2>/dev/null)"; do
+ (( java_test_bundle_index += 1 ))
+ [[ -e "$OUTPUT_DIR/plugins/${java_test_bundle:t}" ]] && continue
+ cp "$java_test_extraction/extension/${java_test_bundle#./}" "$OUTPUT_DIR/java-test/extensions/${java_test_bundle:t}"
+ print -r -- "${java_test_bundle:t}" >> "$OUTPUT_DIR/$java_test_bundle_list"
+done
+(( java_test_bundle_index > 0 )) || { print -u2 -- "Java Test extension declares no JDT LS bundles"; exit 1; }
+cp "$java_test_extraction/extension/server/$java_test_runner_name" "$OUTPUT_DIR/java-test/runner/$java_test_runner_name"
+rm -rf -- "$java_test_extraction"
+actual_java_test_plugin_sha256="$(file_sha256 "$OUTPUT_DIR/java-test/extensions/$java_test_plugin_name")"
+if [[ "$actual_java_test_plugin_sha256" != "$java_test_plugin_sha256" ]]; then
+ print -u2 -- "Java Test plugin checksum mismatch: expected $java_test_plugin_sha256, got $actual_java_test_plugin_sha256"
+ exit 1
+fi
+actual_java_test_runner_sha256="$(file_sha256 "$OUTPUT_DIR/java-test/runner/$java_test_runner_name")"
+if [[ "$actual_java_test_runner_sha256" != "$java_test_runner_sha256" ]]; then
+ print -u2 -- "Java Test runner checksum mismatch: expected $java_test_runner_sha256, got $actual_java_test_runner_sha256"
+ exit 1
+fi
+cp "$java_test_license_path" "$OUTPUT_DIR/java-test/LICENSE-MIT.txt"
+
+cat > "$OUTPUT_DIR/bin/jdtls" <<'EOF'
+#!/bin/zsh
+
+set -euo pipefail
+
+SCRIPT_DIR="${0:A:h}"
+JAVA_EXECUTABLE="${JAVA_HOME:-}/bin/java"
+if [[ ! -x "$JAVA_EXECUTABLE" ]]; then
+ JAVA_EXECUTABLE="${JAVA:-java}"
+fi
+
+LOMBOK_AGENT="$SCRIPT_DIR/../lombok/lombok.jar"
+[[ -f "$LOMBOK_AGENT" ]] || { print -u2 -- "JDTLS Lombok agent was not found: $LOMBOK_AGENT"; exit 1; }
+JVM_ARGUMENTS=(
+ "-javaagent:$LOMBOK_AGENT"
+ "--add-modules=ALL-SYSTEM"
+ "--add-opens=java.base/java.util=ALL-UNNAMED"
+ "--add-opens=java.base/java.lang=ALL-UNNAMED"
+)
+SERVER_ARGUMENTS=()
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --java-executable)
+ [[ $# -ge 2 ]] || { print -u2 -- "--java-executable requires a path"; exit 2; }
+ JAVA_EXECUTABLE="$2"
+ shift 2
+ ;;
+ --jvm-arg=*)
+ JVM_ARGUMENTS+=("${1#--jvm-arg=}")
+ shift
+ ;;
+ --jvm-arg)
+ [[ $# -ge 2 ]] || { print -u2 -- "--jvm-arg requires a value"; exit 2; }
+ JVM_ARGUMENTS+=("$2")
+ shift 2
+ ;;
+ *)
+ SERVER_ARGUMENTS+=("$1")
+ shift
+ ;;
+ esac
+done
+
+LAUNCHER_JAR=$(find "$SCRIPT_DIR/../plugins" -maxdepth 1 -name 'org.eclipse.equinox.launcher_*.jar' -print | sort | head -n 1)
+[[ -n "$LAUNCHER_JAR" ]] || { print -u2 -- "JDTLS Equinox launcher was not found"; exit 1; }
+case "$(uname -m)" in
+ arm64) CONFIGURATION="$SCRIPT_DIR/../config_mac_arm" ;;
+ x86_64) CONFIGURATION="$SCRIPT_DIR/../config_mac" ;;
+ *) print -u2 -- "Unsupported macOS architecture: $(uname -m)"; exit 1 ;;
+esac
+[[ -d "$CONFIGURATION" ]] || { print -u2 -- "JDTLS configuration was not found: $CONFIGURATION"; exit 1; }
+
+exec "$JAVA_EXECUTABLE" \
+ "${JVM_ARGUMENTS[@]}" \
+ -Declipse.application=org.eclipse.jdt.ls.core.id1 \
+ -Declipse.product=org.eclipse.jdt.ls.core.product \
+ -Dosgi.bundles.defaultStartLevel=4 \
+ -Dlog.protocol=true \
+ -Dlog.level=ALL \
+ -jar "$LAUNCHER_JAR" \
+ -configuration "$CONFIGURATION" \
+ "${SERVER_ARGUMENTS[@]}"
+EOF
+
+cat > "$OUTPUT_DIR/bin/jdtls.ps1" <<'EOF'
+$ErrorActionPreference = "Stop"
+
+$javaExecutable = if ($env:JAVA_HOME) { Join-Path $env:JAVA_HOME "bin\java.exe" } else { "java" }
+$lombokAgent = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\lombok\lombok.jar"))
+if (-not (Test-Path -LiteralPath $lombokAgent -PathType Leaf)) { throw "JDTLS Lombok agent was not found: $lombokAgent" }
+$jvmArguments = [System.Collections.Generic.List[string]]::new()
+$jvmArguments.Add("-javaagent:$lombokAgent")
+$jvmArguments.Add("--add-modules=ALL-SYSTEM")
+$jvmArguments.Add("--add-opens=java.base/java.util=ALL-UNNAMED")
+$jvmArguments.Add("--add-opens=java.base/java.lang=ALL-UNNAMED")
+$serverArguments = [System.Collections.Generic.List[string]]::new()
+
+for ($index = 0; $index -lt $args.Count; $index++) {
+ $argument = [string]$args[$index]
+ if ($argument -eq "--java-executable") {
+ if ($index + 1 -ge $args.Count) { throw "--java-executable requires a path" }
+ $javaExecutable = [string]$args[++$index]
+ } elseif ($argument.StartsWith("--jvm-arg=")) {
+ $jvmArguments.Add($argument.Substring("--jvm-arg=".Length))
+ } elseif ($argument -eq "--jvm-arg") {
+ if ($index + 1 -ge $args.Count) { throw "--jvm-arg requires a value" }
+ $jvmArguments.Add([string]$args[++$index])
+ } else {
+ $serverArguments.Add($argument)
+ }
+}
+
+$launcherJar = Get-ChildItem -LiteralPath (Join-Path $PSScriptRoot "..\plugins") -Filter "org.eclipse.equinox.launcher_*.jar" |
+ Sort-Object Name |
+ Select-Object -First 1
+if ($null -eq $launcherJar) { throw "JDTLS Equinox launcher was not found" }
+$configuration = Join-Path $PSScriptRoot "..\config_win"
+
+& $javaExecutable @jvmArguments `
+ "-Declipse.application=org.eclipse.jdt.ls.core.id1" `
+ "-Declipse.product=org.eclipse.jdt.ls.core.product" `
+ "-Dosgi.bundles.defaultStartLevel=4" `
+ "-Dlog.protocol=true" `
+ "-Dlog.level=ALL" `
+ "-jar" $launcherJar.FullName `
+ "-configuration" $configuration `
+ @serverArguments
+exit $LASTEXITCODE
+EOF
+
+cat > "$OUTPUT_DIR/bin/jdtls.bat" <<'EOF'
+@echo off
+powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0jdtls.ps1" %*
+exit /b %ERRORLEVEL%
+EOF
+
+chmod +x "$OUTPUT_DIR/bin/jdtls"
+validate_output
+print -r -- "$OUTPUT_DIR"
diff --git a/scripts/verify-package.sh b/scripts/verify-package.sh
new file mode 100755
index 0000000..6ff549a
--- /dev/null
+++ b/scripts/verify-package.sh
@@ -0,0 +1,105 @@
+#!/bin/zsh
+
+set -euo pipefail
+
+# Verifies an assembled Java Support package directory before it is signed or
+# archived:
+# - plugin.json is the repository manifest, unchanged;
+# - the package holds only the declared top-level entries and no symbolic
+# links, and nothing outside runtime/ is executable;
+# - every file plugin.json names for the language server exists;
+# - the packaged JDK is the requested architecture and keeps its upstream
+# code signature (the publisher signature alone does not prove macOS can
+# launch it).
+# With --host-tools it also runs Lithe's official package verifier and, when
+# the package is signed, checks the publisher signature.
+
+ROOT_DIR="${0:A:h:h}"
+PLUGIN_ID="dev.lithe.plugin.java-support"
+PACKAGE=""
+ARCH=""
+HOST_TOOLS=""
+REQUIRE_SIGNATURE=false
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --package) PACKAGE="$2"; shift 2 ;;
+ --arch) ARCH="$2"; shift 2 ;;
+ --host-tools) HOST_TOOLS="$2"; shift 2 ;;
+ --require-signature) REQUIRE_SIGNATURE=true; shift ;;
+ *) print -u2 -- "Usage: $0 --package directory --arch arm64|x86_64|universal [--host-tools bin-directory] [--require-signature]"; exit 2 ;;
+ esac
+done
+[[ -d "$PACKAGE" ]] || { print -u2 -- "--package must name an assembled package directory"; exit 2; }
+case "$ARCH" in
+ arm64|x86_64) runtime_architectures=("$ARCH") ;;
+ universal) runtime_architectures=(arm64 x86_64) ;;
+ *) print -u2 -- "Unsupported architecture: $ARCH"; exit 2 ;;
+esac
+
+fail() {
+ print -u2 -- "Java Support package check failed: $*"
+ exit 1
+}
+
+manifest="$PACKAGE/plugin.json"
+cmp -s "$manifest" "$ROOT_DIR/$PLUGIN_ID/plugin.json" ||
+ fail "plugin.json differs from $PLUGIN_ID/plugin.json"
+[[ "$(/usr/bin/plutil -extract id raw -o - "$manifest")" == "$PLUGIN_ID" ]] ||
+ fail "plugin.json does not declare $PLUGIN_ID"
+[[ "${PACKAGE:A:t}" == "$PLUGIN_ID" ]] ||
+ fail "the package directory must be named $PLUGIN_ID"
+
+allowed=(plugin.json icons templates language-server runtime lithe-plugin-signature.json)
+for entry in "$PACKAGE"/*(DN); do
+ (( ${allowed[(Ie)${entry:t}]} )) || fail "undeclared top-level entry ${entry:t}"
+done
+symlink=$(find "$PACKAGE" -type l -print -quit)
+[[ -z "$symlink" ]] || fail "symbolic link $symlink"
+outside_runtime=$(find "$PACKAGE" -path "$PACKAGE/runtime" -prune -o -type f -perm -u+x -print -quit)
+[[ -z "$outside_runtime" ]] || fail "executable file outside runtime/: $outside_runtime"
+
+launcher=$(/usr/bin/plutil -extract languageServers.0.launch.entry raw -o - "$manifest")
+lombok=$(/usr/bin/plutil -extract languageServers.0.hostIntegration.resources.lombokAgent raw -o - "$manifest")
+test_runner=$(/usr/bin/plutil -extract languageServers.0.hostIntegration.resources.testRunner raw -o - "$manifest")
+for declared in "$launcher" "$lombok" "$test_runner"; do
+ [[ -f "$PACKAGE/$declared" ]] || fail "plugin.json declares $declared, which the package does not contain"
+done
+bundle_count=$(/usr/bin/plutil -extract languageServers.0.hostIntegration.resources.extensionBundleDirectories raw -o - "$manifest")
+for (( index = 0; index < bundle_count; index++ )); do
+ directory=$(/usr/bin/plutil -extract "languageServers.0.hostIntegration.resources.extensionBundleDirectories.$index" raw -o - "$manifest")
+ [[ -n "$(find "$PACKAGE/$directory" -maxdepth 1 -name '*.jar' -print -quit 2>/dev/null)" ]] ||
+ fail "extension bundle directory $directory holds no bundles"
+done
+for license in LICENSE-EPL-2.0.txt lombok/LICENSE-MIT.txt java-debug/LICENSE-EPL-1.0.txt java-test/LICENSE-MIT.txt; do
+ [[ -f "$PACKAGE/language-server/$license" ]] || fail "missing third-party license language-server/$license"
+done
+
+present_runtimes=("$PACKAGE"/runtime/*(/N:t))
+[[ "${(j: :)${(o)present_runtimes}}" == "${(j: :)${(o)runtime_architectures}}" ]] ||
+ fail "runtime/ holds ${present_runtimes[*]:-nothing}, expected ${runtime_architectures[*]}"
+for runtime_arch in "${runtime_architectures[@]}"; do
+ runtime="$PACKAGE/runtime/$runtime_arch"
+ configuration="$PACKAGE/language-server/configuration/$runtime_arch/config.ini"
+ [[ -f "$configuration" ]] || fail "missing $configuration"
+ [[ -x "$runtime/bin/java" ]] || fail "missing $runtime/bin/java"
+ /usr/bin/lipo "$runtime/bin/java" -verify_arch "$runtime_arch" >/dev/null 2>&1 ||
+ fail "$runtime/bin/java does not contain $runtime_arch"
+ grep -Eq '^JAVA_VERSION="21\.' "$runtime/release" || fail "$runtime is not a Java 21 runtime"
+ /usr/bin/codesign --verify --strict "$runtime/bin/java" ||
+ fail "$runtime/bin/java lost its upstream code signature"
+ /usr/bin/codesign --verify --strict "$runtime/lib/server/libjvm.dylib" ||
+ fail "$runtime/lib/server/libjvm.dylib lost its upstream code signature"
+done
+
+signature="$PACKAGE/lithe-plugin-signature.json"
+if [[ "$REQUIRE_SIGNATURE" == true && ! -f "$signature" ]]; then
+ fail "the package is not publisher-signed"
+fi
+if [[ -n "$HOST_TOOLS" ]]; then
+ "$HOST_TOOLS/LitheOfficialPluginVerifier" "$PACKAGE"
+ if [[ -f "$signature" ]]; then
+ "$HOST_TOOLS/LithePluginPackageSigner" --verify "$PACKAGE"
+ fi
+fi
+print -u2 -- "Verified Java Support package $PACKAGE ($ARCH)"
diff --git a/third_party/README.md b/third_party/README.md
new file mode 100644
index 0000000..3ba3995
--- /dev/null
+++ b/third_party/README.md
@@ -0,0 +1,22 @@
+# 第三方输入
+
+`third_party/` 只记录插件包需要的上游输入,每个组件一个 `manifest.json`:上游版本、HTTPS 下载地址和 SHA-256。
+
+| 清单 | 内容 | 使用者 |
+| --- | --- | --- |
+| `jdtls/manifest.json` | Eclipse JDT Language Server、Lombok、java-debug、vscode-java-test 的版本、下载地址和 SHA-256 | `scripts/prepare-jdtls.sh` |
+| `jdk/manifest.json` | 每个平台一份 Temurin JDK 21 的下载地址、SHA-256 和解压后的根目录名 | `scripts/prepare-jdk.sh` |
+
+规则:
+
+- 下载物放在被 `.gitignore` 忽略的 `.artifacts/` 下,先校验 SHA-256 再使用;缓存校验失败时删除重下。
+ 不要把 jar、JDK 或其他发布二进制提交到仓库。
+- 升级组件时同时改版本、地址和 SHA-256,并在 PR 里写明上游发布页。`cargo test` 会检查每个地址都是
+ HTTPS、每个校验和都是小写 SHA-256(`backend/tests/repository_pins.rs`)。
+- 插件包里的第三方许可证由组装脚本从上游包复制,`scripts/verify-package.sh` 检查它们存在;仓库根目录的
+ `NOTICE` 列出全部组件和许可。
+- `jdk/manifest.json` 里的 Windows 条目留给 Windows 版插件包使用,目前的打包脚本只读取 macOS 条目。
+
+> English: `third_party/` pins every upstream input of the plugin package by version, HTTPS URL and SHA-256.
+> The prepare scripts download into the ignored `.artifacts/` directory and verify the checksum before use; no
+> upstream binaries are committed. `backend/tests/repository_pins.rs` checks the pins.
diff --git a/third_party/jdk/manifest.json b/third_party/jdk/manifest.json
new file mode 100644
index 0000000..3b2eec6
--- /dev/null
+++ b/third_party/jdk/manifest.json
@@ -0,0 +1,26 @@
+{
+ "version": "21.0.12.1+1",
+ "licenseURL": "https://github.com/adoptium/temurin21-binaries/blob/main/LICENSE",
+ "platforms": {
+ "macos-aarch64": {
+ "url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_mac_hotspot_21.0.12.1_1.tar.gz",
+ "sha256": "3623232f33a9c3baadf304480b2535f9a3cba8a58d42ecbb438ba267315d9998",
+ "jdkRoot": "jdk-21.0.12.1+1"
+ },
+ "macos-x86_64": {
+ "url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_mac_hotspot_21.0.12.1_1.tar.gz",
+ "sha256": "44db0f08196daf19a47f90d13388b0c943b67663cb537f998fe29e836fa842ce",
+ "jdkRoot": "jdk-21.0.12.1+1"
+ },
+ "windows-aarch64": {
+ "url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12%2B8/OpenJDK21U-jdk_aarch64_windows_hotspot_21.0.12_8.zip",
+ "sha256": "e7ddf1cf45885cae2cfb8a018db60b0041ef69e96843b93a92b447d9a4cba676",
+ "jdkRoot": "jdk-21.0.12+8"
+ },
+ "windows-x86_64": {
+ "url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_windows_hotspot_21.0.12.1_1.zip",
+ "sha256": "f9d6e191ab098c0d416e7d588a24420a8621cd2f4720dab2459b8b7b2d2d8b4e",
+ "jdkRoot": "jdk-21.0.12.1+1"
+ }
+ }
+}
diff --git a/third_party/jdtls/manifest.json b/third_party/jdtls/manifest.json
new file mode 100644
index 0000000..bf3263f
--- /dev/null
+++ b/third_party/jdtls/manifest.json
@@ -0,0 +1,28 @@
+{
+ "version": "1.61.0",
+ "archiveURL": "https://download.eclipse.org/jdtls/milestones/1.61.0/jdt-language-server-1.61.0-202609031315.tar.gz",
+ "archiveSHA256": "338e7e73d61836651ba2453919a0d34fa763eb4e7c03342092309bffb8934c64",
+ "licenseURL": "https://www.eclipse.org/org/documents/epl-2.0/EPL-2.0.txt",
+ "licenseSHA256": "0becf16567beb77fa252b7664631dd177c8f9a1889e48995b45379c7130e5303",
+ "lombokVersion": "1.18.46",
+ "lombokURL": "https://repo.maven.apache.org/maven2/org/projectlombok/lombok/1.18.46/lombok-1.18.46.jar",
+ "lombokSHA256": "01f7b1a015e33e2b62d5f5f37053306357ab1415fd181fcba7794f5d198c1126",
+ "lombokLicenseURL": "https://raw.githubusercontent.com/projectlombok/lombok/v1.18.46/LICENSE",
+ "lombokLicenseSHA256": "76479448741d7a7a3a97b6afd9ab9699d95621faafc65a1b8e9149342ea00feb",
+ "javaDebugExtensionVersion": "0.59.0",
+ "javaDebugServerVersion": "0.53.2",
+ "javaDebugArchiveURL": "https://open-vsx.org/api/vscjava/vscode-java-debug/0.59.0/file/vscjava.vscode-java-debug-0.59.0.vsix",
+ "javaDebugArchiveSHA256": "87627e24dbb5b01137decc0265f043cb08adad22af3c195f1ba39898dafb1588",
+ "javaDebugPluginSHA256": "69128bebd6c46d3a9daeeb77a3bbe877423c2f66eb773fd1ae5375658ce0c9bf",
+ "javaDebugLicenseURL": "https://raw.githubusercontent.com/microsoft/java-debug/0.53.2/LICENSE.txt",
+ "javaDebugLicenseSHA256": "f494326c16bc95ebb14874ea5fa2c16a963eb36d1f2ab6fe99490073709771c1",
+ "javaTestExtensionVersion": "0.46.0",
+ "javaTestPluginVersion": "0.43.1",
+ "javaTestArchiveURL": "https://open-vsx.org/api/vscjava/vscode-java-test/0.46.0/file/vscjava.vscode-java-test-0.46.0.vsix",
+ "javaTestArchiveSHA256": "56c1e14dc73a30e9574c47042106fa52893bf8325b580f47c14ace07d5eef255",
+ "javaTestPluginSHA256": "e01cc491d866f996499e8ee55d0c3d8ba4f6ec44053975b791e3d0d2c76044e6",
+ "javaTestRunnerSHA256": "8ea737ab5c882688c021b440dabcd5a1188099896d6c64d41193911a482266bd",
+ "javaTestLicenseURL": "https://raw.githubusercontent.com/microsoft/vscode-java-test/0.46.0/LICENSE.txt",
+ "javaTestLicenseSHA256": "8314299543336aa4fe5c1d1d6cf278d538b387aad74cba37e50f5c4d34add9f3",
+ "minimumJavaVersion": 21
+}
From 6701a9873187887c087b62dc62fca85df5f99568 Mon Sep 17 00:00:00 2001
From: Yao Jingxi <23722032@bjtu.edu.cn>
Date: Wed, 4 Nov 2026 21:58:33 +0800
Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=E7=A7=BB=E6=A4=8D=20Java=20?=
=?UTF-8?q?=E9=A2=86=E5=9F=9F=E8=A7=84=E5=88=99=E4=B8=BA=20lithe-java-doma?=
=?UTF-8?q?in=20crate?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
把 Core 和 macOS 层里的 Java 规则移成不依赖宿主的 Rust crate:入口与 main
方法识别、运行标记、JDT LS 设置与启动、工作区策略与缓存保留、构建就绪判断、
项目准备、Spring 与 MyBatis 索引、JDK 选择、启动命令与 argfile、Java 测试
发现与调试启动、测试结果通道状态机。命令名、载荷、响应和错误码与 Core 一致。
Maven 和 Gradle 的导入逻辑不在这里。JDT LS 的导入设置由 Maven/Gradle 插件
提供,Java 只声明接受的键(CONTRIBUTED_SETTINGS_KEYS)并校验收到的片段。
jdt_contributions 定义跨插件接口 languageImport.settings 与
languageImport.settingsChanged:按提供方分槽的合并、状态和失败记录。
宿主差异(存储、日志、Git、语言服务器通道)保留为带计划编号的
TODO(host-api: API-NN) 占位。fixtures 是与主仓库共享的契约样例。
本提交与下一个提交(后端)合在一起才是完整的 Cargo 工作区。
---
Cargo.lock | 733 +++++
Cargo.toml | 19 +
crates/java-domain/Cargo.toml | 17 +
crates/java-domain/src/commands.rs | 228 ++
crates/java-domain/src/debug/java_test.rs | 293 ++
crates/java-domain/src/debug/mod.rs | 68 +
crates/java-domain/src/execution/argfile.rs | 260 ++
.../src/execution/classpath_jar.rs | 619 ++++
.../src/execution/java_selection.rs | 149 +
.../src/execution/launch_command.rs | 553 ++++
crates/java-domain/src/execution/mod.rs | 14 +
.../src/execution/toolchain_requirements.rs | 124 +
crates/java-domain/src/host/git.rs | 17 +
crates/java-domain/src/host/indexing.rs | 12 +
crates/java-domain/src/host/mod.rs | 16 +
crates/java-domain/src/host/storage.rs | 57 +
.../src/jdt_integration/launch_resources.rs | 245 ++
crates/java-domain/src/jdt_integration/mod.rs | 22 +
.../src/jdt_integration/runtime.rs | 235 ++
.../src/jdt_integration/workspace_state.rs | 579 ++++
.../src/languages/code_vision_hints.rs | 159 ++
crates/java-domain/src/languages/java.rs | 1181 ++++++++
.../java-domain/src/languages/java_syntax.rs | 556 ++++
crates/java-domain/src/languages/mod.rs | 16 +
crates/java-domain/src/languages/mybatis.rs | 492 ++++
crates/java-domain/src/languages/spring.rs | 2443 ++++++++++++++++
crates/java-domain/src/languages/text.rs | 54 +
crates/java-domain/src/lib.rs | 24 +
.../src/lsp/languages/java_entrypoints.rs | 385 +++
.../src/lsp/languages/java_launch.rs | 283 ++
.../src/lsp/languages/java_main_methods.rs | 235 ++
.../lsp/languages/java_navigation_syntax.rs | 264 ++
.../src/lsp/languages/java_run_markers.rs | 647 +++++
.../src/lsp/languages/java_tests.rs | 293 ++
.../src/lsp/languages/java_workspace.rs | 537 ++++
crates/java-domain/src/lsp/languages/jdt.rs | 2532 +++++++++++++++++
.../src/lsp/languages/jdt_build.rs | 1227 ++++++++
.../src/lsp/languages/jdt_configuration.rs | 453 +++
.../src/lsp/languages/jdt_contributions.rs | 352 +++
.../src/lsp/languages/jdt_navigation.rs | 350 +++
.../src/lsp/languages/jdt_progress.rs | 527 ++++
.../src/lsp/languages/jdt_project_metadata.rs | 499 ++++
crates/java-domain/src/lsp/languages/mod.rs | 35 +
.../src/lsp/languages/project_preparation.rs | 114 +
crates/java-domain/src/lsp/mod.rs | 112 +
.../java-domain/src/protocol/cancellation.rs | 275 ++
crates/java-domain/src/protocol/contracts.rs | 288 ++
crates/java-domain/src/protocol/error.rs | 90 +
crates/java-domain/src/protocol/mod.rs | 12 +
.../java-domain/src/testing/debug_launch.rs | 233 ++
.../java-domain/src/testing/jdt_test_items.rs | 804 ++++++
crates/java-domain/src/testing/mod.rs | 12 +
.../java-domain/src/testing/result_channel.rs | 374 +++
crates/java-domain/src/testing/workflow.rs | 515 ++++
crates/java-domain/src/tests/java_commands.rs | 503 ++++
.../java-domain/src/tests/java_selection.rs | 93 +
crates/java-domain/src/tests/mod.rs | 10 +
crates/java-domain/src/tests/mybatis.rs | 498 ++++
crates/java-domain/src/tests/spring.rs | 602 ++++
crates/java-domain/src/tests/support.rs | 73 +
fixtures/debug/java-test-launch-v1.json | 70 +
fixtures/java/basic.json | 21 +
fixtures/java/run-markers-v1.json | 216 ++
fixtures/lsp/java-build-report-v1.json | 31 +
fixtures/lsp/java-completion-v1.json | 23 +
fixtures/lsp/java-launch-preparation-v1.json | 80 +
fixtures/lsp/java-navigation-v1.json | 58 +
fixtures/lsp/java-project-runtimes-v1.json | 78 +
fixtures/lsp/java-workspace-policy-v1.json | 79 +
fixtures/lsp/jdt-cache-retention-v1.json | 52 +
fixtures/lsp/jdt-direct-launch-v1.json | 34 +
fixtures/lsp/jdt-readiness-v1.json | 74 +
.../lsp/jdt-workspace-fingerprint-v1.json | 35 +
fixtures/lsp/jdt-workspace-key-v1.json | 17 +
fixtures/lsp/project-preparation-v1.json | 12 +
fixtures/mybatis/basic.json | 25 +
.../automatic-java-selection.json | 284 ++
fixtures/spring/basic.json | 45 +
78 files changed, 23646 insertions(+)
create mode 100644 Cargo.lock
create mode 100644 Cargo.toml
create mode 100644 crates/java-domain/Cargo.toml
create mode 100644 crates/java-domain/src/commands.rs
create mode 100644 crates/java-domain/src/debug/java_test.rs
create mode 100644 crates/java-domain/src/debug/mod.rs
create mode 100644 crates/java-domain/src/execution/argfile.rs
create mode 100644 crates/java-domain/src/execution/classpath_jar.rs
create mode 100644 crates/java-domain/src/execution/java_selection.rs
create mode 100644 crates/java-domain/src/execution/launch_command.rs
create mode 100644 crates/java-domain/src/execution/mod.rs
create mode 100644 crates/java-domain/src/execution/toolchain_requirements.rs
create mode 100644 crates/java-domain/src/host/git.rs
create mode 100644 crates/java-domain/src/host/indexing.rs
create mode 100644 crates/java-domain/src/host/mod.rs
create mode 100644 crates/java-domain/src/host/storage.rs
create mode 100644 crates/java-domain/src/jdt_integration/launch_resources.rs
create mode 100644 crates/java-domain/src/jdt_integration/mod.rs
create mode 100644 crates/java-domain/src/jdt_integration/runtime.rs
create mode 100644 crates/java-domain/src/jdt_integration/workspace_state.rs
create mode 100644 crates/java-domain/src/languages/code_vision_hints.rs
create mode 100644 crates/java-domain/src/languages/java.rs
create mode 100644 crates/java-domain/src/languages/java_syntax.rs
create mode 100644 crates/java-domain/src/languages/mod.rs
create mode 100644 crates/java-domain/src/languages/mybatis.rs
create mode 100644 crates/java-domain/src/languages/spring.rs
create mode 100644 crates/java-domain/src/languages/text.rs
create mode 100644 crates/java-domain/src/lib.rs
create mode 100644 crates/java-domain/src/lsp/languages/java_entrypoints.rs
create mode 100644 crates/java-domain/src/lsp/languages/java_launch.rs
create mode 100644 crates/java-domain/src/lsp/languages/java_main_methods.rs
create mode 100644 crates/java-domain/src/lsp/languages/java_navigation_syntax.rs
create mode 100644 crates/java-domain/src/lsp/languages/java_run_markers.rs
create mode 100644 crates/java-domain/src/lsp/languages/java_tests.rs
create mode 100644 crates/java-domain/src/lsp/languages/java_workspace.rs
create mode 100644 crates/java-domain/src/lsp/languages/jdt.rs
create mode 100644 crates/java-domain/src/lsp/languages/jdt_build.rs
create mode 100644 crates/java-domain/src/lsp/languages/jdt_configuration.rs
create mode 100644 crates/java-domain/src/lsp/languages/jdt_contributions.rs
create mode 100644 crates/java-domain/src/lsp/languages/jdt_navigation.rs
create mode 100644 crates/java-domain/src/lsp/languages/jdt_progress.rs
create mode 100644 crates/java-domain/src/lsp/languages/jdt_project_metadata.rs
create mode 100644 crates/java-domain/src/lsp/languages/mod.rs
create mode 100644 crates/java-domain/src/lsp/languages/project_preparation.rs
create mode 100644 crates/java-domain/src/lsp/mod.rs
create mode 100644 crates/java-domain/src/protocol/cancellation.rs
create mode 100644 crates/java-domain/src/protocol/contracts.rs
create mode 100644 crates/java-domain/src/protocol/error.rs
create mode 100644 crates/java-domain/src/protocol/mod.rs
create mode 100644 crates/java-domain/src/testing/debug_launch.rs
create mode 100644 crates/java-domain/src/testing/jdt_test_items.rs
create mode 100644 crates/java-domain/src/testing/mod.rs
create mode 100644 crates/java-domain/src/testing/result_channel.rs
create mode 100644 crates/java-domain/src/testing/workflow.rs
create mode 100644 crates/java-domain/src/tests/java_commands.rs
create mode 100644 crates/java-domain/src/tests/java_selection.rs
create mode 100644 crates/java-domain/src/tests/mod.rs
create mode 100644 crates/java-domain/src/tests/mybatis.rs
create mode 100644 crates/java-domain/src/tests/spring.rs
create mode 100644 crates/java-domain/src/tests/support.rs
create mode 100644 fixtures/debug/java-test-launch-v1.json
create mode 100644 fixtures/java/basic.json
create mode 100644 fixtures/java/run-markers-v1.json
create mode 100644 fixtures/lsp/java-build-report-v1.json
create mode 100644 fixtures/lsp/java-completion-v1.json
create mode 100644 fixtures/lsp/java-launch-preparation-v1.json
create mode 100644 fixtures/lsp/java-navigation-v1.json
create mode 100644 fixtures/lsp/java-project-runtimes-v1.json
create mode 100644 fixtures/lsp/java-workspace-policy-v1.json
create mode 100644 fixtures/lsp/jdt-cache-retention-v1.json
create mode 100644 fixtures/lsp/jdt-direct-launch-v1.json
create mode 100644 fixtures/lsp/jdt-readiness-v1.json
create mode 100644 fixtures/lsp/jdt-workspace-fingerprint-v1.json
create mode 100644 fixtures/lsp/jdt-workspace-key-v1.json
create mode 100644 fixtures/lsp/project-preparation-v1.json
create mode 100644 fixtures/mybatis/basic.json
create mode 100644 fixtures/run-configuration/automatic-java-selection.json
create mode 100644 fixtures/spring/basic.json
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..39e1130
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,733 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "arbitrary"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
+dependencies = [
+ "derive_arbitrary",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "cc"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "derive_arbitrary"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
+
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "icu_collections"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
+
+[[package]]
+name = "icu_properties"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
+
+[[package]]
+name = "icu_provider"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.191"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "lithe-java-backend"
+version = "0.1.0"
+dependencies = [
+ "lithe-java-domain",
+ "lithe-plugin-sdk",
+ "serde_json",
+]
+
+[[package]]
+name = "lithe-java-domain"
+version = "0.1.0"
+dependencies = [
+ "regex",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tree-sitter",
+ "tree-sitter-java",
+ "url",
+ "zip",
+]
+
+[[package]]
+name = "lithe-plugin-sdk"
+version = "0.1.0"
+source = "git+https://github.com/Lithe-IDEA/lithe-plugin-docs?tag=sdk-rust-v0.1.0#e689971c000c15a044df0502052fe7b2c1c6f84f"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "log"
+version = "0.4.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.154"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
+dependencies = [
+ "foldhash",
+ "indexmap",
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "smallvec"
+version = "1.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
+
+[[package]]
+name = "syn"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tree-sitter"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
+dependencies = [
+ "cc",
+ "regex",
+ "regex-syntax",
+ "serde_json",
+ "streaming-iterator",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-java"
+version = "0.23.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-language"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0"
+dependencies = [
+ "utf_types",
+]
+
+[[package]]
+name = "utf_types"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "yoke"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zip"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744"
+dependencies = [
+ "arbitrary",
+ "crc32fast",
+ "crossbeam-utils",
+ "flate2",
+ "indexmap",
+ "memchr",
+ "zopfli",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zopfli"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..5b813ad
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,19 @@
+[workspace]
+members = ["backend", "crates/java-domain"]
+resolver = "2"
+
+[workspace.package]
+version = "0.1.0"
+edition = "2021"
+license = "Apache-2.0"
+publish = false
+
+[workspace.dependencies]
+regex = "1.11"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+sha2 = "0.10"
+tree-sitter = "0.25"
+tree-sitter-java = "0.23"
+url = "2.5"
+zip = { version = "2.4", default-features = false, features = ["deflate"] }
diff --git a/crates/java-domain/Cargo.toml b/crates/java-domain/Cargo.toml
new file mode 100644
index 0000000..d3f4c55
--- /dev/null
+++ b/crates/java-domain/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "lithe-java-domain"
+description = "Java, JDT LS, Spring and MyBatis rules of the Lithe Java Support plugin."
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[dependencies]
+regex.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+sha2.workspace = true
+tree-sitter.workspace = true
+tree-sitter-java.workspace = true
+url.workspace = true
+zip.workspace = true
diff --git a/crates/java-domain/src/commands.rs b/crates/java-domain/src/commands.rs
new file mode 100644
index 0000000..99f8fd0
--- /dev/null
+++ b/crates/java-domain/src/commands.rs
@@ -0,0 +1,228 @@
+//! JSON command surface of the Java domain.
+//!
+//! Rust Core used to route these commands through its central dispatcher.
+//! [`execute`] keeps the same command names, payload shapes, response shapes
+//! and `InvalidRequest` messages, so the host and the plugin backend can move
+//! a caller from Core to the plugin without changing its JSON.
+//!
+//! Commands that need a live JDT LS session (`java.navigationMarkers`,
+//! `java.resolveNavigation` and `java.prepareLaunch`) answer `NotSupported`
+//! until the host exposes the language-server request channel to plugin
+//! backends.
+//!
+//! Core's `lsp.retryMavenProfiles` and `lsp.updateMavenConfiguration` are not
+//! here: Maven profiles and settings belong to the Maven plugin, which hands
+//! them to this plugin through a cross-plugin function call (API-35; see
+//! `lsp::languages::jdt::CONTRIBUTED_SETTINGS_KEYS`).
+
+use serde::de::DeserializeOwned;
+use serde::Serialize;
+use serde_json::Value;
+
+use crate::protocol::cancellation::{self, Scope};
+use crate::protocol::{CoreError, ErrorCode};
+
+/// Command names that [`execute`] answers with domain logic.
+pub const SUPPORTED_COMMANDS: &[&str] = &[
+ "java.workspacePolicy",
+ "java.runMarkers",
+ "java.jdtCacheRetention",
+ "java.jdtWorkspaceFingerprint",
+ "lsp.jdtWorkspaceKey",
+ "java.codeVision",
+ "java.className",
+ "java.sourceDefinition",
+ "java.serverPort",
+ "java.structure",
+ "spring.index",
+ "mybatis.index",
+ "runConfig.selectJava",
+ "debug.javaTestLaunch",
+ "execution.planLaunchCommand",
+];
+
+/// Command names that Core served from a live JDT LS session.
+///
+/// They stay recognised so that callers receive a clear `NotSupported` error
+/// instead of `UnknownCommand` while the host API is missing.
+pub const SESSION_COMMANDS: &[&str] = &[
+ "java.navigationMarkers",
+ "java.resolveNavigation",
+ "java.prepareLaunch",
+];
+
+/// Options that frame one command execution.
+#[derive(Debug, Clone, Default)]
+pub struct ExecuteOptions {
+ /// Identifier that [`cancel`] can target while the command runs.
+ pub operation_id: Option,
+ /// Deadline after which cooperative cancellation checks fail.
+ pub timeout_milliseconds: Option,
+}
+
+/// Runs one command with default options.
+pub fn execute(command: &str, payload: Value) -> Result {
+ execute_with_options(command, payload, ExecuteOptions::default())
+}
+
+/// Runs one command inside a cancellation scope.
+pub fn execute_with_options(
+ command: &str,
+ payload: Value,
+ options: ExecuteOptions,
+) -> Result {
+ let _scope = Scope::begin(options.operation_id, options.timeout_milliseconds);
+ cancellation::check()?;
+ dispatch(command, payload)
+}
+
+/// Requests cooperative cancellation of a running command.
+///
+/// Returns `true` when an active scope with this operation identifier exists.
+pub fn cancel(operation_id: &str) -> bool {
+ cancellation::cancel(operation_id)
+}
+
+fn dispatch(command: &str, payload: Value) -> Result {
+ match command {
+ "java.workspacePolicy" => run(
+ payload,
+ "Invalid Java workspace-policy request",
+ crate::lsp::java_workspace_policy,
+ ),
+ "java.runMarkers" => run(
+ payload,
+ "Invalid Java Run-marker request",
+ crate::lsp::java_run_markers,
+ ),
+ "java.jdtCacheRetention" => run(
+ payload,
+ "Invalid JDT LS cache-retention request",
+ crate::lsp::jdt_cache_retention,
+ ),
+ "java.jdtWorkspaceFingerprint" => run(
+ payload,
+ "Invalid JDT LS workspace-fingerprint request",
+ crate::lsp::jdt_workspace_fingerprint,
+ ),
+ "lsp.jdtWorkspaceKey" => run(payload, "Invalid JDT LS workspace-key request", |request| {
+ Ok(crate::lsp::resolve_workspace_key(request))
+ }),
+ "java.codeVision" => run(
+ payload,
+ "Invalid Java code vision request",
+ crate::languages::code_vision,
+ ),
+ "java.className" => run(
+ payload,
+ "Invalid Java class name request",
+ crate::languages::class_name,
+ ),
+ "java.sourceDefinition" => run(
+ payload,
+ "Invalid Java source definition request",
+ crate::languages::source_definition,
+ ),
+ "java.serverPort" => run(
+ payload,
+ "Invalid Java server port request",
+ crate::languages::server_port,
+ ),
+ "java.structure" => run(
+ payload,
+ "Invalid Java structure request",
+ crate::languages::structure,
+ ),
+ "spring.index" => run(
+ payload,
+ "Invalid Spring index request",
+ crate::languages::spring_index,
+ ),
+ "mybatis.index" => run(
+ payload,
+ "Invalid MyBatis index request",
+ crate::languages::mybatis_index,
+ ),
+ "runConfig.selectJava" => run(
+ payload,
+ "Invalid Java selection request",
+ crate::execution::select_java,
+ ),
+ "debug.javaTestLaunch" => run(
+ payload,
+ "Invalid Java test debug launch request",
+ crate::debug::java_test_launch,
+ ),
+ "execution.planLaunchCommand" => {
+ run(payload, "Invalid Java launch-command request", |request| {
+ Ok(crate::execution::plan_launch_command_request(request))
+ })
+ }
+ // TODO(host-api: API-25 language server): these commands drive requests
+ // through the running JDT LS session. They need a host channel that
+ // lets a plugin backend send `workspace/executeCommand` and custom
+ // requests to the language server it declared, and receive its
+ // notifications.
+ command if SESSION_COMMANDS.contains(&command) => Err(CoreError::new(
+ ErrorCode::NotSupported,
+ format!("{command} needs the host language-server request channel"),
+ )),
+ command => Err(CoreError::new(
+ ErrorCode::InvalidRequest,
+ format!("Unknown Java Support command: {command}"),
+ )),
+ }
+}
+
+fn run(
+ payload: Value,
+ invalid_message: &str,
+ handler: impl FnOnce(Request) -> Result,
+) -> Result
+where
+ Request: DeserializeOwned,
+ Response: Serialize,
+{
+ let request = serde_json::from_value::(payload).map_err(|error| {
+ CoreError::new(ErrorCode::InvalidRequest, invalid_message).with_details(error.to_string())
+ })?;
+ let response = handler(request)?;
+ serde_json::to_value(response).map_err(|error| {
+ CoreError::new(ErrorCode::Unknown, "Java Support response did not encode")
+ .with_details(error.to_string())
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ #[test]
+ fn unknown_commands_are_invalid_requests() {
+ let error = execute("java.unknown", json!({})).unwrap_err();
+ assert_eq!(error.code, ErrorCode::InvalidRequest);
+ assert_eq!(error.message, "Unknown Java Support command: java.unknown");
+ }
+
+ #[test]
+ fn session_commands_wait_for_the_host_channel() {
+ for command in SESSION_COMMANDS {
+ let error = execute(command, json!({})).unwrap_err();
+ assert_eq!(error.code, ErrorCode::NotSupported, "{command}");
+ }
+ }
+
+ #[test]
+ fn malformed_payloads_keep_core_messages() {
+ let error = execute("java.className", json!({"unexpected": true})).unwrap_err();
+ assert_eq!(error.code, ErrorCode::InvalidRequest);
+ assert_eq!(error.message, "Invalid Java class name request");
+ assert!(error.details.is_some());
+ }
+
+ #[test]
+ fn cancelling_an_unknown_operation_reports_false() {
+ assert!(!cancel("no-such-operation"));
+ }
+}
diff --git a/crates/java-domain/src/debug/java_test.rs b/crates/java-domain/src/debug/java_test.rs
new file mode 100644
index 0000000..4002211
--- /dev/null
+++ b/crates/java-domain/src/debug/java_test.rs
@@ -0,0 +1,293 @@
+//! Deterministic Java test launch configuration shared by native products.
+
+use std::collections::HashSet;
+
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
+
+use crate::protocol::{CoreError, ErrorCode};
+
+use super::{DebugLaunchConfiguration, DebugRequestKind};
+
+#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+/// Java test framework whose runner arguments must be projected into DAP.
+pub enum JavaTestFramework {
+ /// JUnit 4 or 5 through the Eclipse JUnit runner.
+ Junit,
+ /// TestNG through the Java Test extension's runner.
+ Testng,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+/// Platform-observed JDT LS launch metadata plus one loopback result port.
+pub struct JavaTestLaunchRequest {
+ pub name: String,
+ pub framework: JavaTestFramework,
+ pub working_directory: String,
+ pub main_class: String,
+ #[serde(default)]
+ pub project_name: Option,
+ #[serde(default)]
+ pub class_paths: Vec,
+ #[serde(default)]
+ pub module_paths: Vec,
+ #[serde(default)]
+ pub vm_arguments: Vec,
+ #[serde(default)]
+ pub program_arguments: Vec,
+ pub result_port: u16,
+ #[serde(default)]
+ pub testng_runner_path: Option,
+ #[serde(default)]
+ pub testng_test_names: Vec,
+}
+
+/// Creates the provider arguments consumed by Java Debug Server.
+pub fn java_test_launch(
+ request: JavaTestLaunchRequest,
+) -> Result {
+ let name = required(request.name, "Java test launch name is required.")?;
+ let working_directory = required(
+ request.working_directory,
+ "Java test working directory is required.",
+ )?;
+ let main_class = required(request.main_class, "Java test main class is required.")?;
+ if request.result_port == 0 {
+ return Err(invalid_request(
+ "Java test result port must be between 1 and 65535.",
+ ));
+ }
+
+ let mut class_paths = non_empty_unique(request.class_paths);
+ let module_paths = non_empty_unique(request.module_paths);
+ let vm_arguments = non_empty(request.vm_arguments);
+ let mut arguments = Map::new();
+ arguments.insert("mainClass".to_string(), Value::String(main_class));
+ arguments.insert("cwd".to_string(), Value::String(working_directory));
+ arguments.insert(
+ "console".to_string(),
+ Value::String("integratedTerminal".to_string()),
+ );
+ if let Some(project_name) = optional_non_empty(request.project_name) {
+ arguments.insert("projectName".to_string(), Value::String(project_name));
+ }
+
+ let program_arguments = match request.framework {
+ JavaTestFramework::Junit => junit_arguments(request.program_arguments, request.result_port),
+ JavaTestFramework::Testng => {
+ let runner_path = required(
+ request.testng_runner_path.unwrap_or_default(),
+ "The Java TestNG runner is unavailable.",
+ )?;
+ if !class_paths.iter().any(|path| path == &runner_path) {
+ class_paths.push(runner_path);
+ }
+ let test_names = non_empty_unique(request.testng_test_names);
+ if test_names.is_empty() {
+ return Err(invalid_request(
+ "At least one TestNG test method is required.",
+ ));
+ }
+ std::iter::once(request.result_port.to_string())
+ .chain(std::iter::once("testng".to_string()))
+ .chain(test_names)
+ .collect()
+ }
+ };
+
+ insert_string_array(&mut arguments, "classPaths", class_paths);
+ insert_string_array(&mut arguments, "modulePaths", module_paths);
+ insert_java_debug_arguments(&mut arguments, "args", program_arguments);
+ insert_java_debug_arguments(&mut arguments, "vmArgs", vm_arguments);
+
+ Ok(DebugLaunchConfiguration {
+ name,
+ request: DebugRequestKind::Launch,
+ arguments,
+ stepping_filters: None,
+ })
+}
+
+fn junit_arguments(arguments: Vec, result_port: u16) -> Vec {
+ let mut arguments = arguments;
+ let port = result_port.to_string();
+ if let Some(index) = arguments.iter().rposition(|value| value == "-port") {
+ if index + 1 < arguments.len() {
+ arguments[index + 1] = port;
+ return arguments;
+ }
+ }
+ arguments.push("-port".to_string());
+ arguments.push(port);
+ arguments
+}
+
+fn required(value: String, message: &str) -> Result {
+ let value = value.trim().to_string();
+ if value.is_empty() {
+ Err(invalid_request(message))
+ } else {
+ Ok(value)
+ }
+}
+
+fn optional_non_empty(value: Option) -> Option {
+ value.and_then(|value| {
+ let value = value.trim().to_string();
+ (!value.is_empty()).then_some(value)
+ })
+}
+
+fn non_empty(values: Vec) -> Vec {
+ values
+ .into_iter()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ .collect()
+}
+
+fn non_empty_unique(values: Vec) -> Vec {
+ let mut seen = HashSet::new();
+ non_empty(values)
+ .into_iter()
+ .filter(|value| seen.insert(value.clone()))
+ .collect()
+}
+
+fn insert_string_array(arguments: &mut Map, key: &str, values: Vec) {
+ if values.is_empty() {
+ return;
+ }
+ arguments.insert(
+ key.to_string(),
+ Value::Array(values.into_iter().map(Value::String).collect()),
+ );
+}
+
+fn insert_java_debug_arguments(arguments: &mut Map, key: &str, values: Vec) {
+ let value = java_debug_argument_string(values);
+ if !value.is_empty() {
+ arguments.insert(key.to_string(), Value::String(value));
+ }
+}
+
+fn java_debug_argument_string(values: Vec) -> String {
+ // Java Test exposes arrays to VS Code, but Java Debug Server's DAP model
+ // accepts one command-line string. Mirror the upstream extension's
+ // serialization so the adapter can reconstruct spaces, quotes, and paths.
+ non_empty(values)
+ .into_iter()
+ .map(|value| {
+ if value
+ .chars()
+ .any(|character| character == '"' || character.is_whitespace())
+ {
+ let escaped = value.chars().fold(String::new(), |mut result, character| {
+ if matches!(character, '"' | '\\') {
+ result.push('\\');
+ }
+ result.push(character);
+ result
+ });
+ format!("\"{escaped}\"")
+ } else {
+ value
+ }
+ })
+ .collect::>()
+ .join(" ")
+}
+
+fn invalid_request(message: &str) -> CoreError {
+ CoreError::new(ErrorCode::InvalidRequest, message)
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use super::*;
+
+ #[test]
+ fn junit_launch_replaces_the_server_placeholder_port() {
+ let configuration = java_test_launch(JavaTestLaunchRequest {
+ name: "UserServiceTest".to_string(),
+ framework: JavaTestFramework::Junit,
+ working_directory: "/workspace".to_string(),
+ main_class: "org.eclipse.jdt.internal.junit.runner.RemoteTestRunner".to_string(),
+ project_name: Some("service".to_string()),
+ class_paths: vec!["/workspace/classes".to_string()],
+ module_paths: Vec::new(),
+ vm_arguments: vec!["--enable-preview".to_string()],
+ program_arguments: vec![
+ "-version".to_string(),
+ "3".to_string(),
+ "-port".to_string(),
+ "-1".to_string(),
+ ],
+ result_port: 43127,
+ testng_runner_path: None,
+ testng_test_names: Vec::new(),
+ })
+ .expect("JUnit launch should resolve");
+
+ assert_eq!(
+ configuration.arguments["args"],
+ json!("-version 3 -port 43127")
+ );
+ assert_eq!(
+ configuration.arguments["classPaths"],
+ json!(["/workspace/classes"])
+ );
+ assert_eq!(configuration.arguments["vmArgs"], json!("--enable-preview"));
+ }
+
+ #[test]
+ fn testng_launch_appends_the_packaged_runner_once() {
+ let configuration = java_test_launch(JavaTestLaunchRequest {
+ name: "UserServiceTest".to_string(),
+ framework: JavaTestFramework::Testng,
+ working_directory: "/workspace".to_string(),
+ main_class: "com.microsoft.java.test.runner.Launcher".to_string(),
+ project_name: Some("service".to_string()),
+ class_paths: vec![
+ "/workspace/classes".to_string(),
+ "/lithe/java-test-runner.jar".to_string(),
+ ],
+ module_paths: Vec::new(),
+ vm_arguments: Vec::new(),
+ program_arguments: Vec::new(),
+ result_port: 43128,
+ testng_runner_path: Some("/lithe/java-test-runner.jar".to_string()),
+ testng_test_names: vec![
+ "example.UserServiceTest#logsIn".to_string(),
+ "example.UserServiceTest#logsIn".to_string(),
+ ],
+ })
+ .expect("TestNG launch should resolve");
+
+ assert_eq!(
+ configuration.arguments["classPaths"],
+ json!(["/workspace/classes", "/lithe/java-test-runner.jar"])
+ );
+ assert_eq!(
+ configuration.arguments["args"],
+ json!("43128 testng example.UserServiceTest#logsIn")
+ );
+ }
+
+ #[test]
+ fn java_debug_arguments_match_the_adapter_command_line_contract() {
+ assert_eq!(
+ java_debug_argument_string(vec![
+ "-Dlabel=hello world".to_string(),
+ "say\"hello".to_string(),
+ r"C:\plain".to_string(),
+ r"C:\Program Files\Java".to_string(),
+ ]),
+ r#""-Dlabel=hello world" "say\"hello" C:\plain "C:\\Program Files\\Java""#
+ );
+ }
+}
diff --git a/crates/java-domain/src/debug/mod.rs b/crates/java-domain/src/debug/mod.rs
new file mode 100644
index 0000000..654c240
--- /dev/null
+++ b/crates/java-domain/src/debug/mod.rs
@@ -0,0 +1,68 @@
+//! Java test debug launches.
+//!
+//! [`DebugLaunchConfiguration`] and [`DebugRequestKind`] are the parts of Rust
+//! Core's language-neutral debug contract the Java test launcher produces.
+
+mod java_test;
+
+pub use java_test::*;
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+/// DAP request used to begin a debuggee session.
+pub enum DebugRequestKind {
+ /// Start a new debuggee.
+ Launch,
+ /// Attach to a running debuggee.
+ Attach,
+}
+
+impl DebugRequestKind {
+ /// DAP command name of the request.
+ pub fn command(self) -> &'static str {
+ match self {
+ Self::Launch => "launch",
+ Self::Attach => "attach",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+/// Provider-specific launch arguments wrapped in a language-neutral contract.
+pub struct DebugLaunchConfiguration {
+ /// Name shown for the debug session.
+ pub name: String,
+ /// Whether the session launches or attaches.
+ pub request: DebugRequestKind,
+ /// Adapter-specific launch arguments.
+ #[serde(default)]
+ pub arguments: serde_json::Map,
+ /// Optional portable stepping policy projected into adapter-specific launch arguments.
+ #[serde(default)]
+ pub stepping_filters: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+/// Portable class and method filters used for stepping and stack-frame presentation.
+pub struct DebugSteppingFilters {
+ /// Adapter-neutral class-name patterns; Java also accepts `$JDK` and `$Libraries`.
+ #[serde(default)]
+ pub class_name_filters: Vec,
+ /// Whether compiler-generated methods should be skipped.
+ #[serde(default)]
+ pub skip_synthetics: bool,
+ /// Whether class static initializers should be skipped.
+ #[serde(default)]
+ pub skip_static_initializers: bool,
+ /// Whether constructors should be skipped.
+ #[serde(default)]
+ pub skip_constructors: bool,
+ /// Whether native clients should collapse stack frames matched by the class filters.
+ #[serde(default)]
+ pub hide_filtered_stack_frames: bool,
+}
diff --git a/crates/java-domain/src/execution/argfile.rs b/crates/java-domain/src/execution/argfile.rs
new file mode 100644
index 0000000..4ed4a00
--- /dev/null
+++ b/crates/java-domain/src/execution/argfile.rs
@@ -0,0 +1,260 @@
+//! Java argument files that keep long launch commands within the platform
+//! command-line limit.
+//!
+//! Ported from the Swift `MacJavaLaunchArgumentPreparer`. The plan itself
+//! (eligibility, option placement and quoting) is
+//! [`plan_launch_command`](super::plan_launch_command); this module reads the
+//! JDK feature version, writes the planned file exclusively with owner-only
+//! permissions and deletes it when the launch no longer needs it.
+//!
+//! TODO(host-api: API-20 storage): the backend has no plugin-owned temporary
+//! directory yet; callers pass the directory, and the host keeps writing
+//! argument files for launches it spawns itself.
+
+use std::collections::hash_map::RandomState;
+use std::fs;
+use std::hash::BuildHasher;
+use std::io::{self, Write};
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use super::{java_feature_version_from_release, plan_launch_command, LaunchCommandPlan};
+use crate::protocol::{CoreError, ErrorCode};
+
+/// Directories above the executable searched for the JDK `release` file:
+/// `bin/java` reaches `Contents/Home/release` and bundle layouts above it.
+const RELEASE_SEARCH_DEPTH: usize = 6;
+
+/// Feature version of the JDK that owns `executable`, read from its
+/// `release` file without running Java.
+pub fn java_feature_version(executable: &Path) -> Option {
+ let mut directory = executable.parent()?;
+ for _ in 0..RELEASE_SEARCH_DEPTH {
+ if let Ok(contents) = fs::read_to_string(directory.join("release")) {
+ // The first release file that names a version decides, even when
+ // the version cannot be parsed.
+ if contents
+ .lines()
+ .any(|line| line.trim().starts_with("JAVA_VERSION="))
+ {
+ return java_feature_version_from_release(&contents);
+ }
+ }
+ directory = directory.parent()?;
+ }
+ None
+}
+
+/// A fresh argument-file path inside `directory`.
+pub fn next_argfile_path(directory: &Path) -> PathBuf {
+ static SEQUENCE: AtomicU64 = AtomicU64::new(0);
+ let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
+ let nonce = RandomState::new().hash_one(sequence);
+ directory.join(format!(
+ "launch-{}-{sequence}-{nonce:016x}.argfile",
+ std::process::id()
+ ))
+}
+
+/// Keeps an argument file alive until the launched process has read it.
+#[derive(Debug)]
+pub struct ArgumentFileLease {
+ path: PathBuf,
+}
+
+impl ArgumentFileLease {
+ /// Path of the argument file.
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+}
+
+impl Drop for ArgumentFileLease {
+ fn drop(&mut self) {
+ match fs::remove_file(&self.path) {
+ Ok(()) => {}
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {}
+ // TODO(host-api: API-22 log and progress): plugin log channel.
+ Err(error) => eprintln!(
+ "storage cleanup failed: stage=java-argfile-remove error_code={:?}",
+ error.kind()
+ ),
+ }
+ }
+}
+
+/// Arguments to spawn, and the argument file they reference.
+#[derive(Debug)]
+pub struct PreparedJavaLaunch {
+ /// Arguments to pass after the executable.
+ pub arguments: Vec,
+ /// Argument file to keep until the process started; `None` for direct
+ /// launches.
+ pub lease: Option,
+}
+
+/// Shortens `arguments` with an argument file at `argfile_path` when the
+/// command would exceed the platform limit.
+pub fn prepare_java_launch(
+ executable: &str,
+ arguments: Vec,
+ argfile_path: &Path,
+ limit: Option,
+) -> Result {
+ let feature_version = java_feature_version(Path::new(executable));
+ match plan_launch_command(executable, &arguments, argfile_path, limit, feature_version) {
+ LaunchCommandPlan::Direct => Ok(PreparedJavaLaunch {
+ arguments,
+ lease: None,
+ }),
+ LaunchCommandPlan::Argfile {
+ arguments,
+ argfile_contents,
+ } => {
+ write_exclusively(argfile_path, argfile_contents.as_bytes()).map_err(|error| {
+ CoreError::new(
+ ErrorCode::Unknown,
+ format!(
+ "Could not write Java launch argument file at {}: {error}",
+ argfile_path.display()
+ ),
+ )
+ })?;
+ Ok(PreparedJavaLaunch {
+ arguments,
+ lease: Some(ArgumentFileLease {
+ path: argfile_path.to_path_buf(),
+ }),
+ })
+ }
+ }
+}
+
+/// Creates `path` (never replacing a file), readable only by its owner.
+fn write_exclusively(path: &Path, contents: &[u8]) -> io::Result<()> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ let mut options = fs::OpenOptions::new();
+ options.write(true).create_new(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ options.mode(0o600);
+ }
+ let mut file = options.open(path)?;
+ if let Err(error) = file.write_all(contents) {
+ drop(file);
+ let _ = fs::remove_file(path);
+ return Err(error);
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ struct TempDir(PathBuf);
+
+ impl TempDir {
+ fn new(name: &str) -> Self {
+ let path = std::env::temp_dir()
+ .join(format!("lithe-java-argfile-{name}-{}", std::process::id()));
+ let _ = fs::remove_dir_all(&path);
+ fs::create_dir_all(&path).unwrap();
+ Self(path)
+ }
+ }
+
+ impl Drop for TempDir {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.0);
+ }
+ }
+
+ fn jdk(root: &Path, release: &str) -> PathBuf {
+ let home = root.join("jdk/Contents/Home");
+ fs::create_dir_all(home.join("bin")).unwrap();
+ fs::write(home.join("release"), release).unwrap();
+ home.join("bin/java")
+ }
+
+ #[test]
+ fn the_feature_version_comes_from_the_bundle_release_file() {
+ let temp = TempDir::new("version");
+ let java = jdk(
+ &temp.0,
+ "IMPLEMENTOR=\"Eclipse\"\nJAVA_VERSION=\"21.0.2\"\n",
+ );
+ assert_eq!(java_feature_version(&java), Some(21));
+ fs::write(
+ temp.0.join("jdk/Contents/Home/release"),
+ "JAVA_VERSION=\"1.8.0_442\"\n",
+ )
+ .unwrap();
+ assert_eq!(java_feature_version(&java), Some(8));
+ fs::remove_file(temp.0.join("jdk/Contents/Home/release")).unwrap();
+ assert_eq!(java_feature_version(&java), None);
+ }
+
+ #[test]
+ fn long_commands_write_an_owner_only_argfile_that_the_lease_removes() {
+ let temp = TempDir::new("write");
+ let java = jdk(&temp.0, "JAVA_VERSION=\"21.0.2\"\n");
+ let class_path = (0..400)
+ .map(|index| format!("/repository/dependency-{index}.jar"))
+ .collect::>()
+ .join(":");
+ let arguments = vec!["-cp".to_string(), class_path, "app.Main".into()];
+ let path = next_argfile_path(&temp.0.join("lithe-run"));
+ let prepared = prepare_java_launch(
+ java.to_str().unwrap(),
+ arguments.clone(),
+ &path,
+ Some(1_000),
+ )
+ .unwrap();
+ let lease = prepared.lease.expect("argument file");
+ assert_eq!(lease.path(), path);
+ assert!(prepared.arguments.contains(&format!("@{}", path.display())));
+ assert!(fs::read_to_string(&path)
+ .unwrap()
+ .contains("dependency-399.jar"));
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ assert_eq!(
+ fs::metadata(&path).unwrap().permissions().mode() & 0o777,
+ 0o600
+ );
+ }
+ // An existing file is never replaced.
+ let error =
+ prepare_java_launch(java.to_str().unwrap(), arguments, &path, Some(1_000)).unwrap_err();
+ assert!(error
+ .message
+ .starts_with("Could not write Java launch argument file at "));
+ drop(lease);
+ assert!(!path.exists());
+ }
+
+ #[test]
+ fn short_commands_and_old_jdks_launch_directly() {
+ let temp = TempDir::new("direct");
+ let java = jdk(&temp.0, "JAVA_VERSION=\"1.8.0_442\"\n");
+ let path = temp.0.join("unused.argfile");
+ let long = vec!["-cp".to_string(), "x".repeat(5_000), "app.Main".into()];
+ let prepared =
+ prepare_java_launch(java.to_str().unwrap(), long.clone(), &path, Some(1_000)).unwrap();
+ assert_eq!(prepared.arguments, long);
+ assert!(prepared.lease.is_none());
+ assert!(!path.exists());
+ }
+
+ #[test]
+ fn argfile_paths_are_unique() {
+ let directory = Path::new("/tmp/lithe-run");
+ assert_ne!(next_argfile_path(directory), next_argfile_path(directory));
+ }
+}
diff --git a/crates/java-domain/src/execution/classpath_jar.rs b/crates/java-domain/src/execution/classpath_jar.rs
new file mode 100644
index 0000000..85821fa
--- /dev/null
+++ b/crates/java-domain/src/execution/classpath_jar.rs
@@ -0,0 +1,619 @@
+//! Shortens an oversized Java 8 class path through a manifest-only JAR.
+//!
+//! JDK 9 added `java @file` argument files, so [`super::plan_launch_command`]
+//! cannot help a JDK 8 launch. The launcher has always accepted a JAR whose
+//! `META-INF/MANIFEST.MF` lists further class-path entries in its `Class-Path`
+//! attribute. Passing that one JAR with `-cp` keeps the command line short
+//! while the application class loader still sees every original entry in order.
+//!
+//! Core converts each entry to an absolute `file:` URL and renders the manifest
+//! text, including the 72-byte line rule. The host owns the filesystem: it
+//! answers whether an entry is a directory, writes the JAR at the path it
+//! supplied, and deletes it after that execution exits. Every URL is
+//! percent-encoded UTF-8, so the manifest is pure ASCII and, unlike an argument
+//! file, does not depend on the Windows system code page.
+
+use super::launch_command::{command_line_length, COMMAND_LINE_MARGIN, WINDOWS_COMMAND_LINE_LIMIT};
+use std::path::Path;
+
+/// First JDK release whose launcher reads argument files; from this version on
+/// the argument-file planner owns shortening.
+const FIRST_ARGFILE_JAVA_VERSION: u32 = 9;
+
+/// Launcher options that a JDK 8 `java` accepts for the class path. The long
+/// `--class-path` spelling only exists from JDK 9 on.
+const CLASSPATH_OPTIONS: &[&str] = &["-cp", "-classpath"];
+
+/// Longest manifest line in bytes, excluding the line break. Longer headers
+/// continue on lines that start with one space (JAR File Specification).
+const MANIFEST_LINE_LIMIT: usize = 72;
+
+/// Path syntax of the host that will start the JVM. It decides the class-path
+/// separator and how absolute paths are recognized and turned into URLs.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ClasspathStyle {
+ /// `;`-separated entries with drive-letter or UNC absolute paths.
+ Windows,
+ /// `:`-separated entries with `/`-rooted absolute paths.
+ Posix,
+}
+
+/// Inputs for [`plan_classpath_jar_launch`].
+#[derive(Debug, Clone, Copy)]
+pub struct ClasspathJarRequest<'a> {
+ /// Java executable path used for launcher detection.
+ pub executable: &'a str,
+ /// Complete argument vector before shortening.
+ pub arguments: &'a [String],
+ /// Directory the JVM starts in. Relative class-path entries resolve against
+ /// it on the command line, but against the JAR inside a manifest, so Core
+ /// makes them absolute first.
+ pub working_directory: &'a str,
+ /// Host-owned path that may be referenced by a classpath-JAR plan.
+ pub classpath_jar_path: &'a Path,
+ /// Optional host-specific command-line cap; `None` uses the Windows cap.
+ pub limit: Option,
+ /// JDK feature version read by the host from its `release` file.
+ pub java_feature_version: Option,
+ /// Path syntax used by the class-path value.
+ pub style: ClasspathStyle,
+}
+
+/// How a host should start a JDK 8 launch whose class path may be too long.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ClasspathJarPlan {
+ /// Spawn with the original arguments.
+ Direct,
+ /// Write a JAR containing only `META-INF/MANIFEST.MF` with `manifest` at the
+ /// supplied path, then spawn with `arguments`.
+ ClasspathJar {
+ /// Arguments whose effective class-path value is the JAR path.
+ arguments: Vec,
+ /// Complete ASCII manifest text with CRLF line breaks.
+ manifest: String,
+ },
+}
+
+/// Plans a manifest-JAR launch for a known JDK older than 9.
+///
+/// `is_directory` receives each absolute entry and reports whether it names a
+/// directory: a manifest URL must end in `/` for the class loader to treat it as
+/// a directory rather than an archive. Returns [`ClasspathJarPlan::Direct`] when
+/// the JDK is unknown or supports argument files, the command fits, the launch
+/// uses `-jar` (which ignores `-cp`), or an entry cannot be expressed as an
+/// absolute URL without changing its meaning, such as a `lib/*` wildcard.
+pub fn plan_classpath_jar_launch(
+ request: ClasspathJarRequest<'_>,
+ is_directory: impl Fn(&str) -> bool,
+) -> ClasspathJarPlan {
+ let executable_name = request
+ .executable
+ .rsplit(['/', '\\'])
+ .next()
+ .unwrap_or(request.executable);
+ if !["java", "java.exe", "javaw", "javaw.exe"]
+ .iter()
+ .any(|name| executable_name.eq_ignore_ascii_case(name))
+ || request
+ .java_feature_version
+ .is_none_or(|version| version >= FIRST_ARGFILE_JAVA_VERSION)
+ {
+ return ClasspathJarPlan::Direct;
+ }
+ let budget = request
+ .limit
+ .unwrap_or(WINDOWS_COMMAND_LINE_LIMIT)
+ .saturating_sub(COMMAND_LINE_MARGIN);
+ if command_line_length(request.executable, request.arguments) <= budget {
+ return ClasspathJarPlan::Direct;
+ }
+ let Some(value_index) = effective_classpath_value_index(request.arguments) else {
+ return ClasspathJarPlan::Direct;
+ };
+ let Some(urls) = classpath_urls(
+ &request.arguments[value_index],
+ request.working_directory,
+ request.style,
+ &is_directory,
+ ) else {
+ return ClasspathJarPlan::Direct;
+ };
+ let mut arguments = request.arguments.to_vec();
+ arguments[value_index] = request.classpath_jar_path.display().to_string();
+ let mut manifest = String::from("Manifest-Version: 1.0\r\n");
+ manifest.push_str(&manifest_header("Class-Path", &urls.join(" ")));
+ // A header is only recognized once its line is terminated, and the blank
+ // line closes the main section.
+ manifest.push_str("\r\n");
+ ClasspathJarPlan::ClasspathJar {
+ arguments,
+ manifest,
+ }
+}
+
+/// Finds the class-path value the JDK 8 launcher will use.
+///
+/// Launcher options end at the main class or `-jar`. When `-cp` appears more
+/// than once the last occurrence wins, so only that value is replaced. `-jar`
+/// ignores `-cp` entirely, which leaves nothing to shorten.
+fn effective_classpath_value_index(arguments: &[String]) -> Option {
+ let mut effective = None;
+ let mut index = 0;
+ while index < arguments.len() {
+ let argument = arguments[index].as_str();
+ if argument == "-jar" {
+ return None;
+ }
+ if !argument.starts_with('-') {
+ break;
+ }
+ if CLASSPATH_OPTIONS.contains(&argument) && index + 1 < arguments.len() {
+ effective = Some(index + 1);
+ index += 2;
+ } else {
+ index += 1;
+ }
+ }
+ effective
+}
+
+/// Converts a joined class-path value into manifest URLs in launcher order.
+fn classpath_urls(
+ value: &str,
+ working_directory: &str,
+ style: ClasspathStyle,
+ is_directory: &dyn Fn(&str) -> bool,
+) -> Option> {
+ let separator = match style {
+ ClasspathStyle::Windows => ';',
+ ClasspathStyle::Posix => ':',
+ };
+ value
+ .split(separator)
+ .map(|entry| {
+ // The launcher reads an empty element as the current directory.
+ let entry = if entry.is_empty() { "." } else { entry };
+ let absolute = absolute_entry(entry, working_directory, style)?;
+ // Launcher wildcards are expanded only on the command line; the
+ // manifest class loader would look for a file literally named `*`.
+ if absolute.rsplit(['/', '\\']).next() == Some("*") {
+ return None;
+ }
+ Some(file_url(&absolute, style, is_directory(&absolute)))
+ })
+ .collect()
+}
+
+/// Makes one entry absolute, or returns `None` when its base is ambiguous.
+fn absolute_entry(entry: &str, working_directory: &str, style: ClasspathStyle) -> Option {
+ match style {
+ ClasspathStyle::Posix => {
+ if entry.starts_with('/') {
+ Some(entry.to_string())
+ } else if working_directory.starts_with('/') {
+ Some(format!(
+ "{}/{entry}",
+ working_directory.trim_end_matches('/')
+ ))
+ } else {
+ None
+ }
+ }
+ ClasspathStyle::Windows => {
+ let entry = strip_verbatim_prefix(&entry.replace('/', "\\"))?;
+ let working_directory = strip_verbatim_prefix(&working_directory.replace('/', "\\"))?;
+ let working_directory = working_directory.as_str();
+ let path = if windows_root(&entry).is_some() {
+ entry
+ } else if entry.starts_with('\\') {
+ // `\lib\a.jar` is rooted on the working directory's drive.
+ format!(
+ "{}{entry}",
+ windows_root(working_directory)?.trim_end_matches('\\')
+ )
+ } else if entry.as_bytes().get(1) == Some(&b':') {
+ // `C:lib` depends on a per-drive current directory that the
+ // host never sees, so it cannot be resolved deterministically.
+ return None;
+ } else {
+ windows_root(working_directory)?;
+ format!(
+ "{}\\{entry}",
+ working_directory.trim_end_matches(['\\', '/'])
+ )
+ };
+ Some(normalize_windows_path(&path))
+ }
+ }
+}
+
+/// Rewrites `\\?\C:\x` to `C:\x` and `\\?\UNC\host\share` to `\\host\share`.
+///
+/// Other `\\?\` and `\\.\` device paths have no `file:` URL form, so they
+/// return `None` and keep the launch direct instead of becoming a bogus UNC
+/// host named `?` or `.`.
+fn strip_verbatim_prefix(entry: &str) -> Option {
+ if let Some(rest) = entry.strip_prefix("\\\\?\\UNC\\") {
+ return Some(format!("\\\\{rest}"));
+ }
+ if let Some(rest) = entry.strip_prefix("\\\\?\\") {
+ let bytes = rest.as_bytes();
+ let drive = bytes.len() >= 3
+ && bytes[0].is_ascii_alphabetic()
+ && bytes[1] == b':'
+ && bytes[2] == b'\\';
+ return drive.then(|| rest.to_string());
+ }
+ if entry.starts_with("\\\\.\\") {
+ return None;
+ }
+ Some(entry.to_string())
+}
+
+/// Returns the root prefix of an absolute Windows path: `C:\` or `\\host\share`.
+fn windows_root(path: &str) -> Option {
+ let path = path.replace('/', "\\");
+ let bytes = path.as_bytes();
+ if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' {
+ return Some(path[..3].to_string());
+ }
+ let unc = path.strip_prefix("\\\\")?;
+ let mut parts = unc.splitn(3, '\\');
+ let host = parts.next().filter(|part| !part.is_empty())?;
+ let share = parts.next().filter(|part| !part.is_empty())?;
+ Some(format!("\\\\{host}\\{share}"))
+}
+
+/// Removes `.` and `..` segments the way Windows does, which is lexical.
+fn normalize_windows_path(path: &str) -> String {
+ let root = windows_root(path).expect("absolute Windows path");
+ let mut segments: Vec<&str> = Vec::new();
+ for segment in path[root.len()..].split('\\') {
+ match segment {
+ "" | "." => {}
+ ".." => {
+ segments.pop();
+ }
+ _ => segments.push(segment),
+ }
+ }
+ let root = root.trim_end_matches('\\');
+ if segments.is_empty() {
+ format!("{root}\\")
+ } else {
+ format!("{root}\\{}", segments.join("\\"))
+ }
+}
+
+/// Renders an absolute path as a `file:` URL the JDK class loader can open.
+///
+/// Drive paths become `file:/C:/...` and UNC paths `file:////host/share/...`,
+/// matching `java.io.File.toURI`. Every byte outside a small unreserved set is
+/// percent-encoded as UTF-8; the JDK decodes the URL before opening the file.
+fn file_url(path: &str, style: ClasspathStyle, directory: bool) -> String {
+ let path = match style {
+ ClasspathStyle::Windows => {
+ let slashed = path.replace('\\', "/");
+ if slashed.starts_with("//") {
+ format!("//{slashed}")
+ } else {
+ format!("/{slashed}")
+ }
+ }
+ ClasspathStyle::Posix => path.to_string(),
+ };
+ let mut url = String::from("file:");
+ for byte in path.bytes() {
+ if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/' | b':') {
+ url.push(byte as char);
+ } else {
+ url.push_str(&format!("%{byte:02X}"));
+ }
+ }
+ if directory && !url.ends_with('/') {
+ url.push('/');
+ }
+ url
+}
+
+/// Renders one manifest header, continuing lines after 72 bytes.
+///
+/// The text is ASCII because every URL is percent-encoded, so splitting at a
+/// byte offset can never cut a character in half.
+fn manifest_header(name: &str, value: &str) -> String {
+ let line = format!("{name}: {value}");
+ debug_assert!(line.is_ascii());
+ let mut rendered = String::with_capacity(line.len() + line.len() / 35 + 2);
+ let mut rest = line.as_str();
+ let mut width = MANIFEST_LINE_LIMIT;
+ loop {
+ let (head, tail) = rest.split_at(rest.len().min(width));
+ rendered.push_str(head);
+ rendered.push_str("\r\n");
+ if tail.is_empty() {
+ return rendered;
+ }
+ rendered.push(' ');
+ rest = tail;
+ width = MANIFEST_LINE_LIMIT - 1;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn jar_path() -> &'static Path {
+ Path::new("C:\\Temp\\lithe-run\\launch-1-0.classpath.jar")
+ }
+
+ /// Mirrors a resolved Maven runtime classpath that exceeds the Windows cap.
+ fn classpath(entries: usize) -> String {
+ (0..entries)
+ .map(|index| {
+ format!(
+ "C:\\Users\\developer\\.m2\\repository\\org\\example\\artifact-{index}\\1.0.{index}\\artifact-{index}-1.0.{index}.jar"
+ )
+ })
+ .collect::>()
+ .join(";")
+ }
+
+ fn request<'a>(arguments: &'a [String], version: Option) -> ClasspathJarRequest<'a> {
+ ClasspathJarRequest {
+ executable: "C:\\jdk8\\bin\\java.exe",
+ arguments,
+ working_directory: "C:\\work\\demo",
+ classpath_jar_path: jar_path(),
+ limit: None,
+ java_feature_version: version,
+ style: ClasspathStyle::Windows,
+ }
+ }
+
+ /// Joins continuation lines back into logical headers.
+ fn class_path_urls(manifest: &str) -> Vec {
+ let logical = manifest.replace("\r\n ", "");
+ logical
+ .split("\r\n")
+ .find_map(|line| line.strip_prefix("Class-Path: "))
+ .expect("manifest has a Class-Path header")
+ .split(' ')
+ .map(str::to_string)
+ .collect()
+ }
+
+ fn no_directories(_: &str) -> bool {
+ false
+ }
+
+ /// Regression for #955: a large JDK 8 project failed in `CreateProcessW`
+ /// because argument files only exist from JDK 9 on.
+ #[test]
+ fn an_oversized_jdk8_classpath_moves_into_a_manifest_jar() {
+ let arguments = vec![
+ "-Dfile.encoding=UTF-8".to_string(),
+ "-cp".to_string(),
+ classpath(500),
+ "com.example.Main".to_string(),
+ "--server.port=8080".to_string(),
+ ];
+ let ClasspathJarPlan::ClasspathJar {
+ arguments: shortened,
+ manifest,
+ } = plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories)
+ else {
+ panic!("an oversized JDK 8 classpath must be shortened");
+ };
+ assert_eq!(
+ shortened,
+ [
+ "-Dfile.encoding=UTF-8",
+ "-cp",
+ "C:\\Temp\\lithe-run\\launch-1-0.classpath.jar",
+ "com.example.Main",
+ "--server.port=8080",
+ ]
+ );
+ assert!(manifest.starts_with("Manifest-Version: 1.0\r\nClass-Path: file:/C:/Users/"));
+ assert!(manifest.ends_with("\r\n\r\n"));
+ let urls = class_path_urls(&manifest);
+ assert_eq!(urls.len(), 500);
+ assert_eq!(
+ urls[0],
+ "file:/C:/Users/developer/.m2/repository/org/example/artifact-0/1.0.0/artifact-0-1.0.0.jar"
+ );
+ }
+
+ #[test]
+ fn manifest_lines_never_exceed_72_bytes() {
+ let arguments = vec!["-cp".into(), classpath(500), "Main".into()];
+ let ClasspathJarPlan::ClasspathJar { manifest, .. } =
+ plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories)
+ else {
+ panic!("classpath should move");
+ };
+ let body = manifest
+ .strip_suffix("\r\n\r\n")
+ .expect("terminated section");
+ let lines: Vec<&str> = body.split("\r\n").collect();
+ assert!(lines.len() > 100, "a long header must wrap");
+ assert!(lines.iter().all(|line| line.len() <= MANIFEST_LINE_LIMIT));
+ // Continuation lines start with the one marker space and fill the
+ // remaining 71 bytes, except the last one. The content after the
+ // marker may itself begin with the space that separates two URLs.
+ for line in &lines[2..lines.len() - 1] {
+ assert_eq!(line.len(), MANIFEST_LINE_LIMIT);
+ assert!(line.starts_with(' '));
+ }
+ let urls = class_path_urls(&manifest);
+ assert_eq!(urls.len(), 500);
+ assert!(urls.iter().all(|url| url.starts_with("file:/C:/Users/")));
+ // Every break is CRLF, the form the `jar` tool writes.
+ assert_eq!(
+ manifest.matches('\n').count(),
+ manifest.matches("\r\n").count()
+ );
+ }
+
+ #[test]
+ fn spaces_chinese_and_reserved_characters_are_percent_encoded() {
+ let entries = format!(
+ "C:\\Program Files\\示例 库\\a#b%c+d.jar;D:/mixed/slash.jar;{}",
+ classpath(500)
+ );
+ let arguments = vec!["-cp".into(), entries, "Main".into()];
+ let ClasspathJarPlan::ClasspathJar { manifest, .. } =
+ plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories)
+ else {
+ panic!("classpath should move");
+ };
+ assert!(
+ manifest.is_ascii(),
+ "manifest must not depend on a code page"
+ );
+ let urls = class_path_urls(&manifest);
+ assert_eq!(
+ urls[0],
+ "file:/C:/Program%20Files/%E7%A4%BA%E4%BE%8B%20%E5%BA%93/a%23b%25c%2Bd.jar"
+ );
+ assert_eq!(urls[1], "file:/D:/mixed/slash.jar");
+ }
+
+ #[test]
+ fn directories_relative_unc_and_empty_entries_become_absolute_urls() {
+ let entries = format!(
+ "target\\classes;..\\shared\\lib.jar;\\tools\\x.jar;\\\\server\\share\\dep.jar;;\\\\?\\C:\\long\\v.jar;\\\\?\\UNC\\server\\share\\w.jar;{}",
+ classpath(500)
+ );
+ let arguments = vec!["-cp".into(), entries, "Main".into()];
+ let directories = ["C:\\work\\demo\\target\\classes", "C:\\work\\demo"];
+ let ClasspathJarPlan::ClasspathJar { manifest, .. } =
+ plan_classpath_jar_launch(request(&arguments, Some(8)), |path| {
+ directories.contains(&path)
+ })
+ else {
+ panic!("classpath should move");
+ };
+ let urls = class_path_urls(&manifest);
+ assert_eq!(urls[0], "file:/C:/work/demo/target/classes/");
+ assert_eq!(urls[1], "file:/C:/work/shared/lib.jar");
+ assert_eq!(urls[2], "file:/C:/tools/x.jar");
+ assert_eq!(urls[3], "file:////server/share/dep.jar");
+ // An empty element means the working directory to the launcher.
+ assert_eq!(urls[4], "file:/C:/work/demo/");
+ // Verbatim paths from canonicalized tooling keep their real target.
+ assert_eq!(urls[5], "file:/C:/long/v.jar");
+ assert_eq!(urls[6], "file:////server/share/w.jar");
+ }
+
+ #[test]
+ fn jdk9_unknown_short_and_non_java_launches_stay_direct() {
+ let long = vec!["-cp".into(), classpath(500), "Main".into()];
+ for version in [None, Some(9), Some(21)] {
+ assert_eq!(
+ plan_classpath_jar_launch(request(&long, version), no_directories),
+ ClasspathJarPlan::Direct
+ );
+ }
+ let short = vec!["-cp".into(), classpath(3), "Main".into()];
+ assert_eq!(
+ plan_classpath_jar_launch(request(&short, Some(8)), no_directories),
+ ClasspathJarPlan::Direct
+ );
+ let relative = vec![
+ "-cp".into(),
+ format!("classes;{}", classpath(500)),
+ "Main".into(),
+ ];
+ let mut verbatim = request(&relative, Some(8));
+ verbatim.working_directory = "\\\\?\\C:\\work\\demo";
+ let ClasspathJarPlan::ClasspathJar { manifest, .. } =
+ plan_classpath_jar_launch(verbatim, no_directories)
+ else {
+ panic!("a verbatim working directory still resolves relative entries");
+ };
+ assert_eq!(class_path_urls(&manifest)[0], "file:/C:/work/demo/classes");
+ let mut node = request(&long, Some(8));
+ node.executable = "node.exe";
+ assert_eq!(
+ plan_classpath_jar_launch(node, no_directories),
+ ClasspathJarPlan::Direct
+ );
+ }
+
+ #[test]
+ fn launches_whose_meaning_a_manifest_cannot_preserve_stay_direct() {
+ for (entries, target) in [
+ (format!("lib\\*;{}", classpath(500)), "Main"),
+ (format!("C:lib\\a.jar;{}", classpath(500)), "Main"),
+ (format!("\\\\.\\pipe\\x;{}", classpath(500)), "Main"),
+ (classpath(500), "-jar"),
+ ] {
+ let arguments = vec!["-cp".into(), entries, target.into(), "app.jar".into()];
+ assert_eq!(
+ plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories),
+ ClasspathJarPlan::Direct,
+ "{target}"
+ );
+ }
+ let without_classpath = vec!["Main".into(), classpath(500)];
+ assert_eq!(
+ plan_classpath_jar_launch(request(&without_classpath, Some(8)), no_directories),
+ ClasspathJarPlan::Direct
+ );
+ }
+
+ #[test]
+ fn only_the_effective_classpath_before_the_main_class_is_replaced() {
+ let arguments = vec![
+ "-cp".into(),
+ "ignored.jar".into(),
+ "-classpath".into(),
+ classpath(500),
+ "Main".into(),
+ "-cp".into(),
+ "program-value".into(),
+ ];
+ let ClasspathJarPlan::ClasspathJar {
+ arguments: shortened,
+ manifest,
+ } = plan_classpath_jar_launch(request(&arguments, Some(8)), no_directories)
+ else {
+ panic!("classpath should move");
+ };
+ assert_eq!(&shortened[..3], &arguments[..3]);
+ assert_eq!(
+ shortened[3],
+ "C:\\Temp\\lithe-run\\launch-1-0.classpath.jar"
+ );
+ assert_eq!(&shortened[4..], &arguments[4..]);
+ assert!(!manifest.contains("program-value"));
+ assert!(!manifest.contains("ignored.jar"));
+ }
+
+ #[test]
+ fn posix_paths_use_colon_separators() {
+ let entries = (0..800)
+ .map(|index| format!("/home/dev/.m2/repository/lib {index}/artifact-{index}.jar"))
+ .chain(["classes".to_string()])
+ .collect::>()
+ .join(":");
+ let arguments = vec!["-cp".into(), entries, "Main".into()];
+ let mut posix = request(&arguments, Some(8));
+ posix.executable = "/opt/jdk8/bin/java";
+ posix.working_directory = "/work/demo";
+ posix.style = ClasspathStyle::Posix;
+ let ClasspathJarPlan::ClasspathJar { manifest, .. } =
+ plan_classpath_jar_launch(posix, |path| path == "/work/demo/classes")
+ else {
+ panic!("classpath should move");
+ };
+ let urls = class_path_urls(&manifest);
+ assert_eq!(
+ urls[0],
+ "file:/home/dev/.m2/repository/lib%200/artifact-0.jar"
+ );
+ assert_eq!(urls[800], "file:/work/demo/classes/");
+ }
+}
diff --git a/crates/java-domain/src/execution/java_selection.rs b/crates/java-domain/src/execution/java_selection.rs
new file mode 100644
index 0000000..fba4564
--- /dev/null
+++ b/crates/java-domain/src/execution/java_selection.rs
@@ -0,0 +1,149 @@
+//! Selects an automatic project JDK from platform-probed candidates.
+//! Project requirements remain owned by the existing run-configuration document.
+
+use super::toolchain_requirements::{
+ java_feature_version_parts, read_requirements, version_parts, version_satisfies,
+ ToolchainRequirementsDocument,
+};
+use crate::protocol::{CoreError, ErrorCode};
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+
+/// A probed candidate; `id` is opaque and is never persisted or opened by Core.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct JavaSelectionCandidate {
+ /// Opaque machine-local identity supplied by the host.
+ pub id: String,
+ /// Version reported by the Java executable; empty means unknown.
+ pub version: String,
+ /// Lower values win: JAVA_HOME, PATH, installations, project-managed JDK.
+ pub priority: u32,
+}
+
+/// Platform candidates and the workspace whose existing requirements apply.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct JavaSelectionRequest {
+ /// Open workspace; absent means no project requirements.
+ pub root: Option,
+ /// Host-read snapshot for UI-safe selection; mutually exclusive with `root`.
+ /// The same document validation and selection policy apply to both sources.
+ pub requirements: Option,
+ /// Usable executable homes probed by the platform.
+ pub candidates: Vec,
+ /// The platform's unconstrained choice; used when requirements are absent
+ /// or no candidate satisfies them. This keeps fallback behavior explicit.
+ pub fallback_id: Option,
+}
+
+/// Selected candidate and an actionable warning when no compatible JDK exists.
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct JavaSelection {
+ /// Selected identity, or absent when the host found no usable JDK.
+ pub id: Option,
+ /// No candidate satisfies the minimum; the fallback remains usable.
+ pub warning: Option,
+}
+
+/// Orders equivalent sources numerically, including legacy Java `1.8` versions.
+/// Stable IDs break equal-version ties independently of filesystem enumeration.
+pub fn compare_java_candidates(
+ left: &JavaSelectionCandidate,
+ right: &JavaSelectionCandidate,
+) -> std::cmp::Ordering {
+ left.priority
+ .cmp(&right.priority)
+ .then_with(|| {
+ java_feature_version_parts(version_parts(&right.version))
+ .cmp(&java_feature_version_parts(version_parts(&left.version)))
+ })
+ .then_with(|| left.id.cmp(&right.id))
+}
+
+/// Reuses Core's requirement reader and version semantics without generating
+/// requirements, probing executables, or changing an explicit user selection.
+pub fn select_java(request: JavaSelectionRequest) -> Result {
+ if request.root.is_some() && request.requirements.is_some() {
+ return Err(CoreError::new(
+ ErrorCode::InvalidRequest,
+ "Java selection must use either a workspace or a requirements snapshot",
+ ));
+ }
+ let document = request.requirements.or(request
+ .root
+ .as_deref()
+ .map(read_requirements)
+ .transpose()?
+ .flatten());
+ if let Some(document) = &document {
+ super::toolchain_requirements::validate_sidecar_version(document.version)?;
+ }
+ let minimum = document
+ .as_ref()
+ .and_then(|doc| doc.toolchains.get("project-jdk"))
+ .filter(|requirement| requirement.kind == "java")
+ .and_then(|requirement| requirement.minimum_version.as_deref());
+ Ok(select_java_candidates(
+ &request.candidates,
+ request.fallback_id.as_deref(),
+ minimum,
+ ))
+}
+
+/// Java feature version (`8` for `1.8.0_402`, `21` for `21.0.4`); `None` when unknown.
+fn java_feature(version: &str) -> Option {
+ java_feature_version_parts(version_parts(version))
+ .first()
+ .copied()
+}
+
+/// Applies only the project's declared Java version; explicit paths and vendor
+/// preferences remain the responsibility of existing launch validation and diagnostics.
+///
+/// A JDK of exactly the declared feature version wins over a newer one, even from a
+/// higher-priority source such as `JAVA_HOME`: a Java 8 project must not run on the
+/// JDK 21 a shell exports. Without one, the oldest newer JDK is the closest match.
+/// Source priority only breaks ties within the same feature version.
+///
+/// The policy follows OpenIDEA's `pick_jdk` (`crates/ide-sdk/src/java.rs`,
+/// , Apache-2.0); see the repository `NOTICE`.
+pub fn select_java_candidates(
+ candidates: &[JavaSelectionCandidate],
+ fallback_id: Option<&str>,
+ minimum: Option<&str>,
+) -> JavaSelection {
+ let fallback = candidates
+ .iter()
+ .find(|candidate| Some(candidate.id.as_str()) == fallback_id)
+ .or_else(|| {
+ candidates
+ .iter()
+ .min_by(|a, b| compare_java_candidates(a, b))
+ });
+ let Some(minimum) = minimum else {
+ return JavaSelection {
+ id: fallback.map(|candidate| candidate.id.clone()),
+ warning: None,
+ };
+ };
+ let required = java_feature(minimum);
+ let distance = |candidate: &JavaSelectionCandidate| {
+ java_feature(&candidate.version)
+ .zip(required)
+ .map(|(feature, required)| feature.saturating_sub(required))
+ };
+ let matching = candidates
+ .iter()
+ .filter(|candidate| version_satisfies("java", &candidate.version, minimum, true))
+ .min_by(|a, b| {
+ distance(a)
+ .cmp(&distance(b))
+ .then_with(|| compare_java_candidates(a, b))
+ });
+ JavaSelection {
+ id: matching.or(fallback).map(|candidate| candidate.id.clone()),
+ warning: matching.is_none().then(|| format!("No installed JDK satisfies Java {minimum} or newer. Install a compatible JDK or choose one in project settings.")),
+ }
+}
diff --git a/crates/java-domain/src/execution/launch_command.rs b/crates/java-domain/src/execution/launch_command.rs
new file mode 100644
index 0000000..10518b7
--- /dev/null
+++ b/crates/java-domain/src/execution/launch_command.rs
@@ -0,0 +1,553 @@
+//! Keeps an assembled launch command within the operating system's limit.
+//!
+//! A Java project launch passes the resolved runtime classpath on the command
+//! line. For a large multi-module Maven project that is hundreds of absolute
+//! jar paths, and Windows refuses to create a process whose command line
+//! exceeds 32,767 UTF-16 code units. The JDK's own answer is an argument file:
+//! `java @file` reads options from disk, so only the short reference stays on
+//! the command line.
+//!
+//! Core decides whether shortening is required and what the file must contain.
+//! The host owns the filesystem: it chooses the path, writes the file, and
+//! deletes it after that execution exits. The returned text is Unicode; the
+//! host must encode it losslessly with the launcher's native platform encoding.
+//! JEP 400 does not make Windows launcher argument files UTF-8.
+//!
+//! JDK 8 has no argument files; [`super::plan_classpath_jar_launch`] covers
+//! that release with a manifest-only class-path JAR under the same budget.
+
+use serde::{Deserialize, Serialize};
+use std::path::Path;
+
+/// Maximum command line `CreateProcessW` accepts, including its terminating
+/// null. Exceeding it fails the spawn with `ERROR_FILENAME_EXCED_RANGE` (206).
+pub const WINDOWS_COMMAND_LINE_LIMIT: usize = 32_767;
+
+/// Headroom for the null terminator and for host quoting that this estimate
+/// does not model exactly. Shortening early costs nothing; shortening too late
+/// fails the launch.
+pub(super) const COMMAND_LINE_MARGIN: usize = 2_048;
+
+/// Options whose value is a joined list of absolute paths. These carry
+/// practically all of an oversized command line, so moving them into the
+/// argument file leaves the JVM options and program arguments untouched.
+const PATH_LIST_OPTIONS: &[&str] = &["-cp", "-classpath", "--class-path", "-p", "--module-path"];
+
+/// How a host should pass the assembled arguments to the operating system.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum LaunchCommandPlan {
+ /// The command line fits: spawn with the original arguments.
+ Direct,
+ /// The command line is too long. Write `argfile_contents` to the argfile
+ /// path that was supplied, then spawn with `arguments`.
+ Argfile {
+ /// Arguments to spawn with; the moved options become one `@file` entry.
+ arguments: Vec,
+ /// Complete argument-file text, already quoted and newline terminated.
+ argfile_contents: String,
+ },
+}
+
+/// JSON request used by hosts that cannot call the Rust planner directly.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LaunchCommandPlanRequest {
+ /// Java executable path used for launcher detection.
+ pub executable: String,
+ /// Complete argument vector before shortening.
+ pub arguments: Vec,
+ /// Host-owned path that may be referenced by an argument-file plan.
+ pub argfile_path: String,
+ /// Optional host-specific command-line cap; null uses the Windows cap.
+ pub limit: Option,
+ /// JDK feature version read by the host from its `release` file.
+ pub java_feature_version: Option,
+}
+
+/// JSON response for [`LaunchCommandPlanRequest`].
+#[derive(Debug, Serialize)]
+#[serde(tag = "kind", rename_all = "camelCase")]
+pub enum LaunchCommandPlanResponse {
+ /// The original argument vector is safe to spawn directly.
+ Direct,
+ /// The host must write `argfileContents` before spawning `arguments`.
+ Argfile {
+ /// Arguments with path-list options replaced by an `@file` reference.
+ arguments: Vec,
+ /// Newline-terminated JDK argument-file contents.
+ #[serde(rename = "argfileContents")]
+ argfile_contents: String,
+ },
+}
+
+impl From for LaunchCommandPlanResponse {
+ fn from(plan: LaunchCommandPlan) -> Self {
+ match plan {
+ LaunchCommandPlan::Direct => Self::Direct,
+ LaunchCommandPlan::Argfile {
+ arguments,
+ argfile_contents,
+ } => Self::Argfile {
+ arguments,
+ argfile_contents,
+ },
+ }
+ }
+}
+
+/// Plans a launch through the shared JSON command dispatcher.
+pub fn plan_launch_command_request(request: LaunchCommandPlanRequest) -> LaunchCommandPlanResponse {
+ plan_launch_command(
+ &request.executable,
+ &request.arguments,
+ Path::new(&request.argfile_path),
+ request.limit,
+ request.java_feature_version,
+ )
+ .into()
+}
+
+/// First JDK release that accepts `@file` argument files.
+const MINIMUM_ARGFILE_JAVA_VERSION: u32 = 9;
+
+/// Reads the feature version from a JDK `release` file.
+///
+/// `JAVA_VERSION="21.0.2"` yields 21 and the legacy `"1.8.0_392"` yields 8.
+/// Hosts pass the result to [`plan_launch_command`], which needs it to know
+/// whether an argument file is supported. Encoding is the host's responsibility.
+pub fn java_feature_version_from_release(contents: &str) -> Option {
+ let value = java_version_from_release(contents)?;
+ release_feature_version(value)
+}
+
+/// Borrows the full version from JDK-owned release metadata without running Java.
+/// Native discovery uses this value with the shared runtime candidate ordering;
+/// absent or unrecognized metadata must not trigger an implicit process probe.
+pub fn java_version_from_release(contents: &str) -> Option<&str> {
+ let value = contents
+ .lines()
+ .find_map(|line| line.trim().strip_prefix("JAVA_VERSION="))?
+ .trim()
+ .trim_matches('"');
+ release_feature_version(value)?;
+ Some(value)
+}
+
+fn release_feature_version(value: &str) -> Option {
+ let mut parts = value.split(['.', '_', '-', '+']);
+ let first = parts.next()?.parse::().ok()?;
+ if first == 1 {
+ parts.next()?.parse::().ok()
+ } else {
+ Some(first)
+ }
+}
+
+/// Plans how to spawn `executable` with `arguments` under `limit`.
+///
+/// `argfile_path` is the file the host will write when the returned plan asks
+/// for one; it is only referenced, never created here. `limit` defaults to the
+/// Windows command-line cap, which is the only mainstream limit small enough
+/// for a Java class path to reach. `java_feature_version` is the JVM that will
+/// expand the file; `None` means unknown and keeps the plan conservative.
+pub fn plan_launch_command(
+ executable: &str,
+ arguments: &[String],
+ argfile_path: &Path,
+ limit: Option,
+ java_feature_version: Option,
+) -> LaunchCommandPlan {
+ let executable_name = executable.rsplit(['/', '\\']).next().unwrap_or(executable);
+ if !["java", "java.exe", "javaw", "javaw.exe"]
+ .iter()
+ .any(|name| executable_name.eq_ignore_ascii_case(name))
+ || java_feature_version.is_none_or(|version| version < MINIMUM_ARGFILE_JAVA_VERSION)
+ {
+ return LaunchCommandPlan::Direct;
+ }
+ let limit = limit.unwrap_or(WINDOWS_COMMAND_LINE_LIMIT);
+ let budget = limit.saturating_sub(COMMAND_LINE_MARGIN);
+ if command_line_length(executable, arguments) <= budget {
+ return LaunchCommandPlan::Direct;
+ }
+ let mut remaining = Vec::with_capacity(arguments.len());
+ let mut argfile_lines = Vec::new();
+ let mut index = 0;
+ while index < arguments.len() {
+ let argument = arguments[index].as_str();
+ let value = arguments.get(index + 1);
+ // The first application target ends launcher options. Everything after
+ // it belongs to the program, even when named -p or --class-path.
+ if !argument.starts_with('-')
+ || matches!(argument, "-jar" | "-m" | "--module")
+ || argument.starts_with("--module=")
+ {
+ remaining.extend_from_slice(&arguments[index..]);
+ break;
+ }
+ if argument == "--disable-@files" {
+ return LaunchCommandPlan::Direct;
+ }
+ match value {
+ Some(value) if PATH_LIST_OPTIONS.contains(&argument) => {
+ if argfile_lines.is_empty() {
+ remaining.push(format!("@{}", argfile_path.display()));
+ }
+ argfile_lines.push(argument.to_string());
+ argfile_lines.push(quote_argfile_value(value));
+ index += 2;
+ }
+ Some(value)
+ if matches!(
+ argument,
+ "--add-modules"
+ | "--limit-modules"
+ | "--add-reads"
+ | "--add-exports"
+ | "--add-opens"
+ | "--patch-module"
+ | "--upgrade-module-path"
+ | "--enable-native-access"
+ | "--source"
+ | "--describe-module"
+ | "-d"
+ ) =>
+ {
+ remaining.push(argument.to_string());
+ remaining.push(value.clone());
+ index += 2;
+ }
+ _ => {
+ remaining.push(argument.to_string());
+ index += 1;
+ }
+ }
+ }
+ if argfile_lines.is_empty() {
+ // Nothing movable: the caller's own arguments are oversized, and the
+ // host should report the operating system's refusal rather than
+ // silently rewrite a command it does not understand.
+ return LaunchCommandPlan::Direct;
+ }
+ let mut argfile_contents = argfile_lines.join("\n");
+ argfile_contents.push('\n');
+ LaunchCommandPlan::Argfile {
+ arguments: remaining,
+ argfile_contents,
+ }
+}
+
+/// Estimates the command line the host will hand to the operating system.
+///
+/// Windows counts UTF-16 code units, and each argument containing whitespace or
+/// a quote is wrapped in quotes. The estimate never has to be exact because
+/// [`COMMAND_LINE_MARGIN`] absorbs the difference.
+pub(super) fn command_line_length(executable: &str, arguments: &[String]) -> usize {
+ let mut length = quoted_length(executable);
+ for argument in arguments {
+ length += 1 + quoted_length(argument);
+ }
+ length
+}
+
+fn quoted_length(value: &str) -> usize {
+ let units = value.encode_utf16().count();
+ if value.is_empty() || value.contains([' ', '\t', '"']) {
+ units + 2 + value.matches('"').count()
+ } else {
+ units
+ }
+}
+
+/// Quotes one value for a JDK argument file.
+///
+/// Inside a quoted argfile string the backslash is an escape character, so
+/// every separator in a Windows path has to be doubled. Quoting also keeps
+/// paths that contain spaces in one argument.
+fn quote_argfile_value(value: &str) -> String {
+ let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
+ format!("\"{escaped}\"")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::path::PathBuf;
+
+ fn argfile() -> PathBuf {
+ PathBuf::from("C:\\Temp\\lithe-run\\launch.argfile")
+ }
+
+ /// Mirrors a real Maven runtime classpath: absolute repository paths whose
+ /// length is what pushes a multi-module project past the Windows limit.
+ fn classpath(entries: usize) -> String {
+ (0..entries)
+ .map(|index| {
+ format!(
+ "C:\\Users\\developer\\.m2\\repository\\org\\springframework\\boot\\spring-boot-starter-{index}\\3.2.{index}\\spring-boot-starter-{index}-3.2.{index}.jar"
+ )
+ })
+ .collect::>()
+ .join(";")
+ }
+
+ #[test]
+ fn a_command_that_fits_is_spawned_unchanged() {
+ let arguments = vec![
+ "-cp".to_string(),
+ classpath(4),
+ "org.dromara.DromaraApplication".to_string(),
+ ];
+ assert_eq!(
+ plan_launch_command(
+ "C:\\jdk\\bin\\java.exe",
+ &arguments,
+ &argfile(),
+ None,
+ Some(21)
+ ),
+ LaunchCommandPlan::Direct
+ );
+ }
+
+ /// Regression: RuoYi-Vue-Plus resolves hundreds of jars, and the inline
+ /// `-cp` made `CreateProcessW` fail before the JVM ever started.
+ #[test]
+ fn an_oversized_classpath_moves_into_an_argument_file() {
+ let entries = classpath(500);
+ let arguments = vec![
+ "-Dfile.encoding=UTF-8".to_string(),
+ "-cp".to_string(),
+ entries.clone(),
+ "org.dromara.DromaraApplication".to_string(),
+ "--spring.profiles.active=dev".to_string(),
+ ];
+
+ let plan = plan_launch_command(
+ "C:\\jdk\\bin\\java.exe",
+ &arguments,
+ &argfile(),
+ None,
+ Some(21),
+ );
+ let LaunchCommandPlan::Argfile {
+ arguments: shortened,
+ argfile_contents,
+ } = plan
+ else {
+ panic!("an oversized classpath must be shortened");
+ };
+ // The argfile replaces the option in place, so JVM options still
+ // precede the main class and program arguments still follow it.
+ assert_eq!(
+ shortened,
+ [
+ "-Dfile.encoding=UTF-8",
+ "@C:\\Temp\\lithe-run\\launch.argfile",
+ "org.dromara.DromaraApplication",
+ "--spring.profiles.active=dev",
+ ]
+ );
+ assert!(
+ command_line_length("C:\\jdk\\bin\\java.exe", &shortened) < 1_000,
+ "the remaining command line must be short: {shortened:?}"
+ );
+ let expected_value = format!("\"{}\"", entries.replace('\\', "\\\\"));
+ assert_eq!(argfile_contents, format!("-cp\n{expected_value}\n"));
+ }
+
+ #[test]
+ fn module_paths_are_moved_with_the_classpath() {
+ let arguments = vec![
+ "--module-path".to_string(),
+ classpath(300),
+ "-cp".to_string(),
+ classpath(300),
+ "-m".to_string(),
+ "app/com.example.Main".to_string(),
+ ];
+
+ let LaunchCommandPlan::Argfile {
+ arguments: shortened,
+ argfile_contents,
+ } = plan_launch_command(
+ "C:\\jdk\\bin\\java.exe",
+ &arguments,
+ &argfile(),
+ None,
+ Some(21),
+ )
+ else {
+ panic!("both path lists must be shortened");
+ };
+ // One argfile reference covers every moved option, and `-m` keeps its
+ // own value because it names a module, not a path list.
+ assert_eq!(
+ shortened,
+ [
+ "@C:\\Temp\\lithe-run\\launch.argfile",
+ "-m",
+ "app/com.example.Main",
+ ]
+ );
+ assert!(argfile_contents.starts_with("--module-path\n"));
+ assert!(argfile_contents.contains("\n-cp\n"));
+ assert!(argfile_contents.ends_with('\n'));
+ }
+
+ #[test]
+ fn a_value_with_spaces_stays_one_argument_in_the_file() {
+ let entries = format!("C:\\Program Files\\lib.jar;{}", classpath(500));
+ let arguments = vec!["-cp".to_string(), entries, "Main".to_string()];
+
+ let LaunchCommandPlan::Argfile {
+ argfile_contents, ..
+ } = plan_launch_command("java", &arguments, &argfile(), None, Some(21))
+ else {
+ panic!("an oversized classpath must be shortened");
+ };
+ assert!(argfile_contents.contains("\"C:\\\\Program Files\\\\lib.jar;"));
+ }
+
+ #[test]
+ fn an_oversized_command_without_path_lists_is_left_to_the_operating_system() {
+ let arguments = vec!["Main".to_string(), classpath(500)];
+ assert_eq!(
+ plan_launch_command("java", &arguments, &argfile(), None, Some(21)),
+ LaunchCommandPlan::Direct
+ );
+ }
+
+ #[test]
+ fn the_limit_is_configurable_for_hosts_with_a_different_cap() {
+ let arguments = vec![
+ "-cp".to_string(),
+ classpath(20),
+ "com.example.Main".to_string(),
+ ];
+ assert_eq!(
+ plan_launch_command("java", &arguments, &argfile(), None, Some(21)),
+ LaunchCommandPlan::Direct
+ );
+ assert!(matches!(
+ plan_launch_command("java", &arguments, &argfile(), Some(2_500), Some(21)),
+ LaunchCommandPlan::Argfile { .. }
+ ));
+ }
+
+ #[test]
+ fn unicode_text_is_returned_for_the_host_to_encode_on_supported_jdks() {
+ let arguments = vec![
+ "-cp".into(),
+ format!("C:\\Users\\示例;{}", classpath(500)),
+ "Main".into(),
+ ];
+ for version in [9, 17, 21] {
+ let LaunchCommandPlan::Argfile {
+ argfile_contents, ..
+ } = plan_launch_command("java.exe", &arguments, &argfile(), None, Some(version))
+ else {
+ panic!("a known supported JDK can use a host-encoded file");
+ };
+ assert!(argfile_contents.contains("示例"));
+ }
+ }
+
+ #[test]
+ fn non_java_and_unknown_or_unsupported_jdks_are_not_rewritten() {
+ let arguments = vec!["-p".into(), classpath(500)];
+ for executable in ["node.exe", "python.exe", "custom-launcher"] {
+ assert_eq!(
+ plan_launch_command(executable, &arguments, &argfile(), None, Some(21)),
+ LaunchCommandPlan::Direct
+ );
+ }
+ for version in [None, Some(8)] {
+ assert_eq!(
+ plan_launch_command("java.exe", &arguments, &argfile(), None, version),
+ LaunchCommandPlan::Direct
+ );
+ }
+ }
+
+ #[test]
+ fn application_arguments_keep_their_meaning_after_every_launch_target() {
+ for target in [
+ vec!["Main"],
+ vec!["-jar", "app.jar"],
+ vec!["-m", "app/Main"],
+ vec!["--module=app/Main"],
+ ] {
+ let mut arguments = vec!["-cp".into(), classpath(500)];
+ arguments.extend(target.into_iter().map(str::to_string));
+ arguments.extend(["-p", "8080", "--class-path", "program-value"].map(str::to_string));
+ let LaunchCommandPlan::Argfile {
+ arguments: shortened,
+ argfile_contents,
+ } = plan_launch_command("java.exe", &arguments, &argfile(), None, Some(21))
+ else {
+ panic!("classpath should move");
+ };
+ assert_eq!(&shortened[1..], &arguments[2..]);
+ assert!(!argfile_contents.contains("8080"));
+ assert!(!argfile_contents.contains("program-value"));
+ }
+ }
+
+ #[test]
+ fn launcher_option_values_are_not_mistaken_for_path_options() {
+ let arguments = vec![
+ "--add-modules".into(),
+ "-p".into(),
+ "-cp".into(),
+ classpath(500),
+ "Main".into(),
+ ];
+ let LaunchCommandPlan::Argfile {
+ arguments: shortened,
+ ..
+ } = plan_launch_command("java.exe", &arguments, &argfile(), None, Some(21))
+ else {
+ panic!("classpath should move");
+ };
+ assert_eq!(&shortened[..2], &arguments[..2]);
+ let disabled = vec![
+ "--disable-@files".into(),
+ "-cp".into(),
+ classpath(500),
+ "Main".into(),
+ ];
+ assert_eq!(
+ plan_launch_command("java.exe", &disabled, &argfile(), None, Some(21)),
+ LaunchCommandPlan::Direct
+ );
+ }
+
+ #[test]
+ fn the_release_file_reports_the_feature_version() {
+ for version in ["21.0.2", "1.8.0_392", "25-ea+35", "17.0.12+7"] {
+ let release = format!("IMPLEMENTOR=\"fixture\"\nJAVA_VERSION=\"{version}\"\n");
+ assert_eq!(java_version_from_release(&release), Some(version));
+ }
+ assert_eq!(
+ java_version_from_release("JAVA_VERSION=\"unknown\"\n"),
+ None
+ );
+ assert_eq!(
+ java_feature_version_from_release("IMPLEMENTOR=\"x\"\nJAVA_VERSION=\"21.0.2\"\n"),
+ Some(21)
+ );
+ assert_eq!(
+ java_feature_version_from_release("JAVA_VERSION=\"1.8.0_392\"\n"),
+ Some(8)
+ );
+ assert_eq!(
+ java_feature_version_from_release("JAVA_VERSION=\"17\"\n"),
+ Some(17)
+ );
+ assert_eq!(
+ java_feature_version_from_release("MODULES=\"java.base\"\n"),
+ None
+ );
+ }
+}
diff --git a/crates/java-domain/src/execution/mod.rs b/crates/java-domain/src/execution/mod.rs
new file mode 100644
index 0000000..eb37ec4
--- /dev/null
+++ b/crates/java-domain/src/execution/mod.rs
@@ -0,0 +1,14 @@
+//! Java launch planning: automatic JDK selection, argument files and
+//! class-path JARs that keep a Java command line within the platform limit.
+
+mod argfile;
+mod classpath_jar;
+mod java_selection;
+mod launch_command;
+mod toolchain_requirements;
+
+pub use argfile::*;
+pub use classpath_jar::*;
+pub use java_selection::*;
+pub use launch_command::*;
+pub use toolchain_requirements::*;
diff --git a/crates/java-domain/src/execution/toolchain_requirements.rs b/crates/java-domain/src/execution/toolchain_requirements.rs
new file mode 100644
index 0000000..7f166ee
--- /dev/null
+++ b/crates/java-domain/src/execution/toolchain_requirements.rs
@@ -0,0 +1,124 @@
+//! Toolchain requirements and version comparison used by JDK selection.
+//!
+//! Copied from `rust/lithe-core/src/execution/configuration.rs`. The document
+//! types and version helpers are unchanged. Reading the requirements of a
+//! workspace stays with the host: they come from its generated run
+//! configuration layer or from build files (`pom.xml`, `.java-version`, the
+//! Maven wrapper) that the Maven and Gradle plugins own.
+
+use crate::protocol::{CoreError, ErrorCode};
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+use std::path::Path;
+
+/// Version of the run-configuration sidecar documents this crate understands.
+const SIDECAR_VERSION: u32 = 1;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+/// Versioned requirements written separately from run configurations.
+pub struct ToolchainRequirementsDocument {
+ /// Sidecar document version; only [`SIDECAR_VERSION`] is accepted.
+ pub version: u32,
+ /// Requirements keyed by toolchain role.
+ #[serde(default)]
+ pub toolchains: BTreeMap,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+/// Constraints the host uses when selecting one toolchain candidate.
+pub struct ToolchainRequirement {
+ /// Toolchain role, such as `java`.
+ #[serde(rename = "type")]
+ pub kind: String,
+ /// Lowest acceptable version.
+ #[serde(default)]
+ pub minimum_version: Option,
+ /// Preferred distribution vendor.
+ #[serde(default)]
+ pub preferred_vendor: Option,
+ /// Wrapper that pins the toolchain, such as `mvnw`.
+ #[serde(default)]
+ pub wrapper: Option,
+ /// Exact version prefix the toolchain must match.
+ #[serde(default)]
+ pub version: Option,
+ /// Java version a non-Java toolchain needs.
+ #[serde(default)]
+ pub java: Option,
+}
+
+/// Rejects a sidecar document written by another format version.
+pub(super) fn validate_sidecar_version(version: u32) -> Result<(), CoreError> {
+ if version != SIDECAR_VERSION {
+ return Err(
+ CoreError::new(ErrorCode::NotSupported, "Unsupported document version")
+ .with_details(format!("expected {SIDECAR_VERSION}, found {version}")),
+ );
+ }
+ Ok(())
+}
+
+/// Toolchain requirements of the workspace at `root`.
+///
+/// Rust Core read them from the generated run-configuration layer and fell
+/// back to detecting `pom.xml`, `.java-version` and the Maven wrapper. Both
+/// sources belong to the host and to the build-tool plugins, so the plugin
+/// only accepts requirements the host passes in the request.
+// TODO(host-api: API-35 cross-plugin function): the Maven and Gradle plugins
+// own the build import, so they export the function that returns the
+// workspace's toolchain requirements document, and this plugin calls it
+// through the host instead of reading build files itself. The selection
+// request then carries that document instead of a workspace root.
+pub(super) fn read_requirements(
+ root: &Path,
+) -> Result