diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..905249e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +# Draft pull requests only run the Linux checks. The macOS package job starts +# when a pull request is ready for review and on every push to main. +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + rust: + name: Rust checks and tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Check formatting + run: cargo fmt --all --check + - name: Lint + run: cargo clippy --workspace --all-targets --locked -- -D warnings + - name: Test + run: cargo test --workspace --locked + - name: Check the pinned plugin SDK release + run: scripts/verify-sdk-lock.sh --download + + package: + name: Build and verify the macOS packages + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: macos-26 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + # The host tools build with the Swift compiler of the pinned Lithe + # revision; scripts/host-tools.sh --require-pinned-swift checks it. + - name: Select Xcode 26.6 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "26.6" + # Downloads are named by their SHA-256 and verified again before use. + - name: Restore verified third-party downloads + uses: actions/cache@v4 + with: + path: | + .artifacts/jdtls-downloads + .artifacts/jdk-downloads + key: third-party-${{ hashFiles('third_party/*/manifest.json') }} + - name: Build the Lithe package tools + id: host-tools + run: echo "bin=$(scripts/host-tools.sh --require-pinned-swift)" >> "$GITHUB_OUTPUT" + - name: Build, verify and archive both architectures + env: + HOST_TOOLS: ${{ steps.host-tools.outputs.bin }} + run: | + set -euo pipefail + for arch in arm64 x86_64; do + package=$(scripts/build-package.sh --arch "$arch" --host-tools "$HOST_TOOLS") + scripts/archive-package.sh --package "$package" --arch "$arch" \ + --development-unsigned --host-tools "$HOST_TOOLS" + done + - name: Upload the unsigned development archives + uses: actions/upload-artifact@v4 + with: + name: java-support-unsigned + path: dist/ + retention-days: 7 + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8269d42 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,101 @@ +name: Release + +# Push a tag v matching plugin.json to publish a GitHub Release with +# one publisher-signed archive per macOS architecture and its SHA-256 file. +# +# Only the "Sign, archive and publish" step receives the signing key; the +# steps before it build and verify unsigned packages. Checkouts do not keep +# the workflow token. +# +# Releases are published as pre-releases: Lithe still bundles Java Support and +# lists it from its own official index (see MIGRATION.md). Remove --prerelease +# below once the official index lists this repository's releases. +on: + push: + tags: ["v*"] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + test: + name: Rust checks and tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Check formatting + run: cargo fmt --all --check + - name: Lint + run: cargo clippy --workspace --all-targets --locked -- -D warnings + - name: Test + run: cargo test --workspace --locked + - name: Check the pinned plugin SDK release + run: scripts/verify-sdk-lock.sh --download + + release: + name: Publish ${{ github.ref_name }} + needs: test + runs-on: macos-26 + timeout-minutes: 60 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Check the tag matches plugin.json + run: | + set -euo pipefail + version=$(plutil -extract version raw -o - dev.lithe.plugin.java-support/plugin.json) + if [[ "$GITHUB_REF_NAME" != "v$version" ]]; then + echo "::error::Tag $GITHUB_REF_NAME does not match plugin.json version $version" + exit 1 + fi + - name: Select Xcode 26.6 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "26.6" + - name: Build the Lithe package tools + id: host-tools + run: echo "bin=$(scripts/host-tools.sh --require-pinned-swift)" >> "$GITHUB_OUTPUT" + - name: Build and verify both architectures + env: + HOST_TOOLS: ${{ steps.host-tools.outputs.bin }} + run: | + set -euo pipefail + for arch in arm64 x86_64; do + scripts/build-package.sh --arch "$arch" --host-tools "$HOST_TOOLS" + done + - name: Sign, archive and publish + env: + HOST_TOOLS: ${{ steps.host-tools.outputs.bin }} + LITHE_PLUGIN_PACKAGE_PRIVATE_KEY: ${{ secrets.LITHE_PLUGIN_PACKAGE_PRIVATE_KEY }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + checksums="$RUNNER_TEMP/checksums.txt" + : > "$checksums" + for arch in arm64 x86_64; do + scripts/archive-package.sh --package ".artifacts/package/$arch/dev.lithe.plugin.java-support" \ + --arch "$arch" --sign --host-tools "$HOST_TOOLS" >> "$checksums" + done + { + echo "Lithe 官方 Java 插件 dev.lithe.plugin.java-support ${GITHUB_REF_NAME#v}。每个 macOS 架构一个经发布者签名的归档,附 SHA-256 文件。" + echo + echo "SHA-256:" + echo '```' + sed 's# dist/# #' "$checksums" + echo '```' + } > "$RUNNER_TEMP/notes.md" + gh release create "$GITHUB_REF_NAME" dist/*.zip dist/*.zip.sha256 --verify-tag --prerelease \ + --title "Java Support $GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md" 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/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..8761c75 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,517 @@ +# 迁移说明:Java 逻辑从 Lithe 主仓库移到本仓库 + +本文是 Java Support 插件(`dev.lithe.plugin.java-support`)从 Lithe 主仓库拆分出来的移植账本,回答四个问题: + +1. 主仓库里的每个 Java 相关文件或符号,现在在本仓库的哪里,状态是什么。 +2. 哪些部分在等宿主(Lithe Core 与 macOS 应用)提供通用插件 API,等的是计划里的哪个编号。 +3. Maven 和 Gradle 的导入逻辑为什么、怎样交给 Maven/Gradle 插件。 +4. 主仓库最终要删什么,按什么顺序删。 + +> English: This is the porting ledger for splitting the Java Support plugin out of the Lithe main repository. It maps +> every Java-related source file of Lithe (`path@1daaeb525`) to its new location with exactly one status, lists what +> waits for generic host APIs (tagged with the plan number from `docs/development/macos-plugin-api-plan.md`), records +> that Maven/Gradle import logic belongs to the Maven/Gradle plugins, and gives the phased deletion list for the +> main repository. Paragraph text is Chinese; identifiers, paths and API numbers are literal. + +## 1. 概览 + +- 来源快照:Lithe `origin/preview` 的提交 `1daaeb525`。表里的路径都写成 `路径@1daaeb525`,只读,不在本仓库改动。 +- 本仓库产物: + - Rust 工作区共 22,847 行 Rust:`crates/java-domain`(领域规则,不依赖宿主)和 `backend`(基于已固定版本 SDK 的插件后端进程)。 + - 295 个测试:领域 crate 272 个,后端 23 个。全部是有界、确定性的:不联网,不读全局时间,不占固定端口。 + 其中跨插件函数相关的 24 个(`jdt_contributions.rs` 10 个、`plugin_functions.rs` 14 个)是在不允许编译的约束下写成的, + 还没有运行过,见第 10 节。 + - 18 个 fixture(共 1,234 行)放在 `fixtures/`。其中 12 个被 Rust 测试读取;另外 6 个是从主仓库复制来的契约样例, + 暂时没有 Rust 测试读取(见第 7 节)。 + - 插件包:`dev.lithe.plugin.java-support/`,加上组装、校验、归档脚本、CI 和发布工作流。 +- 领域代码的注释(`//!`、`///`)全部是英文。每个来自 Core 或 Swift 的模块在模块文档里写明来源。 +- 宿主侧还没有接线:`plugin.json` 暂时不声明 `backend`(`backend/tests/repository_pins.rs` 里有测试断言这一点), + 后端目前只把领域命令注册成插件函数,并带着跨插件函数的调用方一侧(第 5 节),等第 6 节列出的宿主 API 就位后再声明。 + +## 2. 边界决定 + +### 2.1 Maven 和 Gradle 的导入逻辑整块归 Maven/Gradle 插件 + +纯 Java 项目没有"导入工程"这一步,JDT LS 直接按源码目录工作。导入只出现在 Maven 或 Gradle 项目里,所以: + +- settings 文件、本地仓库、profiles、Gradle offline、gradleUserHome、从 POM 读出的源码目录,全部由 Maven/Gradle 插件计算。 +- Java 插件不解析 `settings.xml`,不处理 profiles,不读 POM,也不写任何 Maven/Gradle 状态目录。 +- Java 插件只声明"我的 JDT LS 接受别的插件通过 settings 片段给它哪些键",见 `CONTRIBUTED_SETTINGS_KEYS` + (`crates/java-domain/src/lsp/languages/jdt.rs`),并校验收到的片段不越界(`accept_contributed_settings`)。 + 这是 JDT LS 自己的设置名空间,不是 Maven 逻辑。 + +已经从移植结果里删掉、交给 Maven/Gradle 插件的内容见第 4 节。 + +### 2.2 插件之间只经 Core 中转的跨插件函数通信(API-35) + +插件 1 → Core → 插件 2: + +- 接收方在清单里显式导出函数,调用方声明它要调用的函数 ID(接口名),不写对方的插件 ID。 +- Java 插件定义接口函数 ID(例如 `languageImport.settings`),Maven 和 Gradle 插件各自导出同一个 ID。 +- 启动 JDT LS 之前,Java 的后端向宿主发现所有已启用且导出该 ID 的插件,逐个调用,合并结果,交给 JDT LS。 +- Maven 设置变化时,Maven 插件反过来调用 Java 导出的"变化通知"函数,并把新值放在调用参数里。Java 不在这次调用里回调 Maven, + 否则调用链 A→B→A 会被 Core 当作环路拒绝。 +- 信任不传递:被调方用自己的权限执行,调用方不获得任何权限。 + +中转本身由主仓库 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 在 Core 里实现。本仓库实现了 Java 一侧:接口常量、答案校验与合并(`jdt_contributions.rs`),以及发现并调用提供方的 +后端模块(`plugin_functions.rs`,中转协议暂按设计简报写,集中在这一个文件里,见第 5 节)。中转没有合并之前这些代码不会被宿主调用, +缺的部分在代码里写 `TODO(host-api: API-35 跨插件函数)`。 + +### 2.3 计划编号约定 + +每个"等待宿主 API"的条目都标注 `docs/development/macos-plugin-api-plan.md` 里的编号,不新增编号。本任务指定的编号是: + +| 编号 | 范围 | +| --- | --- | +| API-25 | 语言服务器 | +| API-27 | 编辑器动作与标记 | +| API-29 | 构建与测试 | +| API-33 | 项目模板 | +| API-35 | 跨插件函数 | + +少数条目最贴合的是计划里已有、但不在上面五个之内的编号:API-20(存储)、API-22(日志与进度)、API-23(工具链与进程)、 +API-30(调试适配器)、API-37(环境与 l10n)、API-42(内置 Git 只读)。这些条目在表里的编号后面加 `†`, +意思是"编号来自计划,但不在任务指定的五个之内,请协调者确认归类"。没有自造任何新编号。 + +## 3. 路径映射表 + +状态只有四种,每行一种:**已移植并有测试**、**已移植无测试**、**等待宿主 API(编号)**、**不移植(原因)**。 +"新位置"中的路径都相对本仓库根目录;`dj` 表示 `crates/java-domain/src`。括号里的数字是该文件里的测试数量。 + +### 3.1 Rust Core 源码 + +| 来源 | 新位置 | 状态 | 说明 | +| --- | --- | --- | --- | +| `rust/lithe-core/src/lsp/languages/java_entrypoints.rs@1daaeb525` | `dj/lsp/languages/java_entrypoints.rs` | 已移植并有测试 | 10 个测试 | +| `…/lsp/languages/java_launch.rs@1daaeb525` | `dj/lsp/languages/java_launch.rs` | 已移植并有测试 | 2 个测试,读 `java-launch-preparation-v1.json` | +| `…/lsp/languages/java_main_methods.rs@1daaeb525` | `dj/lsp/languages/java_main_methods.rs` | 已移植并有测试 | 5 个测试 | +| `…/lsp/languages/java_navigation_syntax.rs@1daaeb525` | `dj/lsp/languages/java_navigation_syntax.rs` | 已移植并有测试 | 3 个测试 | +| `…/lsp/languages/java_run_markers.rs@1daaeb525` | `dj/lsp/languages/java_run_markers.rs` | 已移植并有测试 | 8 个测试,含 Swift `JavaRunMarkerTests` 的场景 | +| `…/lsp/languages/java_tests.rs@1daaeb525` | `dj/lsp/languages/java_tests.rs` | 已移植并有测试 | 5 个测试 | +| `…/lsp/languages/java_workspace.rs@1daaeb525` | `dj/lsp/languages/java_workspace.rs` | 已移植并有测试 | 7 个测试:工作区策略、缓存保留、指纹 | +| `…/lsp/languages/jdt.rs@1daaeb525` | `dj/lsp/languages/jdt.rs` | 已移植并有测试 | 31 个测试。Maven/Gradle 部分已删,换成贡献设置模型(第 4、5 节) | +| `…/lsp/languages/jdt_build.rs@1daaeb525` | `dj/lsp/languages/jdt_build.rs` | 已移植并有测试 | 14 个测试。Maven profiles 阻塞项已删,换成 `ContributedSettings` 阻塞项 | +| `…/lsp/languages/jdt_configuration.rs@1daaeb525` | `dj/lsp/languages/jdt_configuration.rs` | 已移植并有测试 | 7 个测试 | +| `…/lsp/languages/jdt_navigation.rs@1daaeb525` | `dj/lsp/languages/jdt_navigation.rs` | 已移植并有测试 | 3 个测试 | +| `…/lsp/languages/jdt_progress.rs@1daaeb525` | `dj/lsp/languages/jdt_progress.rs` | 已移植并有测试 | 5 个测试,读 `jdt-readiness-v1.json` | +| `…/lsp/languages/jdt_project_metadata.rs@1daaeb525` | `dj/lsp/languages/jdt_project_metadata.rs` | 已移植并有测试 | 6 个测试。"哪些文件被 Git 忽略"的查询是占位,见第 6 节 API-42† | +| `…/lsp/languages/project_preparation.rs@1daaeb525` | `dj/lsp/languages/project_preparation.rs` | 已移植并有测试 | 3 个测试。Maven profile 阶段已删 | +| `…/lsp/languages/mod.rs@1daaeb525` | `dj/lsp/languages/mod.rs` | 已移植无测试 | 只做模块注册,只保留 Java 模块 | +| `…/lsp/languages/jdt_maven_settings.rs@1daaeb525` | 无(交付目录,见第 4 节) | 不移植(Maven 插件) | 生成 JDT 用的 Maven settings;12 个测试随文件交给 Maven 插件 | +| `…/lsp/languages/{catalog,indexing,swift}.rs@1daaeb525` | 无 | 不移植(通用语言目录与 Swift,不是 Java 逻辑) | 留在 Core | +| `…/lsp/mod.rs@1daaeb525`(生命周期类型) | `dj/lsp/mod.rs` | 已移植无测试 | 复制 `LspLifecycleState`、`JavaRuntimeCandidate`、`LspRuntimeError`,只服务 Java 模块 | +| `…/lsp/interface/engine.rs@1daaeb525`:`java_navigation_markers`、`java_resolve_navigation`、`java_prepare_launch`、`prepare_java_launch`、`dispatch_java_entrypoints_locked`、`dispatch_java_build_locked`、`complete_java_build_locked`、`java_executable_from_environment`、就绪门、`StartServerRequest` 的 Java 字段 | 无;命令名在 `dj/commands.rs` 里占位(`SESSION_COMMANDS`,调用返回 `NotSupported`) | 等待宿主 API(API-25) | 这些函数驱动正在运行的 JDT LS 会话,需要宿主把"向声明的语言服务器发请求并收通知"的通道开放给插件后端 | +| `…/lsp/interface/engine.rs@1daaeb525`:`retry_maven_profiles`、`update_maven_configuration`、`maven_profile_requests`、`push_maven_profile_*` | 无 | 不移植(Maven 插件,经 API-35 通知 Java) | 见第 4、5 节 | +| `…/lsp/interface/engine_real_jdt_tests.rs@1daaeb525` | 无 | 等待宿主 API(API-25) | 真实 JDT 端到端测试依赖宿主会话;API-25 通道就绪后在本仓库重做为 e2e | +| `…/lsp/interface/engine_real_jdt_maven_provider_tests.rs@1daaeb525` | 无 | 不移植(Maven 插件) | 依赖 Maven provider,归 Maven 插件的 e2e | +| `…/languages/java.rs@1daaeb525` | `dj/languages/java.rs` | 已移植并有测试 | 6 个测试,另有 `tests/java_commands.rs` | +| `…/languages/java_syntax.rs@1daaeb525` | `dj/languages/java_syntax.rs` | 已移植并有测试 | 3 个测试 | +| `…/languages/spring.rs@1daaeb525` | `dj/languages/spring.rs` | 已移植并有测试 | 17 个测试,另有 `tests/spring.rs` | +| `…/languages/mybatis.rs@1daaeb525` | `dj/languages/mybatis.rs` | 已移植并有测试 | 文件内没有单元测试,由 `tests/mybatis.rs` 的 10 个测试覆盖 | +| `…/languages/text.rs@1daaeb525` | `dj/languages/text.rs` | 已移植并有测试 | 2 个测试;从 Core 复制,Java 模块要用的文本工具 | +| `…/debug/java_test.rs@1daaeb525` | `dj/debug/java_test.rs` | 已移植并有测试 | 3 个测试,读 `java-test-launch-v1.json` | +| `…/debug/engine.rs@1daaeb525`、`debug/types.rs`、`debug/adapters.rs` 里的 Java 特例(RedefineClasses、step filters、DevTools、vmArgs、类名过滤、`SELECTED_RUNTIME_TOOLS`) | 无 | 等待宿主 API(API-30†) | 通用化为调试适配器声明的 `features`/`defaults`,Java 插件在 `plugin.json` 里声明 | +| `…/execution/java_selection.rs@1daaeb525` | `dj/execution/java_selection.rs` | 已移植并有测试 | 文件内没有单元测试,由 `tests/java_selection.rs` 的 4 个测试覆盖 | +| `…/execution/configuration.rs@1daaeb525`:`version_parts`、`version_satisfies`、`read_requirements`、`java_feature_version_parts` | `dj/execution/toolchain_requirements.rs` | 已移植无测试 | 复制 `java_selection` 依赖的几个纯函数,只由 `java_selection` 的测试间接覆盖。Maven/Gradle 要求由它们自己的插件提供,见第 6 节 | +| `…/execution/configuration.rs@1daaeb525` 其余 Java 类型(`javaMain`/`springBoot` 到 `java.main` 的 provider 映射、`java-main:` ID、`toolchain.java.homePath`、`extensions["java"]`) | 无 | 等待宿主 API(API-29) | 运行配置里 Java 类型的字段需要由插件声明、宿主通用编辑器渲染 | +| `…/execution/launch_command.rs@1daaeb525` | `dj/execution/launch_command.rs` | 已移植并有测试 | 11 个测试 | +| `…/execution/classpath_jar.rs@1daaeb525` | `dj/execution/classpath_jar.rs` | 已移植并有测试 | 8 个测试 | +| `…/execution/detectors/{maven,gradle}.rs@1daaeb525` | 无 | 不移植(Maven/Gradle 插件,声明式 `buildSystems.detection`) | | +| `…/execution/workspace_scan.rs@1daaeb525`、`project/indexing.rs@1daaeb525` 里的 Java 分支 | 无;`dj/host/indexing.rs` 只有占位 | 等待宿主 API(API-25) | 索引排除规则必须由宿主交给语言服务器的所有配置通道,插件没有这个通道 | +| `…/project/{maven,maven_dependency_tree,maven_test_reports}.rs@1daaeb525` | 无 | 不移植(Maven 插件) | Windows 仍在用,见第 8 节 | +| `…/build/language_import.rs@1daaeb525`、`build/integrations/{mod,maven}.rs@1daaeb525` | 无 | 不移植(Maven/Gradle 插件,被 API-35 取代) | 见第 4 节 | +| `…/build/language_import.rs@1daaeb525` 的调用方一侧(启动 JDT LS 前收集各构建系统的导入设置) | `dj/lsp/languages/jdt_contributions.rs`(新写) | 已移植并有测试 | 10 个测试。接口常量 `languageImport.settings` 与 `languageImport.settingsChanged`、答案校验、按提供方分槽的合并与状态;不含任何 Maven/Gradle 逻辑,见第 5 节 | +| 同上(跨进程调用部分) | `backend/src/plugin_functions.rs`(新写) | 已移植并有测试 | 14 个测试。经 Core 中转发现并调用提供方,全部用桩中转测试;宿主方法名与形状是暂定的,见第 5.7 节 | +| `…/lsp/interface/engine.rs@1daaeb525`:`update_maven_configuration` 的接收一侧(Maven 设置变化后通知 Java) | `languageImport.settingsChanged` 的常量与分槽逻辑已在 `jdt_contributions.rs`;函数注册还没有 | 等待宿主 API(API-35) | SDK 0.1.0 不告诉处理函数调用方是哪个插件,而分槽需要可信的提供方 ID,见第 5.7 节 | +| `…/plugins/declarative.rs@1daaeb525`、`build/{invocation,manifest,detect,plans}.rs@1daaeb525` 里的 Java 特判(`KNOWN_RUNTIME_TOOLS*`、`HOST_INTEGRATION_*`、`ModelSource::HostIntegration`) | 无 | 不移植(宿主通用代码里的 Java 特判,不是插件逻辑) | 在主仓库删,见第 8 节 | +| `…/protocol/cancellation.rs@1daaeb525` | `dj/protocol/cancellation.rs` | 已移植并有测试 | 8 个测试 | +| `…/protocol/contracts.rs@1daaeb525`(Java、Spring、MyBatis 模型) | `dj/protocol/contracts.rs` | 已移植无测试 | 只复制 Java 相关请求与响应类型,由各命令的测试间接覆盖;`Maven*Response` 不复制 | +| `…/protocol/error.rs@1daaeb525`(`CoreError`、`ErrorCode`) | `dj/protocol/error.rs` | 已移植无测试 | 保持错误码与 Core 一一对应,`backend/src/lib.rs` 把它们映射到稳定的 `pluginErrorCode` | +| `…/protocol/command.rs@1daaeb525`、`runtime/dispatcher.rs@1daaeb525` 里的 Java 命令(`java.*`、`spring.index`、`mybatis.index`、`lsp.jdtWorkspaceKey`、`runConfig.selectJava`、`debug.javaTestLaunch`) | `dj/commands.rs`(`execute`、`cancel`);`backend/src/lib.rs` 把每条命令注册成同名插件函数 | 已移植并有测试 | `commands.rs` 4 个测试,后端 `session.rs` 与单元测试 3 个。命令名、载荷、响应和 `InvalidRequest` 消息与 Core 一致,宿主切换调用方时不改 JSON | +| `…/protocol/command.rs@1daaeb525`:`java.navigationMarkers`、`java.resolveNavigation`、`java.prepareLaunch` | `dj/commands.rs`(`SESSION_COMMANDS`,答 `NotSupported`) | 等待宿主 API(API-25) | 同 `engine.rs` 那一行 | +| `…/protocol/command.rs@1daaeb525`:`maven.*`、`lsp.retryMavenProfiles`、`lsp.updateMavenConfiguration`、`build.languageImport` | 无 | 不移植(Maven/Gradle 插件) | | +| `…/lib.rs@1daaeb525`:公开函数 `jdt_workspace_key` | `lsp.jdtWorkspaceKey` 命令(`dj/commands.rs`) | 已移植并有测试 | `tests/java_commands.rs` 里读 `jdt-workspace-key-v1.json` | +| `…/lib.rs@1daaeb525`:公开函数 `settings_with_local_repository` | 无(交付目录,见第 4 节) | 不移植(Maven 插件) | | +| `…/diagnostics/catalog_generated.rs@1daaeb525` 里的 Java 日志事件 | 无;后端用 `Context::log` | 等待宿主 API(API-22†) | 插件日志通道就绪前,只有 `argfile.rs` 等少数位置留了 TODO | +| Swift `MacJavaLaunchArgumentPreparer` 的规则部分(Core 里没有) | `dj/execution/argfile.rs` | 已移植并有测试 | 4 个测试:只在命令过长时写 argfile,仅属主可读,租约释放时删除 | + +### 3.2 Rust Core 测试与 fixture + +| 来源 | 新位置 | 状态 | 说明 | +| --- | --- | --- | --- | +| `rust/lithe-core/src/tests/languages.rs@1daaeb525`(Java 部分) | `dj/tests/java_commands.rs` | 已移植并有测试 | 14 个测试 | +| `…/tests/run_configuration.rs@1daaeb525`(JDK 选择部分) | `dj/tests/java_selection.rs` | 已移植并有测试 | 4 个测试 | +| `…/tests/run_configuration.rs@1daaeb525` 其余(约 6,100 行,651 处 Java/Maven 命中) | 无 | 不移植(通用运行配置测试,留在 Core;改用第二种工具做桩,Java/Maven 用例随主仓库删除一起去掉) | | +| `…/tests/mybatis.rs@1daaeb525` | `dj/tests/mybatis.rs` | 已移植并有测试 | 10 个测试 | +| `…/tests/spring.rs@1daaeb525` | `dj/tests/spring.rs` | 已移植并有测试 | 8 个测试 | +| `…/tests/` 里的辅助函数 | `dj/tests/support.rs` | 已移植并有测试 | 2 个测试 | +| `…/tests/{detectors,build_systems,community_build_plugins,toolchain_resolution,project,project_generators}.rs@1daaeb525`、`lsp/tests.rs@1daaeb525`、`tests/fixtures/community_build_plugins/{maven,gradle}` | 无 | 不移植(通用或 Maven/Gradle 测试) | | +| `shared/fixtures/debug/java-test-launch-v1.json@1daaeb525` | `fixtures/debug/java-test-launch-v1.json` | 已移植并有测试 | `tests/java_commands.rs` 读取 | +| `shared/fixtures/java/run-markers-v1.json@1daaeb525` | `fixtures/java/run-markers-v1.json` | 已移植并有测试 | `tests/java_commands.rs` 读取 | +| `shared/fixtures/java/basic.json@1daaeb525` | `fixtures/java/basic.json` | 已移植无测试 | 契约样例,暂无 Rust 测试读取 | +| `shared/fixtures/lsp/java-completion-v1.json@1daaeb525` | `fixtures/lsp/java-completion-v1.json` | 已移植并有测试 | `jdt.rs` 读取 | +| `shared/fixtures/lsp/java-launch-preparation-v1.json@1daaeb525` | `fixtures/lsp/java-launch-preparation-v1.json` | 已移植并有测试 | `java_launch.rs` 读取 | +| `shared/fixtures/lsp/java-project-runtimes-v1.json@1daaeb525` | `fixtures/lsp/java-project-runtimes-v1.json` | 已移植并有测试 | `jdt.rs` 读取 | +| `shared/fixtures/lsp/java-workspace-policy-v1.json@1daaeb525` | `fixtures/lsp/java-workspace-policy-v1.json` | 已移植并有测试 | `tests/java_commands.rs` 读取 | +| `shared/fixtures/lsp/jdt-cache-retention-v1.json@1daaeb525` | `fixtures/lsp/jdt-cache-retention-v1.json` | 已移植并有测试 | `tests/java_commands.rs` 读取 | +| `shared/fixtures/lsp/jdt-readiness-v1.json@1daaeb525` | `fixtures/lsp/jdt-readiness-v1.json` | 已移植并有测试 | `jdt_progress.rs` 读取 | +| `shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json@1daaeb525` | `fixtures/lsp/jdt-workspace-fingerprint-v1.json` | 已移植并有测试 | `tests/java_commands.rs` 读取 | +| `shared/fixtures/lsp/jdt-workspace-key-v1.json@1daaeb525` | `fixtures/lsp/jdt-workspace-key-v1.json` | 已移植并有测试 | `tests/java_commands.rs` 读取 | +| `shared/fixtures/lsp/project-preparation-v1.json@1daaeb525` | `fixtures/lsp/project-preparation-v1.json` | 已移植并有测试 | `project_preparation.rs` 读取;已去掉 Maven profile 阶段 | +| `shared/fixtures/lsp/java-build-report-v1.json`、`java-navigation-v1.json`、`jdt-direct-launch-v1.json@1daaeb525` | `fixtures/lsp/` 同名文件 | 已移植无测试 | 契约样例,暂无 Rust 测试读取 | +| `shared/fixtures/mybatis/basic.json`、`spring/basic.json@1daaeb525` | `fixtures/mybatis/basic.json`、`fixtures/spring/basic.json` | 已移植无测试 | 契约样例,暂无 Rust 测试读取 | +| `shared/fixtures/run-configuration/automatic-java-selection.json@1daaeb525` | `fixtures/run-configuration/automatic-java-selection.json` | 已移植并有测试 | `tests/java_selection.rs` 读取 | +| `shared/fixtures/lsp/maven-profile-events-v1.json@1daaeb525` | 无 | 不移植(Maven 插件) | | +| `shared/fixtures/execution/{maven,gradle,maven-java-main-source-sets-v1,maven-runtime-versions}.json@1daaeb525` | 无 | 不移植(Maven/Gradle 插件) | | +| `shared/fixtures/execution/standalone-java-compile-run-v1.json@1daaeb525` | 无 | 等待宿主 API(API-29) | 无项目文件的单文件编译运行属于运行准备,等通用运行配置接口 | +| `shared/fixtures/run-configuration/{hybrid-spring-vue,maven-module-ownership,provider-prepared-java}.json@1daaeb525` | 无 | 不移植(通用运行配置与 Maven 归属,留在 Core) | | +| `shared/fixtures/projects/lithe-spring-boot-git-graph/**@1daaeb525` | 无 | 不移植(Git 图示例工程,不是 Java 插件的测试输入) | | +| `shared/contracts/maven-launch-context-v1.schema.json`、`maven-portable-configuration-v1.schema.json@1daaeb525` | 无 | 不移植(Maven 插件) | | + +### 3.3 macOS Swift 源码 + +| 来源 | 新位置 | 状态 | 说明 | +| --- | --- | --- | --- | +| `macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacJavaLaunchArgumentPreparer.swift@1daaeb525`、`macos/Sources/LitheExecutionModule/Services/JavaLaunchArguments.swift@1daaeb525` | `dj/execution/argfile.rs` | 已移植并有测试 | 租约和命令规划在 Rust;文件 I/O 留给后端 | +| `…/Platform/MacOS/Plugins/HostIntegrations/Jdt/MacJdtLaunchResources.swift@1daaeb525` | `dj/jdt_integration/launch_resources.rs` | 已移植并有测试 | 3 个测试 | +| `…/Platform/MacOS/Plugins/HostIntegrations/Jdt/MacJdtHostIntegration.swift@1daaeb525` | `dj/jdt_integration/runtime.rs`、`dj/jdt_integration/mod.rs` | 已移植并有测试 | runtime 4 个测试;启动入口本身等宿主,见下一行 | +| 同上,启动 JDT LS 进程本身 | `dj/jdt_integration/mod.rs`(TODO 说明) | 等待宿主 API(API-25) | 由插件声明的语言服务器启动取代 | +| `…/Platform/MacOS/Plugins/HostIntegrations/Jdt/MacJdtWorkspaceState.swift@1daaeb525` | `dj/jdt_integration/workspace_state.rs` | 已移植并有测试 | 6 个测试。缓存目录来自宿主,见第 6 节 API-20† | +| `…/Services/Java/ProjectRuntimeService.swift@1daaeb525` 里的 JDT 运行时规则(版本解析、Java 21 下限、JDK home 推导) | `dj/jdt_integration/runtime.rs` | 已移植并有测试 | 探测 `java -version` 的 I/O 不移植,见第 6 节 API-23† | +| 同上,其余(JDK 发现、Maven 选择、运行时设置) | 无 | 不移植(宿主通用的运行时发现与设置,Maven 部分归 Maven 插件) | | +| `…/Services/Java/JavaCodeVisionService.swift@1daaeb525` | `dj/languages/code_vision_hints.rs` | 已移植并有测试 | 2 个测试。blame 作者来自宿主 Git,见第 6 节 API-42† | +| `…/Services/Debug/JavaTestDebugLaunchService.swift@1daaeb525` | `dj/testing/debug_launch.rs` | 已移植并有测试 | 4 个测试 | +| `…/Application/Features/JavaTestWorkflowState.swift@1daaeb525`、`JavaTestDebugWorkflowCoordinator.swift@1daaeb525` | `dj/testing/workflow.rs` | 已移植并有测试 | 11 个测试 | +| `…/Core/Debug/JavaTestResultServer.swift@1daaeb525`、`…/Platform/MacOS/Debug/MacJavaTestResultServer.swift@1daaeb525` | `dj/testing/result_channel.rs` | 已移植并有测试 | 7 个测试;按无 I/O 状态机重写。127.0.0.1 监听本身等宿主,见第 6 节 API-29 | +| `…/Services/Debug` 与 `LanguageToolingSessionManager.swift@1daaeb525` 里的 Java 测试分支(`discoverJavaTestItems`、`javaBuildTestSelectors`、`resolveJavaTestDebugLaunchTarget`、`javaTestLaunchArguments`) | `dj/testing/jdt_test_items.rs` | 已移植并有测试 | 12 个测试 | +| `…/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift@1daaeb525` 里的 Java 会话调度(导入等待、重启、`updateJavaMavenConfiguration`) | 无 | 等待宿主 API(API-25) | 会话由宿主通用管理;Maven 导入重启走 API-35,见第 5 节 | +| `…/Application/Composition/JavaLanguageServerPreparationCoordinator.swift@1daaeb525`、`Models/AppModel/AppModel+JavaBuildPreparation.swift@1daaeb525` | 无;状态规则在 `dj/lsp/languages/project_preparation.rs` | 等待宿主 API(API-25) | 宿主展示插件上报的准备状态 | +| `…/Models/AppModel/AppModel+JavaRunMarkers.swift@1daaeb525`、`AppModel+JavaEntrypoints.swift@1daaeb525` | 无;投影规则在 `java_run_markers.rs`、`java_entrypoints.rs` | 等待宿主 API(API-27) | 编辑器运行/调试标记由插件提供 | +| `…/Models/AppModel/AppModel+JavaNavigation.swift@1daaeb525`、`Application/Features/JavaFeatureModel.swift@1daaeb525` | 无;规则在 `java_navigation_syntax.rs`、`jdt_navigation.rs` | 等待宿主 API(API-27) | 实现/父声明导航标记由插件提供,跳转请求经 API-25 | +| `…/Models/AppModel/AppModel+JavaLaunchDecision.swift@1daaeb525`、`AppModel+JavaLaunchPreparation.swift@1daaeb525`、`Views/Run/JavaLaunchDecisionDialog.swift@1daaeb525`、`Views/Run/JavaRunConfigurationEditorView.swift@1daaeb525` | 无;规则在 `java_launch.rs`、`java_selection.rs` | 等待宿主 API(API-29) | 运行配置编辑与启动决策由宿主通用界面承载,Java 字段由插件声明 | +| `…/Models/AppModel/AppModel+JavaIndex.swift@1daaeb525`、`Application/Features/SpringFeatureModel.swift@1daaeb525`、`MybatisFeatureModel.swift@1daaeb525`、`Core/Rust/RustJavaMavenOperations.swift@1daaeb525` | 无;索引规则在 `spring.rs`、`mybatis.rs` | 等待宿主 API(API-25) | 补全、悬浮、诊断、导航由插件作为语言提供者给出 | +| `…/Views/Run/SpringEndpointsView.swift@1daaeb525` | 无 | 等待宿主 API(API-27) | Endpoints 面板需要插件原生界面的承载方式;本文不新增编号,落点待协调者确认 | +| `…/Theme/JavaFileIconResolver.swift@1daaeb525` | 图标在 `dev.lithe.plugin.java-support/icons/` | 等待宿主 API(API-27) | 文件图标由插件贡献 | +| `…/Application/Features/JavaPluginTransition.swift@1daaeb525`、`AppModel+JavaPlugin.swift@1daaeb525` | 无 | 不移植(宿主"推荐安装 Java Support"的引导,应由商店索引取代,不是插件逻辑) | | +| `…/Views/Language/JavaProblemsView.swift@1daaeb525`、`JavaReferencesView.swift@1daaeb525` | 无 | 不移植(宿主通用的问题和引用视图,没有 Java 领域规则,只需改名) | | +| `…/Models/Java/{JavaDiagnosticModels,JavaNavigationModels,MybatisModels,SpringModels,ActiveTestTarget}.swift@1daaeb525` | 无;JSON 形状在 `dj/protocol/contracts.rs` | 不移植(宿主界面模型) | | +| `…/Models/Java/{MavenModels,MavenResolutionProblems}.swift@1daaeb525` | 无 | 不移植(Maven 插件) | | +| `…/Models/Java/JavaRunModels.swift@1daaeb525`、`Services/Java/{JavaRunService,RunExecutableResolver,RunLaunchEnvironment,RunToolchainMetadataResolver}.swift@1daaeb525` | 无 | 不移植(宿主通用的运行与工具解析,只是放在 Java 目录里;`RunToolchainMetadataResolver` 的探测就不含 Java 分支) | | + +### 3.4 macOS Swift 测试 + +| 来源 | 新位置 | 状态 | 说明 | +| --- | --- | --- | --- | +| `macos/Tests/LitheLanguageIntelligenceModuleTests/JavaRunMarkerTests.swift@1daaeb525`(4 个) | `java_run_markers.rs` 的测试 | 已移植并有测试 | 光标选择最内层测试、回退到 main、测试方法的 Maven 选择器与 Java 测试项调试、载荷解码 | +| `macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift@1daaeb525`(4 个) | `dj/jdt_integration/workspace_state.rs` 的测试 | 已移植并有测试 | 指纹、清索引、缓存清理 | +| `…/MacJdtHostIntegrationStateTests.swift@1daaeb525`(4 个) | `dj/jdt_integration/runtime.rs`(`state_is_removed_only_when_no_installed_plugin_declares_the_integration`)与 `workspace_state.rs`(`removing_all_state_keeps_other_servers_caches`) | 已移植并有测试 | 卸载带走状态、扫描失败不删状态的规则;与宿主磁盘扫描有关的断言留给宿主测试 | +| `…/JavaTestDebugLaunchServiceTests.swift@1daaeb525`(9 个) | `dj/testing/debug_launch.rs`、`dj/testing/workflow.rs` 的测试 | 已移植并有测试 | 取消、替换、结果服务器释放;"暂停时显示调试器并激活应用"属宿主界面,不在其中 | +| `…/MacJavaTestResultServerTests.swift@1daaeb525`(2 个) | `dj/testing/result_channel.rs` 的测试 | 已移植并有测试 | 以状态机事件驱动,不开真实监听 | +| `…/JavaLanguageServerRuntimeTests.swift@1daaeb525` 里的准备阶段 fixture、Java 版本解析、Java 21 下限、JDK `release` 文件 | `project_preparation.rs`、`runtime.rs`、`argfile.rs` 的测试 | 已移植并有测试 | 共 5 个场景 | +| 同上,插件 JDK 选择(3 个)、准备任务取消与替换、导入等待、virtual document(约 10 个) | 无 | 等待宿主 API(API-25) | 属宿主会话管理的编排测试 | +| 同上,Maven 导入重启(3 个)、Maven runtime fixture | 无 | 不移植(Maven 插件,重启走 API-35) | | +| `…/MybatisFeatureModelTests.swift@1daaeb525`(7 个)、`SpringFeatureModelTests.swift@1daaeb525`(4 个) | 无;规则测试在 `tests/mybatis.rs`、`tests/spring.rs` | 等待宿主 API(API-25) | 这些是宿主功能模型的调度与跳转测试,要等提供者接线后在宿主测试里重做 | +| `…/RealJavaDebugIntegrationTests.swift@1daaeb525`(Spring/JUnit/TestNG 断点,需要真实 JDK) | 无 | 等待宿主 API(API-30†) | 真实调试端到端,调试适配器接线后在本仓库做 e2e | +| `…/Support/NoProjectJavaOperations.swift@1daaeb525` | 无 | 不移植(宿主测试替身) | | +| `macos/Tests/LitheTests/{LegacyMavenConfigurationMigrationTests,MacLegacyMavenRunConfigurationMigrationTests,MavenRuntimeTests,WorkbenchMavenLayoutTests,AgentMavenAppearanceTests}.swift@1daaeb525` | 无 | 不移植(Maven 插件或宿主界面) | | + +### 3.5 插件包、脚本、CI、资源 + +| 来源 | 新位置 | 状态 | 说明 | +| --- | --- | --- | --- | +| `Plugins/mac/Official/JavaSupport/plugin.json@1daaeb525` | `dev.lithe.plugin.java-support/plugin.json` | 已移植并有测试 | 身份、`requires`、权限、设置不变;`backend` 暂不声明,测试断言这一点 | +| `Plugins/mac/Official/JavaSupport/{icons,templates}/**@1daaeb525` | `dev.lithe.plugin.java-support/{icons,templates}/` | 已移植并有测试 | `backend/tests/repository_pins.rs` 检查清单里每个文件都存在;Maven 图标和模板的归属见第 7 节 | +| `Plugins/mac/Official/JavaSupport/assemble-package.sh@1daaeb525` | `scripts/assemble-package.sh` | 已移植无测试 | 由 `scripts/verify-package.sh` 校验产物 | +| `scripts/macos/prepare/prepare-jdtls.sh@1daaeb525`、`prepare-jdk.sh@1daaeb525` | `scripts/prepare-jdtls.sh`、`scripts/prepare-jdk.sh` | 已移植无测试 | 来源地址和 SHA-256 固定在 `third_party/*/manifest.json`,`repository_pins.rs` 检查格式 | +| `third_party/jdtls/manifest.json@1daaeb525`、`third_party/jdk/manifest.json@1daaeb525` | `third_party/jdtls/manifest.json`、`third_party/jdk/manifest.json` | 已移植并有测试 | 每个地址是 HTTPS,每个校验和是小写 SHA-256 | +| `.github/workflows/ci-plugins.yml@1daaeb525`、`release-official-plugins.yml@1daaeb525` 里 Java 的部分 | `.github/workflows/ci.yml`、`release.yml` | 已移植无测试 | 发布流程只有签名步骤拿私钥,见第 9 节 | +| `scripts/plugins/{build,verify}-official-plugins.sh@1daaeb525` 里 Java 的部分 | `scripts/build-package.sh`、`archive-package.sh`、`verify-package.sh`、`host-tools.sh` | 已移植无测试 | 宿主工具(签名器、校验器)按固定提交构建,见第 9 节 | +| `scripts/windows/prepare/prepare-jdtls.ps1@1daaeb525`、`prepare-jdk.ps1@1daaeb525` | 无 | 不移植(Windows 插件包不在本阶段;Windows 仍在用) | `third_party/jdk/manifest.json` 保留了 Windows 条目,供以后使用 | +| `scripts/resources/worktree-resources.json@1daaeb525` 里 Java 的条目 | 无 | 不移植(主仓库的工作树复用清单,随主仓库删除同步更新) | 见第 8 节 | +| `scripts/architecture/verify-java-semantic-ownership.mjs@1daaeb525` | 无 | 不移植(由主仓库新的"宿主不含语言专属内容"闸门取代) | | +| `Plugins/marketplace/official-plugins.json@1daaeb525` 的 Java 条目 | 无 | 不移植(商店索引在主仓库,发布稳定版后才改) | 第 9 节 | + +### 3.6 Windows 与共享前端、功能矩阵、文案 + +| 来源 | 新位置 | 状态 | 说明 | +| --- | --- | --- | --- | +| `windows/tauri/src-tauri/src/lsp/jdt_workspace.rs@1daaeb525`、`src-tauri/src/lsp.rs@1daaeb525`(`jdt_workspace_key`、`resolve_jdtls_launch_resources`)、`tauri.jdtls.conf.json@1daaeb525` | 无 | 不移植(Windows 壳;Windows 宿主还没有插件运行时) | 阶段 W1/W2 才能删,见第 8 节 | +| `windows/tauri/src-tauri/src/maven.rs@1daaeb525` 与 `src/features/maven/**` | 无 | 不移植(Maven 插件,Windows 同样要等 W1) | | +| `windows/tauri/src/features/{spring,mybatis}/**`、`features/editor/lsp/java-*.ts`、`features/editor/engines/monaco/java-*.ts`、`features/run/services/java-*.ts`、`features/file-explorer/**/java-*.ts`、`platform/java-*.ts`、`utils/java-name-validation.ts` | 无 | 不移植(Windows 界面壳,没有 Java 领域规则的副本之外的逻辑) | 同上 | +| `frontend/editor/src/` 里的 Java 标记、`lithe.javaRun.*`、`lithe.javaNavigation.*` 动作 | 无 | 等待宿主 API(API-27) | 改成通用编辑器动作,标题由插件按语言提供 | +| `shared/platform-feature-matrix/features/*java*`、`*spring*`、`*mybatis*` | 无 | 不移植(功能矩阵在主仓库,归属改为"由插件提供") | 第 8 节 | +| `shared/i18n/messages.json@1daaeb525` 里 Java 相关词条 | 无 | 等待宿主 API(API-37†) | 插件文案由插件自己提供,需要宿主 l10n 通道;目前后端的错误消息是英文 Core 消息 | + +## 4. 交给 Maven/Gradle 插件的内容 + +### 4.1 从 Java 移植结果里删除的内容 + +以下内容不在本仓库,也不应该再回到 Java 插件: + +- `rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs`:为 JDT 生成 Maven settings。 +- `rust/lithe-core/src/lsp/languages/jdt.rs` 里的 Maven 与 Gradle 部分: + - Maven:`userSettings`、`globalSettings`、`sourcePaths`、`profiles`、`project_uris`、`localRepository`。 + - Gradle:`enabled`、`wrapper.enabled`、`offline.enabled`、`user.home`。 +- `jdt_build.rs` 里的 Maven profiles 阻塞项,`project_preparation.rs` 里的 Maven profile 阶段。 +- 命令 `lsp.retryMavenProfiles`、`lsp.updateMavenConfiguration`。 +- `rust/lithe-core/src/build/language_import.rs`,以及主仓库 `jdt-maven-settings` 工作树资源(`/jdtls//.lithe/maven/`)。 +- `lib.rs` 公开的 `settings_with_local_repository`(把本地仓库路径写进派生 settings.xml)。 + +### 4.2 交付来源 + +交给 Maven/Gradle 插件的代码以主仓库的原文件为准,固定在 preview 提交 `dbe8441e2`: + +- [`lsp/languages/jdt_maven_settings.rs`](https://github.com/1lck/Lithe-IDEA/blob/dbe8441e27486fef26f0e915e8c6a862c307e180/rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs):Maven settings 的生成与合并,含测试。 +- [`project/maven.rs`](https://github.com/1lck/Lithe-IDEA/blob/dbe8441e27486fef26f0e915e8c6a862c307e180/rust/lithe-core/src/project/maven.rs):`MavenJdtConfiguration`(`profiles`、`settings_path`、`global_settings_path`、 + `local_repository_path`、`project_paths`、`source_paths`)和 `settings_with_local_repository`。 +- 移植时对照的改造前版本:同一提交下的 [`lsp/languages/jdt.rs`](https://github.com/1lck/Lithe-IDEA/blob/dbe8441e27486fef26f0e915e8c6a862c307e180/rust/lithe-core/src/lsp/languages/jdt.rs)、 + [`jdt_build.rs`](https://github.com/1lck/Lithe-IDEA/blob/dbe8441e27486fef26f0e915e8c6a862c307e180/rust/lithe-core/src/lsp/languages/jdt_build.rs)、[`project_preparation.rs`](https://github.com/1lck/Lithe-IDEA/blob/dbe8441e27486fef26f0e915e8c6a862c307e180/rust/lithe-core/src/lsp/languages/project_preparation.rs)、 + [`mod.rs`](https://github.com/1lck/Lithe-IDEA/blob/dbe8441e27486fef26f0e915e8c6a862c307e180/rust/lithe-core/src/lsp/languages/mod.rs) 和 [`build/language_import.rs`](https://github.com/1lck/Lithe-IDEA/blob/dbe8441e27486fef26f0e915e8c6a862c307e180/rust/lithe-core/src/build/language_import.rs)。 + +### 4.3 键归属表 + +| JDT LS 设置键 | 谁计算 | 怎样到达 JDT LS | +| --- | --- | --- | +| `java.configuration.maven.userSettings`、`java.configuration.maven.globalSettings` | Maven 插件(settings 文件,localRepository 写进它自己状态目录里的派生 settings.xml) | `languageImport.settings` 的片段,Java 校验前缀后合并 | +| `java.import.maven.*` | Maven 插件 | 同上 | +| `java.project.sourcePaths` | Maven 插件(从 POM 读出的源码目录) | 同上 | +| `java.import.gradle.enabled`、`wrapper.enabled`、`offline.enabled`、`user.home` | Gradle 插件(offline、gradleUserHome) | 同上 | +| Maven profiles(`java.project.updateSettings` 命令,参数 `org.eclipse.m2e.core.selectedProfiles`) | Maven 插件 | 缺口:settings 片段无法表达,见第 5.4 节 | +| `java.maven.downloadSources: false` | Java 插件自有 | 随 Java 自己的 `settings.java` 推送,不属于 Maven 逻辑 | +| 补全、导入排除、索引根、`java.configuration.runtimes` 等 | Java 插件 | 随 Java 自己的 `settings.java` 推送 | + +## 5. 跨插件函数设计(API-35) + +Java 一侧已经实现:接口常量、答案校验与合并在 `dj/lsp/languages/jdt_contributions.rs`(纯逻辑,不依赖 SDK), +发现并调用提供方在 `backend/src/plugin_functions.rs`。宿主中转由 Core 的 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 实现,在它合并之前,下面标"暂定"的协议形状可能改, +缺失的宿主能力在代码里是 `TODO(host-api: API-35 跨插件函数)`。 + +### 5.1 接口函数 ID + +两个函数,版本都是 1。`languageImport.settings` 的参数和返回值各是一个对象: + +- `languageImport.settings`:Java 定义的接口名,由 Maven 和 Gradle 插件各自导出。调用方 Java 只写这个 ID,不写对方插件 ID。 + - 参数:`{ "workspaceRoot": "<工作区根>" }`(`settings_request`)。 + - 返回:`{ "settings": { "java.import.maven.enabled": true } }`。`settings` 可以嵌套,也可以用点分键;未知的同级字段被忽略, + 以后加字段不破坏这一版。 +- `languageImport.settingsChanged`:Java 导出的变化通知函数。Maven/Gradle 在设置变化时调用,参数与上面的返回值同形, + 携带完整的新设置(不是差量),Java 用它替换该提供方的槽位。收到后 Java 用 `workspace/didChangeConfiguration` 把合并后的设置 + 推给 JDT LS(API-25 的语言服务器通道)。 + +### 5.2 启动时序 + +1. 语言服务器启动解析函数调用 `plugin_functions::collect_for_call`(API-25 的启动解析器就位后接入;现在没有调用方)。 +2. 向宿主发现已启用且导出 `languageImport.settings` 的插件。结果按插件 ID 排序、去重,最多取 16 个(`MAX_PROVIDERS`), + 避免插件很多时拖慢 JDT LS 启动。 +3. 逐个调用,每个提供方最多等 10 秒(`PROVIDER_TIMEOUT`,宿主另有自己的上限)。超时只取消那一次请求,不影响其他提供方。 +4. 每个答案交给 `accept_answer`:落在 `CONTRIBUTED_SETTINGS_KEYS` 里的叶子键被采用,其余记入 `rejected`,只把键名写进日志, + 永远不会到达 JDT LS。 +5. 合并按插件 ID 排序,叶子键相同时后者覆盖前者。合并结果(`Contributions::fragments`)放进 `JdtSettings::contributed`,再启动 JDT LS。 + +### 5.3 超时、失败与状态 + +- 超时或失败都不阻塞打开窗口:按已收到的设置启动。一个提供方失败不影响其他提供方。 +- 调用函数自己被取消是唯一的错误(`Cancelled`);其余失败都记在 `Contributions` 里。 +- `ContributedSettingsStatus` 表示状态:`Settled`(全部回答,或没有关联插件)、`Pending`(仍在收集或应用,由调用方在收集期间持有)、 + `Failed`(有提供方失败、超时、答案不可用,或宿主根本无法列出提供方)。`Pending` 时 `java_build` 的阻塞项是 `ContributedSettings` + (`waitingForContributedSettings`),和原来等待 Maven profiles 的位置相同。 +- 状态行里的失败原因只带稳定的错误码(例如"The call failed (plugin.host.timeout)."),不带宿主返回的消息,因为消息可能带路径; + 详细消息只写 stderr 日志。 +- 不在 `languageImport.settings` 的调用里回调 Maven,避免调用链环路。 + +### 5.4 Maven profiles 缺口 + +JDT LS 1.61.0 没有 Maven profiles 的设置键。Core 原来只能用 `java.project.updateSettings` 命令加 +`org.eclipse.m2e.core.selectedProfiles` 逐工程应用。settings 片段表达不了这件事,所以: + +- profiles 仍由 Maven 插件计算。 +- 提议:Java 导出的变化通知函数除了 settings 片段之外,还接受一个逐工程的条目,形如 `{projectUri, selectedProfiles}`, + Java 后端收到后发 `java.project.updateSettings`;`project_update_notification(project_uris)` 已经在 + `dj/lsp/languages/jdt.rs` 里实现了通用部分(`java/projectConfigurationsUpdate`)。 +- 条目形状需要在 API-35 的 PR 里与 Maven 插件一起敲定,本仓库没有实现,只留 TODO。 + +### 5.5 工具链要求 + +Maven 和 Gradle 对 JDK 版本的要求(例如 Maven 工程的 `maven.compiler.release`)不由 Java 读取。 +`dj/execution/toolchain_requirements.rs` 只保留版本比较函数,要求本身以后由构建插件通过 API-35 的函数提供。 + +### 5.6 测试运行 + +Java 的编辑器标记(API-27)被点击后,Java 后端只产生测试选择器(`build_test_selectors`)。运行由拥有该工程的构建插件完成: +Java 后端向宿主查询导出测试运行函数 ID 的已启用插件,把选择器发过去,不写死 Maven 或 Gradle。结果显示在宿主原生测试面板的落点, +等 API-27 的编辑器动作 API 就位后确定。 + +### 5.7 中转协议形状(暂定)与清单声明 + +`backend/src/plugin_functions.rs` 是后端里唯一知道中转协议的地方。方法名与形状按设计简报写,Core 的中转 PR [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 还没定稿; +改名或改形状只动这个文件里的两个方法名常量、两个参数构造函数和两个结果解析函数,收集逻辑和它的测试不变。 + +| 宿主方法 | 参数 | 结果 | +| --- | --- | --- | +| `lithe/host/functions/providers` | `{ context, functionID, version }` | `{ providers: [{ pluginID }] }` | +| `lithe/host/functions/invoke` | `{ context, pluginID, functionID, version, params }` | `{ result }` | + +`context` 是当前调用所在窗口的 workspace context。收集逻辑只依赖 `FunctionRelay` 这个 trait,测试用桩实现,不连宿主。 + +按设计简报,Java 的清单将来要这样声明(具体写法以中转 PR 合并后的契约 README 为准;现在 `plugin.json` 没有 `backend`,所以还没有写): + +- 调用方声明 `languageImport.settings`(版本 1); +- 导出 `languageImport.settingsChanged`(版本 1,`context: "workspace"`); +- 需要 `pluginFunctions` 能力 1、后端协议 2,以及不低于 3 的插件 API 级别。 + +**为什么变化通知函数还没有注册。** 槽位是按提供方分的:一次变化调用只替换发送者自己的那一份。可信的发送者只能来自宿主, +而 SDK 0.1.0 没有把调用方插件 ID 交给处理函数。如果从调用参数里读一个"我是谁",任何插件都能覆盖别人的设置。 +所以 `Contributions::record_settings`、`accept_answer` 和 `plugin_functions::record_answer` 已就位,等 SDK 或中转暴露调用方之后, +注册函数只需几行。这一项记为 `TODO(host-api: API-35 跨插件函数)`。 + +**信任边界。** 宿主只中转,不传递信任:Maven/Gradle 插件用自己的权限执行,Java 不因此获得权限;Java 只接受 `CONTRIBUTED_SETTINGS_KEYS` +里的键,所以一个提供方既不能改 `java.home`,也不能改别的插件的槽位。 + +## 6. `TODO(host-api)` 清单 + +每条都是代码里的占位注释,格式 `TODO(host-api: API-NN 说明)`;`†` 的含义见第 2.3 节。 + +主仓库里对应的宿主 PR(状态以 PR 页面为准): + +| 计划编号 | 宿主 PR | +| --- | --- | +| API-25 语言服务器会话、后端决定启动方式(含 API-12、20、22) | [#1745](https://github.com/1lck/Lithe-IDEA/pull/1745) | +| API-25、API-27 编辑器 Provider 与编辑器动作 | [#1730](https://github.com/1lck/Lithe-IDEA/pull/1730) | +| API-22、23、30 后端发起运行与调试、调试适配器行为、输出链接 | [#1746](https://github.com/1lck/Lithe-IDEA/pull/1746) | +| API-33 项目生成器通用工具字段 | [#1740](https://github.com/1lck/Lithe-IDEA/pull/1740) | +| API-35 跨插件函数中转(第一阶段) | [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) | +| 宿主语言特定内容闸门(已合入) | [#1727](https://github.com/1lck/Lithe-IDEA/pull/1727) | + +### API-25 语言服务器 + +- `dj/commands.rs`:会话命令 `java.navigationMarkers`、`java.resolveNavigation`、`java.prepareLaunch` 现在回答 `NotSupported`。 + 需要宿主把向声明的语言服务器发 `workspace/executeCommand` 与自定义请求、收通知的通道开放给插件后端。 +- `dj/host/indexing.rs`:索引排除规则必须经宿主送到语言服务器的所有配置通道。 +- `dj/testing/debug_launch.rs`:真实解析要向 JDT LS 发 Java 测试请求。 +- `dj/jdt_integration/mod.rs`:宿主仍在自己运行这些启动与状态规则,要由语言服务器启动声明取代。 +- `backend/src/lib.rs`(与 API-27、API-29 并列):宿主能为编辑器提供者、运行配置、Java 测试流程调用插件函数之后, + 在 `plugin.json` 声明 `backend`(`requires.backend: 2`,`requires.serverProcess: 1`, + `backend.launch {kind: "packaged", path: "bin/lithe-java-backend"}`,`--state ${stateDir}`),并把宿主每个调用点从 Core 切到这些函数。 + +### API-27 编辑器动作与标记 + +- `backend/src/lib.rs`(同上):运行/调试标记、导航标记、CodeLens 要由插件提供。 +- 表中对应的 Swift 行(`AppModel+JavaRunMarkers`、`AppModel+JavaEntrypoints`、`AppModel+JavaNavigation`、`SpringEndpointsView`、`JavaFileIconResolver`)也都等这个编号。 + +### API-29 构建与测试 + +- `dj/testing/result_channel.rs`:测试结果需要一个回环监听通道。插件后端不能自己监听,要由宿主提供回环通道 + 或 `network.loopbackListen` 能力。 +- `dj/testing/jdt_test_items.rs`(`build_test_selectors`):选择器由构建插件运行,见第 5.6 节(与 API-35 并列)。 +- 表中的运行配置、启动决策、`standalone-java-compile-run-v1.json`、`execution/configuration.rs` 的 Java 类型等行也等这个编号。 + +### API-33 项目模板 + +- 本仓库没有 `TODO(host-api: API-33)` 占位:三个项目生成器(`java`、`maven-archetype`、`spring-boot`)已经是 `plugin.json` 里的 + 声明式生成器,用的是已有的 `projectGenerators` 1。API-33 在计划里是"既有能力"。 +- 与它相关的判断见第 7 节(`maven-archetype` 生成器、pom 与 gradle 模板的归属)。 + +### API-35 跨插件函数 + +- `backend/src/plugin_functions.rs`:中转协议(方法名和形状)是暂定的,等 Core 的中转 PR [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 定稿(第 5.7 节)。 +- `backend/src/plugin_functions.rs`:`languageImport.settingsChanged` 还没有注册,等 SDK 或中转暴露调用方插件 ID(第 5.7 节)。 +- `dj/lsp/languages/jdt.rs`:没有中转时 `CONTRIBUTED_SETTINGS_KEYS` 的贡献者不会出现(第 5.1 到 5.3 节)。 +- `dj/lsp/languages/jdt.rs`:Maven profiles 缺口(第 5.4 节)。 +- `dj/lsp/languages/jdt.rs`:`ContributedSettingsStatus` 的 `Pending` 由调用方在收集期间持有,变化后的 `workspace/didChangeConfiguration` + 还需要 API-25 的通道(第 5.3 节)。 +- `dj/execution/toolchain_requirements.rs`:构建插件通过函数提供工具链要求(第 5.5 节)。 +- `dj/testing/jdt_test_items.rs`:测试运行委托给构建插件(第 5.6 节)。 + +### 来自计划里其他编号的占位(`†`,不在指定的五个之内) + +- API-20 存储:`dj/execution/argfile.rs`(后端没有插件自有的临时目录)、`dj/jdt_integration/workspace_state.rs`(缓存目录来自宿主)。 +- API-22 日志与进度:`dj/host/storage.rs`、`dj/execution/argfile.rs`(插件日志通道)。 +- API-23 工具链与进程:`dj/jdt_integration/runtime.rs`(探测 `java -version` 的进程执行)。 +- API-37 环境与 l10n:`dj/jdt_integration/runtime.rs`、`dj/jdt_integration/launch_resources.rs`、`backend/src/lib.rs`(用户可见文案要走宿主 i18n)。 +- API-42 内置 Git 只读:`dj/host/git.rs`、`dj/languages/code_vision_hints.rs`(blame 作者)、`dj/lsp/languages/jdt_project_metadata.rs`(一条测试注释,Git 忽略查询)。 +- API-30 调试适配器:第 3.1 与 3.4 节表里的 `debug/engine.rs` 特例与 `RealJavaDebugIntegrationTests`,代码里没有单独占位。 + +## 7. 判断记录 + +这些是移植中做的、值得让评审者知道的取舍: + +- **工作区指纹**(`java_workspace.rs`):只保留与 JDT 状态目录命名有关的部分,指纹里读取的是根构建文件的存在与直接模块目录, + 不解析 POM 内容。读取文件仍是"这个工作区结构变了没有"的判断,不是 Maven 逻辑。 +- **`toolchain_requirements.rs` 保留**:JDK 选择要比较版本,这几个纯函数是复制过来的最小集合,不含 Maven/Gradle 要求。 +- **`java.project.sourcePaths` 键**:Core 当时用这个键发 POM 里的源码目录,所以把它放进接受列表;键本身属于 JDT LS 的命名空间,值由 Maven 插件给。 +- **`project_update_notification`**:是 JDT LS 的通用协议(`java/projectConfigurationsUpdate`),保留在 Java 一侧,Maven 插件改了设置后由 Java 发出。 +- **`java.maven.downloadSources: false`**:留在 Java 自己的设置里。它是 JDT LS 的行为开关,不涉及 Maven 的配置值。 +- **模板、图标与 `maven-archetype` 生成器**:`plugin.json` 里 `java` 生成器带 Maven/Gradle 构建文件模板,`maven-archetype` 生成器运行 + `mvn archetype:generate`,`icons/maven*.svg` 是 Maven 图标。它们是"新建项目"(API-33),不是"导入工程",这次按已有行为一起移植。 + 这是否应该归 Maven 插件,由协调者决定;归过去时只需移动文件和 `plugin.json` 声明。 +- **6 个没有 Rust 测试读取的 fixture**:`lsp/java-build-report-v1.json`、`lsp/java-navigation-v1.json`、`lsp/jdt-direct-launch-v1.json`、 + `java/basic.json`、`mybatis/basic.json`、`spring/basic.json`。它们是主仓库里的共享契约样例,复制过来为了让两边保持同一份样例; + 本仓库没有消费它们的类型测试。`repository_pins.rs` 只断言 fixture 数量不少于 18 且都是合法 JSON。 +- **没有联网测试和 Linux 之外的平台**:所有测试都不联网;CI 的 Rust 任务在 Ubuntu 上跑,打包任务在 macOS 上跑。 + +## 8. 主仓库删除清单(分阶段) + +不能一次删完:Windows 宿主还没有插件运行时,Windows 仍在用 Core 里的 Java 模块和 `third_party` 清单。顺序如下。 + +### 8.1 前置条件 + +- Core 的跨插件函数中转(API-35)合并,Maven/Gradle 插件导出 `languageImport.settings`。 +- 宿主的语言服务器通道(API-25)、编辑器标记(API-27)、运行与测试(API-29)合并,Java 插件声明 `backend` 并通过 e2e。 +- 本仓库发布一个签名的、支持 API 4 的版本;只有当稳定版 Lithe 支持这个 API 版本时才能转正(见第 9 节)。 + +### 8.2 macOS 阶段:Core 与 macOS 应用 + +- Core: + - `lsp/languages/{java_*,jdt*,project_preparation}.rs` 与 `mod.rs` 里的 re-export,`languages/{java,java_syntax,spring,mybatis}.rs`, + `debug/java_test.rs`,`execution/{java_selection,launch_command,classpath_jar}.rs` 的 Java 部分,`protocol/contracts.rs` 里的 Java、Spring、MyBatis、Maven 模型。 + - `protocol/command.rs` 与 `runtime/dispatcher.rs` 里的 Java 命令;`engine.rs` 里的 Java 函数与 `StartServerRequest` 的 Java 字段; + `engine_real_jdt*_tests.rs`。 + - `lsp.retryMavenProfiles`、`lsp.updateMavenConfiguration`、`build/language_import.rs`、`build/integrations/`、`jdt_maven_settings.rs`、 + `project/maven*.rs`(Windows 完成 W1 之前保留)、`detectors/{maven,gradle}.rs`(由 Maven/Gradle 插件取代之后)。 + - 通用代码里的 Java 特判:`KNOWN_RUNTIME_TOOLS*`、`HOST_INTEGRATION_*`、`ModelSource::HostIntegration`、`TestResultsSource::HostIntegration`、`SELECTED_RUNTIME_TOOLS`、 + `host_runtime_version_arguments` 的 java 分支。 + - 只服务 Java 的测试与 fixture(第 3.2 节标"已移植"的那些)。 + - `diagnostics` 里的 Java 日志事件:改 `shared/logging/events/*.json` 后重新生成。 +- macOS:第 3.3 节标"已移植"或"等待宿主 API"的 Swift 文件与第 3.4 节的测试,`LitheIcons` 里的 Java 命名改成通用符号种类图标, + `BuiltInModuleCatalog` 里的 Java 条目。 +- 宿主资源与脚本: + - `Plugins/mac/Official/JavaSupport` 迁出后删除;`scripts/macos/prepare/prepare-{jdtls,jdk}.sh`(Windows 的 `.ps1` 不动)。 + - `scripts/resources/worktree-resources.json` 里 `jdtls`、`jdk`、`jdt-maven-settings`、`java-launch-temporaries`、Java 插件包路径的条目, + 以及复用脚本的校验路由、对应测试和 `docs/ci-builds.md` 的"独立工作树的本地编译"章节,要在同一个 PR 里同步更新(AGENTS.md 的规定)。 + - `scripts/architecture/verify-java-semantic-ownership.mjs` 由新的闸门取代;`ci-plugins.yml` 里的 `jdtls: "true"`,`release-official-plugins.yml` 里的路径。 +- 文案与矩阵:删除宿主不再使用的 Java 词条,重新生成 i18n 并运行两个检查脚本;功能矩阵里的 Java 条目改为"由插件提供"。 +- 契约:`shared/contracts/plugins/v1/` 里与 Java 专属相关的内容(`hostIntegration jdt`、`runtime:"java"` 字段等)按契约发布规则改, + 合并后在 lithe-plugin-docs 运行 `node scripts/contract.mjs sync --revision <合并后的 preview commit>`。 + +### 8.3 Windows 阶段:不属于第一批 + +- 保留到 W1:`windows/tauri/src-tauri/src/maven.rs`、`lsp.rs` 里的 `jdt_workspace_key`、`lsp/jdt_workspace.rs`、`src/features/{maven,spring,mybatis}`、 + 各 `java-*.ts`、`lib.rs` 公开的 `jdt_workspace_key` 与 `settings_with_local_repository`、`third_party/{jdtls,jdk}/manifest.json`、 + `scripts/windows/prepare/prepare-{jdtls,jdk}.ps1`、`tauri.jdtls.conf.json`。 +- 这些在 Windows 宿主能安装并运行本插件之前,不能删。 + +## 9. 发布、签名与商店 + +- 标签是 `v`,必须等于 `plugin.json` 的 `version`(当前 1.1.0)。发布资产是 `Lithe-Java-Support-{version}-darwin-{arm64,x64}.zip` + 加 `.sha256`,用 `ditto --keepParent` 制作。 +- 发布工作流分成测试和发布两段,只有最后的签名步骤拿 `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`;发布标为预发布(`--prerelease`), + 原因是主仓库仍然内置 Java Support(`official-plugins.json` 里 Java 条目 `releaseEnabled: false`,`bundledUntilPublishedVersion: "1.1.0"`), + 而且 `pluginAPIVersion` 4 还没有随稳定版 Lithe 发布。 +- 宿主工具(`LithePluginPackageSigner`、`LitheOfficialPluginVerifier`)从 Lithe 提交 `1daaeb525831999ad518b4c6fd3b11b5b1b64b5a` 构建, + 需要那个提交 `.swift-version` 里的 Swift 6.3.3;固定在 `host-tools.lock.json`。SDK 固定在 `sdk.lock.json` + (`lithe_plugin_sdk` 0.1.0,标签 `sdk-rust-v0.1.0`,提交 `e689971c000c15a044df0502052e7b2c1c6f84f`), + `scripts/verify-sdk-lock.sh` 检查 `Cargo.lock` 解析到的是同一个提交。 +- 插件包在运行时只读:JDT LS、Lombok、java-debug、vscode-java-test 运行器和 JDK 都在构建阶段固定来源、校验哈希后放进包再签名; + 运行时的状态只写宿主给的状态目录(`--state ${stateDir}`),不写包目录,保证 Sparkle 增量更新的基线不变。 +- 还没有登记到商店索引。登记要等稳定版 Lithe 支持这个 API 版本,并在主仓库的索引里加条目。 + +## 10. 未完成事项 + +- 宿主没有接线:`plugin.json` 没有 `backend` 声明,第 6 节的 API 全部没有实现,三个会话命令只回答 `NotSupported`。 +- API-35 的 Java 一侧大部分已实现(第 5 节),但中转协议是暂定的;变化通知函数没有注册;没有任何地方调用 `collect_for_call`(要等 API-25 的启动解析器)。Maven profiles 的逐工程交付形状没有定。 +- `jdt_contributions.rs` 和 `plugin_functions.rs` 的 24 个测试只验证了插件内部逻辑:它们已经编译并通过,但用来对照的中转协议是暂定的,等 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 合入后要按最终协议复核。 +- 没有真实 JDT 的端到端测试,也没有在 Linux 上实跑过(本机没有 Docker),没有真实签名。 +- 打包的 `package` 任务只在 macOS 上构建未签名的开发归档,没有在 CI 上跑过一次完整的签名发布。 +- Windows 插件包没有做;`third_party/jdk/manifest.json` 里保留了 Windows 条目。 +- 6 个 fixture 没有测试读取(第 7 节)。 +- Spring Endpoints 面板的插件承载方式没有落点(第 3.3 节)。 diff --git a/README.md b/README.md index 5c7ba74..0982bd1 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,102 @@ 打包 Eclipse JDT Language Server、Lombok、java-debug、vscode-java-test 运行器和每个架构一份 Temurin JDK, 为 Java 项目提供补全、诊断、导航、运行/调试入口、测试运行和 Spring Boot / MyBatis 索引。 -> English: The official Java plugin for Lithe. It bundles Eclipse JDT LS, Lombok, java-debug, the vscode-java-test -> runner and a per-architecture Temurin JDK, and provides Java language features, run/debug entry points, test runs -> and Spring Boot / MyBatis indexes. This repository is being split out of the Lithe main repository; see -> `MIGRATION.md` on the porting branch for status. +> English: The official Java plugin for Lithe. It bundles Eclipse JDT LS, Lombok, the java-debug and vscode-java-test +> runners and a per-architecture Temurin JDK, and provides Java language features, run/debug entry points, test runs +> and Spring Boot / MyBatis indexes. The Java rules live in a Rust domain crate and a plugin backend built on the +> published Lithe Rust plugin SDK. Maven and Gradle import settings come from the Maven and Gradle plugins through +> a function interface that Lithe Core relays between plugins. -本仓库正在从 Lithe 主仓库拆分出来,目标是 Lithe 宿主(Rust Core 和 macOS 应用)不再包含任何 Java 专属逻辑: -Java 的领域规则全部由本插件提供,宿主只提供通用插件接口。 +> **进行中**:本仓库正在从 Lithe 主仓库拆分出来,目标是 Lithe 宿主(Rust Core 和 macOS 应用)不再包含任何 Java 专属逻辑, +> Java 的领域规则全部由本插件提供,宿主只提供通用插件接口。插件后端还没有接入宿主:`plugin.json` 暂不声明 `backend`, +> 要等 `MIGRATION.md` 第 6 节列出的宿主插件接口合并。在那之前 Lithe 仍然内置 Java Support,本仓库发布的版本标为预发布。 + +## 功能 + +| 功能 | 说明 | +| --- | --- | +| 语言服务 | 用打包的 JDT LS(含 Lombok)提供补全、诊断、导航;JDK 随包提供,只用来运行语言服务器,项目自己的 SDK 仍由用户选择 | +| 运行与调试入口 | 识别 `main` 方法和测试方法,生成运行/调试标记 | +| 测试 | 发现 JUnit 测试项,生成测试选择器,并驱动 java-debug 与 vscode-java-test 运行器 | +| Spring Boot / MyBatis | 索引 Spring 端点与 MyBatis 映射,支持跳转 | +| 新建项目 | 三个声明式项目生成器:Java、Maven Archetype、Spring Boot | +| Maven / Gradle 工程 | 导入设置(settings 文件、本地仓库、offline、Gradle user home 等)由 Maven、Gradle 插件通过 `languageImport.settings` 提供,Java 不计算也不解析它们 | + +## 工作方式 + +- **领域规则**在 `crates/java-domain`,不依赖宿主:命令的 JSON 载荷和错误码与主仓库 Core 一一对应。 + 所有宿主差异(存储、日志、Git、语言服务器通道)都是带 `TODO(host-api: API-NN)` 的占位。 +- **插件后端**在 `backend/`:基于固定版本的 Rust SDK(`sdk.lock.json`),把每条领域命令注册为同名插件函数, + 并带着跨插件函数的调用方一侧。 +- **跨插件函数**:启动 JDT LS 之前,后端通过 Core 中转发现所有导出 `languageImport.settings` 的插件(Maven、Gradle), + 逐个调用,只接受落在 JDT LS 导入设置命名空间里的键,合并后交给语言服务器。 + Maven 设置变化时调用 Java 导出的 `languageImport.settingsChanged`;Java 不会反过来调用正在调用它的插件。 + 细节见 `MIGRATION.md` 第 5 节。 +- **运行时只读**:插件包签名后在运行时只读。JDT LS、Lombok、java-debug、测试运行器和 JDK 都在构建阶段固定来源、校验哈希后放进包里, + 运行时状态只写宿主提供的状态目录。 + +## 安装 + +目前还不能从 Lithe Store 安装;Lithe 稳定版支持本插件所需的插件 API 之后,才会登记到商店索引(见 `MIGRATION.md` 第 9 节)。 +在 Lithe 开发版中,可以通过 **设置 → 插件 → ⚙ → 从磁盘安装插件…** 选择本地构建的包目录安装 +(发行版 Lithe 不加载未签名的开发包)。 + +## 开发 + +```text +dev.lithe.plugin.java-support/ # 插件包的静态部分:plugin.json、icons/、templates/,目录名必须等于插件 id +crates/java-domain/ # Java 领域规则(Rust),测试在各模块内和 tests/ 下 +backend/ # 插件后端进程(lithe-java-backend),基于 Lithe Rust 插件 SDK +fixtures/ # 与主仓库共享的契约样例(JSON) +third_party/ # JDT LS 与 JDK 的来源地址和 SHA-256 +sdk.lock.json # 后端链接的 Rust SDK:标签、提交、crate 归档的 SHA-256 +host-tools.lock.json # 签名器和校验器所用的 Lithe 提交 +scripts/ # 组装、校验、归档插件包,构建宿主工具,检查 SDK 锁 +.github/workflows/ci.yml # PR:格式、lint、测试、SDK 锁,以及 macOS 上的开发包构建 +.github/workflows/release.yml # 推送 v<版本> 标签:测试后签名并发布 GitHub Release +MIGRATION.md # 主仓库路径到本仓库的映射、待宿主接口清单、主仓库删除清单 +``` + +Rust 部分需要 Rust 工具链和 Cargo;打包脚本需要 macOS、`zsh`、`curl`、`ditto`,构建宿主工具时还需要 `host-tools.lock.json` +所对应提交要求的 Swift 工具链。 + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo test --workspace --locked +scripts/verify-sdk-lock.sh +``` + +所有测试都是有界、确定性的:不联网,不读全局时间,不占固定端口。 + +构建本地未签名开发包(会下载并校验 JDT LS 与 JDK,体积较大): + +```bash +scripts/build-package.sh --arch arm64 # 或 x86_64;打印包目录 .artifacts/package//dev.lithe.plugin.java-support +scripts/archive-package.sh --package <上一步打印的目录> --arch arm64 --development-unsigned +``` + +签名发布包需要宿主工具(签名器和校验器,按 `host-tools.lock.json` 固定的 Lithe 提交构建)和发布私钥,只在发布工作流里进行: + +```bash +scripts/host-tools.sh --require-pinned-swift # 打印宿主工具所在目录 +``` + +`plugin.json` 的完整格式见 lithe-plugin-docs 里的 +[插件契约](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/contract/v1/README.md)。 + +## 发布新版本 + +1. 修改 `dev.lithe.plugin.java-support/plugin.json` 的 `version`,合入 `main`。 +2. 推送同名标签(带 `v` 前缀,必须等于清单版本): + + ```bash + git tag v1.2.0 && git push origin v1.2.0 + ``` + +3. `Release` 工作流先在 Ubuntu 上跑 Rust 检查,再在 macOS 上构建并校验 arm64 与 x86_64 两个包;只有最后的签名与发布步骤拿 + `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`。发布资产是 `Lithe-Java-Support-<版本>-darwin-{arm64,x64}.zip` 和对应的 `.sha256`。 +4. 在 Lithe 稳定版不再内置 Java Support 之前,发布保持为预发布(`--prerelease`)。 ## 许可 diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..c09692d --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "lithe-java-backend" +description = "Plugin backend process of the Lithe Java Support plugin." +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "lithe-java-backend" +path = "src/main.rs" + +[dependencies] +lithe-java-domain = { path = "../crates/java-domain" } +# Pinned to the published Rust SDK release; ../sdk.lock.json records the +# matching commit, crate archive and SHA-256; scripts/verify-sdk-lock.sh +# checks that Cargo.lock resolved this exact commit. +lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs", tag = "sdk-rust-v0.1.0" } +serde_json.workspace = true diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..abc2e8f --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,152 @@ +//! Plugin backend of the Lithe Java Support plugin. +//! +//! The backend is a [`lithe_plugin_sdk`] process that offers the commands of +//! [`lithe_java_domain::commands`] as plugin functions with the same IDs and +//! JSON payloads Rust Core accepted. It has no host wiring yet: the host +//! still runs these rules itself, and the plugin manifest does not declare a +//! `backend` until the host APIs listed in `MIGRATION.md` exist. +//! +//! TODO(host-api: API-25 language server, API-27 editor actions and markers, +//! API-29 build and test): declare the backend in `plugin.json` +//! (`requires.backend` 2, `backend.launch`, `bin/lithe-java-backend`) once the +//! host can call plugin functions for editor providers, run configurations +//! and the Java test workflow, and move each host call site from Core to +//! these functions. +//! +//! [`plugin_functions`] is the backend's side of the cross-plugin interface +//! `languageImport.settings` (API-35): it asks the Maven and Gradle plugins +//! for the settings JDT LS imports with. It is the only module that knows the +//! host relay's wire. + +pub mod plugin_functions; + +use lithe_java_domain::commands::{self, ExecuteOptions, SESSION_COMMANDS, SUPPORTED_COMMANDS}; +use lithe_java_domain::protocol::{CoreError, ErrorCode}; +use lithe_plugin_sdk::{Backend, Context, FunctionError, PluginError, SdkError, Value}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +/// Name reported as `serverInfo.name`. +pub const BACKEND_NAME: &str = "lithe-java-backend"; + +/// Builds the backend with every Java Support function registered. +pub fn backend() -> Result { + let mut backend = Backend::new(BACKEND_NAME).version(env!("CARGO_PKG_VERSION")); + for command in SUPPORTED_COMMANDS.iter().chain(SESSION_COMMANDS) { + let command = *command; + backend.function(command, move |params, context| { + invoke(command, params, context) + })?; + } + Ok(backend) +} + +/// Runs one domain command for a plugin function call. +/// +/// The call's SDK cancellation is forwarded to the command's cooperative +/// cancellation scope. A call cancelled before the scope existed still +/// answers as cancelled, so a late cancellation is never lost. +fn invoke(command: &str, params: Value, context: &Context) -> Result { + static NEXT_OPERATION: AtomicU64 = AtomicU64::new(1); + context.check_cancelled()?; + let operation_id = format!( + "plugin-call-{}", + NEXT_OPERATION.fetch_add(1, Ordering::Relaxed) + ); + let cancelled = Arc::new(AtomicBool::new(false)); + context.cancellation().on_cancel({ + let operation_id = operation_id.clone(); + let cancelled = Arc::clone(&cancelled); + move || { + cancelled.store(true, Ordering::SeqCst); + commands::cancel(&operation_id); + } + }); + let result = commands::execute_with_options( + command, + params, + ExecuteOptions { + operation_id: Some(operation_id), + timeout_milliseconds: None, + }, + ); + if cancelled.load(Ordering::SeqCst) { + return Err(FunctionError::Cancelled); + } + result.map_err(|error| function_error(error, context)) +} + +/// Maps a domain failure to the error the host receives. +/// +/// `details` may carry paths or tool output, so it only reaches stderr. +fn function_error(error: CoreError, context: &Context) -> FunctionError { + if error.code == ErrorCode::Cancelled { + return FunctionError::Cancelled; + } + if let Some(details) = &error.details { + context.log(format!( + "{} failed: {}: {details}", + context.function_id(), + error.message + )); + } + // TODO(host-api: API-37 i18n): the messages are English Core messages. + // Localize them through the host i18n service once plugin backends can + // reach it. + PluginError::new(plugin_error_code(&error.code), error.message).into() +} + +/// Stable `pluginErrorCode` for each domain error category. +pub fn plugin_error_code(code: &ErrorCode) -> &'static str { + match code { + ErrorCode::InvalidRequest => "java.invalidRequest", + ErrorCode::WorkspaceNotFound => "java.workspaceNotFound", + ErrorCode::PermissionDenied => "java.permissionDenied", + ErrorCode::NotSupported => "java.notSupported", + ErrorCode::RuntimeMissing => "java.runtimeMissing", + ErrorCode::ProcessStartFailed => "java.processStartFailed", + ErrorCode::ProcessFailed => "java.processFailed", + ErrorCode::ParseFailed => "java.parseFailed", + ErrorCode::Cancelled => "java.cancelled", + ErrorCode::TimedOut => "java.timedOut", + ErrorCode::ConnectionClosed => "java.connectionClosed", + ErrorCode::Unknown => "java.unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_error_category_has_a_distinct_valid_plugin_code() { + let codes = [ + ErrorCode::InvalidRequest, + ErrorCode::WorkspaceNotFound, + ErrorCode::PermissionDenied, + ErrorCode::NotSupported, + ErrorCode::RuntimeMissing, + ErrorCode::ProcessStartFailed, + ErrorCode::ProcessFailed, + ErrorCode::ParseFailed, + ErrorCode::Cancelled, + ErrorCode::TimedOut, + ErrorCode::ConnectionClosed, + ErrorCode::Unknown, + ] + .map(|code| plugin_error_code(&code)); + let distinct: std::collections::BTreeSet<_> = codes.iter().collect(); + assert_eq!(distinct.len(), codes.len()); + assert!(codes + .iter() + .all(|code| lithe_plugin_sdk::is_plugin_error_code(code))); + } + + #[test] + fn every_command_is_a_valid_function_id() { + for command in SUPPORTED_COMMANDS.iter().chain(SESSION_COMMANDS) { + assert!(lithe_plugin_sdk::is_function_id(command), "{command}"); + } + assert!(backend().is_ok()); + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..b861629 --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,14 @@ +//! Entry point of the Java Support plugin backend. +//! +//! Stdout carries only protocol frames; diagnostics go to stderr. + +fn main() { + let backend = match lithe_java_backend::backend() { + Ok(backend) => backend, + Err(error) => { + eprintln!("lithe-java-backend: could not register functions: {error}"); + std::process::exit(1); + } + }; + std::process::exit(backend.listen()); +} diff --git a/backend/src/plugin_functions.rs b/backend/src/plugin_functions.rs new file mode 100644 index 0000000..998c72d --- /dev/null +++ b/backend/src/plugin_functions.rs @@ -0,0 +1,671 @@ +//! Cross-plugin function calls of the backend (API-35). +//! +//! A plugin function is private to the plugin that registered it. The host +//! relays calls between plugins (this plugin, Core, the other plugin), but +//! only for functions the receiver exports and the caller declares. This +//! module is the one place that knows the relay's wire: the host method +//! names, their params and their results. If the relay protocol is renamed +//! or reshaped, only this file changes. +//! +//! What it does for the Java plugin: before JDT LS starts, ask every enabled +//! provider of `languageImport.settings` (a Maven or Gradle plugin, never +//! named here) for the settings JDT LS should import with, and collect the +//! answers in [`Contributions`]. The interface itself is defined by +//! `lithe_java_domain::lsp::languages::jdt_contributions`. +//! +//! TODO(host-api: API-35 cross-plugin function): the relay is built in a +//! separate Lithe change and its wire is not final. The method names and +//! shapes below follow the design brief: +//! +//! - `lithe/host/functions/providers` takes `{ context, functionID, version }` +//! and answers `{ providers: [{ pluginID }] }` for the enabled plugins that +//! export that function, sorted by plugin ID. +//! - `lithe/host/functions/invoke` takes +//! `{ context, pluginID, functionID, version, params }` and answers +//! `{ result }`. +//! +//! Adjust the constants and the two `*_params` / parse functions when the +//! relay lands; the collector and its tests do not change. +//! +//! TODO(host-api: API-35 cross-plugin function): the change function +//! `languageImport.settingsChanged` is not registered yet. It must store the +//! new settings in the slot of the *calling plugin*, and the SDK this crate is +//! pinned to does not tell a handler which plugin called it. Register it +//! with `jdt_contributions::SETTINGS_CHANGED_FUNCTION` and [`record_answer`] +//! once the SDK exposes the caller. + +use lithe_java_domain::lsp::languages::jdt_contributions::{ + accept_answer, settings_request, Contributions, FunctionContract, MAX_PROVIDERS, + SETTINGS_FUNCTION, +}; +use lithe_plugin_sdk::{ + sdk_codes, Cancellation, Cancelled, Context, FunctionError, Host, PluginError, SdkError, Value, + WorkspaceContext, +}; +use serde_json::json; +use std::thread; +use std::time::Duration; + +/// Host method that lists the enabled plugins exporting a function. +pub const PROVIDERS_METHOD: &str = "lithe/host/functions/providers"; + +/// Host method that calls an exported function of another plugin. +pub const INVOKE_METHOD: &str = "lithe/host/functions/invoke"; + +/// Longest wait for one provider before JDT LS starts without its settings. +/// The host applies its own, larger limit on top. +pub const PROVIDER_TIMEOUT: Duration = Duration::from_secs(10); + +/// A relay request that was refused or failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelayFailure { + /// Stable code, such as `plugin.host.timeout` or an `sdk.*` code. + pub code: String, + /// Diagnostic text. It may name paths, so it is only logged. + pub message: String, +} + +impl RelayFailure { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } + + /// Whether the request was cut short by cancellation: either the calling + /// function was cancelled, or only this request ran out of time. + fn is_cancelled(&self) -> bool { + self.code == sdk_codes::CALL_CANCELLED + } +} + +impl From for RelayFailure { + fn from(error: SdkError) -> Self { + Self::new(error.code(), error.message()) + } +} + +/// The two relay requests the collector needs. +pub trait FunctionRelay { + /// Plugin IDs of the enabled plugins that export `function`. + fn providers(&self, function: &FunctionContract) -> Result, RelayFailure>; + + /// Calls `function` of `provider` and returns the function's result. + fn invoke( + &self, + provider: &str, + function: &FunctionContract, + params: Value, + ) -> Result; +} + +/// The relay as the host offers it to one running function call. +pub struct HostRelay<'a> { + host: &'a Host, + workspace: &'a WorkspaceContext, + call_timeout: Duration, +} + +impl<'a> HostRelay<'a> { + pub fn new(host: &'a Host, workspace: &'a WorkspaceContext, call_timeout: Duration) -> Self { + Self { + host, + workspace, + call_timeout, + } + } + + /// Adds the workspace context every relayed request names. + fn scoped(&self, mut params: Value) -> Value { + params["context"] = self.workspace.to_value(); + params + } +} + +impl FunctionRelay for HostRelay<'_> { + fn providers(&self, function: &FunctionContract) -> Result, RelayFailure> { + let params = self.scoped(providers_params(function)); + let result = self.host.request(PROVIDERS_METHOD, params)?; + parse_providers(&result) + } + + fn invoke( + &self, + provider: &str, + function: &FunctionContract, + params: Value, + ) -> Result { + // The deadline cancels only this request, so one slow provider does + // not end the whole function call. + let attempt = Cancellation::new(); + let _deadline = Deadline::start(&attempt, self.call_timeout); + let params = self.scoped(invoke_params(provider, function, params)); + let result = self + .host + .with_cancellation(&attempt) + .request(INVOKE_METHOD, params)?; + parse_invoke_result(result) + } +} + +/// Params of [`PROVIDERS_METHOD`], without the workspace context. +fn providers_params(function: &FunctionContract) -> Value { + json!({ "functionID": function.id, "version": function.version }) +} + +/// Params of [`INVOKE_METHOD`], without the workspace context. +fn invoke_params(provider: &str, function: &FunctionContract, params: Value) -> Value { + json!({ + "pluginID": provider, + "functionID": function.id, + "version": function.version, + "params": params, + }) +} + +fn parse_providers(result: &Value) -> Result, RelayFailure> { + let entries = result + .get("providers") + .and_then(Value::as_array) + .ok_or_else(|| invalid_result(PROVIDERS_METHOD))?; + entries + .iter() + .map(|entry| { + entry + .get("pluginID") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| invalid_result(PROVIDERS_METHOD)) + }) + .collect() +} + +fn parse_invoke_result(result: Value) -> Result { + match result { + Value::Object(mut fields) => fields + .remove("result") + .ok_or_else(|| invalid_result(INVOKE_METHOD)), + _ => Err(invalid_result(INVOKE_METHOD)), + } +} + +fn invalid_result(method: &str) -> RelayFailure { + RelayFailure::new( + "java.relay.invalidResult", + format!("{method} answered with an unexpected shape."), + ) +} + +/// Cancels one request after a timeout, unless the request ends first. +/// +/// Dropping the guard cancels the token, which wakes and ends the timer +/// thread; the token is not used again afterwards. +struct Deadline { + attempt: Cancellation, +} + +impl Deadline { + fn start(attempt: &Cancellation, timeout: Duration) -> Self { + let watched = attempt.clone(); + thread::spawn(move || { + // `wait_timeout` answers whether the token was cancelled; only a + // token that is still live after the timeout needs cancelling. + if !watched.wait_timeout(timeout) { + watched.cancel(); + } + }); + Self { + attempt: attempt.clone(), + } + } +} + +impl Drop for Deadline { + fn drop(&mut self) { + self.attempt.cancel(); + } +} + +/// Collects the settings every enabled `languageImport.settings` provider +/// contributes for `workspace_root`. +/// +/// A provider that fails, times out or answers with something unusable is +/// recorded as failed and does not stop the others, so JDT LS can always +/// start. The only error is the calling function's own cancellation. +/// `log` receives diagnostics for stderr. +pub fn collect_contributions( + relay: &dyn FunctionRelay, + workspace_root: &str, + cancellation: &Cancellation, + log: &dyn Fn(&str), +) -> Result { + let mut contributions = Contributions::default(); + cancellation.check()?; + let mut providers = match relay.providers(&SETTINGS_FUNCTION) { + Ok(providers) => providers, + Err(failure) if failure.is_cancelled() => return Err(Cancelled), + Err(failure) => { + log(&format!( + "Listing settings providers failed: {}: {}", + failure.code, failure.message + )); + contributions.record_discovery_failure(failure_reason(&failure)); + return Ok(contributions); + } + }; + providers.sort(); + providers.dedup(); + providers.truncate(MAX_PROVIDERS); + for provider in providers { + cancellation.check()?; + let request = settings_request(workspace_root); + match relay.invoke(&provider, &SETTINGS_FUNCTION, request) { + Ok(answer) => record_answer(&mut contributions, &provider, &answer, log), + Err(failure) if failure.is_cancelled() => { + // The calling function was cancelled, or only this request + // ran out of time. + cancellation.check()?; + contributions.record_failure(&provider, "The call timed out."); + } + Err(failure) => { + log(&format!( + "{provider} failed to provide settings: {}: {}", + failure.code, failure.message + )); + contributions.record_failure(&provider, failure_reason(&failure)); + } + } + } + Ok(contributions) +} + +/// Validates one provider's answer and stores it in the provider's slot. +/// +/// An unusable answer marks the provider as failed instead of keeping what it +/// sent before, so the status never claims settings that were not accepted. +pub fn record_answer( + contributions: &mut Contributions, + provider: &str, + answer: &Value, + log: &dyn Fn(&str), +) { + match accept_answer(answer) { + Ok(accepted) => { + if !accepted.rejected.is_empty() { + log(&format!( + "{provider} contributed keys outside the accepted namespace: {}", + accepted.rejected.join(", ") + )); + } + contributions.record_settings(provider, accepted); + } + Err(reason) => { + log(&format!( + "{provider} answered with unusable settings: {reason}" + )); + contributions.record_failure(provider, reason); + } + } +} + +/// Status-line reason for a failed request. It carries the stable code only, +/// because the message may name machine paths. +fn failure_reason(failure: &RelayFailure) -> String { + format!("The call failed ({}).", failure.code) +} + +/// Collects the contributions for the workspace a function call runs for. +/// +/// This is the call the language-server launch resolver makes before JDT LS +/// starts; its result feeds `JdtSettings::contributed`. +pub fn collect_for_call( + context: &Context, + workspace_root: &str, +) -> Result { + let Some(workspace) = context.workspace_context() else { + return Err(PluginError::new( + "java.noWorkspaceContext", + "The call carries no workspace context.", + ) + .into()); + }; + let relay = HostRelay::new(context.host(), workspace, PROVIDER_TIMEOUT); + let log = |message: &str| context.log(message); + Ok(collect_contributions( + &relay, + workspace_root, + context.cancellation(), + &log, + )?) +} + +#[cfg(test)] +mod tests { + use super::*; + use lithe_java_domain::lsp::languages::jdt::ContributedSettingsStatus; + use std::cell::RefCell; + use std::collections::BTreeMap; + + /// A relay that answers from a script and records the calls it received. + struct FakeRelay { + providers: Result, RelayFailure>, + answers: BTreeMap>, + calls: RefCell>, + /// Cancels this token when a provider is called, like a host that + /// cancels the whole function call mid-request. + cancel_on_invoke: Option, + } + + impl FakeRelay { + fn new(providers: &[&str]) -> Self { + Self { + providers: Ok(providers.iter().map(|id| id.to_string()).collect()), + answers: BTreeMap::new(), + calls: RefCell::new(Vec::new()), + cancel_on_invoke: None, + } + } + + fn answering(mut self, provider: &str, answer: Result) -> Self { + self.answers.insert(provider.to_string(), answer); + self + } + + fn called_providers(&self) -> Vec { + self.calls + .borrow() + .iter() + .map(|(provider, _)| provider.clone()) + .collect() + } + } + + impl FunctionRelay for FakeRelay { + fn providers(&self, _function: &FunctionContract) -> Result, RelayFailure> { + self.providers.clone() + } + + fn invoke( + &self, + provider: &str, + _function: &FunctionContract, + params: Value, + ) -> Result { + self.calls.borrow_mut().push((provider.to_string(), params)); + if let Some(cancellation) = &self.cancel_on_invoke { + cancellation.cancel(); + } + self.answers + .get(provider) + .cloned() + .unwrap_or_else(|| Err(RelayFailure::new("plugin.host.notFound", "No provider."))) + } + } + + fn settings(entries: Value) -> Result { + Ok(json!({ "settings": entries })) + } + + fn cancelled() -> RelayFailure { + RelayFailure::new(sdk_codes::CALL_CANCELLED, "Cancelled.") + } + + fn collect(relay: &FakeRelay) -> Contributions { + collect_contributions(relay, "/work/app", &Cancellation::new(), &|_| {}) + .expect("the call is not cancelled") + } + + #[test] + fn the_interface_functions_are_valid_function_ids() { + use lithe_java_domain::lsp::languages::jdt_contributions::SETTINGS_CHANGED_FUNCTION; + for function in [SETTINGS_FUNCTION, SETTINGS_CHANGED_FUNCTION] { + assert!( + lithe_plugin_sdk::is_function_id(function.id), + "{}", + function.id + ); + } + } + + #[test] + fn the_request_params_name_the_function_and_its_contract_version() { + assert_eq!( + providers_params(&SETTINGS_FUNCTION), + json!({ "functionID": "languageImport.settings", "version": 1 }) + ); + assert_eq!( + invoke_params( + "dev.lithe.plugin.maven", + &SETTINGS_FUNCTION, + json!({ "workspaceRoot": "/work/app" }) + ), + json!({ + "pluginID": "dev.lithe.plugin.maven", + "functionID": "languageImport.settings", + "version": 1, + "params": { "workspaceRoot": "/work/app" }, + }) + ); + } + + #[test] + fn provider_lists_and_invoke_results_must_have_the_expected_shape() { + assert_eq!( + parse_providers( + &json!({ "providers": [{ "pluginID": "a.b" }, { "pluginID": "c.d" }] }) + ), + Ok(vec!["a.b".to_string(), "c.d".to_string()]) + ); + for malformed in [ + json!({}), + json!({ "providers": "a.b" }), + json!({ "providers": [{ "id": "a.b" }] }), + json!({ "providers": [{ "pluginID": 7 }] }), + ] { + let failure = parse_providers(&malformed).expect_err("malformed list"); + assert_eq!(failure.code, "java.relay.invalidResult"); + } + + assert_eq!( + parse_invoke_result(json!({ "result": { "settings": {} } })), + Ok(json!({ "settings": {} })) + ); + assert!(parse_invoke_result(json!({})).is_err()); + assert!(parse_invoke_result(json!("done")).is_err()); + } + + #[test] + fn providers_are_called_once_each_in_plugin_id_order() { + let relay = FakeRelay::new(&[ + "dev.lithe.plugin.maven", + "dev.lithe.plugin.gradle", + "dev.lithe.plugin.maven", + ]) + .answering("dev.lithe.plugin.maven", settings(json!({}))) + .answering("dev.lithe.plugin.gradle", settings(json!({}))); + collect(&relay); + assert_eq!( + relay.called_providers(), + ["dev.lithe.plugin.gradle", "dev.lithe.plugin.maven"] + ); + for (_, params) in relay.calls.borrow().iter() { + assert_eq!(params, &json!({ "workspaceRoot": "/work/app" })); + } + } + + #[test] + fn answers_are_merged_in_plugin_id_order() { + let relay = FakeRelay::new(&["dev.lithe.plugin.maven", "dev.lithe.plugin.gradle"]) + .answering( + "dev.lithe.plugin.maven", + settings(json!({ "java.configuration.maven.userSettings": "/m/settings.xml" })), + ) + .answering( + "dev.lithe.plugin.gradle", + settings(json!({ "java.import.gradle.offline.enabled": true })), + ); + let contributions = collect(&relay); + assert_eq!(contributions.status(), ContributedSettingsStatus::Settled); + assert_eq!( + contributions.fragments(), + [ + json!({ "java.import.gradle.offline.enabled": true }), + json!({ "java.configuration.maven.userSettings": "/m/settings.xml" }), + ] + ); + } + + #[test] + fn no_provider_means_a_plain_java_workspace() { + let relay = FakeRelay::new(&[]); + let contributions = collect(&relay); + assert_eq!(contributions.status(), ContributedSettingsStatus::Settled); + assert!(contributions.fragments().is_empty()); + assert!(relay.called_providers().is_empty()); + } + + #[test] + fn a_failing_provider_does_not_stop_the_others() { + let relay = FakeRelay::new(&["dev.lithe.plugin.gradle", "dev.lithe.plugin.maven"]) + .answering( + "dev.lithe.plugin.gradle", + Err(RelayFailure::new( + "plugin.host.timeout", + "/home/dev/.gradle is slow", + )), + ) + .answering( + "dev.lithe.plugin.maven", + settings(json!({ "java.import.maven.enabled": true })), + ); + let contributions = collect(&relay); + assert_eq!(contributions.status(), ContributedSettingsStatus::Failed); + assert_eq!( + contributions.fragments(), + [json!({ "java.import.maven.enabled": true })] + ); + // The reason carries the code and never the message with its path. + assert_eq!( + contributions.failures(), + [( + "dev.lithe.plugin.gradle", + "The call failed (plugin.host.timeout)." + )] + ); + } + + #[test] + fn an_unusable_answer_marks_only_that_provider_failed() { + let relay = FakeRelay::new(&["dev.lithe.plugin.gradle", "dev.lithe.plugin.maven"]) + .answering( + "dev.lithe.plugin.gradle", + Ok(json!({ "settings": "offline" })), + ) + .answering( + "dev.lithe.plugin.maven", + settings(json!({ "java.import.maven.enabled": true })), + ); + let contributions = collect(&relay); + assert_eq!(contributions.status(), ContributedSettingsStatus::Failed); + assert_eq!(contributions.failures().len(), 1); + assert_eq!(contributions.failures()[0].0, "dev.lithe.plugin.gradle"); + assert_eq!(contributions.fragments().len(), 1); + } + + #[test] + fn rejected_keys_are_logged_by_name_and_never_stored() { + let relay = FakeRelay::new(&["dev.lithe.plugin.maven"]).answering( + "dev.lithe.plugin.maven", + settings(json!({ + "java.import.maven.enabled": true, + "java.home": "/opt/other-jdk" + })), + ); + let lines = RefCell::new(Vec::::new()); + let contributions = + collect_contributions(&relay, "/work/app", &Cancellation::new(), &|line| { + lines.borrow_mut().push(line.to_string()) + }) + .expect("the call is not cancelled"); + assert_eq!( + contributions.fragments(), + [json!({ "java.import.maven.enabled": true })] + ); + let lines = lines.borrow(); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("java.home"), "{}", lines[0]); + assert!(!lines[0].contains("/opt/other-jdk"), "{}", lines[0]); + } + + #[test] + fn a_failed_discovery_still_lets_the_server_start() { + let mut relay = FakeRelay::new(&[]); + relay.providers = Err(RelayFailure::new("plugin.host.unavailable", "No relay.")); + let contributions = collect(&relay); + assert_eq!(contributions.status(), ContributedSettingsStatus::Failed); + assert_eq!( + contributions.discovery_failure(), + Some("The call failed (plugin.host.unavailable).") + ); + assert!(relay.called_providers().is_empty()); + } + + #[test] + fn at_most_max_providers_are_asked() { + let ids: Vec = (0..MAX_PROVIDERS + 4) + .map(|index| format!("dev.lithe.plugin.p{index:02}")) + .collect(); + let relay = FakeRelay::new(&ids.iter().map(String::as_str).collect::>()); + collect(&relay); + assert_eq!(relay.called_providers().len(), MAX_PROVIDERS); + } + + #[test] + fn a_request_that_ran_out_of_time_fails_only_its_provider() { + let relay = FakeRelay::new(&["dev.lithe.plugin.gradle", "dev.lithe.plugin.maven"]) + .answering("dev.lithe.plugin.gradle", Err(cancelled())) + .answering( + "dev.lithe.plugin.maven", + settings(json!({ "java.import.maven.enabled": true })), + ); + let contributions = collect(&relay); + assert_eq!( + contributions.failures(), + [("dev.lithe.plugin.gradle", "The call timed out.")] + ); + assert_eq!(contributions.fragments().len(), 1); + } + + #[test] + fn cancelling_the_function_call_ends_the_collection() { + let cancellation = Cancellation::new(); + cancellation.cancel(); + let relay = FakeRelay::new(&["dev.lithe.plugin.maven"]); + let outcome = collect_contributions(&relay, "/work/app", &cancellation, &|_| {}); + assert!(outcome.is_err()); + assert!(relay.called_providers().is_empty()); + + // Cancelled while a provider is being called: stop before the next one. + let cancellation = Cancellation::new(); + let mut relay = FakeRelay::new(&["dev.lithe.plugin.gradle", "dev.lithe.plugin.maven"]) + .answering("dev.lithe.plugin.gradle", Err(cancelled())); + relay.cancel_on_invoke = Some(cancellation.clone()); + let outcome = collect_contributions(&relay, "/work/app", &cancellation, &|_| {}); + assert!(outcome.is_err()); + assert_eq!(relay.called_providers(), ["dev.lithe.plugin.gradle"]); + } + + #[test] + fn a_deadline_cancels_its_request_and_dropping_it_stops_the_timer() { + let expired = Cancellation::new(); + let _deadline = Deadline::start(&expired, Duration::ZERO); + // The wait ends as soon as the timer thread cancels; the bound only + // matters when the deadline is broken. + assert!(expired.wait_timeout(Duration::from_secs(30))); + + let finished = Cancellation::new(); + drop(Deadline::start(&finished, Duration::from_secs(3600))); + assert!(finished.is_cancelled()); + } +} diff --git a/backend/tests/repository_pins.rs b/backend/tests/repository_pins.rs new file mode 100644 index 0000000..feb654a --- /dev/null +++ b/backend/tests/repository_pins.rs @@ -0,0 +1,219 @@ +//! Offline checks of what the repository pins: the plugin SDK the backend +//! links, the plugin manifest and its packaged files, and the third-party +//! inputs the package scripts download. +//! +//! These checks only read files in the repository, so they run on every +//! platform in `cargo test`; the assembled package is checked separately by +//! `scripts/verify-package.sh`. + +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +const PLUGIN_ID: &str = "dev.lithe.plugin.java-support"; + +fn repository_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("backend/ has a parent") + .to_path_buf() +} + +fn read(relative: &str) -> String { + let path = repository_root().join(relative); + fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())) +} + +fn read_json(relative: &str) -> Value { + serde_json::from_str(&read(relative)).unwrap_or_else(|error| panic!("{relative}: {error}")) +} + +fn text<'a>(value: &'a Value, key: &str) -> &'a str { + value[key] + .as_str() + .unwrap_or_else(|| panic!("{key} is not a string in {value}")) +} + +#[test] +fn cargo_resolves_the_sdk_commit_recorded_in_the_lock() { + let lock = read_json("sdk.lock.json"); + let repository = text(&lock, "repository"); + let tag = text(&lock, "tag"); + let commit = text(&lock, "commit"); + assert_eq!(commit.len(), 40, "the lock must name a full commit"); + assert!(text(&lock, "url").starts_with(&format!("{repository}/releases/download/{tag}/"))); + assert_sha256(text(&lock, "sha256"), "sdk.lock.json sha256"); + + let manifest = read("backend/Cargo.toml"); + let dependency = format!(r#"lithe-plugin-sdk = {{ git = "{repository}", tag = "{tag}" }}"#); + assert!( + manifest.lines().any(|line| line.trim() == dependency), + "backend/Cargo.toml must depend on `{dependency}`" + ); + + let cargo_lock = read("Cargo.lock"); + let source = format!(r#"source = "git+{repository}?tag={tag}#{commit}""#); + let package = cargo_lock + .split("[[package]]") + .find(|package| package.contains("name = \"lithe-plugin-sdk\"")) + .expect("Cargo.lock resolves lithe-plugin-sdk"); + assert!( + package.contains(&source), + "Cargo.lock must resolve {source}, found:{package}" + ); + assert!(package.contains(&format!("version = \"{}\"", text(&lock, "version")))); +} + +#[test] +fn the_manifest_keeps_the_official_identity_and_declares_no_backend_yet() { + let manifest = read_json(&format!("{PLUGIN_ID}/plugin.json")); + assert_eq!(text(&manifest, "id"), PLUGIN_ID); + assert_eq!(manifest["schemaVersion"], 2); + assert_eq!(text(&manifest["vendor"], "id"), "dev.lithe"); + assert_eq!( + text(&manifest["vendor"], "signatureRequirement"), + "publisherPackage" + ); + let version = text(&manifest, "version"); + assert!( + version.split('.').count() == 3 + && version + .split('.') + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())), + "plugin version must be major.minor.patch: {version}" + ); + // The backend is declared once the host exposes the APIs it serves; a + // declaration before that would start a process nothing calls. + assert!( + manifest.get("backend").is_none(), + "declare the backend together with the host APIs it needs (see MIGRATION.md)" + ); +} + +#[test] +fn every_file_the_manifest_ships_from_the_repository_exists() { + let plugin = repository_root().join(PLUGIN_ID); + let manifest = read_json(&format!("{PLUGIN_ID}/plugin.json")); + let mut checked = 0; + for generator in manifest["projectGenerators"].as_array().unwrap() { + for key in ["path", "darkPath"] { + let icon = text(&generator["icon"], key); + assert!(plugin.join(icon).is_file(), "missing icon {icon}"); + checked += 1; + } + for step in generator["steps"].as_array().unwrap() { + if let Some(source) = step["source"].as_str() { + assert!( + plugin.join(source).is_dir(), + "missing template directory {source}" + ); + checked += 1; + } + } + } + assert!( + checked >= 15, + "expected icons and templates, checked {checked}" + ); +} + +#[test] +fn language_server_resources_point_inside_the_assembled_package() { + let manifest = read_json(&format!("{PLUGIN_ID}/plugin.json")); + let server = &manifest["languageServers"][0]; + let launch = &server["launch"]; + assert_eq!(text(launch, "packagedRuntime"), "runtime/${arch}/bin/java"); + assert!(text(launch, "entry").starts_with("language-server/plugins/")); + let resources = &server["hostIntegration"]["resources"]; + let mut paths = vec![ + text(resources, "configuration"), + text(resources, "lombokAgent"), + text(resources, "testRunner"), + ]; + paths.extend( + resources["extensionBundleDirectories"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()), + ); + for path in paths { + assert!( + path.starts_with("language-server/") && !path.split('/').any(|part| part == ".."), + "{path} must stay inside language-server/" + ); + } +} + +#[test] +fn third_party_inputs_are_pinned_by_https_url_and_sha256() { + for relative in [ + "third_party/jdtls/manifest.json", + "third_party/jdk/manifest.json", + ] { + let mut pins = 0; + visit(&read_json(relative), &mut |key, value| { + let lowered = key.to_ascii_lowercase(); + if lowered.ends_with("sha256") { + assert_sha256(value, &format!("{relative} {key}")); + pins += 1; + } else if lowered.ends_with("url") { + assert!(value.starts_with("https://"), "{relative} {key}: {value}"); + } + }); + assert!(pins >= 4, "{relative} pins only {pins} checksums"); + } +} + +#[test] +fn every_fixture_is_valid_json() { + // Some fixtures are contract samples kept for the host APIs that will + // carry them (see MIGRATION.md); they must at least stay parseable. + let mut directories = vec![repository_root().join("fixtures")]; + let mut parsed = 0; + while let Some(directory) = directories.pop() { + for entry in fs::read_dir(&directory).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + directories.push(path); + } else if path + .extension() + .is_some_and(|extension| extension == "json") + { + let text = fs::read_to_string(&path).unwrap(); + serde_json::from_str::(&text) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + parsed += 1; + } + } + } + assert!( + parsed >= 18, + "expected the ported fixtures, parsed {parsed}" + ); +} + +fn visit(value: &Value, check: &mut impl FnMut(&str, &str)) { + match value { + Value::Object(map) => { + for (key, child) in map { + match child { + Value::String(text) => check(key, text), + other => visit(other, check), + } + } + } + Value::Array(items) => items.iter().for_each(|item| visit(item, check)), + _ => {} + } +} + +fn assert_sha256(value: &str, label: &str) { + assert!( + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "{label} is not a lowercase SHA-256: {value}" + ); +} diff --git a/backend/tests/session.rs b/backend/tests/session.rs new file mode 100644 index 0000000..a105949 --- /dev/null +++ b/backend/tests/session.rs @@ -0,0 +1,148 @@ +//! The backend driven by a scripted host over in-process pipes. +//! +//! Every wait is bounded, so a broken session fails with the frames it sent +//! instead of hanging. + +use serde_json::{json, Value}; +use std::io::{self, BufRead, BufReader, Write}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +const WAIT: Duration = Duration::from_secs(10); + +struct Host { + input: Option, + frames: mpsc::Receiver, + exit: mpsc::Receiver, +} + +impl Host { + fn start() -> Self { + let backend = lithe_java_backend::backend().expect("functions register"); + let (backend_input, input) = io::pipe().expect("input pipe"); + let (output, backend_output) = io::pipe().expect("output pipe"); + let (exit_sender, exit) = mpsc::channel(); + thread::spawn(move || { + let _ = exit_sender.send(backend.serve(backend_input, backend_output)); + }); + let (frame_sender, frames) = mpsc::channel(); + thread::spawn(move || { + let mut reader = BufReader::new(output); + while let Some(frame) = read_frame(&mut reader) { + if frame_sender.send(frame).is_err() { + return; + } + } + }); + Self { + input: Some(input), + frames, + exit, + } + } + + fn request(&mut self, id: i64, method: &str, params: Value) { + let body = + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(); + self.input + .as_mut() + .expect("open input") + .write_all(format!("Content-Length: {}\r\n\r\n{body}", body.len()).as_bytes()) + .expect("write to the backend"); + } + + fn answer(&self, id: i64) -> Value { + loop { + let frame = self + .frames + .recv_timeout(WAIT) + .expect("a frame from the backend in time"); + if frame["id"] == id && frame.get("method").is_none() { + return frame; + } + } + } + + fn invoke(&mut self, id: i64, function: &str, params: Value) -> Value { + self.request( + id, + "lithe/function/invoke", + json!({"functionID": function, "operationID": format!("op-{id}"), "params": params}), + ); + self.answer(id) + } + + fn close(mut self) -> i32 { + self.input = None; + self.exit.recv_timeout(WAIT).expect("the backend to end") + } +} + +fn read_frame(reader: &mut impl BufRead) -> Option { + let mut length = None; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).ok()? == 0 { + return None; + } + let line = line.trim_end(); + if line.is_empty() { + break; + } + if let Some(value) = line.strip_prefix("Content-Length: ") { + length = value.parse().ok(); + } + } + let mut body = vec![0; length?]; + reader.read_exact(&mut body).ok()?; + serde_json::from_slice(&body).ok() +} + +#[test] +fn the_backend_offers_the_java_commands_as_plugin_functions() { + let mut host = Host::start(); + host.request( + 0, + "initialize", + json!({"rootUri": "file:///tmp/state/", "capabilities": { + "experimental": {"litheFunctions": {"versions": [1]}} + }}), + ); + let initialized = host.answer(0); + let encoded = initialized.to_string(); + assert!(encoded.contains("java.className"), "{encoded}"); + assert!(encoded.contains("runConfig.selectJava"), "{encoded}"); + + let class_name = host.invoke( + 1, + "java.className", + json!({"source": "package com.example;\nclass App {}", "simpleName": "App"}), + ); + assert_eq!( + class_name["result"]["className"], "com.example.App", + "{class_name}" + ); + + let invalid = host.invoke(2, "java.className", json!({"unexpected": true})); + assert_eq!( + invalid["error"]["data"]["code"], "java.invalidRequest", + "{invalid}" + ); + assert_eq!( + invalid["error"]["message"], + "Invalid Java class name request" + ); + + let session = host.invoke(3, "java.prepareLaunch", json!({})); + assert_eq!( + session["error"]["data"]["code"], "java.notSupported", + "{session}" + ); + + assert_eq!( + host.close(), + 1, + "closing stdin without exit is a failure exit" + ); +} 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, CoreError> { + if !root.is_dir() { + return Ok(None); + } + Err(CoreError::new( + ErrorCode::NotSupported, + "Reading toolchain requirements from a workspace root needs the host", + ) + .with_details("Pass the requirements document in the request")) +} + +/// Whether `actual` satisfies `required` for a toolchain of `kind`. +/// +/// `minimum` compares as a lower bound; otherwise `required` is a prefix. +pub(super) fn version_satisfies(kind: &str, actual: &str, required: &str, minimum: bool) -> bool { + let mut actual_parts = version_parts(actual); + let mut required_parts = version_parts(required); + if kind == "java" { + actual_parts = java_feature_version_parts(actual_parts); + required_parts = java_feature_version_parts(required_parts); + } + if actual_parts.is_empty() || required_parts.is_empty() { + return false; + } + if minimum { + actual_parts >= required_parts + } else { + actual_parts.starts_with(&required_parts) + } +} + +/// Numeric components of a version string, ignoring every separator. +pub(super) fn version_parts(value: &str) -> Vec { + value + .split(|character: char| !character.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse().ok()) + .collect() +} + +/// Drops the legacy `1.` prefix Java 8 and earlier report (`1.8.0_504`), so +/// those runtimes compare on the same feature-version scale as `8` or `17.0.12`. +pub(super) fn java_feature_version_parts(parts: Vec) -> Vec { + if parts.len() > 1 && parts[0] == 1 { + parts[1..].to_vec() + } else { + parts + } +} diff --git a/crates/java-domain/src/host/git.rs b/crates/java-domain/src/host/git.rs new file mode 100644 index 0000000..2c43678 --- /dev/null +++ b/crates/java-domain/src/host/git.rs @@ -0,0 +1,17 @@ +//! Git queries the JDT workspace preparation needs. + +use std::path::Path; + +/// Returns the entries of `candidates` that the Git repository enclosing +/// `directory` does not track. +/// +/// `None` means Git could not answer, and callers must treat every candidate +/// as possibly owned by the user. Until the host offers a read-only Git query +/// to plugins, the plugin always answers `None`, so legacy Eclipse project +/// files are never deleted; JDT LS keeps working with them as before. +// TODO(host-api: API-42 built-in Git read-only): read-only VCS query — "which +// of these paths are untracked" (Rust Core ran `git --literal-pathspecs +// ls-files -z -- `). +pub fn untracked_candidates(_directory: &Path, _candidates: &[String]) -> Option> { + None +} diff --git a/crates/java-domain/src/host/indexing.rs b/crates/java-domain/src/host/indexing.rs new file mode 100644 index 0000000..dcd138d --- /dev/null +++ b/crates/java-domain/src/host/indexing.rs @@ -0,0 +1,12 @@ +//! Project indexing exclusions forwarded to JDT LS. + +/// Import exclusion globs of the workspace at `root_uri`. +/// +/// Rust Core derived them from the project indexing context (Git ignore rules +/// and plugin-declared exclusions). That context belongs to the host. +// TODO(host-api: API-25 language server): indexing exclusions — the host must +// pass the workspace's excluded roots (or the derived globs) to the plugin with +// the launch request. +pub fn java_import_exclusions(_root_uri: &str) -> Vec { + Vec::new() +} diff --git a/crates/java-domain/src/host/mod.rs b/crates/java-domain/src/host/mod.rs new file mode 100644 index 0000000..92f5b8e --- /dev/null +++ b/crates/java-domain/src/host/mod.rs @@ -0,0 +1,16 @@ +//! Capabilities the Java rules borrowed from other parts of Rust Core. +//! +//! In Rust Core these functions reached into project indexing, Git and the +//! diagnostics log. A plugin cannot call them, so each one is either +//! reimplemented here with the same observable result, or answers the safe +//! "unknown" value until the host exposes a plugin API for it. Every +//! placeholder is marked `TODO(host-api: API-NN ...)` with its plan number +//! and listed in `MIGRATION.md`. +//! +//! Maven and Gradle settings are not borrowed here. The build-system plugins +//! compute them and hand them to this plugin through a cross-plugin function +//! call (API-35), as described in `lsp::languages::jdt`. + +pub mod git; +pub mod indexing; +pub mod storage; diff --git a/crates/java-domain/src/host/storage.rs b/crates/java-domain/src/host/storage.rs new file mode 100644 index 0000000..2140d34 --- /dev/null +++ b/crates/java-domain/src/host/storage.rs @@ -0,0 +1,57 @@ +//! Best-effort cleanup bookkeeping for plugin-owned storage. +//! +//! Rust Core recorded failed cleanups as one `app.storage.cleanup_failed` +//! diagnostics event per batch. The plugin keeps the same fixed-size summary +//! and reports it on its own log channel (standard error of the backend). + +/// Fixed-size cleanup summary: batch callers emit once rather than once per file. +#[derive(Debug, Default)] +pub struct StorageCleanup { + count: i64, + first: Option, +} + +impl StorageCleanup { + /// Records one removal result; missing files are normal after failed + /// creation or concurrent eviction and are not counted. + pub fn observe(&mut self, result: std::io::Result<()>) { + match result { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + self.count = self.count.saturating_add(1); + self.first.get_or_insert(error.kind()); + } + } + } + + /// Removes a directory only when empty; retained contents belong to the caller. + pub fn empty_directory(&mut self, path: &std::path::Path) { + match std::fs::read_dir(path) { + Ok(mut entries) => match entries.next() { + None => self.observe(std::fs::remove_dir(path)), + Some(Ok(_)) => {} + Some(Err(error)) => self.observe(Err(error)), + }, + Err(error) => self.observe(Err(error)), + } + } + + /// Number of failed removals observed so far. + pub fn failures(&self) -> i64 { + self.count + } + + /// Reports the batch once, preserving the caller's normal result. + // TODO(host-api: API-22 log and progress): plugin log channel — report + // through the host's structured plugin log instead of standard error once + // it is available. + pub fn finish(self, stage: &str) { + if let Some(kind) = self.first { + eprintln!( + "storage cleanup failed: stage={stage} count={} error_code={kind:?}", + self.count + ); + } + } +} diff --git a/crates/java-domain/src/jdt_integration/launch_resources.rs b/crates/java-domain/src/jdt_integration/launch_resources.rs new file mode 100644 index 0000000..cb14f84 --- /dev/null +++ b/crates/java-domain/src/jdt_integration/launch_resources.rs @@ -0,0 +1,245 @@ +//! Files the JDT LS launch takes from the plugin declaration. +//! +//! Ported from the Swift `MacJdtLaunchResources`. Exactly the declared files +//! are handed over; nothing searches the host application or the machine for +//! JDT LS, so removing the plugin removes Java language support with it. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::protocol::{CoreError, ErrorCode}; + +/// Declaration key of the writable-copy source for the Equinox configuration. +pub const CONFIGURATION_RESOURCE: &str = "configuration"; +/// Declaration key of the Lombok Java agent. +pub const LOMBOK_AGENT_RESOURCE: &str = "lombokAgent"; +/// Declaration key of the directories whose JARs extend JDT LS. +pub const EXTENSION_BUNDLE_DIRECTORIES_RESOURCE: &str = "extensionBundleDirectories"; +/// Declaration key of the Java Test runner JAR. +pub const TEST_RUNNER_RESOURCE: &str = "testRunner"; + +/// A resolved `hostIntegration` declaration: package-relative paths already +/// joined to the installed package directory. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DeclaredHostIntegration { + /// The language server's `launch.entry`, the Equinox launcher JAR. + pub entry: Option, + /// `hostIntegration.resources`; single values are one-element lists. + pub resources: BTreeMap>, +} + +impl DeclaredHostIntegration { + /// First path declared under `name`. + pub fn resource(&self, name: &str) -> Option<&Path> { + self.resources + .get(name) + .and_then(|paths| paths.first()) + .map(PathBuf::as_path) + } +} + +/// Files of one JDT LS launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JdtLaunchResources { + /// Equinox launcher JAR. + pub launcher_jar: PathBuf, + /// Packaged configuration directory. It is passed as is: the launch gives + /// Equinox a writable copy in the host cache and never writes into the + /// package. + pub configuration_directory: PathBuf, + /// Lombok Java agent. + pub lombok_agent: PathBuf, + /// JDT LS extension bundles in load order. + pub extension_bundles: Vec, + /// Java Test runner JAR, when the package ships one. + pub test_runner: Option, +} + +/// Maps a declaration to launch files, naming the first missing one. +pub fn launch_resources( + integration: &DeclaredHostIntegration, +) -> Result { + let launcher_jar = integration + .entry + .clone() + .ok_or_else(|| missing_resource("the JDT LS launcher"))?; + let configuration_directory = integration + .resource(CONFIGURATION_RESOURCE) + .ok_or_else(|| missing_resource("the JDT LS configuration"))? + .to_path_buf(); + let lombok_agent = integration + .resource(LOMBOK_AGENT_RESOURCE) + .ok_or_else(|| missing_resource("the Lombok agent"))? + .to_path_buf(); + let extension_bundles = integration + .resources + .get(EXTENSION_BUNDLE_DIRECTORIES_RESOURCE) + .map(|directories| { + directories + .iter() + .flat_map(|directory| bundles_in(directory)) + .collect() + }) + .unwrap_or_default(); + Ok(JdtLaunchResources { + launcher_jar, + configuration_directory, + lombok_agent, + extension_bundles, + test_runner: integration + .resource(TEST_RUNNER_RESOURCE) + .map(Path::to_path_buf), + }) +} + +/// Message shown when the package lacks a declared file. +/// +/// TODO(host-api: API-37 i18n): user-facing text must go through the host's +/// i18n table. +pub fn missing_resource_message(name: &str) -> String { + format!("The Java Support plugin does not provide {name}. Reinstall the plugin.") +} + +fn missing_resource(name: &str) -> CoreError { + CoreError::new(ErrorCode::RuntimeMissing, missing_resource_message(name)) +} + +/// JAR files directly inside `directory`, sorted by name so JDT LS loads the +/// same extensions in the same order on every launch. Hidden entries, +/// directories and unreadable directories contribute nothing. +pub fn bundles_in(directory: &Path) -> Vec { + let Ok(entries) = fs::read_dir(directory) else { + return Vec::new(); + }; + let mut bundles = entries + .filter_map(Result::ok) + .filter(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + !name.starts_with('.') + && Path::new(name.as_ref()) + .extension() + .is_some_and(|ext| ext == "jar") + && fs::metadata(entry.path()).is_ok_and(|metadata| metadata.is_file()) + }) + .map(|entry| entry.path()) + .collect::>(); + bundles.sort_by(|left, right| left.file_name().cmp(&right.file_name())); + bundles +} + +#[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-launch-resources-{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 declaration(root: &Path) -> DeclaredHostIntegration { + let mut resources = BTreeMap::new(); + resources.insert( + CONFIGURATION_RESOURCE.into(), + vec![root.join("language-server/configuration/arm64")], + ); + resources.insert( + LOMBOK_AGENT_RESOURCE.into(), + vec![root.join("language-server/lombok/lombok.jar")], + ); + resources.insert( + EXTENSION_BUNDLE_DIRECTORIES_RESOURCE.into(), + vec![ + root.join("language-server/java-debug"), + root.join("language-server/java-test/extensions"), + ], + ); + DeclaredHostIntegration { + entry: Some(root.join("language-server/plugins/launcher.jar")), + resources, + } + } + + #[test] + fn declared_files_map_to_the_launch_with_sorted_extension_bundles() { + let temp = TempDir::new("map"); + let debug = temp.0.join("language-server/java-debug"); + let test = temp.0.join("language-server/java-test/extensions"); + fs::create_dir_all(&debug).unwrap(); + fs::create_dir_all(test.join("nested.jar")).unwrap(); + for file in [ + debug.join("z-debug.jar"), + debug.join("a-debug.jar"), + debug.join(".hidden.jar"), + debug.join("notes.txt"), + test.join("test.jar"), + ] { + fs::write(file, "").unwrap(); + } + let resources = launch_resources(&declaration(&temp.0)).unwrap(); + assert_eq!( + resources.launcher_jar, + temp.0.join("language-server/plugins/launcher.jar") + ); + assert_eq!( + resources.extension_bundles, + vec![ + debug.join("a-debug.jar"), + debug.join("z-debug.jar"), + test.join("test.jar") + ] + ); + assert_eq!(resources.test_runner, None); + } + + #[test] + fn missing_declarations_name_the_missing_file() { + let temp = TempDir::new("missing"); + let mut declared = declaration(&temp.0); + declared.entry = None; + assert_eq!( + launch_resources(&declared).unwrap_err().message, + "The Java Support plugin does not provide the JDT LS launcher. Reinstall the plugin." + ); + let mut declared = declaration(&temp.0); + declared.resources.remove(LOMBOK_AGENT_RESOURCE); + assert_eq!( + launch_resources(&declared).unwrap_err().message, + "The Java Support plugin does not provide the Lombok agent. Reinstall the plugin." + ); + let mut declared = declaration(&temp.0); + declared.resources.remove(CONFIGURATION_RESOURCE); + assert_eq!( + launch_resources(&declared).unwrap_err().message, + "The Java Support plugin does not provide the JDT LS configuration. Reinstall the plugin." + ); + } + + #[test] + fn a_missing_bundle_directory_contributes_nothing() { + let temp = TempDir::new("absent"); + let mut declared = declaration(&temp.0); + declared + .resources + .insert(TEST_RUNNER_RESOURCE.into(), vec![temp.0.join("runner.jar")]); + let resources = launch_resources(&declared).unwrap(); + assert!(resources.extension_bundles.is_empty()); + assert_eq!(resources.test_runner, Some(temp.0.join("runner.jar"))); + } +} diff --git a/crates/java-domain/src/jdt_integration/mod.rs b/crates/java-domain/src/jdt_integration/mod.rs new file mode 100644 index 0000000..e78c4b1 --- /dev/null +++ b/crates/java-domain/src/jdt_integration/mod.rs @@ -0,0 +1,22 @@ +//! Rules of the `jdt` host integration that the macOS host used to own. +//! +//! The host located JDT LS and its JDK through the plugin declaration, kept +//! the per-workspace JDT LS indexes in its cache and removed them when the +//! plugin went away. These modules are the decision rules of that code, +//! rewritten without any host type: +//! +//! - [`launch_resources`] maps the declared `hostIntegration.resources` to +//! the files the JDT LS launch needs; +//! - [`runtime`] validates the packaged JDK and decides when cached state may +//! be discarded; +//! - [`workspace_state`] fingerprints a workspace, resets its index and +//! applies the cache-retention policy. +//! +//! TODO(host-api: API-25 language server): the host still drives these rules +//! through its generic language-server integration until a plugin backend can +//! answer `languageServer.prepareLaunch`, `languageServer.workspaceFingerprint` +//! and `languageServer.resetWorkspaceState` itself. See `MIGRATION.md`. + +pub mod launch_resources; +pub mod runtime; +pub mod workspace_state; diff --git a/crates/java-domain/src/jdt_integration/runtime.rs b/crates/java-domain/src/jdt_integration/runtime.rs new file mode 100644 index 0000000..c441163 --- /dev/null +++ b/crates/java-domain/src/jdt_integration/runtime.rs @@ -0,0 +1,235 @@ +//! The JDK that JDT LS runs on, and the lifetime of cached JDT LS state. +//! +//! Ported from the JDT parts of the Swift `ProjectRuntimeService` and +//! `MacJdtHostIntegration`. JDT LS runs only on the runtime the plugin ships; +//! project JDKs and user environment variables never influence it. + +use std::path::{Path, PathBuf}; + +/// Oldest Java feature release that runs the packaged JDT LS. +pub const MINIMUM_JDTLS_JAVA_MAJOR: u32 = 21; + +/// Major version of a `java -version` string. +/// +/// Legacy `1.x` versions report their second component: `1.8.0_442` is 8. +pub fn java_major_version(version: &str) -> Option { + let mut components = version + .split(|character: char| !character.is_ascii_digit()) + .filter(|component| !component.is_empty()) + .map(str::parse::); + match components.next()? { + Ok(1) => components.next()?.ok(), + Ok(major) => Some(major), + Err(_) => None, + } +} + +/// Whether a runtime with `version` can run JDT LS. +pub fn supports_jdtls(version: &str) -> bool { + java_major_version(version).is_some_and(|major| major >= MINIMUM_JDTLS_JAVA_MAJOR) +} + +/// JDK home of a packaged `/bin/java` executable. +pub fn runtime_home(packaged_java: &Path) -> Option { + packaged_java.parent()?.parent().map(Path::to_path_buf) +} + +/// A JDK found at a home directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProbedRuntime { + /// Version reported by the runtime. + pub version: String, + /// Home directory the runtime reported. + pub home: PathBuf, +} + +/// Outcome of preparing the JDT LS runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuntimePreparation { + /// JDT LS can start with this executable. + Ready { + /// `/bin/java` of the packaged runtime. + executable: PathBuf, + }, + /// No installed plugin provides the runtime. Not worth caching: installing + /// or restoring the plugin changes the answer. + PluginMissing, + /// The packaged runtime cannot run JDT LS. + Failed { + /// Explanation for the user. + message: String, + }, +} + +impl RuntimePreparation { + /// Explanation for the user when JDT LS cannot start. + /// + /// TODO(host-api: API-37 i18n): user-facing text must go through the host's + /// i18n table. + pub fn failure_message(&self) -> Option { + match self { + Self::Ready { .. } => None, + Self::PluginMissing => Some( + "Java language support is provided by the Java Support plugin, which is not installed. Install or restore it in Settings > Plugins." + .into(), + ), + Self::Failed { message } => Some(message.clone()), + } + } +} + +/// Validates the packaged runtime at `home`. +/// +/// `probe` reports the runtime installed at a home, and `is_valid_home` +/// whether a reported home contains a usable `bin/java`. +/// +/// TODO(host-api: API-23 toolchains and process): probing runs `java -version`; +/// the backend needs the host's executable-resolution probe or a +/// `process.spawn` permission for it. +pub fn prepare_runtime( + home: Option<&Path>, + probe: impl FnOnce(&Path) -> Option, + is_valid_home: impl FnOnce(&Path) -> bool, +) -> RuntimePreparation { + let Some(home) = home else { + return RuntimePreparation::PluginMissing; + }; + let Some(runtime) = probe(home) else { + return RuntimePreparation::Failed { + message: + "The Java Support plugin's runtime is missing or invalid. Reinstall the plugin." + .into(), + }; + }; + if !supports_jdtls(&runtime.version) || !is_valid_home(&runtime.home) { + return RuntimePreparation::Failed { + message: format!( + "The Java Support plugin's runtime must be Java {MINIMUM_JDTLS_JAVA_MAJOR} or newer; found {}. Reinstall the plugin.", + runtime.version + ), + }; + } + RuntimePreparation::Ready { + executable: runtime.home.join("bin/java"), + } +} + +/// An installed plugin as the cache-ownership rule sees it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InstalledPlugin { + /// The installation was removed; disabled or rejected packages are not. + pub removed: bool, + /// One of its language servers declares the `jdt` host integration. + pub declares_jdt_integration: bool, +} + +/// Whether the JDT LS state in the host cache may be discarded. +/// +/// The state belongs to the plugin, but lives outside its own state +/// directory. It is discarded once no installed plugin declares the +/// integration. A package that is disabled or rejected at startup is not a +/// removal, and a failed scan (`None`) never deletes anything. +pub fn should_remove_state(installed: Option<&[InstalledPlugin]>) -> bool { + installed.is_some_and(|plugins| { + !plugins + .iter() + .any(|plugin| !plugin.removed && plugin.declares_jdt_integration) + }) +} + +/// Message when the language server is asked to start before the runtime +/// was prepared. +pub const RUNTIME_NOT_PREPARED_MESSAGE: &str = "The Java Support plugin's runtime is not prepared."; +/// Message when the plugin does not declare its JDT LS files. +pub const UNDECLARED_FILES_MESSAGE: &str = + "The Java Support plugin does not declare its JDT LS files. Reinstall the plugin."; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn major_versions_follow_the_java_version_scheme() { + assert_eq!(java_major_version("17.0.18"), Some(17)); + assert_eq!(java_major_version("21-ea"), Some(21)); + assert_eq!(java_major_version("1.8.0_442"), Some(8)); + assert_eq!(java_major_version("unknown"), None); + assert!(supports_jdtls("21.0.2")); + assert!(supports_jdtls("25")); + assert!(!supports_jdtls("17.0.18")); + assert!(!supports_jdtls("1.8.0_442")); + } + + #[test] + fn the_runtime_home_is_two_levels_above_java() { + assert_eq!( + runtime_home(Path::new("/p/runtime/arm64/bin/java")), + Some(PathBuf::from("/p/runtime/arm64")) + ); + } + + #[test] + fn preparation_reports_why_the_runtime_cannot_run_jdtls() { + let home = Path::new("/p/runtime/arm64"); + assert_eq!( + prepare_runtime(None, |_| unreachable!(), |_| true), + RuntimePreparation::PluginMissing + ); + assert_eq!( + prepare_runtime(Some(home), |_| None, |_| true).failure_message(), + Some( + "The Java Support plugin's runtime is missing or invalid. Reinstall the plugin." + .into() + ) + ); + let old = || { + Some(ProbedRuntime { + version: "17.0.18".into(), + home: home.to_path_buf(), + }) + }; + assert_eq!( + prepare_runtime(Some(home), |_| old(), |_| true).failure_message(), + Some("The Java Support plugin's runtime must be Java 21 or newer; found 17.0.18. Reinstall the plugin.".into()) + ); + let current = |_: &Path| { + Some(ProbedRuntime { + version: "21.0.2".into(), + home: home.to_path_buf(), + }) + }; + assert!(matches!( + prepare_runtime(Some(home), current, |_| false), + RuntimePreparation::Failed { .. } + )); + assert_eq!( + prepare_runtime(Some(home), current, |_| true), + RuntimePreparation::Ready { + executable: home.join("bin/java") + } + ); + } + + #[test] + fn state_is_removed_only_when_no_installed_plugin_declares_the_integration() { + let java = InstalledPlugin { + removed: false, + declares_jdt_integration: true, + }; + let removed_java = InstalledPlugin { + removed: true, + ..java + }; + let other = InstalledPlugin { + removed: false, + declares_jdt_integration: false, + }; + assert!(should_remove_state(Some(&[removed_java, other]))); + assert!(should_remove_state(Some(&[]))); + // Installed, disabled or rejected at startup: the plugin may return. + assert!(!should_remove_state(Some(&[java]))); + assert!(!should_remove_state(Some(&[removed_java, java]))); + // A failed scan is not a removal. + assert!(!should_remove_state(None)); + } +} diff --git a/crates/java-domain/src/jdt_integration/workspace_state.rs b/crates/java-domain/src/jdt_integration/workspace_state.rs new file mode 100644 index 0000000..b435d0e --- /dev/null +++ b/crates/java-domain/src/jdt_integration/workspace_state.rs @@ -0,0 +1,579 @@ +//! JDT LS state in the host cache: fingerprints, index reset and retention. +//! +//! Ported from the Swift `MacJdtWorkspaceState`. The rules only inspect +//! immediate build-system inputs and the plugin's own JDT LS installation; +//! key derivation and the retention policy are the shared ones in +//! [`crate::lsp`]. +//! +//! Layout below the cache directory the host gives the language server: +//! `jdtls//` holds each workspace's `-data` directory and +//! `jdtls-configuration/` the writable Equinox configuration areas. +//! +//! TODO(host-api: API-20 storage): the cache directory comes from the host. +//! Until the host exposes a plugin-owned cache directory to the backend, +//! callers pass the host's language-server cache directory explicitly. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::host::storage::StorageCleanup; +use crate::lsp::{ + jdt_cache_retention, jdt_workspace_fingerprint, workspace_key, JdtBuildFileObservation, + JdtCacheEntry, JdtCacheRetentionRequest, JdtWorkspaceFingerprintRequest, +}; +use crate::protocol::{CoreError, ErrorCode}; + +/// Root build descriptors whose presence selects a JDT LS state directory. +pub const BUILD_FILE_NAMES: &[&str] = &["pom.xml", "build.gradle", "build.gradle.kts"]; +/// Cache directories that hold JDT LS state: per-workspace indexes and +/// Equinox configuration areas. +pub const OWNED_CACHE_DIRECTORY_NAMES: &[&str] = &["jdtls", "jdtls-configuration"]; +/// Marker file whose modification time records the last use of an index. +pub const LAST_USED_MARKER_NAME: &str = ".lithe-last-used"; + +const MAVEN_DESCRIPTOR_NAME: &str = "pom.xml"; +const MANIFEST_NAME: &str = "manifest.json"; +const CORE_PLUGIN_PREFIX: &str = "org.eclipse.jdt.ls.core_"; + +/// Build inputs observed at a workspace root, without recursion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceObservation { + /// Root build descriptors that exist. + pub build_files: Vec, + /// Direct child directories that contain a regular `pom.xml`. + pub direct_maven_modules: Vec, +} + +/// Observes the root build descriptors and direct Maven modules. +/// +/// Only presence counts: JDT LS detects edits to descriptors itself. +pub fn observe_workspace(workspace_root: &Path) -> Result { + let mut build_files = Vec::new(); + for name in BUILD_FILE_NAMES { + match fs::symlink_metadata(workspace_root.join(name)) { + Ok(_) => build_files.push(JdtBuildFileObservation { + path: (*name).to_string(), + }), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error("Could not inspect the Java workspace.", &error)), + } + } + let mut direct_maven_modules = Vec::new(); + let entries = fs::read_dir(workspace_root) + .map_err(|error| io_error("Could not list the Java workspace.", &error))?; + for entry in entries { + let entry = + entry.map_err(|error| io_error("Could not list the Java workspace.", &error))?; + let path = entry.path(); + // Follow links like Foundation's `isDirectoryKey` on the entry URL. + if !path.is_dir() { + continue; + } + match fs::metadata(path.join(MAVEN_DESCRIPTOR_NAME)) { + Ok(metadata) if metadata.is_file() => { + direct_maven_modules.push(entry.file_name().to_string_lossy().into_owned()); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error("Could not inspect a Maven module.", &error)), + } + } + direct_maven_modules.sort(); + Ok(WorkspaceObservation { + build_files, + direct_maven_modules, + }) +} + +#[derive(Deserialize)] +struct JdtManifest { + version: String, +} + +/// Version of the JDT LS installation that `launcher` belongs to. +/// +/// The launcher's directory and its parent are searched for `manifest.json`; +/// without one, the core plug-in's file name supplies the first three +/// version components. +pub fn language_server_version(launcher: &Path) -> Result { + let bin = launcher.parent().map(Path::to_path_buf).unwrap_or_default(); + let mut roots = vec![bin.clone()]; + if let Some(parent) = bin.parent() { + roots.push(parent.to_path_buf()); + } + roots.dedup(); + for root in roots { + let manifest_path = root.join(MANIFEST_NAME); + match fs::read(&manifest_path) { + Ok(data) => { + let version = serde_json::from_slice::(&data) + .map(|manifest| manifest.version.trim().to_string()) + .ok() + .filter(|version| !version.is_empty()); + return version.ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + format!( + "The packaged JDT LS manifest is invalid at {}.", + manifest_path.display() + ), + ) + }); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error("Could not read the JDT LS manifest.", &error)), + } + let names = match fs::read_dir(root.join("plugins")) { + Ok(entries) => entries + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>(), + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(io_error("Could not list the JDT LS plug-ins.", &error)), + }; + let mut names = names; + names.sort(); + if let Some(suffix) = names.iter().find_map(|name| { + name.strip_prefix(CORE_PLUGIN_PREFIX) + .and_then(|rest| rest.strip_suffix(".jar")) + }) { + let components = suffix.split('.').collect::>(); + if components.len() >= 3 { + return Ok(components[..3].join(".")); + } + return Ok(suffix.to_string()); + } + } + Err(CoreError::new( + ErrorCode::RuntimeMissing, + "The packaged JDT LS version could not be determined.", + )) +} + +/// Version and location of a JDT LS installation. +/// +/// JDT LS persists absolute JRE and source paths inside its workspace, so a +/// relocated plugin package must select a fresh state directory. +pub fn language_server_cache_identity(launcher: &Path) -> Result { + let version = language_server_version(launcher)?; + Ok(format!( + "{version}|installation={}", + crate::testing::workflow::normalize(launcher).display() + )) +} + +/// Request for [`jdt_workspace_fingerprint`] describing one workspace. +pub fn fingerprint_request( + workspace_root: &Path, + launcher: &Path, +) -> Result { + let observation = observe_workspace(workspace_root)?; + Ok(JdtWorkspaceFingerprintRequest { + build_files: observation.build_files, + direct_maven_modules: observation.direct_maven_modules, + gradle_wrapper_distribution: None, + jdtls_version: language_server_cache_identity(launcher)?, + }) +} + +/// Fingerprint mixed into the workspace key of `workspace_root`. +pub fn fingerprint(workspace_root: &Path, launcher: &Path) -> Result { + Ok( + jdt_workspace_fingerprint(fingerprint_request(workspace_root, launcher)?)? + .workspace_fingerprint, + ) +} + +/// Whether `key` is a lowercase SHA-256 workspace key. +pub fn is_workspace_key(key: &str) -> bool { + key.len() == 64 + && key + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn checked_key(workspace_root: &Path, fingerprint: Option<&str>) -> Result { + let key = workspace_key(workspace_root, fingerprint); + if is_workspace_key(&key) { + Ok(key) + } else { + Err(CoreError::new( + ErrorCode::InvalidRequest, + "The Java workspace key is invalid.", + )) + } +} + +/// Removes the index of one workspace and fingerprint. +/// +/// Other fingerprints of the same workspace stay, so switching back to them +/// reuses their index. +pub fn clear_index( + cache_directory: &Path, + workspace_root: &Path, + fingerprint: Option<&str>, +) -> Result<(), CoreError> { + let key = checked_key(workspace_root, fingerprint)?; + let target = cache_directory.join("jdtls").join(key); + match fs::remove_dir_all(&target) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(io_error("Could not remove the Java index.", &error)), + } +} + +/// Outcome of [`prune_expired_caches`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CacheCleanupResult { + /// Retention period of the shared policy. + pub retention_days: u64, + /// Workspace keys whose directories were removed. + pub removed_workspace_keys: Vec, +} + +/// Removes inactive indexes the shared retention policy selects. +/// +/// The active workspace's directory is marked as used at `now` first. Hidden +/// entries, symbolic links and names that are not workspace keys are never +/// touched. +pub fn prune_expired_caches( + cache_directory: &Path, + workspace_root: &Path, + fingerprint: Option<&str>, + now: SystemTime, +) -> Result { + let active_key = checked_key(workspace_root, fingerprint)?; + let now_seconds = unix_seconds(now); + let jdtls = cache_directory.join("jdtls"); + let candidates = match fs::read_dir(&jdtls) { + Ok(entries) => entries + .collect::, _>>() + .map_err(|error| io_error("Could not list the Java indexes.", &error))?, + Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(), + Err(error) => return Err(io_error("Could not list the Java indexes.", &error)), + }; + let mut entries = Vec::new(); + for candidate in candidates { + let key = candidate.file_name().to_string_lossy().into_owned(); + if key.starts_with('.') || !is_workspace_key(&key) { + continue; + } + let path = candidate.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| io_error("Could not inspect a Java index.", &error))?; + if !metadata.is_dir() { + continue; + } + let directory_modified = metadata.modified().ok().map(unix_seconds); + let marker_modified = if key == active_key { + write_marker(&path, now_seconds)?; + Some(now_seconds) + } else { + match fs::metadata(path.join(LAST_USED_MARKER_NAME)) { + Ok(marker) => marker.modified().ok().map(unix_seconds), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(io_error("Could not inspect a Java index.", &error)), + } + }; + entries.push(JdtCacheEntry { + workspace_key: key, + last_modified_unix_seconds: directory_modified.max(marker_modified).unwrap_or(0), + }); + } + let observed = entries + .iter() + .map(|entry| entry.workspace_key.clone()) + .collect::>(); + let plan = jdt_cache_retention(JdtCacheRetentionRequest { + now_unix_seconds: now_seconds, + active_workspace_key: Some(active_key.clone()), + entries, + })?; + let mut removed = Vec::new(); + for key in plan.expired_workspace_keys { + if key == active_key || !observed.contains(&key) || !is_workspace_key(&key) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The Java cache-retention policy selected a key that was not observed.", + )); + } + let target = jdtls.join(&key); + let metadata = fs::symlink_metadata(&target) + .map_err(|error| io_error("Could not inspect a Java index.", &error))?; + if !metadata.is_dir() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A Java cache-retention target is not a removable directory.", + )); + } + fs::remove_dir_all(&target) + .map_err(|error| io_error("Could not remove a Java index.", &error))?; + removed.push(key); + } + Ok(CacheCleanupResult { + retention_days: plan.retention_days, + removed_workspace_keys: removed, + }) +} + +/// Removes every index and Equinox area, for when the plugin is gone. +/// +/// Best effort: it only frees disk space. Missing directories are normal. +pub fn remove_all_state(cache_directory: &Path) -> StorageCleanup { + let mut cleanup = StorageCleanup::default(); + for name in OWNED_CACHE_DIRECTORY_NAMES { + cleanup.observe(fs::remove_dir_all(cache_directory.join(name))); + } + cleanup +} + +fn write_marker(directory: &Path, now_seconds: u64) -> Result<(), CoreError> { + let marker = directory.join(LAST_USED_MARKER_NAME); + let staging: PathBuf = directory.join(format!("{LAST_USED_MARKER_NAME}.tmp")); + fs::write(&staging, now_seconds.to_string()) + .and_then(|()| fs::rename(&staging, &marker)) + .map_err(|error| io_error("Could not mark the Java index as used.", &error)) +} + +fn unix_seconds(time: SystemTime) -> u64 { + time.duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn io_error(message: &str, error: &io::Error) -> CoreError { + let code = if error.kind() == io::ErrorKind::PermissionDenied { + ErrorCode::PermissionDenied + } else { + ErrorCode::Unknown + }; + CoreError::new(code, message).with_details(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + use std::time::Duration; + + struct Fixture { + root: PathBuf, + workspace: PathBuf, + cache: PathBuf, + launcher: PathBuf, + } + + impl Fixture { + fn new(name: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "lithe-java-jdt-state-{name}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + let workspace = root.join("workspace"); + let cache = root.join("cache"); + let bin = root.join("jdtls/bin"); + for directory in [&workspace, &cache, &bin] { + fs::create_dir_all(directory).unwrap(); + } + fs::write(root.join("jdtls/manifest.json"), r#"{"version":"7.6.5"}"#).unwrap(); + let launcher = bin.join("jdtls"); + fs::write(&launcher, "#!/bin/zsh\n").unwrap(); + Self { + root, + workspace, + cache, + launcher, + } + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + fn set_modified(path: &Path, seconds: u64) { + File::open(path) + .unwrap() + .set_modified(UNIX_EPOCH + Duration::from_secs(seconds)) + .unwrap(); + } + + #[test] + fn identity_includes_the_version_and_installation_path() { + let fixture = Fixture::new("identity"); + let request = fingerprint_request(&fixture.workspace, &fixture.launcher).unwrap(); + assert!(request.build_files.is_empty()); + assert!(request.direct_maven_modules.is_empty()); + assert_eq!( + request.jdtls_version, + format!("7.6.5|installation={}", fixture.launcher.display()) + ); + assert!(!fingerprint(&fixture.workspace, &fixture.launcher) + .unwrap() + .is_empty()); + } + + #[test] + fn observation_counts_root_descriptors_and_direct_maven_modules_only() { + let fixture = Fixture::new("observation"); + let pom = fixture.workspace.join("pom.xml"); + fs::write(&pom, "a").unwrap(); + let before_edit = observe_workspace(&fixture.workspace).unwrap(); + assert_eq!( + before_edit.build_files, + vec![JdtBuildFileObservation { + path: "pom.xml".into() + }] + ); + // Editing a descriptor keeps the JDT LS state: JDT LS updates the + // changed projects itself instead of reimporting the workspace. + fs::write(&pom, "a larger project descriptor").unwrap(); + set_modified(&pom, 2_000_000_000); + assert_eq!( + observe_workspace(&fixture.workspace).unwrap().build_files, + before_edit.build_files + ); + for module in ["zeta", "alpha"] { + fs::create_dir_all(fixture.workspace.join(module)).unwrap(); + fs::write(fixture.workspace.join(module).join("pom.xml"), "").unwrap(); + } + fs::create_dir_all(fixture.workspace.join("gradle-only")).unwrap(); + fs::write( + fixture.workspace.join("gradle-only/build.gradle"), + "plugins {}", + ) + .unwrap(); + assert_eq!( + observe_workspace(&fixture.workspace) + .unwrap() + .direct_maven_modules, + vec!["alpha", "zeta"] + ); + } + + #[test] + fn the_core_plugin_name_supplies_the_version_without_a_manifest() { + let fixture = Fixture::new("plugin-version"); + fs::remove_file(fixture.root.join("jdtls/manifest.json")).unwrap(); + fs::create_dir_all(fixture.root.join("jdtls/plugins")).unwrap(); + fs::write( + fixture + .root + .join("jdtls/plugins/org.eclipse.jdt.ls.core_1.52.0.202607301513.jar"), + "", + ) + .unwrap(); + assert_eq!( + language_server_version(&fixture.launcher).unwrap(), + "1.52.0" + ); + fs::remove_dir_all(fixture.root.join("jdtls/plugins")).unwrap(); + assert_eq!( + language_server_version(&fixture.launcher).unwrap_err().code, + ErrorCode::RuntimeMissing + ); + } + + #[test] + fn clear_index_removes_only_the_resolved_workspace_key() { + let fixture = Fixture::new("clear"); + let current = workspace_key(&fixture.workspace, Some("current-fingerprint")); + let sibling = "b".repeat(64); + for key in [¤t, &sibling] { + let directory = fixture.cache.join("jdtls").join(key); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join("index.bin"), key.as_bytes()).unwrap(); + } + clear_index( + &fixture.cache, + &fixture.workspace, + Some("current-fingerprint"), + ) + .unwrap(); + assert!(!fixture.cache.join("jdtls").join(¤t).exists()); + assert!(fixture.cache.join("jdtls").join(&sibling).exists()); + clear_index( + &fixture.cache, + &fixture.workspace, + Some("current-fingerprint"), + ) + .unwrap(); + } + + #[test] + fn cache_cleanup_removes_only_expired_inactive_directories() { + let fixture = Fixture::new("prune"); + let jdtls = fixture.cache.join("jdtls"); + let active = workspace_key(&fixture.workspace, Some("active-fingerprint")); + let expired = "a".repeat(64); + let recent = "b".repeat(64); + let linked = "d".repeat(64); + for key in [&expired, &recent, &active] { + fs::create_dir_all(jdtls.join(key)).unwrap(); + } + std::os::unix::fs::symlink(jdtls.join(&recent), jdtls.join(&linked)).unwrap(); + fs::create_dir_all(jdtls.join("not-a-cache-key")).unwrap(); + set_modified(&jdtls.join(&expired), 1_000_000); + set_modified(&jdtls.join(&recent), 3_999_999); + set_modified(&jdtls.join(&active), 1); + + let result = prune_expired_caches( + &fixture.cache, + &fixture.workspace, + Some("active-fingerprint"), + UNIX_EPOCH + Duration::from_secs(4_000_000), + ) + .unwrap(); + + assert_eq!( + result, + CacheCleanupResult { + retention_days: 30, + removed_workspace_keys: vec![expired.clone()], + } + ); + assert!(!jdtls.join(&expired).exists()); + for key in [ + recent.as_str(), + active.as_str(), + linked.as_str(), + "not-a-cache-key", + ] { + assert!(fs::symlink_metadata(jdtls.join(key)).is_ok(), "{key}"); + } + assert_eq!( + fs::read_to_string(jdtls.join(&active).join(LAST_USED_MARKER_NAME)).unwrap(), + "4000000" + ); + } + + #[test] + fn removing_all_state_keeps_other_servers_caches() { + let fixture = Fixture::new("remove-all"); + let key = "a".repeat(64); + let index = fixture.cache.join("jdtls").join(&key); + let equinox = fixture + .cache + .join("jdtls-configuration") + .join(&key) + .join("configuration"); + let unrelated = fixture.cache.join("other-server"); + for directory in [&index, &equinox, &unrelated] { + fs::create_dir_all(directory).unwrap(); + fs::write(directory.join("marker"), "state").unwrap(); + } + assert_eq!(remove_all_state(&fixture.cache).failures(), 0); + assert!(!index.exists()); + assert!(!equinox.exists()); + assert!(unrelated.exists()); + assert_eq!(remove_all_state(&fixture.cache).failures(), 0); + } +} diff --git a/crates/java-domain/src/languages/code_vision_hints.rs b/crates/java-domain/src/languages/code_vision_hints.rs new file mode 100644 index 0000000..890fc5b --- /dev/null +++ b/crates/java-domain/src/languages/code_vision_hints.rs @@ -0,0 +1,159 @@ +//! Code Vision hints with usage counts and the last author of each line. +//! +//! Ported from the Swift `JavaCodeVisionService`: project files are mapped to +//! workspace-relative paths, usage counts come from [`code_vision`], and the +//! blame author of the declaration line is attached. +//! +//! TODO(host-api: API-42 built-in Git read-only): blame lines come from the +//! host Git service. The backend needs a read-only `git.blame(file)` API; until +//! then callers pass the lines the host already computed, or none. + +use std::collections::HashMap; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use super::{code_vision, JavaCodeVisionRequest}; +use crate::testing::workflow::normalize; + +/// The author of one source line, as reported by Git blame. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlameLine { + /// Zero-based line, matching [`JavaCodeVisionHint::line`]. + pub line: usize, + /// Author of the last change to the line. + pub author_name: String, +} + +/// Hint rendered above one Java declaration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaCodeVisionHint { + /// Zero-based declaration line. + pub line: usize, + /// UTF-16 column of the declaration name. + pub utf16_column: usize, + /// Declared symbol. + pub symbol: String, + /// Workspace references to the symbol. + pub usage_count: usize, + /// Implementations; the source scan does not count them. + pub implementation_count: usize, + /// Last author of the declaration line, when blame is known. + #[serde(skip_serializing_if = "Option::is_none")] + pub author_name: Option, +} + +/// `file` relative to `root`, or `None` when it is not inside the root. +pub fn workspace_relative_path(file: &Path, root: &Path) -> Option { + let file = normalize(file); + let root = normalize(root); + let relative = file.strip_prefix(&root).ok()?; + let relative = relative.to_str()?; + (!relative.is_empty()).then(|| relative.to_string()) +} + +/// Hints for `file`; a file outside the workspace or a failed scan has none. +pub fn code_vision_hints( + file: &Path, + project_files: &[impl AsRef], + workspace_root: &Path, + blame_lines: &[BlameLine], +) -> Vec { + let Some(target_path) = workspace_relative_path(file, workspace_root) else { + return Vec::new(); + }; + let paths = project_files + .iter() + .filter_map(|path| workspace_relative_path(path.as_ref(), workspace_root)) + .collect(); + let Ok(response) = code_vision(JavaCodeVisionRequest { + root: normalize(workspace_root).display().to_string(), + target_path, + paths, + }) else { + return Vec::new(); + }; + let authors = blame_lines + .iter() + .map(|blame| (blame.line, blame.author_name.as_str())) + .collect::>(); + response + .hints + .into_iter() + .map(|hint| JavaCodeVisionHint { + author_name: authors.get(&hint.line).map(|name| (*name).to_string()), + line: hint.line, + utf16_column: hint.utf16_column, + symbol: hint.symbol, + usage_count: hint.usage_count, + implementation_count: 0, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + struct TempDir(PathBuf); + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn relative_paths_stay_inside_the_workspace() { + let root = Path::new("/w/project"); + assert_eq!( + workspace_relative_path(Path::new("/w/project/src/./A.java"), root), + Some("src/A.java".into()) + ); + assert_eq!( + workspace_relative_path(Path::new("/w/project-2/A.java"), root), + None + ); + assert_eq!(workspace_relative_path(root, root), None); + } + + #[test] + fn hints_carry_usage_counts_and_blame_authors() { + let root = + std::env::temp_dir().join(format!("lithe-java-code-vision-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let temp = TempDir(root.clone()); + fs::create_dir_all(root.join("src")).unwrap(); + let service = root.join("src/UserService.java"); + let caller = root.join("src/Caller.java"); + fs::write(&service, "class UserService {\n void save() {}\n}\n").unwrap(); + fs::write( + &caller, + "class Caller {\n void run(UserService service) { service.save(); }\n}\n", + ) + .unwrap(); + let outside = PathBuf::from("/elsewhere/Other.java"); + let hints = code_vision_hints( + &service, + &[service.clone(), caller, outside.clone()], + &temp.0, + &[BlameLine { + line: 1, + author_name: "Ada".into(), + }], + ); + let save = hints + .iter() + .find(|hint| hint.symbol == "save") + .expect("save hint"); + assert_eq!(save.line, 1); + assert_eq!(save.usage_count, 1); + assert_eq!(save.implementation_count, 0); + assert_eq!(save.author_name.as_deref(), Some("Ada")); + assert!(code_vision_hints(&outside, &[service], &temp.0, &[]).is_empty()); + } +} diff --git a/crates/java-domain/src/languages/java.rs b/crates/java-domain/src/languages/java.rs new file mode 100644 index 0000000..0512316 --- /dev/null +++ b/crates/java-domain/src/languages/java.rs @@ -0,0 +1,1181 @@ +//! Lightweight Java source and Maven-aware run-configuration inspection. + +use crate::protocol::{CoreError, ErrorCode}; +use crate::protocol::{ + JavaClassNameResponse, JavaCodeVisionHintResponse, JavaCodeVisionResponse, + JavaFoldRegionResponse, JavaInlayHintResponse, JavaMainClassResponse, + JavaRunConfigurationResponse, JavaRunConfigurationsResponse, JavaServerPortResponse, + JavaSourceSetResponse, JavaStructureResponse, +}; +use regex::Regex; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +/// Launchable classes JDT reported, in the shape `lsp.request` returns for the +/// `javaEntrypoints` operation. +/// +/// Note: entry-point ownership is recorded in .agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct JavaEntrypointFacts { + pub schema_version: u32, + pub entries: Vec, +} + +/// One class JDT confirmed the JVM can launch. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct JavaEntrypointFact { + /// Workspace-relative source path with `/` separators. + pub source_path: String, + /// Class name as JDT reports it, possibly prefixed with `module/`. + pub main_class: String, + #[serde(default)] + pub project_name: Option, +} + +/// Only schema version this Core understands for [`JavaEntrypointFacts`]. +const JAVA_ENTRYPOINT_FACTS_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Source inputs for lightweight folds, markers, and inlay hints. +pub struct JavaStructureRequest { + pub source: String, + #[serde(default)] + pub declaration_sources: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Workspace sources used to count references for Code Vision hints. +pub struct JavaCodeVisionRequest { + pub root: String, + pub target_path: String, + #[serde(default)] + pub paths: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Source and simple name used to resolve a package-qualified class name. +pub struct JavaClassNameRequest { + pub source: String, + pub simple_name: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Source lookup for a type, method, or field declaration. +pub struct JavaSourceDefinitionRequest { + pub source: String, + pub declaration_name: String, + #[serde(default)] + pub member_name: Option, + /// Only a real member declaration counts, never a call or an assignment. + #[serde(default)] + pub declarations_only: bool, + /// Where the member is used. Resolves it from that scope instead of as a + /// member of `declaration_name`, and finds nothing when syntax cannot tell. + #[serde(default)] + pub reference: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Zero-based position of the first character of a member reference. +pub struct JavaSourceReference { + pub line: usize, + pub utf16_column: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Spring configuration content inspected for a declared server port. +pub struct JavaServerPortRequest { + pub content: String, + pub file_extension: String, +} + +/// Builds Java and Spring Boot run entries from the entry points JDT confirmed. +/// +/// Whether a class is launchable is JDT's answer and is taken as given; this +/// never inspects a signature. Source is read only to label a confirmed entry +/// as a Spring Boot service, which is a product classification. +pub fn run_configurations_from_entrypoints( + root: &Path, + facts: &JavaEntrypointFacts, + module_paths: &[String], +) -> Result { + if facts.schema_version != JAVA_ENTRYPOINT_FACTS_SCHEMA_VERSION { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Unsupported Java entry-point schema version", + ) + .with_details(facts.schema_version.to_string())); + } + // Sorted so the outcome does not depend on the order JDT or a previous + // generation listed the entries in. + let mut entries = facts.entries.iter().collect::>(); + entries.sort_by(|left, right| { + (&left.source_path, &left.main_class).cmp(&(&right.source_path, &right.main_class)) + }); + let mut main_classes = Vec::new(); + for fact in entries { + let relative = normalize_relative(&fact.source_path).ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Java entry-point source path must be workspace-relative", + ) + .with_details(fact.source_path.clone()) + })?; + // Source is read only to classify a confirmed entry as a Spring Boot + // service. An entry whose source cannot be decoded (missing file or an + // unknown legacy charset) becomes a plain Java entry instead of failing + // the whole run list; JDT already confirmed the class is launchable. + let source = crate::languages::read_text(&root.join(&relative)).unwrap_or_default(); + // A modular project reports `module/pkg.Type`; configuration ids and + // names use the class itself so they stay stable across that change. + let qualified_name = fact + .main_class + .rsplit_once('/') + .map_or(fact.main_class.as_str(), |(_, class)| class) + .to_string(); + let simple_name = qualified_name + .rsplit_once('.') + .map_or(qualified_name.as_str(), |(_, name)| name) + .to_string(); + main_classes.push(JavaMainClassResponse { + path: relative, + qualified_name, + simple_name, + is_spring_boot: super::java_syntax::declares_spring_boot_application(&source), + }); + } + + let mut configurations = main_classes + .iter() + .map(|value| JavaRunConfigurationResponse { + id: if value.is_spring_boot { + format!("spring:{}", value.qualified_name) + } else { + format!("java-main:{}", value.qualified_name) + }, + name: value.simple_name.clone(), + kind: if value.is_spring_boot { + "springBoot".to_string() + } else { + "javaMain".to_string() + }, + module_path: module_path(&value.path, module_paths), + main_class: Some(value.qualified_name.clone()), + source_path: value.path.clone(), + source_set: java_source_set(&value.path), + }) + .collect::>(); + configurations.sort_by(|left, right| { + kind_order(&left.kind) + .cmp(&kind_order(&right.kind)) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + .then_with(|| left.source_path.cmp(&right.source_path)) + }); + + Ok(JavaRunConfigurationsResponse { + main_classes, + configurations, + }) +} + +/// Computes lightweight Java structure without starting a language server. +pub fn structure(request: JavaStructureRequest) -> Result { + let source = request.source; + Ok(JavaStructureResponse { + fold_regions: fold_regions(&source), + inlay_hints: parameter_hints(&source, &request.declaration_sources), + syntax_highlights: super::java_syntax::syntax_highlights(&source), + }) +} + +/// Counts workspace references for declarations in the target source file. +pub fn code_vision(request: JavaCodeVisionRequest) -> Result { + let root = existing_root(&request.root)?; + let target_path = normalize_relative(&request.target_path).ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Java target path must be relative", + ) + })?; + let target_source = crate::languages::read_text(&root.join(&target_path)).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Could not read Java target source") + .with_details(error.to_string()) + })?; + let mut sources = Vec::new(); + for path in request.paths { + let Some(relative) = normalize_relative(&path) else { + continue; + }; + if !relative.to_lowercase().ends_with(".java") { + continue; + } + if let Ok(source) = crate::languages::read_text(&root.join(relative)) { + sources.push(source); + } + } + let mut hints = Vec::new(); + for (line, column, symbol) in java_declarations(&target_source) { + let expression = + Regex::new(&format!(r"\b{}\b", regex::escape(&symbol))).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Could not parse Java symbol") + .with_details(error.to_string()) + })?; + let count = sources + .iter() + .map(|source| expression.find_iter(source).count()) + .sum::(); + hints.push(JavaCodeVisionHintResponse { + line, + utf16_column: column, + symbol, + usage_count: count.saturating_sub(1), + }); + } + Ok(JavaCodeVisionResponse { hints }) +} + +/// Resolves a simple class name against the source package declaration. +pub fn class_name(request: JavaClassNameRequest) -> Result { + let package = Regex::new(r"(?m)^\s*package\s+([A-Za-z_][A-Za-z0-9_.]*)\s*;") + .expect("static Java package expression is valid") + .captures(&request.source) + .and_then(|captures| captures.get(1).map(|value| value.as_str().to_string())); + Ok(JavaClassNameResponse { + class_name: package + .map(|value| format!("{}.{}", value, request.simple_name)) + .unwrap_or(request.simple_name), + }) +} + +/// Finds a type or member declaration in one Java source document. +pub fn source_definition( + request: JavaSourceDefinitionRequest, +) -> Result, CoreError> { + let lines = request.source.split('\n').collect::>(); + if let Some(member) = request.member_name { + let declared = match &request.reference { + Some(reference) => referenced_member(&request.source, &member, reference), + None => member_declaration(&request.source, &request.declaration_name, &member), + }; + if let Some((line, utf16_column)) = declared { + return Ok(Some(crate::protocol::JavaSourceDefinitionResponse { + line, + utf16_column, + })); + } + if request.declarations_only { + return Ok(None); + } + let method = + Regex::new(&format!(r"\b{}\s*\(", regex::escape(&member))).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Could not parse Java member") + .with_details(error.to_string()) + })?; + for (line_number, line) in lines.iter().enumerate() { + if let Some(found) = method.find(line) { + let prefix = line[..found.start()].trim(); + if !prefix.starts_with('*') + && !prefix.starts_with("//") + && !prefix.contains('#') + && !prefix.ends_with('.') + { + return Ok(Some(crate::protocol::JavaSourceDefinitionResponse { + line: line_number, + utf16_column: utf16_column(line, found.start()), + })); + } + } + } + let field = + Regex::new(&format!(r"\b{}\s*(?:=|;)", regex::escape(&member))).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Could not parse Java field") + .with_details(error.to_string()) + })?; + for (line_number, line) in lines.iter().enumerate() { + if let Some(found) = field.find(line) { + return Ok(Some(crate::protocol::JavaSourceDefinitionResponse { + line: line_number, + utf16_column: utf16_column(line, found.start()), + })); + } + } + } + + let declaration = Regex::new(&format!( + r"\b(?:class|interface|enum|record)\s+{}\b", + regex::escape(&request.declaration_name) + )) + .map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Could not parse Java declaration") + .with_details(error.to_string()) + })?; + for (line_number, line) in lines.iter().enumerate() { + if let Some(found) = declaration.find(line) { + let offset = line[found.start()..found.end()] + .find(&request.declaration_name) + .map(|value| found.start() + value) + .unwrap_or(found.start()); + return Ok(Some(crate::protocol::JavaSourceDefinitionResponse { + line: line_number, + utf16_column: utf16_column(line, offset), + })); + } + } + Ok(None) +} + +fn parse_java(source: &str) -> Option { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&tree_sitter_java::LANGUAGE.into()) + .ok()?; + parser.parse(source, None) +} + +/// Zero-based line and UTF-16 column of a declared name. +fn declaration_position(source: &str, declared: tree_sitter::Node) -> Option<(usize, usize)> { + let point = declared.start_position(); + let line = source.split('\n').nth(point.row)?; + Some((point.row, utf16_column(line, point.column))) +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum MemberKind { + Method, + Field, +} + +fn is_type_body(node: tree_sitter::Node) -> bool { + matches!( + node.kind(), + "class_body" | "interface_body" | "enum_body" | "annotation_type_body" + ) +} + +fn has_name(node: tree_sitter::Node, source: &str, name: &str) -> bool { + node.child_by_field_name("name") + .is_some_and(|declared| declared.utf8_text(source.as_bytes()) == Ok(name)) +} + +/// Finds a member declared directly in a type body, not in a type nested inside it. +/// +/// `kind` narrows the search because Java keeps methods and fields in separate +/// namespaces; `None` accepts either. +fn direct_member<'tree>( + body: tree_sitter::Node<'tree>, + name: &str, + kind: Option, + source: &str, +) -> Option> { + let accepts = |wanted: MemberKind| kind.is_none_or(|kind| kind == wanted); + let named = |node: tree_sitter::Node<'tree>| { + has_name(node, source, name) + .then(|| node.child_by_field_name("name")) + .flatten() + }; + // A record component is a field the body can use without declaring it. + if let Some(components) = body + .parent() + .filter(|parent| parent.kind() == "record_declaration" && accepts(MemberKind::Field)) + .and_then(|parent| parent.child_by_field_name("parameters")) + { + let mut cursor = components.walk(); + let component = components.named_children(&mut cursor).find_map(&named); + if component.is_some() { + return component; + } + } + let mut cursor = body.walk(); + for member in body.named_children(&mut cursor) { + let declared = match member.kind() { + "method_declaration" if accepts(MemberKind::Method) => named(member), + "enum_constant" | "annotation_type_element_declaration" + if accepts(MemberKind::Field) => + { + named(member) + } + "field_declaration" | "constant_declaration" if accepts(MemberKind::Field) => { + let mut declarators = member.walk(); + let declared = member + .children_by_field_name("declarator", &mut declarators) + .find_map(&named); + declared + } + "enum_body_declarations" => direct_member(member, name, kind, source), + _ => None, + }; + if declared.is_some() { + return declared; + } + } + None +} + +/// Finds a method, field, or enum constant that the type `type_name` declares itself. +fn member_declaration(source: &str, type_name: &str, name: &str) -> Option<(usize, usize)> { + let tree = parse_java(source)?; + let mut pending = vec![tree.root_node()]; + while let Some(node) = pending.pop() { + if let Some(body) = node + .child_by_field_name("body") + .filter(|body| is_type_body(*body) && has_name(node, source, type_name)) + { + let declared = direct_member(body, name, None, source)?; + return declaration_position(source, declared); + } + // Reversed so the stack visits declarations in source order. + let mut cursor = node.walk(); + let children: Vec<_> = node.children(&mut cursor).collect(); + pending.extend(children.into_iter().rev()); + } + None +} + +/// Whether a type may inherit a member, which syntax cannot see. +fn may_inherit(declaration: tree_sitter::Node) -> bool { + let mut cursor = declaration.walk(); + let extends_interfaces = declaration + .children(&mut cursor) + .any(|child| child.kind() == "extends_interfaces"); + extends_interfaces + || matches!( + declaration.kind(), + "object_creation_expression" | "enum_constant" + ) + || declaration.child_by_field_name("superclass").is_some() + || declaration.child_by_field_name("interfaces").is_some() +} + +/// Whether a member's code declares a local variable or parameter called `name`. +/// +/// This ignores block scope on purpose. A same-named local elsewhere in the +/// member makes the answer uncertain, and an uncertain name stays unanswered. +fn declares_local(member: tree_sitter::Node, name: &str, source: &str) -> bool { + let mut pending = vec![member]; + while let Some(node) = pending.pop() { + let declares = match node.kind() { + "variable_declarator" => { + !matches!( + node.parent().map(|parent| parent.kind()), + Some("field_declaration" | "constant_declaration") + ) && has_name(node, source, name) + } + "formal_parameter" + | "catch_formal_parameter" + | "enhanced_for_statement" + | "resource" + | "instanceof_expression" => has_name(node, source, name), + "inferred_parameters" + | "lambda_expression" + | "type_pattern" + | "record_pattern_component" => { + let mut cursor = node.walk(); + let declares = node + .named_children(&mut cursor) + .filter(|child| child.kind() == "identifier") + .any(|child| child.utf8_text(source.as_bytes()) == Ok(name)); + declares + } + _ => false, + }; + if declares { + return true; + } + let mut cursor = node.walk(); + pending.extend( + node.named_children(&mut cursor) + .filter(|child| !is_type_body(*child)), + ); + } + false +} + +/// Resolves a member reference to the declaration its scope binds it to. +/// +/// Only a plain name or a `this.` access qualifies: any other receiver has a +/// type that syntax cannot resolve. The search starts in the innermost type and +/// moves outward, stopping at a type that may inherit the name and, for a plain +/// field name, at a local that may shadow it. +fn referenced_member( + source: &str, + name: &str, + reference: &JavaSourceReference, +) -> Option<(usize, usize)> { + let tree = parse_java(source)?; + let line = source.split('\n').nth(reference.line)?; + let mut utf16_offset = 0; + let byte = line + .char_indices() + .find(|(_, character)| { + let reached = utf16_offset >= reference.utf16_column; + utf16_offset += character.len_utf16(); + reached + })? + .0; + let start = tree_sitter::Point::new(reference.line, byte); + let end = tree_sitter::Point::new(reference.line, byte + 1); + let identifier = tree.root_node().descendant_for_point_range(start, end)?; + if identifier.kind() != "identifier" || identifier.utf8_text(source.as_bytes()) != Ok(name) { + return None; + } + + let parent = identifier.parent()?; + let receiver = match parent.kind() { + "method_invocation" if parent.child_by_field_name("name") == Some(identifier) => { + parent.child_by_field_name("object") + } + "field_access" if parent.child_by_field_name("field") == Some(identifier) => { + parent.child_by_field_name("object") + } + "method_reference" | "element_value_pair" | "labeled_statement" | "break_statement" + | "continue_statement" => return None, + _ => None, + }; + if receiver.is_some_and(|receiver| receiver.kind() != "this") { + return None; + } + let is_call = parent.kind() == "method_invocation" + && parent.child_by_field_name("name") == Some(identifier); + let kind = if is_call { + MemberKind::Method + } else { + MemberKind::Field + }; + + let mut inner = identifier; + while let Some(scope) = inner.parent() { + if is_type_body(scope) { + if kind == MemberKind::Field + && receiver.is_none() + && declares_local(inner, name, source) + { + return None; + } + if let Some(declared) = direct_member(scope, name, Some(kind), source) { + return declaration_position(source, declared); + } + if receiver.is_some() || scope.parent().is_none_or(may_inherit) { + return None; + } + } + inner = scope; + } + None +} + +/// Reads a Spring server port from properties or YAML content. +pub fn server_port(request: JavaServerPortRequest) -> Result { + if request.file_extension.eq_ignore_ascii_case("properties") { + for line in request.content.lines() { + let value = line.trim(); + if value.starts_with('#') { + continue; + } + let Some((key, port)) = value.split_once([':', '=']) else { + continue; + }; + if key.trim() == "server.port" { + return Ok(JavaServerPortResponse { + port: port.trim().parse().ok(), + }); + } + } + return Ok(JavaServerPortResponse { port: None }); + } + + let mut in_server_section = false; + for line in request.content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if trimmed == "server:" { + in_server_section = true; + continue; + } + if in_server_section && line.chars().next().is_some_and(char::is_whitespace) { + if let Some((key, port)) = trimmed.split_once(':') { + if key.trim() == "port" { + return Ok(JavaServerPortResponse { + port: port.trim().parse().ok(), + }); + } + } + continue; + } + in_server_section = false; + } + Ok(JavaServerPortResponse { port: None }) +} + +/// Returns the fully qualified class name to launch for a standalone Java file. +/// +/// Standalone runs compile the file with `javac` and then start it with +/// `java `, so the launcher needs the class name the JVM will look up — +/// not the file path. The name is derived structurally (declared package plus +/// [`launch_class_name`]) and does not require a `main` method: when the file +/// has none, the JVM emits its own clear diagnostic, which is more useful than a +/// Core error. Files without any `class` declaration fall back to the file stem, +/// matching the convention `javac` uses for the generated class. +pub fn standalone_launch_class(path: &str, source: &str) -> String { + let simple_name = launch_class_name(path, source).unwrap_or_else(|| file_stem(path)); + match declared_package(source) { + Some(package) => format!("{package}.{simple_name}"), + None => simple_name, + } +} + +/// Picks the simple class name the JVM should launch for a single source file. +/// +/// A compilable `.java` file names its public class after the file, so the file +/// stem is the reliable answer whenever the source actually declares that class. +/// Preferring the stem also rejects a `class` token that only appears in a +/// comment, and a helper class that happens to be declared before the public +/// one. When no declared class matches the stem the first declared class is +/// used, which is the JDK source launcher's rule (JEP 330), and `None` is +/// returned when the source declares no class at all — a compact source file, +/// whose implicit class `javac` names after the file. +/// +/// This is `javac` naming, not entry-point detection: which classes are +/// launchable is JDT's answer, and the JVM reports a class without a usable +/// `main` itself. +fn launch_class_name(path: &str, source: &str) -> Option { + let classes = declared_classes(source); + if classes.is_empty() { + return None; + } + let stem = file_stem(path); + if classes.iter().any(|(_, name)| name == &stem) { + return Some(stem); + } + classes.first().map(|(_, name)| name.clone()) +} + +/// Returns every declared class name paired with the byte offset of its +/// declaration, in source order, ignoring modifiers such as `public`/`final`. +fn declared_classes(source: &str) -> Vec<(usize, String)> { + let class_pattern = + Regex::new(r"\b(?:public\s+)?(?:final\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)") + .expect("class declaration pattern is a valid regex"); + class_pattern + .captures_iter(source) + .filter_map(|captures| { + let name = captures.get(1)?; + Some((captures.get(0)?.start(), name.as_str().to_string())) + }) + .collect() +} + +/// Extracts the declared package, or `None` for the default package. +fn declared_package(source: &str) -> Option { + Regex::new(r"(?m)^\s*package\s+([A-Za-z_$][A-Za-z0-9_$.]*)\s*;") + .ok()? + .captures(source) + .and_then(|captures| captures.get(1).map(|value| value.as_str().to_string())) +} + +/// Returns the file name without directories or the `.java` extension. +fn file_stem(path: &str) -> String { + let name = path.rsplit('/').next().unwrap_or(path); + name.strip_suffix(".java") + .or_else(|| name.strip_suffix(".JAVA")) + .unwrap_or(name) + .to_string() +} + +fn module_path(path: &str, modules: &[String]) -> Option { + modules + .iter() + .filter(|module| is_inside(path, module)) + .max_by_key(|module| module.len()) + .cloned() +} + +fn java_source_set(path: &str) -> JavaSourceSetResponse { + let normalized = path.to_ascii_lowercase(); + if normalized.starts_with("src/test/") || normalized.contains("/src/test/") { + JavaSourceSetResponse::Test + } else if normalized.starts_with("src/main/") || normalized.contains("/src/main/") { + JavaSourceSetResponse::Main + } else { + JavaSourceSetResponse::Other + } +} + +fn is_inside(path: &str, directory: &str) -> bool { + path == directory || path.starts_with(&(directory.trim_end_matches('/').to_string() + "/")) +} + +fn kind_order(kind: &str) -> usize { + match kind { + "springBoot" => 0, + "javaMain" => 1, + _ => 2, + } +} + +fn fold_regions(source: &str) -> Vec { + let mut regions = import_region(source).into_iter().collect::>(); + regions.extend(comment_regions(source)); + regions.extend(brace_regions(source)); + regions.sort_by(|left, right| { + left.start_line + .cmp(&right.start_line) + .then_with(|| right.end_line.cmp(&left.end_line)) + }); + regions +} + +fn import_region(source: &str) -> Option { + let expression = Regex::new(r"(?m)^[ \t]*import[ \t]+[^;]+;[ \t]*$").ok()?; + let matches = expression.find_iter(source).collect::>(); + let first = matches.first()?; + let last = matches.last()?; + if matches.len() < 2 { + return None; + } + Some(JavaFoldRegionResponse { + kind: "imports".to_string(), + start_line: line_number(source, first.start()), + end_line: line_number(source, last.start()), + hidden_start: utf16_offset(source, line_end(source, first.start())), + hidden_length: utf16_offset(source, line_end(source, last.start())) + .saturating_sub(utf16_offset(source, line_end(source, first.start()))), + }) +} + +fn comment_regions(source: &str) -> Vec { + let Ok(expression) = Regex::new(r"/\*[\s\S]*?\*/") else { + return Vec::new(); + }; + expression + .find_iter(source) + .filter_map(|matched| { + let start_line = line_number(source, matched.start()); + let end_line = line_number(source, matched.end()); + (end_line > start_line).then(|| JavaFoldRegionResponse { + kind: "comment".to_string(), + start_line, + end_line, + hidden_start: utf16_offset(source, line_end(source, matched.start())), + hidden_length: utf16_offset(source, matched.end()) + .saturating_sub(utf16_offset(source, line_end(source, matched.start()))), + }) + }) + .collect() +} + +fn brace_regions(source: &str) -> Vec { + let bytes = source.as_bytes(); + let mut stack: Vec<(usize, String)> = Vec::new(); + let mut regions = Vec::new(); + let mut index = 0; + let mut state = ScanState::Code; + while index < bytes.len() { + let character = bytes[index]; + let next = bytes.get(index + 1).copied().unwrap_or_default(); + match state { + ScanState::Code => match character { + b'"' => state = ScanState::String, + b'\'' => state = ScanState::Character, + b'/' if next == b'/' => { + state = ScanState::LineComment; + index += 1; + } + b'/' if next == b'*' => { + state = ScanState::BlockComment; + index += 1; + } + b'{' => { + let start = line_start(source, index); + stack.push((index, source[start..index].to_string())); + } + b'}' => { + if let Some((opening, prefix)) = stack.pop() { + let start_line = line_number(source, opening); + let end_line = line_number(source, index); + let hidden_start = line_end(source, opening); + let hidden_end = line_start(source, index); + if end_line > start_line && hidden_end > hidden_start { + regions.push(JavaFoldRegionResponse { + kind: classify(&prefix), + start_line, + end_line, + hidden_start: utf16_offset(source, hidden_start), + hidden_length: utf16_offset(source, hidden_end) + .saturating_sub(utf16_offset(source, hidden_start)), + }); + } + } + } + _ => {} + }, + ScanState::String => { + if character == b'\\' { + index += 1; + } else if character == b'"' { + state = ScanState::Code; + } + } + ScanState::Character => { + if character == b'\\' { + index += 1; + } else if character == b'\'' { + state = ScanState::Code; + } + } + ScanState::LineComment => { + if character == b'\n' { + state = ScanState::Code; + } + } + ScanState::BlockComment => { + if character == b'*' && next == b'/' { + state = ScanState::Code; + index += 1; + } + } + } + index += 1; + } + regions +} + +fn classify(prefix: &str) -> String { + if Regex::new(r"\b(class|interface|enum|record|struct|protocol|extension|actor)\b") + .expect("static Java type expression is valid") + .is_match(prefix) + { + "type".to_string() + } else if prefix.contains('(') && prefix.contains(')') { + "method".to_string() + } else { + "block".to_string() + } +} + +fn parameter_hints(source: &str, declaration_sources: &[String]) -> Vec { + let declaration_pattern = + Regex::new(r"\b([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*\(([^(){};]*)\)[ \t]*(?:throws[^{]+)?\{") + .expect("static Java declaration expression is valid"); + let mut declarations: HashMap>> = HashMap::new(); + for text in declaration_sources { + for matched in declaration_pattern.captures_iter(text) { + let Some(name) = matched.get(1).map(|value| value.as_str()) else { + continue; + }; + if ["if", "for", "while", "switch", "catch", "new"].contains(&name) { + continue; + } + let parameters = matched + .get(2) + .map(|value| split_parameters(value.as_str())) + .unwrap_or_default(); + if !parameters.is_empty() { + declarations + .entry(name.to_string()) + .or_default() + .push(parameters); + } + } + } + let call_pattern = Regex::new(r"\b([A-Za-z_$][A-Za-z0-9_$]*)[ \t]*\(([^(){};]*)\)") + .expect("static Java call expression is valid"); + let mut hints = Vec::new(); + for matched in call_pattern.captures_iter(source) { + let Some(name) = matched.get(1).map(|value| value.as_str()) else { + continue; + }; + let Some(arguments) = matched.get(2) else { + continue; + }; + if looks_like_parameter_list(arguments.as_str()) { + continue; + } + let starts = argument_starts(source, arguments.start(), arguments.end()); + let Some(parameters) = declarations + .get(name) + .and_then(|values| values.iter().find(|value| value.len() == starts.len())) + else { + continue; + }; + let suffix = source[matched.get(0).unwrap().end()..] + .chars() + .take(40) + .collect::(); + let suffix = suffix.trim_start(); + if suffix.starts_with('{') || suffix.starts_with("throws ") { + continue; + } + for (index, start) in starts.iter().enumerate() { + hints.push(JavaInlayHintResponse { + line: line_number(source, *start), + utf16_column: utf16_offset(source, *start) + .saturating_sub(utf16_offset(source, line_start(source, *start))), + label: parameters[index].clone(), + }); + } + } + let mut seen = HashSet::new(); + hints.retain(|value| seen.insert((value.line, value.utf16_column, value.label.clone()))); + hints.sort_by(|left, right| { + left.line + .cmp(&right.line) + .then_with(|| left.utf16_column.cmp(&right.utf16_column)) + }); + hints +} + +fn java_declarations(source: &str) -> Vec<(usize, usize, String)> { + let type_pattern = + Regex::new(r"\b(?:class|interface|enum|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)") + .expect("static Java type expression is valid"); + let method_pattern = + Regex::new(r"\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\([^;{}]*\)\s*(?:throws\s+[^{}]+)?\{") + .expect("static Java method expression is valid"); + let ignored = [ + "if", + "for", + "while", + "switch", + "catch", + "try", + "synchronized", + "new", + ]; + let mut result = Vec::new(); + for (line_index, line) in source.split('\n').enumerate() { + if let Some(captures) = type_pattern.captures(line) { + if let Some(symbol) = captures.get(1) { + result.push(( + line_index, + utf16_column(line, symbol.start()), + symbol.as_str().to_string(), + )); + continue; + } + } + if let Some(captures) = method_pattern.captures(line) { + if let Some(symbol) = captures.get(1) { + if !ignored.contains(&symbol.as_str()) { + result.push(( + line_index, + utf16_column(line, symbol.start()), + symbol.as_str().to_string(), + )); + } + } + } + } + result +} + +fn utf16_column(line: &str, byte: usize) -> usize { + line[..byte.min(line.len())].encode_utf16().count() +} + +fn split_parameters(value: &str) -> Vec { + value + .split(',') + .filter_map(|parameter| { + let cleaned = Regex::new(r"@[A-Za-z_$][A-Za-z0-9_$]*(?:\([^)]*\))?") + .expect("static Java annotation expression is valid") + .replace_all(parameter, "") + .trim() + .to_string(); + cleaned + .split_whitespace() + .last() + .map(|value| value.to_string()) + }) + .collect() +} + +fn looks_like_parameter_list(value: &str) -> bool { + let identifier = Regex::new(r"^[A-Za-z_$][A-Za-z0-9_$]*$") + .expect("static Java identifier expression is valid"); + !value.trim().is_empty() + && value.split(',').all(|item| { + let words = item.split_whitespace().collect::>(); + words.len() >= 2 && identifier.is_match(words.last().copied().unwrap_or_default()) + }) +} + +fn argument_starts(source: &str, start: usize, end: usize) -> Vec { + if start >= end { + return Vec::new(); + } + let bytes = source.as_bytes(); + let mut result = Vec::new(); + let mut item_start = start; + let mut index = start; + let mut depth: usize = 0; + let mut quote = None; + while index < end { + let character = bytes[index]; + if let Some(active) = quote { + if character == b'\\' { + index += 1; + } else if character == active { + quote = None; + } + } else if character == b'"' || character == b'\'' { + quote = Some(character); + } else if matches!(character, b'(' | b'[' | b'{') { + depth += 1; + } else if matches!(character, b')' | b']' | b'}') { + depth = depth.saturating_sub(1); + } else if character == b',' && depth == 0 { + if let Some(value) = first_non_whitespace(source, item_start, index) { + result.push(value); + } + item_start = index + 1; + } + index += 1; + } + if let Some(value) = first_non_whitespace(source, item_start, end) { + result.push(value); + } + result +} + +fn first_non_whitespace(source: &str, start: usize, end: usize) -> Option { + source[start..end] + .char_indices() + .find(|(_, character)| !character.is_whitespace()) + .map(|(offset, _)| start + offset) +} + +fn existing_root(value: &str) -> Result { + let path = PathBuf::from(value); + let metadata = fs::metadata(&path) + .map_err(|_| CoreError::new(ErrorCode::WorkspaceNotFound, "Workspace does not exist"))?; + if !metadata.is_dir() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Workspace root must be a directory", + )); + } + path.canonicalize().map_err(CoreError::from) +} + +fn normalize_relative(value: &str) -> Option { + let path = Path::new(value.trim()); + if path.as_os_str().is_empty() + || path.is_absolute() + || path + .components() + .any(|value| matches!(value, Component::ParentDir)) + { + return None; + } + Some( + path.to_string_lossy() + .replace('\\', "/") + .trim_matches('/') + .to_string(), + ) +} + +fn line_number(source: &str, byte: usize) -> usize { + source[..byte.min(source.len())] + .bytes() + .filter(|value| *value == b'\n') + .count() +} + +fn line_start(source: &str, byte: usize) -> usize { + source[..byte.min(source.len())] + .rfind('\n') + .map(|value| value + 1) + .unwrap_or(0) +} + +fn line_end(source: &str, byte: usize) -> usize { + source[byte.min(source.len())..] + .find('\n') + .map(|value| byte.min(source.len()) + value + 1) + .unwrap_or(source.len()) +} + +fn utf16_offset(source: &str, byte: usize) -> usize { + source[..byte.min(source.len())].encode_utf16().count() +} + +/// Lexical region used to exclude strings, characters, and comments from scans. +enum ScanState { + Code, + String, + Character, + LineComment, + BlockComment, +} + +#[cfg(test)] +mod launch_class_tests { + use super::standalone_launch_class; + + #[test] + fn prefers_the_public_class_named_after_the_file() { + // A helper class declared first must not be chosen over the public class, + // which a compilable file always names after the file. + let source = "class Helper {} public class App { public static void main(String[] a) {} }"; + assert_eq!(standalone_launch_class("src/App.java", source), "App"); + } + + #[test] + fn ignores_a_class_token_inside_a_comment() { + // `// class Widget` must not be mistaken for a declaration. + let source = "// helper class Widget lives elsewhere\npublic class App { public static void main(String[] a) {} }"; + assert_eq!(standalone_launch_class("src/App.java", source), "App"); + } + + #[test] + fn falls_back_to_the_first_class_like_the_jdk_source_launcher() { + // With no class named after the file, the first top-level class is + // launched — the JDK source launcher's own rule (JEP 330). Lithe does + // not look for a `main` signature; the JVM reports a class without one. + let source = "class Utils {}\nclass Runner { public static void main(String[] a) {} }"; + assert_eq!( + standalone_launch_class("scratch/Scratch.java", source), + "Utils" + ); + } + + #[test] + fn a_compact_source_file_launches_the_class_javac_names_after_it() { + let source = "void main() {\n IO.println(\"compact\");\n}\n"; + assert_eq!( + standalone_launch_class("scratch/Compact.java", source), + "Compact" + ); + } + + #[test] + fn qualifies_with_the_declared_package() { + let source = + "package com.example; public class App { public static void main(String[] a) {} }"; + assert_eq!( + standalone_launch_class("src/App.java", source), + "com.example.App" + ); + } + + #[test] + fn falls_back_to_the_file_stem_without_any_class() { + assert_eq!( + standalone_launch_class("src/Loose.java", "int x = 1;"), + "Loose" + ); + } +} diff --git a/crates/java-domain/src/languages/java_syntax.rs b/crates/java-domain/src/languages/java_syntax.rs new file mode 100644 index 0000000..057aa48 --- /dev/null +++ b/crates/java-domain/src/languages/java_syntax.rs @@ -0,0 +1,556 @@ +//! Deterministic Java syntax classification for native editor renderers. + +use crate::protocol::JavaSyntaxHighlightResponse; +use std::collections::HashSet; +use tree_sitter::{Node, Parser}; + +#[derive(Default)] +struct JavaSymbols<'a> { + fields: HashSet<&'a str>, + constants: HashSet<&'a str>, + parameters: HashSet<&'a str>, +} + +/// Classifies Java source into sorted, non-overlapping UTF-16 token ranges. +pub(super) fn syntax_highlights(source: &str) -> Vec { + let mut parser = Parser::new(); + if parser + .set_language(&tree_sitter_java::LANGUAGE.into()) + .is_err() + { + return Vec::new(); + } + let Some(tree) = parser.parse(source, None) else { + return Vec::new(); + }; + let mut values = Vec::new(); + let utf16_offsets = utf16_offsets(source); + let mut symbols = JavaSymbols::default(); + collect_symbols(tree.root_node(), source.as_bytes(), &mut symbols); + collect_highlights( + tree.root_node(), + source, + &utf16_offsets, + &symbols, + &mut values, + ); + values.sort_by_key(|value| (value.utf16_start, value.utf16_length)); + values.dedup_by(|left, right| { + left.utf16_start == right.utf16_start && left.utf16_length == right.utf16_length + }); + values +} + +/// Reports whether a type in this source carries `@SpringBootApplication`. +/// +/// This labels an entry point JDT already confirmed as a Spring Boot service; +/// it never decides whether a class is launchable. The syntax tree is used so +/// that the annotation name inside a string literal or comment does not count. +pub(super) fn declares_spring_boot_application(source: &str) -> bool { + let mut parser = Parser::new(); + if parser + .set_language(&tree_sitter_java::LANGUAGE.into()) + .is_err() + { + return false; + } + let Some(tree) = parser.parse(source, None) else { + return false; + }; + contains_spring_boot_annotation(tree.root_node(), source.as_bytes()) +} + +fn contains_spring_boot_annotation(node: Node<'_>, source: &[u8]) -> bool { + if matches!(node.kind(), "marker_annotation" | "annotation") + && node + .child_by_field_name("name") + .and_then(|name| name.utf8_text(source).ok()) + .is_some_and(|name| { + name == "SpringBootApplication" || name.ends_with(".SpringBootApplication") + }) + { + return true; + } + let mut cursor = node.walk(); + let found = node + .children(&mut cursor) + .any(|child| contains_spring_boot_annotation(child, source)); + found +} + +fn collect_highlights( + node: Node<'_>, + source: &str, + utf16_offsets: &[usize], + symbols: &JavaSymbols<'_>, + values: &mut Vec, +) { + if let Some(role) = whole_node_role(node, source.as_bytes()) { + push_highlight(node, role, utf16_offsets, values); + return; + } + if node.child_count() == 0 { + if let Some(role) = role_for_leaf(node, source.as_bytes(), symbols) { + push_highlight(node, role, utf16_offsets, values); + } + return; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_highlights(child, source, utf16_offsets, symbols, values); + } +} + +fn collect_symbols<'a>(node: Node<'_>, source: &'a [u8], symbols: &mut JavaSymbols<'a>) { + if matches!(node.kind(), "formal_parameter" | "spread_parameter") { + if let Some(name) = node + .child_by_field_name("name") + .and_then(|value| value.utf8_text(source).ok()) + { + symbols.parameters.insert(name); + } + } else if node.kind() == "variable_declarator" && has_ancestor(node, "field_declaration") { + if let Some(name) = node + .child_by_field_name("name") + .and_then(|value| value.utf8_text(source).ok()) + { + let is_constant = ancestor_text(node, "field_declaration", source) + .is_some_and(|text| has_modifier(text, "static") && has_modifier(text, "final")); + if is_constant { + symbols.constants.insert(name); + } else { + symbols.fields.insert(name); + } + } + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_symbols(child, source, symbols); + } +} + +fn utf16_offsets(source: &str) -> Vec { + let mut offsets = vec![0; source.len() + 1]; + let mut utf16_offset = 0; + for (byte_offset, character) in source.char_indices() { + offsets[byte_offset] = utf16_offset; + utf16_offset += character.len_utf16(); + offsets[byte_offset + character.len_utf8()] = utf16_offset; + } + offsets +} + +fn whole_node_role(node: Node<'_>, source: &[u8]) -> Option<&'static str> { + match node.kind() { + "line_comment" | "block_comment" => Some( + if node + .utf8_text(source) + .is_ok_and(|text| text.as_bytes().starts_with(b"/**")) + { + "documentationComment" + } else { + "comment" + }, + ), + "string_literal" | "character_literal" | "text_block" => Some("string"), + "decimal_integer_literal" + | "hex_integer_literal" + | "octal_integer_literal" + | "binary_integer_literal" + | "decimal_floating_point_literal" + | "hex_floating_point_literal" => Some("number"), + "boolean_type" | "integral_type" | "floating_point_type" | "void_type" => Some("keyword"), + _ => None, + } +} + +fn push_highlight( + node: Node<'_>, + role: &str, + utf16_offsets: &[usize], + values: &mut Vec, +) { + let Some(&utf16_start) = utf16_offsets.get(node.start_byte()) else { + return; + }; + let Some(&utf16_end) = utf16_offsets.get(node.end_byte()) else { + return; + }; + let utf16_length = utf16_end.saturating_sub(utf16_start); + if utf16_length == 0 { + return; + } + values.push(JavaSyntaxHighlightResponse { + utf16_start, + utf16_length, + role: role.to_string(), + }); +} + +fn role_for_leaf(node: Node<'_>, source: &[u8], symbols: &JavaSymbols<'_>) -> Option<&'static str> { + let kind = node.kind(); + if kind == "@" + || (matches!(kind, "identifier" | "type_identifier") + && (has_ancestor(node, "marker_annotation") || has_ancestor(node, "annotation"))) + { + return Some("annotation"); + } + if matches!(kind, "true" | "false") { + return Some("boolean"); + } + if kind == "null_literal" { + return Some("null"); + } + if kind == "identifier" { + return Some(identifier_role(node, source, symbols)); + } + if kind == "type_identifier" { + return Some(if has_ancestor(node, "type_parameters") { + "typeParameter" + } else { + "type" + }); + } + if is_keyword(kind) { + return Some("keyword"); + } + if !node.is_named() { + return Some(if is_operator(kind) { + "operator" + } else { + "punctuation" + }); + } + None +} + +fn identifier_role(node: Node<'_>, source: &[u8], symbols: &JavaSymbols<'_>) -> &'static str { + let Some(parent) = node.parent() else { + return "variable"; + }; + if field_is(parent, "name", node) { + return match parent.kind() { + "method_declaration" | "constructor_declaration" => "functionDeclaration", + "method_invocation" => "functionCall", + "formal_parameter" | "spread_parameter" | "receiver_parameter" => "parameter", + "class_declaration" + | "interface_declaration" + | "enum_declaration" + | "record_declaration" + | "annotation_type_declaration" => "type", + _ => declaration_identifier_role(node, source), + }; + } + if has_ancestor(node, "method_invocation") && field_is(parent, "name", node) { + return "functionCall"; + } + if parent.kind() == "field_access" && field_is(parent, "field", node) { + return "field"; + } + if has_ancestor(node, "type_parameters") { + return "typeParameter"; + } + if has_type_ancestor(node) { + return "type"; + } + if let Ok(name) = node.utf8_text(source) { + if symbols.parameters.contains(name) { + return "parameter"; + } + if symbols.constants.contains(name) { + return "constant"; + } + if symbols.fields.contains(name) { + return "field"; + } + if name + .chars() + .next() + .is_some_and(|character| character.is_ascii_uppercase()) + { + return "type"; + } + } + "variable" +} + +fn declaration_identifier_role(node: Node<'_>, source: &[u8]) -> &'static str { + if has_ancestor(node, "formal_parameter") || has_ancestor(node, "spread_parameter") { + return "parameter"; + } + if has_ancestor(node, "field_declaration") { + return if ancestor_text(node, "field_declaration", source) + .is_some_and(|text| has_modifier(text, "static") && has_modifier(text, "final")) + { + "constant" + } else { + "field" + }; + } + if has_ancestor(node, "local_variable_declaration") + || has_ancestor(node, "resource") + || has_ancestor(node, "catch_formal_parameter") + || has_ancestor(node, "enhanced_for_statement") + { + return "variable"; + } + "variable" +} + +fn field_is(parent: Node<'_>, name: &str, node: Node<'_>) -> bool { + parent + .child_by_field_name(name) + .is_some_and(|value| value.id() == node.id()) +} + +fn has_ancestor(mut node: Node<'_>, kind: &str) -> bool { + while let Some(parent) = node.parent() { + if parent.kind() == kind { + return true; + } + node = parent; + } + false +} + +fn has_type_ancestor(mut node: Node<'_>) -> bool { + while let Some(parent) = node.parent() { + if matches!( + parent.kind(), + "type_arguments" + | "superclass" + | "super_interfaces" + | "extends_interfaces" + | "object_creation_expression" + | "cast_expression" + | "instanceof_expression" + | "array_type" + | "generic_type" + | "scoped_type_identifier" + ) { + return true; + } + if matches!(parent.kind(), "method_invocation" | "expression_statement") { + return false; + } + node = parent; + } + false +} + +fn ancestor_text<'a>(mut node: Node<'_>, kind: &str, source: &'a [u8]) -> Option<&'a str> { + while let Some(parent) = node.parent() { + if parent.kind() == kind { + return parent.utf8_text(source).ok(); + } + node = parent; + } + None +} + +fn has_modifier(text: &str, expected: &str) -> bool { + text.split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .any(|word| word == expected) +} + +fn is_keyword(kind: &str) -> bool { + matches!( + kind, + "abstract" + | "assert" + | "break" + | "case" + | "catch" + | "class" + | "continue" + | "default" + | "do" + | "else" + | "enum" + | "exports" + | "extends" + | "final" + | "finally" + | "for" + | "if" + | "implements" + | "import" + | "instanceof" + | "interface" + | "module" + | "native" + | "new" + | "non-sealed" + | "open" + | "opens" + | "package" + | "permits" + | "private" + | "protected" + | "provides" + | "public" + | "record" + | "requires" + | "return" + | "sealed" + | "static" + | "strictfp" + | "super" + | "switch" + | "synchronized" + | "this" + | "throw" + | "throws" + | "to" + | "transient" + | "transitive" + | "try" + | "uses" + | "volatile" + | "while" + | "with" + | "yield" + | "void" + | "boolean_type" + | "integral_type" + | "floating_point_type" + ) +} + +fn is_operator(kind: &str) -> bool { + matches!( + kind, + "=" | ">" + | "<" + | "!" + | "~" + | "?" + | ":" + | "->" + | "==" + | ">=" + | "<=" + | "!=" + | "&&" + | "||" + | "++" + | "--" + | "+" + | "-" + | "*" + | "/" + | "&" + | "|" + | "^" + | "%" + | "<<" + | ">>" + | ">>>" + | "+=" + | "-=" + | "*=" + | "/=" + | "&=" + | "|=" + | "^=" + | "%=" + | "<<=" + | ">>=" + | ">>>=" + | "::" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_java_declarations_references_and_literals() { + let source = concat!( + "/", + "** Docs *", + "/\n", + r#"@Deprecated class Demo { + static final int LIMIT = 2; + String field; + Demo(String value) { + String local = value; + this.field = call(local); + } +}"# + ); + + let values = syntax_highlights(source); + let classified = values + .iter() + .map(|value| { + let token = String::from_utf16( + &source.encode_utf16().collect::>() + [value.utf16_start..value.utf16_start + value.utf16_length], + ) + .expect("highlight range should contain valid UTF-16"); + (token, value.role.as_str()) + }) + .collect::>(); + + let documentation = ["/", "** Docs *", "/"].concat(); + assert!(classified.contains(&(documentation, "documentationComment"))); + assert!(classified.contains(&("@".to_string(), "annotation"))); + assert!(classified.contains(&("Deprecated".to_string(), "annotation"))); + assert!(classified.contains(&("Demo".to_string(), "type"))); + assert!(classified.contains(&("T".to_string(), "typeParameter"))); + assert!(classified.contains(&("LIMIT".to_string(), "constant"))); + assert!(classified.contains(&("field".to_string(), "field"))); + assert!(classified.contains(&("Demo".to_string(), "functionDeclaration"))); + assert!(classified.contains(&("value".to_string(), "parameter"))); + assert!(classified.contains(&("local".to_string(), "variable"))); + assert!(classified.contains(&("call".to_string(), "functionCall"))); + assert!(classified.contains(&("2".to_string(), "number"))); + assert!( + classified + .iter() + .filter(|(token, role)| token == "field" && *role == "field") + .count() + >= 2 + ); + assert!( + classified + .iter() + .filter(|(token, role)| token == "local" && *role == "variable") + .count() + >= 2 + ); + } + + #[test] + fn reports_document_relative_utf16_offsets_after_non_ascii_text() { + let source = "class Demo { String text = \"😀\"; int count = 1; }"; + let values = syntax_highlights(source); + let count_start = source.find("count").expect("count should exist"); + let expected = source[..count_start].encode_utf16().count(); + + assert!(values.iter().any(|value| { + value.utf16_start == expected && value.utf16_length == 5 && value.role == "field" + })); + } + + /// The annotation name inside a string literal or comment is text: only a + /// declared annotation labels an entry point as a Spring Boot service. + #[test] + fn spring_boot_label_requires_a_declared_annotation() { + let embedded = concat!( + "class TemplateTest {\n", + " String annotation = \"@SpringBootApplication\";\n", + " // @SpringBootApplication\n", + "}\n" + ); + assert!(!declares_spring_boot_application(embedded)); + for declared in [ + "@SpringBootApplication\npublic class App {}\n", + "@org.springframework.boot.autoconfigure.SpringBootApplication(scanBasePackages = \"x\")\nclass App {}\n", + ] { + assert!(declares_spring_boot_application(declared), "{declared}"); + } + } +} diff --git a/crates/java-domain/src/languages/mod.rs b/crates/java-domain/src/languages/mod.rs new file mode 100644 index 0000000..06b28ab --- /dev/null +++ b/crates/java-domain/src/languages/mod.rs @@ -0,0 +1,16 @@ +//! Source-level Java, Spring and MyBatis analysis that does not need a +//! language server: structure, code vision, class names, Spring indexes and +//! MyBatis mapper pairing. + +mod code_vision_hints; +mod java; +mod java_syntax; +mod mybatis; +mod spring; +mod text; + +pub use code_vision_hints::*; +pub use java::*; +pub use mybatis::*; +pub use spring::*; +pub use text::*; diff --git a/crates/java-domain/src/languages/mybatis.rs b/crates/java-domain/src/languages/mybatis.rs new file mode 100644 index 0000000..4cd2e50 --- /dev/null +++ b/crates/java-domain/src/languages/mybatis.rs @@ -0,0 +1,492 @@ +//! Deterministic MyBatis mapper-interface and XML statement indexing. + +use crate::protocol::{CoreError, ErrorCode, MybatisIndexResponse, MybatisStatementResponse}; +use regex::Regex; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::sync::LazyLock; +use tree_sitter::{Node, Parser}; + +static XML_COMMENT: LazyLock = + LazyLock::new(|| Regex::new(r"(?s)").expect("literal pattern is valid")); +static MAPPER_TAG: LazyLock = + LazyLock::new(|| Regex::new(r#"(?is)]*)>"#).expect("literal pattern is valid")); +static STATEMENT_TAG: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?is)<(select|insert|update|delete)\b([^>]*)>"#) + .expect("literal pattern is valid") +}); +static ATTRIBUTE_NAMESPACE: LazyLock = LazyLock::new(|| { + Regex::new(r#"namespace\s*=\s*["']([^"']+)["']"#).expect("literal pattern is valid") +}); +static ATTRIBUTE_ID: LazyLock = LazyLock::new(|| { + Regex::new(r#"\bid\s*=\s*["']([^"']+)["']"#).expect("literal pattern is valid") +}); + +/// Regular Java and mapper XML files stay well below this; larger dumps are skipped. +const MAX_SOURCE_BYTES: u64 = 2 * 1024 * 1024; + +#[cfg(test)] +thread_local! { + static DISK_READS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +#[cfg(test)] +pub fn take_mybatis_disk_reads() -> Vec { + DISK_READS.with(|paths| paths.take()) +} + +#[cfg(test)] +fn record_disk_read(path: &str) { + DISK_READS.with(|paths| paths.borrow_mut().push(path.to_string())); +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Workspace paths used to pair mapper interfaces with XML statements. +pub struct MybatisIndexRequest { + /// Absolute workspace root. Relative paths are rejected. + pub root: String, + /// Workspace-relative Java and XML files to read. + #[serde(default)] + pub paths: Vec, + /// Unsaved buffer text keyed by workspace-relative path. + #[serde(default)] + pub text_overrides: HashMap, +} + +#[derive(Clone)] +struct JavaMethod { + name: String, + line: usize, + column: usize, + end_column: usize, + end_line: usize, +} + +#[derive(Clone)] +struct JavaType { + qualified_name: String, + path: String, + methods: Vec, +} + +struct XmlStatement { + statement_id: String, + kind: String, + line: usize, + column: usize, + end_column: usize, +} + +struct XmlMapper { + namespace: String, + path: String, + statements: Vec, +} + +/// Builds one cross-file MyBatis mapper index without starting a language server. +pub fn mybatis_index(request: MybatisIndexRequest) -> Result { + let root = existing_directory(&request.root)?; + let paths = request + .paths + .into_iter() + .filter_map(|path| normalize_relative(&path)) + .collect::>(); + + let mut parser = Parser::new(); + let java_parser = parser + .set_language(&tree_sitter_java::LANGUAGE.into()) + .is_ok(); + + // XML mappers name every Java type worth parsing. A mapper interface lives + // in a file named after its top-level type, which is one segment of some + // namespace (`a.b.Outer.Inner` names `Outer.java`). Parsing every Java + // source instead dominated the index on large projects, and hosts rebuild + // the index whenever a Java buffer changes. + let mut xml_mappers = Vec::new(); + let mut java_paths = Vec::new(); + for path in &paths { + if !is_mybatis_source_path(path) { + continue; + } + if is_java_path(path) { + java_paths.push(path); + continue; + } + let Some(content) = source_content(&root, path, &request.text_overrides) else { + continue; + }; + if let Some(mapper) = xml_mapper(&slash_path(path), &content) { + xml_mappers.push(mapper); + } + } + let mapper_type_names = xml_mappers + .iter() + .flat_map(|mapper| mapper.namespace.split('.')) + .map(str::to_owned) + .collect::>(); + + let mut java_types = Vec::new(); + if java_parser { + for path in java_paths { + let names_mapper_type = path + .file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| mapper_type_names.contains(stem)); + if !names_mapper_type { + continue; + } + let Some(content) = source_content(&root, path, &request.text_overrides) else { + continue; + }; + java_types.extend(java_mapper_types(&mut parser, &slash_path(path), &content)); + } + } + + java_types.sort_by(|left, right| { + left.qualified_name + .cmp(&right.qualified_name) + .then_with(|| left.path.cmp(&right.path)) + }); + xml_mappers.sort_by(|left, right| { + left.namespace + .cmp(&right.namespace) + .then_with(|| left.path.cmp(&right.path)) + }); + + let mut statements = Vec::new(); + for mapper in &xml_mappers { + let Some(java_type) = java_types + .iter() + .find(|candidate| candidate.qualified_name == mapper.namespace) + else { + continue; + }; + for xml_statement in &mapper.statements { + let Some(method) = java_type + .methods + .iter() + .find(|candidate| candidate.name == xml_statement.statement_id) + else { + continue; + }; + statements.push(MybatisStatementResponse { + id: format!( + "{}#{}:{}:{}", + mapper.namespace, xml_statement.statement_id, mapper.path, xml_statement.line + ), + namespace: mapper.namespace.clone(), + statement_id: xml_statement.statement_id.clone(), + kind: xml_statement.kind.clone(), + java_path: java_type.path.clone(), + java_line: method.line, + java_column: method.column, + java_end_line: method.end_line, + java_end_column: method.end_column, + xml_path: mapper.path.clone(), + xml_line: xml_statement.line, + xml_column: xml_statement.column, + xml_end_column: xml_statement.end_column, + }); + } + } + statements.sort_by(|left, right| { + left.namespace + .cmp(&right.namespace) + .then_with(|| left.statement_id.cmp(&right.statement_id)) + .then_with(|| left.xml_path.cmp(&right.xml_path)) + .then_with(|| left.xml_line.cmp(&right.xml_line)) + .then_with(|| left.java_path.cmp(&right.java_path)) + .then_with(|| left.java_line.cmp(&right.java_line)) + }); + Ok(MybatisIndexResponse { statements }) +} + +fn java_mapper_types(parser: &mut Parser, path: &str, source: &str) -> Vec { + let Some(tree) = parser.parse(source, None) else { + return Vec::new(); + }; + let bytes = source.as_bytes(); + let package = package_name(tree.root_node(), bytes); + let mut types = Vec::new(); + collect_types(tree.root_node(), path, &package, source, bytes, &mut types); + types +} + +fn collect_types( + root: Node<'_>, + path: &str, + package: &str, + source: &str, + bytes: &[u8], + types: &mut Vec, +) { + // Java expression trees can be thousands of nodes deep even in small files. + // Keep traversal state on the heap instead of consuming the host thread's stack. + let mut pending = vec![root]; + while let Some(node) = pending.pop() { + if matches!(node.kind(), "class_declaration" | "interface_declaration") { + if let Some(java_type) = java_type(node, path, package, source, bytes) { + types.push(java_type); + } + } + // Reverse the push order to preserve the recursive walk's source preorder. + for index in (0..node.child_count()).rev() { + if let Some(child) = node.child(index) { + pending.push(child); + } + } + } +} + +fn java_type( + node: Node<'_>, + path: &str, + package: &str, + source: &str, + bytes: &[u8], +) -> Option { + let qualified_name = qualified_type_name(node, package, bytes)?; + let mut methods = Vec::new(); + if let Some(body) = node.child_by_field_name("body") { + collect_abstract_methods(body, source, bytes, &mut methods); + } + Some(JavaType { + qualified_name, + path: path.to_string(), + methods, + }) +} + +fn collect_abstract_methods( + body: Node<'_>, + source: &str, + bytes: &[u8], + methods: &mut Vec, +) { + let mut cursor = body.walk(); + for child in body.children(&mut cursor) { + if child.kind() != "method_declaration" { + continue; + } + // Methods with a body, including default methods, are not XML-mapped. + if child.child_by_field_name("body").is_some() { + continue; + } + let Some(name_node) = child.child_by_field_name("name") else { + continue; + }; + let Ok(name) = name_node.utf8_text(bytes) else { + continue; + }; + if methods.iter().any(|existing| existing.name == name) { + continue; + } + let (line, column) = line_column(source, name_node.start_byte()); + let (_, end_column) = line_column(source, name_node.end_byte()); + let (end_line, _) = line_column(source, child.end_byte().saturating_sub(1)); + methods.push(JavaMethod { + name: name.to_string(), + line, + column, + end_column, + end_line, + }); + } +} + +fn package_name(root: Node<'_>, bytes: &[u8]) -> String { + let mut cursor = root.walk(); + for child in root.children(&mut cursor) { + if child.kind() != "package_declaration" { + continue; + } + let mut inner = child.walk(); + for part in child.children(&mut inner) { + if matches!(part.kind(), "identifier" | "scoped_identifier") { + return part.utf8_text(bytes).unwrap_or_default().to_string(); + } + } + } + String::new() +} + +fn qualified_type_name(mut node: Node<'_>, package: &str, bytes: &[u8]) -> Option { + let mut parts = Vec::new(); + loop { + if matches!(node.kind(), "class_declaration" | "interface_declaration") { + let name = node.child_by_field_name("name")?.utf8_text(bytes).ok()?; + parts.push(name.to_string()); + } + match node.parent() { + Some(parent) => node = parent, + None => break, + } + } + parts.reverse(); + if parts.is_empty() { + return None; + } + let joined = parts.join("."); + Some(if package.is_empty() { + joined + } else { + format!("{package}.{joined}") + }) +} + +fn xml_mapper(path: &str, source: &str) -> Option { + let comments = XML_COMMENT + .find_iter(source) + .map(|matched| matched.range()) + .collect::>(); + let mapper = MAPPER_TAG.captures_iter(source).find(|capture| { + capture + .get(0) + .is_some_and(|matched| !in_ranges(&comments, matched.start())) + })?; + let attributes = mapper.get(1)?.as_str(); + let namespace = ATTRIBUTE_NAMESPACE + .captures(attributes) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str().trim().to_string()) + .filter(|value| !value.is_empty())?; + let mapper_end = mapper.get(0)?.end(); + let mut statements = Vec::new(); + for capture in STATEMENT_TAG.captures_iter(source) { + let Some(full) = capture.get(0) else { continue }; + if full.start() < mapper_end || in_ranges(&comments, full.start()) { + continue; + } + let kind = capture + .get(1) + .map(|value| value.as_str().to_ascii_lowercase())?; + let attributes = capture + .get(2) + .map(|value| value.as_str()) + .unwrap_or_default(); + let Some(id_capture) = ATTRIBUTE_ID.captures(attributes) else { + continue; + }; + let statement_id = id_capture.get(1)?.as_str().trim().to_string(); + if statement_id.is_empty() { + continue; + } + let id_start_in_attributes = id_capture.get(1)?.start(); + let attribute_start = capture + .get(2) + .map(|value| value.start()) + .unwrap_or(full.start()); + let id_start = attribute_start + id_start_in_attributes; + let (line, column) = line_column(source, id_start); + let end_column = column + statement_id.encode_utf16().count(); + statements.push(XmlStatement { + statement_id, + kind, + line, + column, + end_column, + }); + } + statements.sort_by(|left, right| { + left.statement_id + .cmp(&right.statement_id) + .then_with(|| left.line.cmp(&right.line)) + }); + Some(XmlMapper { + namespace, + path: path.to_string(), + statements, + }) +} + +fn is_java_path(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("java")) +} + +fn is_mybatis_source_path(path: &Path) -> bool { + match path + .extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("java") => true, + Some("xml") => !path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("pom.xml")), + _ => false, + } +} + +fn source_content(root: &Path, path: &Path, overrides: &HashMap) -> Option { + let relative = slash_path(path); + if let Some(text) = overrides.get(&relative) { + return Some(text.clone()); + } + let absolute = root.join(path); + let metadata = fs::metadata(&absolute).ok()?; + if !metadata.is_file() || metadata.len() > MAX_SOURCE_BYTES { + return None; + } + #[cfg(test)] + record_disk_read(&relative); + fs::read_to_string(absolute).ok() +} + +fn existing_directory(value: &str) -> Result { + let path = PathBuf::from(value); + if !path.is_absolute() || !path.is_dir() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "MyBatis index root must be an existing absolute directory", + )); + } + Ok(path) +} + +fn normalize_relative(value: &str) -> Option { + let path = Path::new(value); + if path.is_absolute() || value.contains('\0') { + return None; + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(value) => normalized.push(value), + Component::CurDir => {} + _ => return None, + } + } + (!normalized.as_os_str().is_empty()).then_some(normalized) +} + +fn slash_path(path: &Path) -> String { + path.components() + .filter_map(|part| match part { + Component::Normal(value) => value.to_str(), + _ => None, + }) + .collect::>() + .join("/") +} + +fn in_ranges(ranges: &[std::ops::Range], offset: usize) -> bool { + ranges.iter().any(|range| range.contains(&offset)) +} + +fn line_column(source: &str, byte_offset: usize) -> (usize, usize) { + let prefix = source.get(..byte_offset).unwrap_or(source); + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1; + let last_line_start = prefix.rfind('\n').map(|index| index + 1).unwrap_or(0); + let column = source + .get(last_line_start..byte_offset) + .map(|value| value.encode_utf16().count() + 1) + .unwrap_or(1); + (line, column) +} diff --git a/crates/java-domain/src/languages/spring.rs b/crates/java-domain/src/languages/spring.rs new file mode 100644 index 0000000..34a248f --- /dev/null +++ b/crates/java-domain/src/languages/spring.rs @@ -0,0 +1,2443 @@ +//! Deterministic Spring Boot configuration and source semantic indexing. + +use crate::protocol::cancellation; +use crate::protocol::{ + CoreError, ErrorCode, SpringBeanResponse, SpringConfigurationValueResponse, + SpringDiagnosticResponse, SpringEndpointResponse, SpringIndexResponse, SpringInjectionResponse, + SpringPropertyReferenceResponse, SpringPropertyResponse, +}; +use regex::Regex; +use serde::Deserialize; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, Condvar, LazyLock, Mutex, OnceLock}; +use std::time::Duration; +use zip::ZipArchive; + +const MAX_METADATA_ARCHIVES: usize = 20_000; + +/// A waiter rechecks cancellation at this interval while another request owns +/// the repository scan. +const REPOSITORY_WAIT_SLICE: Duration = Duration::from_millis(50); + +static REPOSITORY_METADATA_CACHE: OnceLock>>> = + OnceLock::new(); + +/// One repository's metadata cache entry. +/// +/// The first request that misses the cache owns the archive walk; concurrent +/// requests wait on `ready` instead of scanning the same JARs again. +struct RepositoryMetadataSlot { + state: Mutex, + ready: Condvar, +} + +impl RepositoryMetadataSlot { + fn latest_scan(&self) -> u64 { + self.state + .lock() + .expect("repository metadata state should not be poisoned") + .scan() + } +} + +/// Progress of one repository scan and the snapshot it publishes. +/// +/// Every variant carries the number of the latest scan that started, counted +/// from 1. A refresh request records that number when it arrives and accepts +/// only a snapshot from a later scan: a walk that began before the refresh may +/// have listed the repository before new archives were downloaded. +enum RepositoryMetadataState { + /// A request currently owns the scan. + Scanning(u64), + /// A completed snapshot shared by every reader until a refresh claims it. + Ready { + scan: u64, + properties: Arc>, + }, + /// The owner was cancelled or interrupted, so the next reader retries. + Failed(u64), +} + +impl RepositoryMetadataState { + fn scan(&self) -> u64 { + match self { + Self::Scanning(scan) | Self::Failed(scan) | Self::Ready { scan, .. } => *scan, + } + } +} + +/// Outcome of joining a repository scan. +#[derive(Debug)] +enum RepositoryClaim { + /// A snapshot this request may reuse. + Shared(Vec), + /// This request now owns the scan with the given number. + Owned(u64), +} + +/// Publishes `Failed` if the scan owner unwinds without completing the state +/// transition, so waiters never block on a scan that can no longer finish. +struct RepositoryScanCompletion<'a> { + slot: &'a RepositoryMetadataSlot, + published: bool, +} + +impl Drop for RepositoryScanCompletion<'_> { + fn drop(&mut self) { + if self.published { + return; + } + if let Ok(mut state) = self.slot.state.lock() { + if let RepositoryMetadataState::Scanning(scan) = *state { + *state = RepositoryMetadataState::Failed(scan); + } + } + self.slot.ready.notify_all(); + } +} + +/// One Java parameter declaration: optional annotations, an optional `final`, +/// the type, and the parameter name. Record components and constructor +/// parameters share this grammar, so they must not drift into two patterns. +static JAVA_PARAMETER_DECLARATION: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?:@[A-Za-z0-9_$.]+(?:\([^)]*\))?\s+)*(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)$", + ) + .expect("literal pattern is valid") +}); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Workspace paths and an optional trusted dependency repository to index. +pub struct SpringIndexRequest { + pub root: String, + #[serde(default)] + pub paths: Vec, + #[serde(default)] + pub metadata_repository: Option, + /// Trusted dependency repositories whose Spring metadata may be indexed. + #[serde(default)] + pub metadata_repositories: Vec, + /// Forces a dependency metadata rescan. Interactive document updates keep + /// this false so they reuse the process-local repository snapshot. + #[serde(default)] + pub refresh_dependency_metadata: bool, + #[serde(default)] + pub text_overrides: HashMap, +} + +/// Builds one cross-file Spring index without starting an additional server. +pub fn spring_index(request: SpringIndexRequest) -> Result { + cancellation::check()?; + let root = existing_directory(&request.root)?; + let paths = request + .paths + .into_iter() + .filter_map(|path| normalize_relative(&path)) + .collect::>(); + let mut properties = built_in_properties(); + for path in &paths { + cancellation::check()?; + if is_metadata_path(path) { + if let Some(content) = source_content(&root, path, &request.text_overrides) { + append_metadata(&content, None, &mut properties); + } + } + } + let mut repositories = request.metadata_repositories; + if let Some(repository) = request.metadata_repository { + repositories.push(repository); + } + repositories.sort(); + repositories.dedup(); + for repository in repositories { + properties.extend(repository_metadata( + Path::new(&repository), + request.refresh_dependency_metadata, + )?); + } + + let mut java_sources = Vec::new(); + for path in &paths { + cancellation::check()?; + if path.extension().and_then(|value| value.to_str()) == Some("java") { + if let Some(source) = source_content(&root, path, &request.text_overrides) { + java_sources.push((slash_path(path), source)); + } + } + } + append_configuration_properties(&java_sources, &mut properties)?; + deduplicate_properties(&mut properties); + + let mut values = Vec::new(); + for path in &paths { + cancellation::check()?; + let relative = slash_path(path); + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if !is_application_configuration(name) { + continue; + } + let Some(content) = source_content(&root, path, &request.text_overrides) else { + continue; + }; + if name.ends_with(".properties") { + values.extend(parse_properties(&relative, &content)); + } else { + values.extend(parse_yaml(&relative, &content)); + } + } + attach_property_targets(&properties, &mut values); + mark_profile_overrides(&mut values); + let mut diagnostics = configuration_diagnostics(&properties, &values); + let property_references = property_reference_index(&java_sources)?; + let (beans, injections, injection_diagnostics) = bean_index(&java_sources)?; + diagnostics.extend(injection_diagnostics); + diagnostics.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.line.cmp(&right.line)) + .then_with(|| left.column.cmp(&right.column)) + .then_with(|| left.message.cmp(&right.message)) + }); + let endpoints = endpoint_index(&java_sources)?; + + Ok(SpringIndexResponse { + properties, + values, + property_references, + diagnostics, + beans, + injections, + endpoints, + }) +} + +fn property_reference_index( + sources: &[(String, String)], +) -> Result, CoreError> { + static ANNOTATION: LazyLock = LazyLock::new(|| { + Regex::new(r#"@Value\s*\(\s*[\"']\$\{\s*([^}:\s]+)(?::[^}]*)?\s*\}[\"']\s*\)"#) + .expect("literal pattern is valid") + }); + let mut references = Vec::new(); + for (path, source) in sources { + cancellation::check()?; + for (index, line) in source.lines().enumerate() { + for capture in ANNOTATION.captures_iter(line) { + let Some(key) = capture.get(1) else { continue }; + references.push(SpringPropertyReferenceResponse { + key: canonical_property_name(key.as_str()), + path: path.clone(), + line: index + 1, + column: key.start() + 1, + }); + } + } + } + references.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.line.cmp(&right.line)) + .then_with(|| left.column.cmp(&right.column)) + }); + Ok(references) +} + +fn source_content(root: &Path, path: &Path, overrides: &HashMap) -> Option { + overrides + .get(&slash_path(path)) + .cloned() + .or_else(|| fs::read_to_string(root.join(path)).ok()) +} + +fn existing_directory(value: &str) -> Result { + let path = PathBuf::from(value); + if !path.is_absolute() || !path.is_dir() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Spring index root must be an existing absolute directory", + )); + } + Ok(path) +} + +fn normalize_relative(value: &str) -> Option { + let path = Path::new(value); + if path.is_absolute() || value.contains('\0') { + return None; + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(value) => normalized.push(value), + Component::CurDir => {} + _ => return None, + } + } + (!normalized.as_os_str().is_empty()).then_some(normalized) +} + +fn slash_path(path: &Path) -> String { + path.components() + .filter_map(|part| match part { + Component::Normal(value) => value.to_str(), + _ => None, + }) + .collect::>() + .join("/") +} + +fn is_metadata_path(path: &Path) -> bool { + matches!( + path.file_name().and_then(|value| value.to_str()), + Some("spring-configuration-metadata.json") + | Some("additional-spring-configuration-metadata.json") + ) +} + +fn repository_metadata( + repository: &Path, + refresh: bool, +) -> Result, CoreError> { + repository_metadata_with(repository, refresh, scan_repository_metadata) +} + +/// Loads one repository snapshot while sharing a single in-progress scan. +/// +/// `scan` is injectable so the coordination behavior can be tested without a +/// large archive fixture. +fn repository_metadata_with( + repository: &Path, + refresh: bool, + scan: impl FnOnce(&Path) -> Result, CoreError>, +) -> Result, CoreError> { + cancellation::check()?; + if !repository.is_absolute() || !repository.is_dir() { + return Ok(Vec::new()); + } + let key = repository + .canonicalize() + .unwrap_or_else(|_| repository.to_path_buf()); + let (slot, owned_scan) = { + let mut cache = REPOSITORY_METADATA_CACHE + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .expect("repository metadata cache should not be poisoned"); + match cache.get(&key) { + Some(slot) => (Arc::clone(slot), None), + None => { + let slot = Arc::new(RepositoryMetadataSlot { + state: Mutex::new(RepositoryMetadataState::Scanning(1)), + ready: Condvar::new(), + }); + cache.insert(key.clone(), Arc::clone(&slot)); + (slot, Some(1)) + } + } + }; + let scan_number = match owned_scan { + Some(scan_number) => scan_number, + None => { + let arrival = slot.latest_scan(); + match claim_repository_scan(&slot, refresh, arrival)? { + RepositoryClaim::Shared(properties) => return Ok(properties), + RepositoryClaim::Owned(scan_number) => scan_number, + } + } + }; + + let mut completion = RepositoryScanCompletion { + slot: &slot, + published: false, + }; + let result = scan(&key); + { + let mut state = slot + .state + .lock() + .expect("repository metadata state should not be poisoned"); + *state = match &result { + Ok(properties) => RepositoryMetadataState::Ready { + scan: scan_number, + properties: Arc::new(properties.clone()), + }, + Err(_) => RepositoryMetadataState::Failed(scan_number), + }; + completion.published = true; + } + slot.ready.notify_all(); + result +} + +/// Waits for the current owner or claims the scan for this request. +/// +/// Returns a shared snapshot when it is ready and no refresh was requested. +/// `arrival` is the latest scan number seen when the request arrived. A +/// refresh accepts only a snapshot from a scan numbered after it, so a refresh +/// that lands mid-scan waits for that walk and then starts its own; refreshes +/// that land during the same walk still share one follow-up scan. +fn claim_repository_scan( + slot: &RepositoryMetadataSlot, + refresh: bool, + arrival: u64, +) -> Result { + let mut state = slot + .state + .lock() + .expect("repository metadata state should not be poisoned"); + loop { + match &*state { + RepositoryMetadataState::Ready { scan, properties } if !refresh || *scan > arrival => { + return Ok(RepositoryClaim::Shared((**properties).clone())); + } + RepositoryMetadataState::Ready { .. } | RepositoryMetadataState::Failed(_) => { + let scan = state.scan() + 1; + *state = RepositoryMetadataState::Scanning(scan); + return Ok(RepositoryClaim::Owned(scan)); + } + RepositoryMetadataState::Scanning(_) => { + let (next, _) = slot + .ready + .wait_timeout(state, REPOSITORY_WAIT_SLICE) + .expect("repository metadata wait should not be poisoned"); + state = next; + // A cancelled request must release its worker thread rather + // than wait for a scan whose result it will discard. + cancellation::check()?; + } + } + } +} + +fn scan_repository_metadata(repository: &Path) -> Result, CoreError> { + let mut properties = Vec::new(); + let mut pending = vec![repository.to_path_buf()]; + let mut archive_count = 0usize; + while let Some(directory) = pending.pop() { + cancellation::check()?; + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + pending.push(path); + } else if path.extension().and_then(|value| value.to_str()) == Some("jar") { + archive_count += 1; + if archive_count > MAX_METADATA_ARCHIVES { + return Ok(properties); + } + append_archive_metadata(&path, &mut properties)?; + } + } + } + deduplicate_properties(&mut properties); + Ok(properties) +} + +fn append_archive_metadata( + path: &Path, + properties: &mut Vec, +) -> Result<(), CoreError> { + cancellation::check()?; + let Ok(file) = File::open(path) else { + return Ok(()); + }; + let Ok(mut archive) = ZipArchive::new(file) else { + return Ok(()); + }; + for name in [ + "META-INF/spring-configuration-metadata.json", + "META-INF/additional-spring-configuration-metadata.json", + ] { + let Ok(mut entry) = archive.by_name(name) else { + continue; + }; + let mut content = String::new(); + if entry.read_to_string(&mut content).is_ok() { + append_metadata(&content, None, properties); + } + } + Ok(()) +} + +fn append_metadata( + content: &str, + source_path: Option<&str>, + properties: &mut Vec, +) { + let Ok(document) = serde_json::from_str::(content) else { + return; + }; + let Some(items) = document.get("properties").and_then(Value::as_array) else { + return; + }; + for item in items { + let Some(name) = item.get("name").and_then(Value::as_str) else { + continue; + }; + properties.push(SpringPropertyResponse { + name: name.to_string(), + type_name: item.get("type").and_then(Value::as_str).map(String::from), + description: item + .get("description") + .and_then(Value::as_str) + .map(String::from), + default_value: item.get("defaultValue").map(json_scalar), + source_path: source_path.map(String::from), + source_line: None, + source_column: None, + }); + } +} + +fn json_scalar(value: &Value) -> String { + value + .as_str() + .map(String::from) + .unwrap_or_else(|| value.to_string()) +} + +fn built_in_properties() -> Vec { + [ + ( + "server.port", + "java.lang.Integer", + "HTTP server port.", + "8080", + ), + ( + "spring.application.name", + "java.lang.String", + "Application name.", + "", + ), + ( + "spring.profiles.active", + "java.util.List", + "Active profiles.", + "", + ), + ( + "spring.config.activate.on-profile", + "java.lang.String", + "Profile expression for this document.", + "", + ), + ( + "spring.datasource.url", + "java.lang.String", + "JDBC URL of the database.", + "", + ), + ( + "spring.datasource.username", + "java.lang.String", + "Database login username.", + "", + ), + ( + "spring.datasource.password", + "java.lang.String", + "Database login password.", + "", + ), + ( + "spring.jpa.hibernate.ddl-auto", + "java.lang.String", + "Hibernate schema generation mode.", + "none", + ), + ( + "logging.level.root", + "java.lang.String", + "Root logger level.", + "info", + ), + ( + "management.endpoints.web.exposure.include", + "java.util.Set", + "Exposed actuator endpoints.", + "", + ), + ] + .into_iter() + .map( + |(name, type_name, description, default_value)| SpringPropertyResponse { + name: name.to_string(), + type_name: Some(type_name.to_string()), + description: Some(description.to_string()), + default_value: (!default_value.is_empty()).then(|| default_value.to_string()), + source_path: None, + source_line: None, + source_column: None, + }, + ) + .collect() +} + +fn append_configuration_properties( + sources: &[(String, String)], + properties: &mut Vec, +) -> Result<(), CoreError> { + static ANNOTATION: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(?s)@ConfigurationProperties\s*\(\s*(?:prefix\s*=\s*)?[\"']([^\"']+)[\"'][^)]*\).*?\b(?:class|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)"#, + ) + .expect("literal pattern is valid") + }); + let mut types = HashMap::new(); + for (path, source) in sources { + cancellation::check()?; + for value in parse_configuration_types(path, source) { + types.insert(value.name.clone(), value); + } + } + for (_, source) in sources { + cancellation::check()?; + for capture in ANNOTATION.captures_iter(source) { + let Some(prefix) = capture.get(1) else { + continue; + }; + let Some(type_name) = capture.get(2) else { + continue; + }; + append_configuration_type( + canonical_property_name(prefix.as_str()), + type_name.as_str(), + &types, + &mut HashSet::new(), + properties, + ); + } + } + Ok(()) +} + +#[derive(Clone)] +struct ConfigurationField { + name: String, + type_name: String, + path: String, + line: usize, + column: usize, + default_value: Option, +} + +struct ConfigurationType { + name: String, + fields: Vec, + body_depth: isize, +} + +fn parse_configuration_types(path: &str, source: &str) -> Vec { + static DECLARATION: LazyLock = LazyLock::new(|| { + Regex::new(r"\b(?:class|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)(?:\s*\(([^)]*)\))?") + .expect("literal pattern is valid") + }); + static FIELD: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?:private|protected|public)\s+(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?, ]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:=\s*([^;]+))?;", + ) + .expect("literal pattern is valid") + }); + let mut types = Vec::::new(); + let mut stack = Vec::::new(); + let mut depth = 0isize; + for (index, line) in source.lines().enumerate() { + if let Some(capture) = DECLARATION.captures(line) { + let name = capture.get(1).unwrap(); + let body_depth = depth + brace_delta(line); + let mut value = ConfigurationType { + name: name.as_str().to_string(), + fields: Vec::new(), + body_depth, + }; + if let Some(components) = capture.get(2) { + value.fields.extend(parse_record_components( + path, + index + 1, + line, + components.as_str(), + )); + } + types.push(value); + stack.push(types.len() - 1); + } else if let Some(type_index) = stack.last().copied() { + if depth == types[type_index].body_depth { + if let Some(capture) = FIELD.captures(line) { + let name = capture.get(2).unwrap(); + types[type_index].fields.push(ConfigurationField { + name: name.as_str().to_string(), + type_name: capture.get(1).unwrap().as_str().trim().to_string(), + path: path.to_string(), + line: index + 1, + column: name.start() + 1, + default_value: capture.get(3).map(|value| { + value.as_str().trim().trim_matches(['\'', '"']).to_string() + }), + }); + } + } + } + depth += brace_delta(line); + while stack + .last() + .is_some_and(|type_index| depth < types[*type_index].body_depth) + { + stack.pop(); + } + } + types +} + +fn parse_record_components( + path: &str, + line_number: usize, + line: &str, + components: &str, +) -> Vec { + split_parameters(components) + .into_iter() + .filter_map(|value| { + let capture = JAVA_PARAMETER_DECLARATION.captures(value.trim())?; + let name = capture.get(2)?; + Some(ConfigurationField { + name: name.as_str().to_string(), + type_name: capture.get(1)?.as_str().trim().to_string(), + path: path.to_string(), + line: line_number, + column: line.find(name.as_str()).unwrap_or(0) + 1, + default_value: None, + }) + }) + .collect() +} + +fn brace_delta(line: &str) -> isize { + line.chars().fold(0, |value, character| match character { + '{' => value + 1, + '}' => value - 1, + _ => value, + }) +} + +fn append_configuration_type( + prefix: String, + type_name: &str, + types: &HashMap, + visiting: &mut HashSet, + properties: &mut Vec, +) { + let type_name = simple_type(type_name); + if !visiting.insert(type_name.clone()) { + return; + } + let Some(value) = types.get(&type_name) else { + visiting.remove(&type_name); + return; + }; + for field in &value.fields { + let name = format!("{}.{}", prefix, kebab_case(&field.name)); + let nested_type = simple_type(&field.type_name); + if types.contains_key(&nested_type) { + append_configuration_type(name, &nested_type, types, visiting, properties); + } else { + properties.push(SpringPropertyResponse { + name, + type_name: Some(field.type_name.clone()), + description: Some(format!("Binds to `{}`.", field.name)), + default_value: field.default_value.clone(), + source_path: Some(field.path.clone()), + source_line: Some(field.line), + source_column: Some(field.column), + }); + } + } + visiting.remove(&type_name); +} + +fn kebab_case(value: &str) -> String { + let mut result = String::new(); + for character in value.chars() { + if character.is_uppercase() { + if !result.is_empty() { + result.push('-'); + } + result.extend(character.to_lowercase()); + } else if character == '_' { + result.push('-'); + } else { + result.push(character); + } + } + result +} + +fn canonical_property_name(value: &str) -> String { + value + .split('.') + .map(|part| kebab_case(part.trim()).to_ascii_lowercase()) + .collect::>() + .join(".") +} + +fn deduplicate_properties(properties: &mut Vec) { + properties.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.source_path.is_none().cmp(&right.source_path.is_none())) + }); + properties.dedup_by(|right, left| right.name == left.name); +} + +fn is_application_configuration(name: &str) -> bool { + name == "application.properties" + || (name.starts_with("application") + && (name.ends_with(".yml") || name.ends_with(".yaml") || name.ends_with(".properties"))) +} + +fn profile_from_path(path: &str) -> Option { + let name = Path::new(path).file_name()?.to_str()?; + let stem = name + .strip_suffix(".properties") + .or_else(|| name.strip_suffix(".yaml")) + .or_else(|| name.strip_suffix(".yml"))?; + stem.strip_prefix("application-") + .filter(|value| !value.is_empty()) + .map(String::from) +} + +fn parse_properties(path: &str, content: &str) -> Vec { + let profile = profile_from_path(path); + logical_property_lines(content) + .into_iter() + .filter_map(|(index, line)| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('!') { + return None; + } + let separator = property_separator(trimmed)?; + let key = trimmed[..separator].trim(); + if key.is_empty() { + return None; + } + Some(SpringConfigurationValueResponse { + key: canonical_property_name(&unescape_property(key)), + value: unescape_property(trimmed[separator + 1..].trim()), + path: path.to_string(), + line: index, + column: line.find(key).unwrap_or(0) + 1, + profile: profile.clone(), + overrides_base_value: false, + target_path: None, + target_line: None, + target_column: None, + }) + }) + .collect() +} + +fn logical_property_lines(content: &str) -> Vec<(usize, String)> { + let mut values = Vec::new(); + let mut pending: Option<(usize, String)> = None; + for (index, line) in content.lines().enumerate() { + let start_line = index + 1; + let mut part = line.to_string(); + let continued = part + .chars() + .rev() + .take_while(|value| *value == '\\') + .count() + % 2 + == 1; + if continued { + part.pop(); + } + if let Some((_, value)) = pending.as_mut() { + value.push_str(part.trim_start()); + } else { + pending = Some((start_line, part)); + } + if !continued { + if let Some(value) = pending.take() { + values.push(value); + } + } + } + if let Some(value) = pending { + values.push(value); + } + values +} + +fn property_separator(value: &str) -> Option { + let mut escaped = false; + for (index, character) in value.char_indices() { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + } else if character == '=' || character == ':' || character.is_whitespace() { + return Some(index); + } + } + None +} + +fn unescape_property(value: &str) -> String { + value + .replace("\\:", ":") + .replace("\\=", "=") + .replace("\\ ", " ") + .replace("\\\\", "\\") +} + +fn parse_yaml(path: &str, content: &str) -> Vec { + let file_profile = profile_from_path(path); + let lines = content.lines().collect::>(); + let mut values = Vec::new(); + let mut document_start = 0usize; + for index in 0..=lines.len() { + if index == lines.len() || lines[index].trim() == "---" { + values.extend(parse_yaml_document( + path, + &lines[document_start..index], + document_start, + file_profile.clone(), + )); + document_start = index + 1; + } + } + values +} + +fn parse_yaml_document( + path: &str, + lines: &[&str], + line_offset: usize, + file_profile: Option, +) -> Vec { + let mut stack: Vec<(usize, String)> = Vec::new(); + let mut values = Vec::new(); + for (index, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if let Some(item) = trimmed + .strip_prefix('-') + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let key = stack + .iter() + .map(|(_, part)| part.as_str()) + .collect::>() + .join("."); + if !key.is_empty() { + values.push(SpringConfigurationValueResponse { + key: canonical_property_name(&key), + value: item.trim_matches(['\'', '"']).to_string(), + path: path.to_string(), + line: line_offset + index + 1, + column: line.find('-').unwrap_or(0) + 1, + profile: None, + overrides_base_value: false, + target_path: None, + target_line: None, + target_column: None, + }); + } + continue; + } + let Some(separator) = trimmed.find(':') else { + continue; + }; + let key = trimmed[..separator].trim().trim_matches(['\'', '"']); + if key.is_empty() { + continue; + } + let indent = line + .chars() + .take_while(|value| value.is_whitespace()) + .count(); + while stack.last().is_some_and(|(level, _)| *level >= indent) { + stack.pop(); + } + let value = trimmed[separator + 1..].trim().trim_matches(['\'', '"']); + let mut parts = stack + .iter() + .map(|(_, part)| part.clone()) + .collect::>(); + parts.push(key.to_string()); + let full_key = canonical_property_name(&parts.join(".")); + if value.is_empty() { + stack.push((indent, key.to_string())); + continue; + } + values.push(SpringConfigurationValueResponse { + key: full_key, + value: value.to_string(), + path: path.to_string(), + line: line_offset + index + 1, + column: line.find(key).unwrap_or(0) + 1, + profile: None, + overrides_base_value: false, + target_path: None, + target_line: None, + target_column: None, + }); + } + let profile = file_profile.or_else(|| { + values + .iter() + .find(|value| value.key == "spring.config.activate.on-profile") + .map(|value| value.value.clone()) + }); + for value in &mut values { + value.profile = profile.clone(); + } + values +} + +fn attach_property_targets( + properties: &[SpringPropertyResponse], + values: &mut [SpringConfigurationValueResponse], +) { + let by_name = properties + .iter() + .map(|property| (canonical_property_name(&property.name), property)) + .collect::>(); + for value in values { + if let Some(property) = by_name.get(&canonical_property_name(&value.key)) { + value.target_path = property.source_path.clone(); + value.target_line = property.source_line; + value.target_column = property.source_column; + } + } +} + +fn mark_profile_overrides(values: &mut [SpringConfigurationValueResponse]) { + let base_keys = values + .iter() + .filter(|value| value.profile.is_none()) + .map(|value| value.key.clone()) + .collect::>(); + for value in values { + value.overrides_base_value = value.profile.is_some() && base_keys.contains(&value.key); + } +} + +fn configuration_diagnostics( + properties: &[SpringPropertyResponse], + values: &[SpringConfigurationValueResponse], +) -> Vec { + let known = properties + .iter() + .map(|property| canonical_property_name(&property.name)) + .collect::>(); + let types = properties + .iter() + .filter_map(|property| { + property + .type_name + .as_deref() + .map(|type_name| (canonical_property_name(&property.name), type_name)) + }) + .collect::>(); + let mut diagnostics = Vec::new(); + for value in values { + let canonical_key = canonical_property_name(&value.key); + if !known.contains(&canonical_key) { + diagnostics.push(SpringDiagnosticResponse { + path: value.path.clone(), + line: value.line, + column: value.column, + severity: "warning".to_string(), + message: format!("Unknown Spring configuration property `{}`", value.key), + }); + continue; + } + let Some(type_name) = types.get(&canonical_key) else { + continue; + }; + let valid = if type_name.contains("Boolean") || *type_name == "boolean" { + matches!(value.value.as_str(), "true" | "false") + } else if type_name.contains("Integer") || *type_name == "int" || *type_name == "long" { + value.value.parse::().is_ok() + } else { + true + }; + if !valid { + diagnostics.push(SpringDiagnosticResponse { + path: value.path.clone(), + line: value.line, + column: value.column, + severity: "error".to_string(), + message: format!("`{}` is not a valid value for `{}`", value.value, type_name), + }); + } + } + diagnostics +} + +#[derive(Clone)] +struct IndexedBean { + response: SpringBeanResponse, + names: HashSet, + assignable_types: HashSet, + primary: bool, +} + +struct RawInjection { + path: String, + line: usize, + column: usize, + type_name: String, + qualifier: Option, +} + +/// Beans, injection points and diagnostics found in one set of sources. +type BeanIndex = ( + Vec, + Vec, + Vec, +); + +fn bean_index(sources: &[(String, String)]) -> Result { + static TYPE_DECLARATION: LazyLock = LazyLock::new(|| { + Regex::new(r"\b(class|interface|record)\s+([A-Za-z_$][A-Za-z0-9_$]*)([^\{]*)") + .expect("literal pattern is valid") + }); + static METHOD: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?:public|protected|private)?\s*(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(", + ) + .expect("literal pattern is valid") + }); + static FIELD: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?:private|protected|public)\s+(?:static\s+)?(?:final\s+)?([A-Za-z0-9_$.<>?]+)\s+([A-Za-z_$][A-Za-z0-9_$]*)", + ) + .expect("literal pattern is valid") + }); + let mut supertypes = HashMap::>::new(); + for (_, source) in sources { + cancellation::check()?; + for capture in TYPE_DECLARATION.captures_iter(source) { + let Some(name) = capture.get(2) else { continue }; + let tail = capture + .get(3) + .map(|value| value.as_str()) + .unwrap_or_default(); + supertypes.insert(name.as_str().to_string(), declared_supertypes(tail)); + } + } + + let mut indexed_beans = Vec::new(); + let mut raw_injections = Vec::new(); + for (path, source) in sources { + cancellation::check()?; + let lines = source.lines().collect::>(); + let source_type = TYPE_DECLARATION + .captures(source) + .and_then(|capture| capture.get(2)) + .map(|value| value.as_str().to_string()); + // The constructor pattern depends on the declaring type, so it cannot be + // a file-independent constant, but it is identical for every line of one + // source and must not be rebuilt inside the line loop below. + let constructor_pattern = source_type.as_deref().and_then(constructor_regex); + let constructor_count = constructor_pattern + .as_ref() + .map_or(0, |pattern| pattern.captures_iter(source).count()); + for (index, line) in lines.iter().enumerate() { + let context = annotation_context(&lines, index); + if let Some(capture) = TYPE_DECLARATION.captures(line) { + if has_component_annotation(&context) { + let name = capture.get(2).unwrap(); + let default_name = lower_camel(name.as_str()); + let bean_name = component_name(&context).unwrap_or(default_name); + let mut names = HashSet::from([bean_name.clone()]); + names.extend(qualifier_names(&context)); + indexed_beans.push(IndexedBean { + response: SpringBeanResponse { + id: format!("{}:{}", path, bean_name), + name: bean_name, + type_name: name.as_str().to_string(), + path: path.clone(), + line: index + 1, + column: name.start() + 1, + kind: "component".to_string(), + }, + names, + assignable_types: assignable_types(name.as_str(), &supertypes), + primary: SpringAnnotation::Primary.is_present(&context), + }); + } + } + if SpringAnnotation::Bean.is_present(&context) { + if let Some(capture) = METHOD.captures(line) { + let type_name = simple_type(capture.get(1).unwrap().as_str()); + let declaration_name = capture.get(2).unwrap(); + let aliases = bean_names(&context); + let bean_name = aliases + .first() + .cloned() + .unwrap_or_else(|| declaration_name.as_str().to_string()); + let mut names = aliases.into_iter().collect::>(); + names.insert(bean_name.clone()); + names.extend(qualifier_names(&context)); + indexed_beans.push(IndexedBean { + response: SpringBeanResponse { + id: format!("{}:{}", path, bean_name), + name: bean_name, + type_name: type_name.clone(), + path: path.clone(), + line: index + 1, + column: declaration_name.start() + 1, + kind: "beanMethod".to_string(), + }, + names, + assignable_types: assignable_types(&type_name, &supertypes), + primary: SpringAnnotation::Primary.is_present(&context), + }); + } + } + if is_injection_context(&context) { + if let Some(capture) = FIELD.captures(line) { + let type_name = simple_type(capture.get(1).unwrap().as_str()); + let name = capture.get(2).unwrap(); + raw_injections.push(RawInjection { + path: path.clone(), + line: index + 1, + column: name.start() + 1, + type_name, + qualifier: injection_qualifier(&context), + }); + } + } + let Some(pattern) = constructor_pattern.as_ref() else { + continue; + }; + let Some(opening) = pattern.find(line).map(|value| value.end() - 1) else { + continue; + }; + if !is_injection_context(&context) && constructor_count != 1 { + continue; + } + let Some(closing) = line.rfind(')').filter(|value| *value > opening) else { + continue; + }; + raw_injections.extend(parse_constructor_injections( + path, + index + 1, + line, + &line[opening + 1..closing], + )); + } + } + + indexed_beans.sort_by(|left, right| { + left.response + .name + .cmp(&right.response.name) + .then_with(|| left.response.path.cmp(&right.response.path)) + }); + let mut diagnostics = Vec::new(); + let mut injections = raw_injections + .into_iter() + .map(|injection| { + let mut candidates = indexed_beans + .iter() + .filter(|bean| bean.assignable_types.contains(&injection.type_name)) + .collect::>(); + if let Some(qualifier) = injection.qualifier.as_deref() { + candidates.retain(|bean| bean.names.contains(qualifier)); + } else { + let primary = candidates + .iter() + .copied() + .filter(|bean| bean.primary) + .collect::>(); + if !primary.is_empty() { + candidates = primary; + } + } + if candidates.is_empty() { + diagnostics.push(SpringDiagnosticResponse { + path: injection.path.clone(), + line: injection.line, + column: injection.column, + severity: "warning".to_string(), + message: format!( + "No Spring bean satisfies injection type `{}`{}", + injection.type_name, + injection + .qualifier + .as_deref() + .map(|value| format!(" with qualifier `{value}`")) + .unwrap_or_default() + ), + }); + } else if candidates.len() > 1 { + diagnostics.push(SpringDiagnosticResponse { + path: injection.path.clone(), + line: injection.line, + column: injection.column, + severity: "warning".to_string(), + message: format!( + "Multiple Spring beans satisfy injection type `{}`", + injection.type_name + ), + }); + } + SpringInjectionResponse { + path: injection.path, + line: injection.line, + column: injection.column, + type_name: injection.type_name, + qualifier: injection.qualifier, + bean_ids: candidates + .into_iter() + .map(|bean| bean.response.id.clone()) + .collect(), + } + }) + .collect::>(); + injections.sort_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.line.cmp(&right.line)) + .then_with(|| left.column.cmp(&right.column)) + }); + let beans = indexed_beans + .into_iter() + .map(|bean| bean.response) + .collect(); + Ok((beans, injections, diagnostics)) +} + +fn annotation_context(lines: &[&str], index: usize) -> String { + let mut values = vec![lines[index].trim().to_string()]; + for previous in lines[..index].iter().rev().take(8) { + let trimmed = previous.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed.starts_with('@') + || trimmed.starts_with("value") + || trimmed.starts_with("name") + || trimmed == ")" + || trimmed == "}" + { + values.insert(0, trimmed.to_string()); + } else { + break; + } + } + values.join(" ") +} + +/// Matches `@Name` only when the name is not a prefix of a longer annotation, +/// so `@Bean` does not match `@BeanFactory`. +fn annotation_boundary_pattern(name: &str) -> Regex { + Regex::new(&format!(r"@{}(?:\s|\(|$)", regex::escape(name))) + .expect("an escaped annotation name is a valid pattern") +} + +/// Declares the Spring annotations this module recognizes exactly once, and +/// derives the type, the spelling, the full list, and the compiled pattern from +/// that one declaration. +/// +/// Detection runs several times for every line of every Java source, so each +/// pattern is compiled once for the process. `pattern` matches on `self` instead +/// of looking the annotation up in a table, which leaves the compiler to reject +/// a case that was added without a pattern. +macro_rules! spring_annotations { + ($($variant:ident => $name:literal),+ $(,)?) => { + #[derive(Clone, Copy)] + pub enum SpringAnnotation { + $($variant),+ + } + + impl SpringAnnotation { + /// Every recognized annotation, in declaration order. Production + /// code reaches a pattern through a case rather than this list. + #[cfg(test)] + pub const ALL: &'static [Self] = &[$(Self::$variant),+]; + + #[cfg(test)] + pub fn name(self) -> &'static str { + match self { + $(Self::$variant => $name),+ + } + } + + pub fn pattern(self) -> &'static Regex { + match self { + $(Self::$variant => { + static PATTERN: LazyLock = + LazyLock::new(|| annotation_boundary_pattern($name)); + &PATTERN + })+ + } + } + } + }; +} + +spring_annotations! { + Autowired => "Autowired", + Bean => "Bean", + Component => "Component", + Configuration => "Configuration", + Controller => "Controller", + Inject => "Inject", + Primary => "Primary", + Repository => "Repository", + Resource => "Resource", + RestController => "RestController", + Service => "Service", +} + +/// Declares the Spring web mapping annotations exactly once, and derives the +/// type, spelling, boundary pattern, fixed HTTP method, and test list from that +/// one declaration. `RequestMapping` carries `None` so methods come from its +/// `RequestMethod.*` arguments instead. +macro_rules! spring_mapping_annotations { + ($($variant:ident => $name:literal, $method:expr),+ $(,)?) => { + // Variants are the annotation names, which all end in `Mapping`. + #[allow(clippy::enum_variant_names)] + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum SpringMappingAnnotation { + $($variant),+ + } + + impl SpringMappingAnnotation { + /// Every recognized mapping annotation. Production selection walks + /// this list and keeps the earliest source match, not declaration + /// order by itself. + const ALL: &'static [Self] = &[$(Self::$variant),+]; + + #[cfg(test)] + fn name(self) -> &'static str { + match self { + $(Self::$variant => $name),+ + } + } + + fn pattern(self) -> &'static Regex { + match self { + $(Self::$variant => { + static PATTERN: LazyLock = + LazyLock::new(|| annotation_boundary_pattern($name)); + &PATTERN + })+ + } + } + + /// Fixed verb for method mappings; `None` for [`Self::RequestMapping`]. + fn fixed_http_method(self) -> Option<&'static str> { + match self { + $(Self::$variant => $method),+ + } + } + + #[cfg(test)] + fn is_present(self, context: &str) -> bool { + self.pattern().is_match(context) + } + } + }; +} + +spring_mapping_annotations! { + GetMapping => "GetMapping", Some("GET"), + PostMapping => "PostMapping", Some("POST"), + PutMapping => "PutMapping", Some("PUT"), + DeleteMapping => "DeleteMapping", Some("DELETE"), + PatchMapping => "PatchMapping", Some("PATCH"), + RequestMapping => "RequestMapping", None, +} + +impl SpringAnnotation { + /// Annotations that declare a Spring component on a type declaration. + /// Detection and naming share this closed set so they cannot drift; when + /// several are present, the leftmost source match wins rather than this + /// array's declaration order. + const COMPONENTS: [Self; 6] = [ + Self::Component, + Self::Service, + Self::Repository, + Self::Controller, + Self::RestController, + Self::Configuration, + ]; + + /// Annotations that mark a field or constructor parameter for injection. + const INJECTIONS: [Self; 3] = [Self::Autowired, Self::Inject, Self::Resource]; + + pub fn is_present(self, context: &str) -> bool { + self.pattern().is_match(context) + } +} + +fn has_component_annotation(context: &str) -> bool { + SpringAnnotation::COMPONENTS + .iter() + .any(|annotation| annotation.is_present(context)) +} + +/// Returns the explicit name of the earliest component annotation in source +/// order. The value is read only from that annotation's own argument list so a +/// later neighbor such as `@Component("c")` cannot pollute `@Service("s")`. +/// An empty string such as `@Component("")` is not a name; callers then use +/// the default type name. +fn component_name(context: &str) -> Option { + let start = earliest_component_annotation_start(context)?; + quoted_values(isolate_annotation_at(context, start)) + .into_iter() + .find(|value| !value.is_empty()) +} + +/// Locates every exact component annotation with the cached boundary patterns +/// and keeps the leftmost source start. Array order in [`SpringAnnotation::COMPONENTS`] +/// is not a priority. +fn earliest_component_annotation_start(context: &str) -> Option { + SpringAnnotation::COMPONENTS + .iter() + .filter_map(|annotation| { + annotation + .pattern() + .find(context) + .map(|found| found.start()) + }) + .min() +} + +fn qualifier_names(context: &str) -> Vec { + static PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r#"@Qualifier\s*\(\s*[\"']([^\"']+)[\"']\s*\)"#) + .expect("literal pattern is valid") + }); + PATTERN + .captures_iter(context) + .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) + .collect() +} + +/// Returns declared `@Bean` aliases from the exact `@Bean` annotation only. +/// A prefix decoy such as `@BeanFactory("decoy")` cannot supply the name. +fn bean_names(context: &str) -> Vec { + let Some(found) = SpringAnnotation::Bean.pattern().find(context) else { + return Vec::new(); + }; + quoted_values(isolate_annotation_at(context, found.start())) + .into_iter() + .filter(|value| !value.is_empty()) + .collect() +} + +fn quoted_values(value: &str) -> Vec { + static PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r#"[\"']([^\"']*)[\"']"#).expect("literal pattern is valid")); + PATTERN + .captures_iter(value) + .filter_map(|capture| capture.get(1).map(|item| item.as_str().to_string())) + .collect() +} + +fn declared_supertypes(tail: &str) -> Vec { + // Indexed by the fixed keyword order below, which the result order depends on. + static PATTERNS: LazyLock<[Regex; 2]> = LazyLock::new(|| { + ["extends", "implements"].map(|keyword| { + Regex::new(&format!( + r"\b{}\s+([^\{{]+?)(?:\b(?:extends|implements)\b|$)", + keyword + )) + .expect("literal keyword produces a valid pattern") + }) + }); + let mut values = Vec::new(); + for pattern in PATTERNS.iter() { + if let Some(capture) = pattern.captures(tail) { + values.extend( + capture[1] + .split(',') + .map(simple_type) + .filter(|value| !value.is_empty()), + ); + } + } + values +} + +fn assignable_types(type_name: &str, supertypes: &HashMap>) -> HashSet { + let mut values = HashSet::new(); + let mut pending = vec![simple_type(type_name)]; + while let Some(value) = pending.pop() { + if !values.insert(value.clone()) { + continue; + } + if let Some(parents) = supertypes.get(&value) { + pending.extend(parents.iter().cloned()); + } + } + values +} + +fn is_injection_context(context: &str) -> bool { + SpringAnnotation::INJECTIONS + .iter() + .any(|annotation| annotation.is_present(context)) +} + +fn injection_qualifier(context: &str) -> Option { + qualifier_names(context).into_iter().next().or_else(|| { + static RESOURCE: LazyLock = LazyLock::new(|| { + Regex::new(r#"@Resource\s*\([^\)]*name\s*=\s*[\"']([^\"']+)[\"']"#) + .expect("literal pattern is valid") + }); + RESOURCE + .captures(context) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str().to_string()) + }) +} + +fn constructor_regex(type_name: &str) -> Option { + Regex::new(&format!( + r"(?:public|protected|private)?\s*{}\s*\(", + regex::escape(type_name) + )) + .ok() +} + +fn parse_constructor_injections( + path: &str, + line_number: usize, + line: &str, + parameters: &str, +) -> Vec { + split_parameters(parameters) + .into_iter() + .filter_map(|parameter| { + let capture = JAVA_PARAMETER_DECLARATION.captures(parameter.trim())?; + let variable = capture.get(2)?; + Some(RawInjection { + path: path.to_string(), + line: line_number, + column: line.find(variable.as_str()).unwrap_or(0) + 1, + type_name: simple_type(capture.get(1)?.as_str()), + qualifier: injection_qualifier(parameter), + }) + }) + .collect() +} + +fn split_parameters(value: &str) -> Vec<&str> { + let mut values = Vec::new(); + let mut start = 0usize; + let mut depth = 0usize; + for (index, character) in value.char_indices() { + match character { + '<' | '(' | '{' | '[' => depth += 1, + '>' | ')' | '}' | ']' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + values.push(&value[start..index]); + start = index + 1; + } + _ => {} + } + } + if start < value.len() { + values.push(&value[start..]); + } + values +} + +fn endpoint_index(sources: &[(String, String)]) -> Result, CoreError> { + static CLASS: LazyLock = LazyLock::new(|| { + Regex::new(r"\bclass\s+([A-Za-z_$][A-Za-z0-9_$]*)").expect("literal pattern is valid") + }); + static METHOD: LazyLock = LazyLock::new(|| { + Regex::new(r"[A-Za-z0-9_$.<>?]+\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(") + .expect("literal pattern is valid") + }); + let mut endpoints = Vec::new(); + for (path, source) in sources { + cancellation::check()?; + if !SpringAnnotation::Controller.is_present(source) + && !SpringAnnotation::RestController.is_present(source) + { + continue; + } + let controller = CLASS + .captures(source) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str().to_string()) + .unwrap_or_else(|| "Controller".to_string()); + let lines = source.lines().collect::>(); + let mut base_routes = vec![String::new()]; + let mut index = 0usize; + while index < lines.len() { + if !lines[index].trim_start().starts_with('@') { + index += 1; + continue; + } + let (annotation, annotation_end) = annotation_block(&lines, index); + let Some((mapping_annotation, methods, routes)) = mapping(&annotation) else { + index = annotation_end + 1; + continue; + }; + let declaration_index = next_declaration_index(&lines, annotation_end + 1); + let declaration = declaration_index + .and_then(|value| lines.get(value).copied()) + .unwrap_or_default(); + // Class-level base routes come only from an exact @RequestMapping, not + // a longer custom name that merely starts with that spelling. + if mapping_annotation == SpringMappingAnnotation::RequestMapping + && CLASS.is_match(declaration) + { + base_routes = routes; + index = annotation_end + 1; + continue; + } + let method_name = METHOD + .captures(declaration) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str()) + .unwrap_or("handler"); + for base_route in &base_routes { + for route in &routes { + let joined = join_route(base_route, route); + endpoints.push(SpringEndpointResponse { + id: format!("{}:{}:{}:{}", path, index + 1, methods.join(","), joined), + http_methods: methods.clone(), + route: joined, + controller: controller.clone(), + method: method_name.to_string(), + path: path.clone(), + line: index + 1, + column: lines[index].find('@').unwrap_or(0) + 1, + }); + } + } + index = annotation_end + 1; + } + } + endpoints.sort_by(|left, right| { + left.route + .cmp(&right.route) + .then_with(|| left.http_methods.cmp(&right.http_methods)) + }); + Ok(endpoints) +} + +/// Finds the earliest exact Mapping annotation in `annotation_text` and returns +/// its HTTP methods and routes. Custom names that only share a standard prefix +/// (for example `@GetMappingCustom`) do not match. +/// +/// When several Mapping annotations appear in one context, the leftmost source +/// span wins so selection does not depend on declaration order in +/// [`SpringMappingAnnotation::ALL`]. +fn mapping(annotation_text: &str) -> Option<(SpringMappingAnnotation, Vec, Vec)> { + let (annotation, isolated) = find_mapping_annotation(annotation_text)?; + let methods = match annotation.fixed_http_method() { + Some(method) => vec![method.to_string()], + None => request_mapping_methods(isolated), + }; + Some((annotation, methods, annotation_routes(isolated))) +} + +/// Returns the earliest exact Mapping annotation and the isolated annotation +/// text used for route and `RequestMethod` parsing. +fn find_mapping_annotation(text: &str) -> Option<(SpringMappingAnnotation, &str)> { + let mut best: Option<(usize, SpringMappingAnnotation)> = None; + for &annotation in SpringMappingAnnotation::ALL { + if let Some(found) = annotation.pattern().find(text) { + let start = found.start(); + match best { + Some((best_start, _)) if start >= best_start => {} + _ => best = Some((start, annotation)), + } + } + } + let (start, annotation) = best?; + Some((annotation, isolate_annotation_at(text, start))) +} + +/// Slices one annotation starting at `start` (`@Name` followed by an optional +/// argument list) so name or route extraction cannot read string literals from +/// a neighboring decoy. +/// +/// A later annotation's `(` is not this annotation's argument list. After the +/// name, only whitespace may appear before `(`. Parentheses inside quoted +/// strings, including escaped quotes, do not change the argument-list depth. +fn isolate_annotation_at(text: &str, start: usize) -> &str { + let rest = &text[start..]; + let name_end = rest + .char_indices() + .skip(1) + .find(|(_, character)| { + !(character.is_ascii_alphanumeric() || *character == '_' || *character == '$') + }) + .map(|(index, _)| index) + .unwrap_or(rest.len()); + let after_name = rest.get(name_end..).unwrap_or(""); + let whitespace_len = after_name + .chars() + .take_while(|character| character.is_whitespace()) + .map(char::len_utf8) + .sum::(); + let after_whitespace = after_name.get(whitespace_len..).unwrap_or(""); + if !after_whitespace.starts_with('(') { + return &rest[..name_end]; + } + let open_index = name_end + whitespace_len; + let mut depth = 0isize; + let mut in_string = None; + let mut escaped = false; + for (index, character) in rest[open_index..].char_indices() { + if let Some(quote) = in_string { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if character == quote { + in_string = None; + } + continue; + } + match character { + '"' | '\'' => in_string = Some(character), + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return &rest[..open_index + index + 1]; + } + } + _ => {} + } + } + rest +} + +fn request_mapping_methods(annotation: &str) -> Vec { + static METHOD_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"RequestMethod\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|TRACE)") + .expect("literal pattern is valid") + }); + let mut methods = METHOD_PATTERN + .captures_iter(annotation) + .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) + .collect::>(); + if methods.is_empty() { + methods.push("ANY".to_string()); + } + methods.sort(); + methods.dedup(); + methods +} + +fn annotation_routes(annotation: &str) -> Vec { + static NAMED: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?:value|path)\s*=\s*(\{[^}]*\}|[\"'][^\"']*[\"'])"#) + .expect("literal pattern is valid") + }); + let expression = NAMED + .captures(annotation) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str()) + .or_else(|| { + let start = annotation.find('(')? + 1; + let end = annotation.rfind(')')?; + let value = annotation[start..end].trim(); + (value.starts_with(['\'', '"', '{'])).then_some(value) + }); + let mut routes = expression.map(quoted_values).unwrap_or_default(); + if routes.is_empty() { + routes.push(String::new()); + } + routes.sort(); + routes.dedup(); + routes +} + +fn annotation_block(lines: &[&str], start: usize) -> (String, usize) { + let mut value = String::new(); + let mut depth = 0isize; + let mut saw_parenthesis = false; + let mut end = start; + for (index, line) in lines.iter().enumerate().skip(start) { + if !value.is_empty() { + value.push(' '); + } + value.push_str(line.trim()); + for character in line.chars() { + if character == '(' { + depth += 1; + saw_parenthesis = true; + } else if character == ')' { + depth -= 1; + } + } + end = index; + if !saw_parenthesis || depth <= 0 { + break; + } + } + (value, end) +} + +fn next_declaration_index(lines: &[&str], start: usize) -> Option { + lines + .iter() + .enumerate() + .skip(start) + .take(12) + .find_map(|(index, line)| { + let trimmed = line.trim(); + (!trimmed.is_empty() && !trimmed.starts_with('@')).then_some(index) + }) +} + +fn join_route(base: &str, route: &str) -> String { + let value = format!("{}/{}", base.trim_matches('/'), route.trim_matches('/')); + let trimmed = value.trim_matches('/'); + if trimmed.is_empty() { + "/".to_string() + } else { + format!("/{trimmed}") + } +} + +fn simple_type(value: &str) -> String { + value + .split('<') + .next() + .unwrap_or(value) + .rsplit('.') + .next() + .unwrap_or(value) + .trim() + .to_string() +} + +fn lower_camel(value: &str) -> String { + let mut characters = value.chars(); + characters + .next() + .map(|first| first.to_lowercase().collect::() + characters.as_str()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + /// `spring_annotations!` derives the type, the spelling, `ALL`, and the + /// compiled pattern from one declaration, so a case cannot exist without a + /// pattern. This covers the boundary each entry has to keep. + #[test] + fn every_supported_annotation_has_a_compiled_boundary_pattern() { + for annotation in SpringAnnotation::ALL.iter().copied() { + let name = annotation.name(); + assert!( + annotation.is_present(&format!("@{name} public class Demo")), + "{name} should match its own annotation" + ); + assert!( + annotation.is_present(&format!("@{name}(\"value\")")), + "{name} should match when it carries arguments" + ); + assert!( + annotation.is_present(&format!("@{name}")), + "{name} should match at the end of a context" + ); + assert!( + !annotation.is_present(&format!("@{name}Extended public class Demo")), + "{name} must not match a longer annotation sharing its prefix" + ); + } + + let names = SpringAnnotation::ALL + .iter() + .map(|annotation| annotation.name()) + .collect::>(); + assert_eq!( + names.len(), + SpringAnnotation::ALL.len(), + "every case needs a distinct annotation name" + ); + } + + /// Component detection and component naming must recognize the same + /// annotations, so both read COMPONENTS instead of repeating the list. + #[test] + fn component_detection_and_naming_recognize_the_same_annotations() { + for annotation in SpringAnnotation::COMPONENTS { + let name = annotation.name(); + assert!( + has_component_annotation(&format!("@{name}\npublic class Demo")), + "{name} should be detected as a component annotation" + ); + assert_eq!( + component_name(&format!("@{name}(\"custom\")\npublic class Demo")).as_deref(), + Some("custom"), + "{name} should expose its declared bean name" + ); + } + + assert!(!has_component_annotation("@Bean\npublic Clock clock()")); + assert_eq!(component_name("@Qualifier(\"custom\")"), None); + } + + /// `@BeanFactory` shares a prefix with `@Bean`, so a naive `@Bean` search + /// would take the decoy name. Naming must reuse the cached boundary match. + #[test] + fn bean_names_uses_the_exact_bean_annotation_not_a_prefix() { + assert_eq!( + bean_names(r#"@BeanFactory("decoy") @Bean("real")"#), + vec!["real".to_string()] + ); + assert_eq!(bean_names(r#"@BeanFactory("decoy")"#), Vec::::new()); + assert_eq!(bean_names(r#"@Bean("real")"#), vec!["real".to_string()]); + assert_eq!(bean_names("@Bean"), Vec::::new()); + assert_eq!( + bean_names(r#"@BeanFactory("decoy") @Bean"#), + Vec::::new() + ); + } + + /// A `(` inside a string is not the annotation argument list. Counting it + /// would swallow the real closer and pull a later neighbor into the aliases. + #[test] + fn isolate_annotation_at_ignores_parentheses_inside_strings() { + assert_eq!( + isolate_annotation_at(r#"@Bean("foo(") @Bean("bar")"#, 0), + r#"@Bean("foo(")"# + ); + assert_eq!( + isolate_annotation_at(r#"@Bean('foo(') @Service("s")"#, 0), + r#"@Bean('foo(')"# + ); + assert_eq!( + isolate_annotation_at(r#"@Bean("foo\")") @Bean("bar")"#, 0), + r#"@Bean("foo\")")"# + ); + assert_eq!( + bean_names(r#"@Bean("foo(") @Bean("bar")"#), + vec!["foo(".to_string()] + ); + assert_eq!( + component_name(r#"@Service("s(") @Component("c")"#).as_deref(), + Some("s(") + ); + let mapping_with_paren = mapping(r#"@GetMapping("/foo(") @PostMapping("/bar")"#) + .expect("GetMapping with a parenthesis in its route should still isolate"); + assert_eq!(mapping_with_paren.0, SpringMappingAnnotation::GetMapping); + assert_eq!(mapping_with_paren.2, vec!["/foo(".to_string()]); + } + + /// A wide capture can start at the first closing quote and swallow + /// `) @Component(`. Isolation plus source-position selection must keep + /// the first annotation's own value, independent of COMPONENTS order. + #[test] + fn component_name_selects_the_earliest_annotation_and_isolates_its_value() { + assert_eq!( + component_name(r#"@Service("s") @Component("c")"#).as_deref(), + Some("s") + ); + assert_eq!( + component_name(r#"@Component("c") @Service("s")"#).as_deref(), + Some("c") + ); + assert_ne!( + component_name(r#"@Service("s") @Component("c")"#).as_deref(), + Some(") @Component(") + ); + assert_eq!(component_name("@Service @Component"), None); + assert_eq!(component_name(r#"@Service @Component("c")"#), None); + assert_eq!(component_name("@Service"), None); + assert_eq!(component_name("@ServiceLocator(\"x\")"), None); + } + + /// `quoted_values` accepts empty captures. An empty annotation value is not + /// an explicit bean name, so naming must return None and let bean_index use + /// the default type or method name. + #[test] + fn empty_annotation_values_are_not_explicit_bean_names() { + assert_eq!(component_name(r#"@Component("")"#), None); + assert_eq!(component_name(r#"@Service('')"#), None); + assert_eq!( + component_name(r#"@Component("") @Service("s")"#), + None, + "an empty leftmost name must not take a later neighbor" + ); + assert_eq!(bean_names(r#"@Bean("")"#), Vec::::new()); + } + + /// Record components and constructor parameters share one pattern, so a + /// declaration parsed by one path must be parsed identically by the other. + #[test] + fn record_components_and_constructor_parameters_share_one_declaration_pattern() { + let parameters = "@Qualifier(\"stripe\") final PaymentService payments"; + let fields = parse_record_components("Demo.java", 1, parameters, parameters); + let injections = parse_constructor_injections("Demo.java", 1, parameters, parameters); + + assert_eq!(fields.len(), 1); + assert_eq!(injections.len(), 1); + assert_eq!(fields[0].name, "payments"); + assert_eq!(fields[0].type_name, "PaymentService"); + assert_eq!(injections[0].type_name, "PaymentService"); + } + + /// Mapping detection must use the same closed set for spelling, boundary + /// matching, and HTTP methods so a prefix decoy cannot become an endpoint. + #[test] + fn every_mapping_annotation_has_a_compiled_boundary_pattern() { + for annotation in SpringMappingAnnotation::ALL.iter().copied() { + let name = annotation.name(); + assert!( + annotation.is_present(&format!("@{name}(\"/x\")")), + "{name} should match its own annotation" + ); + assert!( + annotation.is_present(&format!("@{name}")), + "{name} should match at the end of a context" + ); + assert!( + !annotation.is_present(&format!("@{name}Custom(\"/x\")")), + "{name} must not match a longer annotation sharing its prefix" + ); + } + + let names = SpringMappingAnnotation::ALL + .iter() + .map(|annotation| annotation.name()) + .collect::>(); + assert_eq!( + names.len(), + SpringMappingAnnotation::ALL.len(), + "every mapping case needs a distinct annotation name" + ); + } + + #[test] + fn mapping_rejects_custom_annotations_that_only_share_a_standard_prefix() { + assert!(mapping("@GetMappingCustom(\"/x\")").is_none()); + assert!(mapping("@PostMappingCustom(\"/x\")").is_none()); + assert!(mapping("@PutMappingCustom(\"/x\")").is_none()); + assert!(mapping("@DeleteMappingCustom(\"/x\")").is_none()); + assert!(mapping("@PatchMappingCustom(\"/x\")").is_none()); + assert!(mapping("@RequestMappingFoo(\"/base\")").is_none()); + } + + #[test] + fn mapping_keeps_standard_annotations_and_request_method_arrays() { + let get = mapping("@GetMapping(\"/x\")").expect("GetMapping should match"); + assert_eq!(get.0, SpringMappingAnnotation::GetMapping); + assert_eq!(get.1, vec!["GET".to_string()]); + assert_eq!(get.2, vec!["/x".to_string()]); + + let post = mapping("@PostMapping(path = \"/y\")").expect("PostMapping should match"); + assert_eq!(post.1, vec!["POST".to_string()]); + assert_eq!(post.2, vec!["/y".to_string()]); + + let put = mapping("@PutMapping(\"/z\")").expect("PutMapping should match"); + assert_eq!(put.1, vec!["PUT".to_string()]); + + let delete = mapping("@DeleteMapping(\"/d\")").expect("DeleteMapping should match"); + assert_eq!(delete.1, vec!["DELETE".to_string()]); + + let patch = mapping("@PatchMapping(\"/p\")").expect("PatchMapping should match"); + assert_eq!(patch.1, vec!["PATCH".to_string()]); + + let any = mapping("@RequestMapping(\"/base\")").expect("RequestMapping should match"); + assert_eq!(any.0, SpringMappingAnnotation::RequestMapping); + assert_eq!(any.1, vec!["ANY".to_string()]); + assert_eq!(any.2, vec!["/base".to_string()]); + + let multi = mapping( + "@RequestMapping(path = \"/multi\", method = { RequestMethod.POST, RequestMethod.GET, RequestMethod.GET })", + ) + .expect("RequestMapping with methods should match"); + assert_eq!(multi.1, vec!["GET".to_string(), "POST".to_string()]); + assert_eq!(multi.2, vec!["/multi".to_string()]); + } + + #[test] + fn mapping_selects_the_earliest_annotation_and_isolates_its_routes() { + let selected = mapping("@GetMappingCustom(\"/decoy\") @PostMapping(\"/real\")") + .expect("exact PostMapping should still match"); + assert_eq!(selected.0, SpringMappingAnnotation::PostMapping); + assert_eq!(selected.1, vec!["POST".to_string()]); + assert_eq!(selected.2, vec!["/real".to_string()]); + + let leftmost = mapping("@PutMapping(\"/first\") @GetMapping(\"/second\")") + .expect("leftmost Mapping should win"); + assert_eq!(leftmost.0, SpringMappingAnnotation::PutMapping); + assert_eq!(leftmost.2, vec!["/first".to_string()]); + } + + #[test] + fn mapping_does_not_take_routes_from_a_following_unrelated_annotation() { + let bare = + mapping("@GetMapping @Custom(\"/decoy\")").expect("bare GetMapping should still match"); + assert_eq!(bare.0, SpringMappingAnnotation::GetMapping); + assert_eq!(bare.1, vec!["GET".to_string()]); + assert_eq!(bare.2, vec![String::new()]); + + let spaced = mapping("@GetMapping (\"/x\") @Custom(\"/decoy\")") + .expect("GetMapping with its own argument list should keep that route"); + assert_eq!(spaced.2, vec!["/x".to_string()]); + } + + fn temporary_repository_root(label: &str) -> PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-core-spring-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + root + } + + /// A large Maven workspace can have several index requests in flight. Only + /// one may walk the dependency repository; the others must observe its + /// snapshot instead of reopening every archive. + #[test] + fn concurrent_repository_readers_share_one_scan() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{mpsc, Arc}; + use std::thread; + + let root = temporary_repository_root("single-flight"); + let scans = Arc::new(AtomicUsize::new(0)); + let (scan_started, started) = mpsc::channel(); + let (release, released) = mpsc::channel::<()>(); + + let owner_root = root.clone(); + let owner_scans = Arc::clone(&scans); + let owner = thread::spawn(move || { + let _scope = crate::protocol::cancellation::Scope::begin(None, Some(5_000)); + repository_metadata_with(&owner_root, false, |_| { + owner_scans.fetch_add(1, Ordering::SeqCst); + scan_started.send(()).expect("scan start should notify"); + released + .recv_timeout(Duration::from_secs(5)) + .expect("scan release should arrive"); + Ok(vec![]) + }) + }); + started + .recv_timeout(Duration::from_secs(5)) + .expect("owner scan should start"); + + let waiter_root = root.clone(); + let waiter_scans = Arc::clone(&scans); + let waiter = thread::spawn(move || { + let _scope = crate::protocol::cancellation::Scope::begin(None, Some(5_000)); + repository_metadata_with(&waiter_root, false, |_| { + waiter_scans.fetch_add(1, Ordering::SeqCst); + Ok(vec![]) + }) + }); + // Release the owner only after the waiter has passed the cache lookup + // and holds the slot, so the waiter must join the running scan. + wait_for_slot_holders(&root, 4); + release.send(()).expect("owner scan should be releasable"); + + owner + .join() + .expect("owner thread should finish") + .expect("owner scan should succeed"); + waiter + .join() + .expect("waiter thread should finish") + .expect("waiter should reuse the owner snapshot"); + assert_eq!( + scans.load(Ordering::SeqCst), + 1, + "concurrent readers must not repeat the repository walk" + ); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); + } + + /// Refreshes that arrive during the same walk share one follow-up scan, but + /// a refresh never accepts a snapshot from a walk that started before it. + #[test] + fn refresh_requests_accept_only_scans_started_after_they_arrived() { + let slot = RepositoryMetadataSlot { + state: Mutex::new(RepositoryMetadataState::Ready { + scan: 2, + properties: Arc::new(Vec::new()), + }), + ready: Condvar::new(), + }; + + assert!(matches!( + claim_repository_scan(&slot, true, 1) + .expect("a refresh that arrived before scan 2 should not wait"), + RepositoryClaim::Shared(_) + )); + assert!( + matches!( + claim_repository_scan(&slot, true, 2) + .expect("a refresh that arrived after scan 2 started should claim"), + RepositoryClaim::Owned(3) + ), + "a refresh must not reuse a walk that started before it" + ); + assert!(matches!( + *slot.state.lock().unwrap(), + RepositoryMetadataState::Scanning(3) + )); + } + + /// A refresh that lands while an ordinary scan is walking the repository + /// may miss archives downloaded before the refresh. It must wait for that + /// walk and then scan again instead of reusing its snapshot. + #[test] + fn a_refresh_during_a_running_scan_rescans_after_it() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::mpsc; + use std::thread; + + let root = temporary_repository_root("refresh-mid-scan"); + let (scan_started, started) = mpsc::channel(); + let (release, released) = mpsc::channel::<()>(); + + let owner_root = root.clone(); + let owner = thread::spawn(move || { + let _scope = crate::protocol::cancellation::Scope::begin(None, Some(5_000)); + repository_metadata_with(&owner_root, false, |_| { + scan_started.send(()).expect("scan start should notify"); + released + .recv_timeout(Duration::from_secs(5)) + .expect("scan release should arrive"); + Ok(vec![]) + }) + }); + started + .recv_timeout(Duration::from_secs(5)) + .expect("owner scan should start"); + + let refresh_root = root.clone(); + let refresh_scans = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&refresh_scans); + let refresher = thread::spawn(move || { + let _scope = crate::protocol::cancellation::Scope::begin(None, Some(5_000)); + repository_metadata_with(&refresh_root, true, |_| { + counter.fetch_add(1, Ordering::SeqCst); + Ok(vec![]) + }) + }); + wait_for_slot_holders(&root, 4); + release.send(()).expect("owner scan should be releasable"); + + owner + .join() + .expect("owner thread should finish") + .expect("owner scan should succeed"); + refresher + .join() + .expect("refresh thread should finish") + .expect("refresh scan should succeed"); + assert_eq!( + refresh_scans.load(Ordering::SeqCst), + 1, + "a refresh must not reuse a walk that started before it" + ); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); + } + + /// Blocks until `holders` references to the repository slot exist: the + /// cache, the running owner, this test, and each request that joined it. + fn wait_for_slot_holders(root: &Path, holders: usize) { + let key = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + let slot = Arc::clone( + REPOSITORY_METADATA_CACHE + .get() + .expect("the owner should have created the cache") + .lock() + .unwrap() + .get(&key) + .expect("the owner should have created the slot"), + ); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while Arc::strong_count(&slot) < holders { + assert!( + std::time::Instant::now() < deadline, + "a second request should join the running scan" + ); + std::thread::yield_now(); + } + } + + /// A waiter belongs to one request. Cancelling that request must release + /// its worker instead of blocking until the owner finishes. + #[test] + fn a_cancelled_waiter_stops_waiting_for_the_repository_owner() { + let slot = RepositoryMetadataSlot { + state: Mutex::new(RepositoryMetadataState::Scanning(1)), + ready: Condvar::new(), + }; + let operation = "spring-repository-waiter-cancel"; + let _scope = crate::protocol::cancellation::Scope::begin(Some(operation.to_string()), None); + assert!(crate::protocol::cancellation::cancel(operation)); + + let error = claim_repository_scan(&slot, false, 1) + .expect_err("a cancelled waiter must stop waiting"); + assert!(matches!(error.code, ErrorCode::Cancelled)); + } + + /// Cancellation must reach the archive walk itself, not only the command + /// entry point, so a timed-out workspace scan releases its worker. + #[test] + fn a_cancelled_request_stops_before_walking_repository_archives() { + let root = temporary_repository_root("cancelled-scan"); + fs::write(root.join("dependency.jar"), b"not a zip archive") + .expect("archive fixture should be writable"); + let operation = "spring-repository-scan-cancel"; + let _scope = crate::protocol::cancellation::Scope::begin(Some(operation.to_string()), None); + assert!(crate::protocol::cancellation::cancel(operation)); + + assert!(matches!( + repository_metadata(&root, true) + .expect_err("a cancelled request must not scan") + .code, + ErrorCode::Cancelled + )); + assert!(matches!( + scan_repository_metadata(&root) + .expect_err("a cancelled scan must stop before reading archives") + .code, + ErrorCode::Cancelled + )); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); + } +} diff --git a/crates/java-domain/src/languages/text.rs b/crates/java-domain/src/languages/text.rs new file mode 100644 index 0000000..a725a3e --- /dev/null +++ b/crates/java-domain/src/languages/text.rs @@ -0,0 +1,54 @@ +//! Lossy text reading for Core's classification passes. + +use std::fs; +use std::io; +use std::path::Path; + +/// Reads a file as text, replacing bytes that are not valid UTF-8 with the +/// Unicode replacement character instead of failing. The markers Core scans +/// for (Spring Boot annotations, launch class names) are ASCII and survive +/// replacement, so a GBK-encoded source never fails a whole run-configuration +/// batch. The native file adapters remain the only byte-to-text converters for +/// documents; this lossy view never guesses a legacy charset and only backs +/// analysis, not editing. +pub fn read_text(path: &Path) -> io::Result { + fs::read(path).map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::read_text; + use std::fs; + use std::path::PathBuf; + + fn temp_dir(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("lithe-text-{name}")) + } + + #[test] + fn passes_through_valid_utf8_without_change() { + let dir = temp_dir("utf8-test"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("app.java"); + let source = "public class App {}\n"; + fs::write(&path, source).unwrap(); + assert_eq!(read_text(&path).unwrap(), source); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn replaces_invalid_utf8_bytes_instead_of_failing() { + // GBK bytes for a Chinese greeting are not valid UTF-8. The ASCII + // structure that classification depends on must survive. + let dir = temp_dir("gbk-test"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("gbk.java"); + let gbk: &[u8] = + b"public class Gbk { public static void main(String[] args) { System.out.println(\"\xC4\xE3\xBA\xC3\"); } }"; + fs::write(&path, gbk).unwrap(); + let decoded = read_text(&path).unwrap(); + assert!(decoded.contains("public class Gbk")); + assert!(decoded.contains("main(String[] args)")); + fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/java-domain/src/lib.rs b/crates/java-domain/src/lib.rs new file mode 100644 index 0000000..31db434 --- /dev/null +++ b/crates/java-domain/src/lib.rs @@ -0,0 +1,24 @@ +//! Java domain rules of the Lithe Java Support plugin. +//! +//! This crate holds the Java, JDT LS, Spring and MyBatis logic that used to +//! live in Lithe's Rust Core and macOS host. It has no dependency on Lithe: +//! the few Core types it needs are copied into [`protocol`], [`lsp`] and +//! [`debug`], and every capability that only the host can provide is a +//! `TODO(host-api: API-NN ...)` placeholder, where `API-NN` is the number in +//! the Lithe plugin API plan; the placeholders in [`host`] are the common ones. +//! +//! The plugin backend exposes the logic through [`commands::execute`], which +//! accepts the same command names and JSON payloads Rust Core accepted. + +pub mod commands; +pub mod debug; +pub mod execution; +pub mod host; +pub mod jdt_integration; +pub mod languages; +pub mod lsp; +pub mod protocol; +pub mod testing; + +#[cfg(test)] +mod tests; diff --git a/crates/java-domain/src/lsp/languages/java_entrypoints.rs b/crates/java-domain/src/lsp/languages/java_entrypoints.rs new file mode 100644 index 0000000..e33f39f --- /dev/null +++ b/crates/java-domain/src/lsp/languages/java_entrypoints.rs @@ -0,0 +1,385 @@ +//! Normalizes JDT's Java entry-point discovery into Lithe's shared contract. +//! +//! JDT LS, through the Java Debug Server's `vscode.java.resolveMainClass`, +//! decides which classes the JVM can launch. The rules change with the Java +//! language (instance and no-argument `main` in Java 25, compact source files), +//! so this module never reads Java source to add, drop, or second-guess an +//! entry. It only turns JDT's answer into workspace-relative, deterministic +//! facts and reports what it could not accept. +//! +//! Note: entry-point ownership is recorded in .agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md + +use crate::lsp::percent_decode; +use serde::Serialize; +use serde_json::{json, Value}; + +/// Java Debug Server command that lists launchable classes in a scope. +pub const JAVA_RESOLVE_MAIN_CLASS_COMMAND: &str = "vscode.java.resolveMainClass"; + +/// Version of the serialized [`JavaEntrypoints`] shape. +pub const JAVA_ENTRYPOINTS_SCHEMA_VERSION: u32 = 1; + +/// `workspace/executeCommand` params that discover entry points under one workspace. +/// +/// Passing the workspace URI scopes the search to projects inside it instead of +/// every project JDT has imported. Java Debug Server compares that path with +/// each project's canonical location textually, so `scope_uri` must name the +/// workspace with symbolic links resolved or no project matches. +pub fn java_entrypoints_command(scope_uri: &str) -> Value { + json!({ + "command": JAVA_RESOLVE_MAIN_CLASS_COMMAND, + "arguments": [scope_uri], + }) +} + +/// Launchable Java classes JDT reported for one workspace. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaEntrypoints { + pub schema_version: u32, + /// Entries ordered by `sourcePath`, then `mainClass`, without duplicates. + pub entries: Vec, + /// JDT results that could not become entries, in JDT's order. + pub diagnostics: Vec, +} + +/// One class JDT confirmed the JVM can launch. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaEntrypoint { + /// Workspace-relative path with `/` separators; the stable identity of the + /// entry, because two modules may declare the same class name. + pub source_path: String, + /// Class name as JDT reports it, including a `module/` prefix for modular + /// projects; it is passed back to JDT and the JVM unchanged. + pub main_class: String, + /// JDT project that owns the class. It only helps JDT resolve the launch + /// again and is not part of any stable identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub project_name: Option, +} + +/// A JDT result that was dropped, with the reason. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaEntrypointDiagnostic { + /// `missingMainClass`, `missingSourcePath`, or `outsideWorkspace`. + pub code: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub main_class: Option, + /// Platform-owned detail, such as the absolute path JDT returned. + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +/// Converts a `resolveMainClass` result into [`JavaEntrypoints`]. +/// +/// `canonical_root` is the workspace directory with symbolic links resolved, +/// when the platform could resolve it. JDT may report either spelling — a +/// workspace opened through `/var` on macOS comes back under `/private/var` — +/// so a file inside either one is inside the workspace. +/// +/// Returns `None` when the result is not a list, which means the server +/// answered something other than this command's contract. +pub fn normalize_java_entrypoints( + root_uri: &str, + canonical_root: Option<&str>, + result: &Value, +) -> Option { + let candidates = result.as_array()?; + let roots = workspace_root_path(root_uri) + .into_iter() + .chain(canonical_root.map(|root| root.replace('\\', "/").trim_end_matches('/').to_string())) + .collect::>(); + let mut entries = Vec::new(); + let mut diagnostics = Vec::new(); + for candidate in candidates { + let main_class = candidate + .get("mainClass") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let Some(main_class) = main_class else { + diagnostics.push(JavaEntrypointDiagnostic { + code: "missingMainClass", + main_class: None, + detail: None, + }); + continue; + }; + // A pathless entry cannot be told apart from a same-named class in + // another module, so it is reported instead of guessed at. + let Some(file_path) = candidate + .get("filePath") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + else { + diagnostics.push(JavaEntrypointDiagnostic { + code: "missingSourcePath", + main_class: Some(main_class.to_string()), + detail: None, + }); + continue; + }; + let Some(source_path) = roots + .iter() + .find_map(|root| workspace_relative_path(root, file_path)) + else { + diagnostics.push(JavaEntrypointDiagnostic { + code: "outsideWorkspace", + main_class: Some(main_class.to_string()), + detail: Some(file_path.to_string()), + }); + continue; + }; + let project_name = candidate + .get("projectName") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + entries.push(JavaEntrypoint { + source_path, + main_class: main_class.to_string(), + project_name, + }); + } + entries.sort(); + entries.dedup(); + Some(JavaEntrypoints { + schema_version: JAVA_ENTRYPOINTS_SCHEMA_VERSION, + entries, + diagnostics, + }) +} + +/// Decodes a `file:` workspace URI into a `/`-separated absolute path. +/// +/// This is done textually rather than through the host OS so that a Windows +/// URI such as `file:///c%3A/work` normalizes identically on every platform. +pub fn workspace_root_path(root_uri: &str) -> Option { + let url = url::Url::parse(root_uri).ok()?; + if url.scheme() != "file" { + return None; + } + let decoded = percent_decode(url.path()); + let path = match decoded.as_bytes() { + // `/C:/work` is how a URI carries a Windows drive path. + [b'/', drive, b':', ..] if drive.is_ascii_alphabetic() => decoded[1..].to_string(), + _ => decoded, + }; + let path = match url.host_str().filter(|host| !host.is_empty()) { + Some(host) => format!("//{host}{path}"), + None => path, + }; + Some(path.trim_end_matches('/').to_string()) +} + +/// Returns `file_path` relative to `root` with `/` separators, or `None` when +/// it is not strictly inside the workspace. +/// +/// Windows drive and UNC paths compare case-insensitively, matching the file +/// system; the returned relative path keeps JDT's casing. +pub fn workspace_relative_path(root: &str, file_path: &str) -> Option { + let file_path = file_path.replace('\\', "/"); + let case_insensitive = is_windows_path(root) || is_windows_path(&file_path); + let prefix_matches = file_path.len() > root.len() + && file_path.is_char_boundary(root.len()) + && if case_insensitive { + file_path[..root.len()].eq_ignore_ascii_case(root) + } else { + file_path[..root.len()] == *root + }; + if !prefix_matches { + return None; + } + let relative = file_path[root.len()..].strip_prefix('/')?; + let is_contained = !relative.is_empty() + && relative + .split('/') + .all(|component| !component.is_empty() && component != "." && component != ".."); + is_contained.then(|| relative.to_string()) +} + +pub fn is_windows_path(path: &str) -> bool { + path.starts_with("//") + || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(source_path: &str, main_class: &str, project_name: Option<&str>) -> JavaEntrypoint { + JavaEntrypoint { + source_path: source_path.to_string(), + main_class: main_class.to_string(), + project_name: project_name.map(str::to_string), + } + } + + #[test] + fn the_command_is_scoped_to_the_workspace() { + assert_eq!( + java_entrypoints_command("file:///work/app/"), + json!({ "command": "vscode.java.resolveMainClass", "arguments": ["file:///work/app/"] }) + ); + } + + #[test] + fn same_class_names_in_two_modules_stay_separate_entries() { + // Maven reactors often repeat `demo.App` per module; the source path, + // not the class name, keeps them apart. + let result = json!([ + { "mainClass": "demo.App", "projectName": "app-b", "filePath": "/work/app-b/src/main/java/demo/App.java" }, + { "mainClass": "demo.App", "projectName": "app-a", "filePath": "/work/app-a/src/main/java/demo/App.java" } + ]); + let normalized = + normalize_java_entrypoints("file:///work/", None, &result).expect("list result"); + assert_eq!( + normalized.entries, + vec![ + entry( + "app-a/src/main/java/demo/App.java", + "demo.App", + Some("app-a") + ), + entry( + "app-b/src/main/java/demo/App.java", + "demo.App", + Some("app-b") + ), + ] + ); + assert!(normalized.diagnostics.is_empty()); + } + + #[test] + fn windows_paths_match_the_workspace_uri_without_regard_to_case() { + let result = json!([ + { "mainClass": "demo.StaticNoArgs", "projectName": "app", "filePath": "c:\\Work\\App\\src\\main\\java\\demo\\StaticNoArgs.java" } + ]); + let normalized = normalize_java_entrypoints("file:///C%3A/work/app", None, &result) + .expect("list result"); + assert_eq!( + normalized.entries, + vec![entry( + "src/main/java/demo/StaticNoArgs.java", + "demo.StaticNoArgs", + Some("app") + )] + ); + } + + #[test] + fn unc_workspaces_keep_their_server_name() { + let result = json!([ + { "mainClass": "App", "filePath": "\\\\server\\share\\app\\App.java" } + ]); + let normalized = normalize_java_entrypoints("file://server/share/app/", None, &result) + .expect("list result"); + assert_eq!(normalized.entries, vec![entry("App.java", "App", None)]); + } + + #[test] + fn percent_encoded_workspace_uris_decode_before_matching() { + let result = json!([ + { "mainClass": "App", "filePath": "/home/dev/my project/App.java" } + ]); + let normalized = + normalize_java_entrypoints("file:///home/dev/my%20project/", None, &result) + .expect("list result"); + assert_eq!(normalized.entries, vec![entry("App.java", "App", None)]); + } + + #[test] + fn unusable_results_become_diagnostics_instead_of_guesses() { + let result = json!([ + { "projectName": "app", "filePath": "/work/app/A.java" }, + { "mainClass": " ", "filePath": "/work/app/B.java" }, + { "mainClass": "demo.Pathless", "projectName": "app" }, + { "mainClass": "demo.Elsewhere", "filePath": "/other/Elsewhere.java" }, + { "mainClass": "demo.Sibling", "filePath": "/work/app-sibling/Sibling.java" }, + { "mainClass": "demo.Escapes", "filePath": "/work/app/../secret/Escapes.java" }, + { "mainClass": "demo.Root", "filePath": "/work/app" } + ]); + let normalized = + normalize_java_entrypoints("file:///work/app/", None, &result).expect("list result"); + assert!(normalized.entries.is_empty(), "{normalized:?}"); + let codes = normalized + .diagnostics + .iter() + .map(|diagnostic| (diagnostic.code, diagnostic.main_class.as_deref())) + .collect::>(); + assert_eq!( + codes, + vec![ + ("missingMainClass", None), + ("missingMainClass", None), + ("missingSourcePath", Some("demo.Pathless")), + ("outsideWorkspace", Some("demo.Elsewhere")), + ("outsideWorkspace", Some("demo.Sibling")), + ("outsideWorkspace", Some("demo.Escapes")), + ("outsideWorkspace", Some("demo.Root")), + ] + ); + assert_eq!( + normalized.diagnostics[3].detail.as_deref(), + Some("/other/Elsewhere.java") + ); + } + + #[test] + fn repeated_results_collapse_and_blank_project_names_are_dropped() { + let result = json!([ + { "mainClass": "B", "projectName": " ", "filePath": "/work/B.java" }, + { "mainClass": "A", "projectName": "p", "filePath": "/work/A.java" }, + { "mainClass": "A", "projectName": "p", "filePath": "/work/A.java" } + ]); + let normalized = + normalize_java_entrypoints("file:///work", None, &result).expect("list result"); + assert_eq!( + normalized.entries, + vec![entry("A.java", "A", Some("p")), entry("B.java", "B", None)] + ); + } + + #[test] + fn files_under_the_resolved_workspace_path_are_inside_the_workspace() { + // macOS: a workspace opened as `/var/...` is reported by JDT under + // `/private/var/...`; both spellings name the same directory. + let result = json!([ + { "mainClass": "demo.App", "filePath": "/private/var/work/app/src/demo/App.java" } + ]); + let normalized = normalize_java_entrypoints( + "file:///var/work/app/", + Some("/private/var/work/app"), + &result, + ) + .expect("list result"); + assert_eq!( + normalized.entries, + vec![entry("src/demo/App.java", "demo.App", None)] + ); + assert!(normalized.diagnostics.is_empty()); + } + + #[test] + fn a_non_list_result_is_not_an_entry_list() { + assert!( + normalize_java_entrypoints("file:///work", None, &json!({ "mainClass": "A" })) + .is_none() + ); + assert!(normalize_java_entrypoints("file:///work", None, &Value::Null).is_none()); + } + + #[test] + fn an_empty_list_is_a_valid_answer() { + let normalized = + normalize_java_entrypoints("file:///work", None, &json!([])).expect("list result"); + assert_eq!( + serde_json::to_value(normalized).expect("serializes"), + json!({ "schemaVersion": 1, "entries": [], "diagnostics": [] }) + ); + } +} diff --git a/crates/java-domain/src/lsp/languages/java_launch.rs b/crates/java-domain/src/lsp/languages/java_launch.rs new file mode 100644 index 0000000..9f5da6d --- /dev/null +++ b/crates/java-domain/src/lsp/languages/java_launch.rs @@ -0,0 +1,283 @@ +//! Shared Java launch preparation, using JDT-owned entry points and runtime paths. +//! The session engine owns requests, build serialization, cancellation and deadlines. + +use super::java_entrypoints::{is_windows_path, workspace_relative_path, workspace_root_path}; +use crate::lsp::LspRuntimeError; +use serde_json::{json, Value}; + +const TARGET_ERROR: &str = + "The Java language service could not identify one launch target for this source file."; + +/// The next upstream request, or the completed product preparation result. +pub enum JavaLaunchAction { + Build(Value), + Classpath(Value), + Complete(Value), +} + +/// Preserves upstream failures; malformed successful replies have no upstream error. +/// The upstream error is boxed because it is much larger than the other variant. +pub enum JavaLaunchFailure { + Upstream(Box), + Invalid(&'static str), +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Phase { + Entrypoints, + Build, + Classpath, +} + +/// One launch attempt. No project model or launch facts survive its completion. +pub struct JavaLaunchPreparation { + source_path: String, + source_file: String, + configured_main_class: Option, + windows_paths: bool, + phase: Phase, + target: Value, + build_failure: Option, +} + +impl JavaLaunchPreparation { + /// Validates the selected source against the same roots as JDT entry normalization. + pub fn new( + root_uri: &str, + canonical_root: Option<&str>, + source_file: String, + configured_main_class: Option, + ) -> Result { + let root = workspace_root_path(root_uri).ok_or(TARGET_ERROR)?; + let source_path = workspace_relative_path(&root, &source_file) + .or_else(|| { + canonical_root.and_then(|root| { + workspace_relative_path( + root.replace('\\', "/").trim_end_matches('/'), + &source_file, + ) + }) + }) + .ok_or(TARGET_ERROR)?; + Ok(Self { + windows_paths: is_windows_path(&root), + source_path, + source_file, + configured_main_class: configured_main_class.filter(|value| !value.trim().is_empty()), + phase: Phase::Entrypoints, + target: Value::Null, + build_failure: None, + }) + } + + /// Advances only on a terminal upstream answer. Cancellation and unknown outcomes + /// never become a successful preparation, including after a failed build. + pub fn advance( + &mut self, + result: Option, + error: Option, + ) -> Result { + if let Some(error) = error { + if self.phase == Phase::Build + && matches!( + error.code.as_str(), + "javaBuildCompilationErrors" | "javaBuildFailed" + ) + { + self.build_failure = Some(json!({ + "code": error.code, + "message": error.message, + "report": error.java_build_report, + })); + } else { + return Err(JavaLaunchFailure::Upstream(Box::new(error))); + } + } + let result = result.unwrap_or(Value::Null); + match self.phase { + Phase::Entrypoints => { + let entries = result["entries"] + .as_array() + .ok_or(JavaLaunchFailure::Invalid(TARGET_ERROR))?; + let exact: Vec<_> = entries + .iter() + .filter(|entry| { + entry["sourcePath"].as_str().is_some_and(|path| { + if self.windows_paths { + path.eq_ignore_ascii_case(&self.source_path) + } else { + path == self.source_path + } + }) + }) + .collect(); + let selected = if exact.len() == 1 { + exact.first().copied() + } else { + let matches: Vec<_> = exact + .into_iter() + .filter(|entry| { + let Some(configured) = self.configured_main_class.as_deref() else { + return false; + }; + entry["mainClass"].as_str().is_some_and(|main| { + main == configured || main.rsplit('/').next() == Some(configured) + }) + }) + .collect(); + (matches.len() == 1).then(|| matches[0]) + } + .ok_or(JavaLaunchFailure::Invalid(TARGET_ERROR))?; + self.target = json!({"mainClass": selected["mainClass"]}); + if let Some(project) = selected.get("projectName") { + self.target["projectName"] = project.clone(); + } + let mut build = json!({ + "mainClass": self.target["mainClass"], + "filePath": self.source_file, + "isFullBuild": false, + }); + if let Some(project) = self.target.get("projectName") { + build["projectName"] = project.clone(); + } + self.phase = Phase::Build; + Ok(JavaLaunchAction::Build(json!({ + "command": "vscode.java.buildWorkspace", + "arguments": [build.to_string()], + }))) + } + Phase::Build => { + if self.build_failure.is_none() + && result["value"] != json!(1) + && result["value"] != json!("1") + { + return Err(JavaLaunchFailure::Invalid( + "The Java language service returned an unexpected project build status.", + )); + } + self.phase = Phase::Classpath; + // Omitting scope lets Java Debug Server include test/custom roots + // for the selected main; never infer paths from source folder names. + Ok(JavaLaunchAction::Classpath(json!({ + "command": "vscode.java.resolveClasspath", + "arguments": [self.target["mainClass"], self.target["projectName"].as_str().unwrap_or("")], + }))) + } + Phase::Classpath => { + let groups = result["value"] + .as_array() + .filter(|groups| groups.len() == 2) + .ok_or(JavaLaunchFailure::Invalid( + "The Java language service returned an invalid runtime classpath.", + ))?; + let paths = |group: &Value| -> Option> { + group + .as_array()? + .iter() + .map(|value| { + value + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(str::to_owned) + }) + .collect() + }; + let module_paths = paths(&groups[0]); + let class_paths = paths(&groups[1]); + let (Some(module_paths), Some(class_paths)) = (module_paths, class_paths) else { + return Err(JavaLaunchFailure::Invalid( + "The Java language service returned an invalid runtime classpath.", + )); + }; + if module_paths.is_empty() && class_paths.is_empty() { + return Err(JavaLaunchFailure::Invalid( + "The Java language service returned no runtime paths.", + )); + } + self.target["modulePaths"] = json!(module_paths); + self.target["classPaths"] = json!(class_paths); + Ok(JavaLaunchAction::Complete( + match self.build_failure.take() { + Some(failure) => { + json!({"kind": "buildFailed", "target": self.target, "failure": failure}) + } + None => json!({"kind": "ready", "target": self.target}), + }, + )) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lsp::languages::java_entrypoints::normalize_java_entrypoints; + + #[test] + fn shared_launch_fixtures_preserve_source_identity_and_upstream_paths() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../fixtures/lsp/java-launch-preparation-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let root = case["rootUri"].as_str().unwrap(); + let mut preparation = JavaLaunchPreparation::new( + root, + None, + case["sourcePath"].as_str().unwrap().into(), + Some(case["mainClass"].as_str().unwrap().into()), + ) + .unwrap(); + let entries = normalize_java_entrypoints(root, None, &case["entries"]).unwrap(); + assert!( + matches!( + preparation.advance(Some(serde_json::to_value(entries).unwrap()), None), + Ok(JavaLaunchAction::Build(_)) + ), + "{}", + case["id"] + ); + assert!(matches!( + preparation.advance(Some(json!({"value": 1})), None), + Ok(JavaLaunchAction::Classpath(_)) + )); + let Ok(JavaLaunchAction::Complete(result)) = + preparation.advance(Some(json!({"value": case["paths"]})), None) + else { + panic!("fixture did not prepare: {}", case["id"]); + }; + assert_eq!(result, json!({"kind": "ready", "target": case["target"]})); + } + } + + #[test] + fn selection_never_borrows_another_file_or_guesses_an_ambiguous_module() { + for entries in [ + json!([{ "sourcePath": "other/Main.java", "mainClass": "demo.Main" }]), + json!([ + { "sourcePath": "app/Main.java", "mainClass": "one/demo.Main" }, + { "sourcePath": "app/Main.java", "mainClass": "two/demo.Main" } + ]), + ] { + let mut preparation = JavaLaunchPreparation::new( + "file:///workspace", + None, + "/workspace/app/Main.java".into(), + Some("demo.Main".into()), + ) + .unwrap(); + assert!(matches!( + preparation.advance(Some(json!({"entries": entries})), None), + Err(JavaLaunchFailure::Invalid(TARGET_ERROR)) + )); + } + assert!(JavaLaunchPreparation::new( + "file:///workspace", + None, + "/workspace-other/Main.java".into(), + None + ) + .is_err()); + } +} diff --git a/crates/java-domain/src/lsp/languages/java_main_methods.rs b/crates/java-domain/src/lsp/languages/java_main_methods.rs new file mode 100644 index 0000000..ace907c --- /dev/null +++ b/crates/java-domain/src/lsp/languages/java_main_methods.rs @@ -0,0 +1,235 @@ +//! Normalizes JDT's per-file `main` method discovery into Lithe's shared contract. +//! +//! The Java Debug Server's `vscode.java.resolveMainMethod` reports, for one +//! source file, every `main` method the JVM can launch together with the +//! source range of its name. Editors use it to place Run markers beside the +//! declaration. As with workspace entry points, JDT alone decides which +//! methods qualify (Java 25 instance and no-argument `main`, compact source +//! files), so this module never reads Java source; it only validates JDT's +//! answer and orders it deterministically. +//! +//! Note: entry-point ownership is recorded in .agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md + +use serde::Serialize; +use serde_json::{json, Value}; + +/// Java Debug Server command that lists launchable `main` methods in one file. +pub const JAVA_RESOLVE_MAIN_METHOD_COMMAND: &str = "vscode.java.resolveMainMethod"; + +/// Version of the serialized [`JavaMainMethods`] shape. +pub const JAVA_MAIN_METHODS_SCHEMA_VERSION: u32 = 1; + +/// `workspace/executeCommand` params that discover `main` methods in one document. +pub fn java_main_methods_command(uri: &str) -> Value { + json!({ + "command": JAVA_RESOLVE_MAIN_METHOD_COMMAND, + "arguments": [uri], + }) +} + +/// Launchable `main` methods JDT reported for one source file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaMainMethods { + pub schema_version: u32, + /// Methods ordered by source position, then `mainClass`, without duplicates. + pub methods: Vec, + /// JDT results that could not become methods, in JDT's order. + pub diagnostics: Vec, +} + +/// One `main` method JDT confirmed the JVM can launch. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaMainMethod { + /// Range of the method name. Field order makes the derived ordering sort + /// by source position first. + pub range: JavaMainMethodRange, + /// Class name as JDT reports it; it matches the `mainClass` of the + /// workspace entry point generated for the same file. + pub main_class: String, + /// JDT project that owns the class; only used to resolve a launch again. + #[serde(skip_serializing_if = "Option::is_none")] + pub project_name: Option, +} + +/// Zero-based UTF-16 source range from JDT, matching `javaTestItems` ranges. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaMainMethodRange { + pub start_line: i64, + pub start_utf16_column: i64, + pub end_line: i64, + pub end_utf16_column: i64, +} + +/// A JDT result that was dropped, with the reason. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaMainMethodDiagnostic { + /// `invalidMethod`, `missingMainClass`, or `missingRange`. + pub code: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub main_class: Option, +} + +/// Converts a `resolveMainMethod` result into [`JavaMainMethods`]. +/// +/// An array is a valid answer, including an empty one. `null` — which the +/// command handler may return for a document outside any Java project — is +/// the same fact as "no launchable method here". Any other shape is a broken server +/// contract and returns `None` so callers keep their previous markers instead +/// of mistaking it for an empty file. +pub fn normalize_java_main_methods(result: &Value) -> Option { + let candidates: &[Value] = match result { + Value::Array(candidates) => candidates, + Value::Null => &[], + _ => return None, + }; + let mut methods = Vec::new(); + let mut diagnostics = Vec::new(); + for candidate in candidates { + let Some(object) = candidate.as_object() else { + diagnostics.push(JavaMainMethodDiagnostic { + code: "invalidMethod", + main_class: None, + }); + continue; + }; + let main_class = object + .get("mainClass") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let Some(main_class) = main_class else { + diagnostics.push(JavaMainMethodDiagnostic { + code: "missingMainClass", + main_class: None, + }); + continue; + }; + // Without a range the marker has no line to sit on; guessing one from + // source text would reintroduce Lithe-owned Java rules. + let Some(range) = object.get("range").and_then(normalize_range) else { + diagnostics.push(JavaMainMethodDiagnostic { + code: "missingRange", + main_class: Some(main_class.to_string()), + }); + continue; + }; + let project_name = object + .get("projectName") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + methods.push(JavaMainMethod { + range, + main_class: main_class.to_string(), + project_name, + }); + } + methods.sort(); + methods.dedup(); + Some(JavaMainMethods { + schema_version: JAVA_MAIN_METHODS_SCHEMA_VERSION, + methods, + diagnostics, + }) +} + +fn integer_value(value: Option<&Value>) -> Option { + value.and_then(Value::as_i64).filter(|value| *value >= 0) +} + +fn normalize_range(value: &Value) -> Option { + let start = value.get("start")?; + let end = value.get("end")?; + Some(JavaMainMethodRange { + start_line: integer_value(start.get("line"))?, + start_utf16_column: integer_value(start.get("character"))?, + end_line: integer_value(end.get("line"))?, + end_utf16_column: integer_value(end.get("character"))?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn range(line: i64, start: i64, end: i64) -> Value { + json!({ + "start": { "line": line, "character": start }, + "end": { "line": line, "character": end } + }) + } + + #[test] + fn command_passes_the_document_uri_to_java_debug() { + assert_eq!( + java_main_methods_command("file:///work/src/App.java"), + json!({ + "command": "vscode.java.resolveMainMethod", + "arguments": ["file:///work/src/App.java"] + }) + ); + } + + // Shape captured from JDT LS 1.61.0 with Java Debug 0.53.2 on a Java 25 + // project; the instance `main` must survive because JDT accepted it. + #[test] + fn keeps_every_method_jdt_accepted_in_source_order() { + let result = json!([ + { "range": range(9, 16, 20), "mainClass": "demo.Outer$Inner", "projectName": "app" }, + { "range": range(1, 33, 37), "mainClass": "demo.InstanceArgs", "projectName": "app" } + ]); + let normalized = normalize_java_main_methods(&result).expect("array result"); + assert!(normalized.diagnostics.is_empty()); + assert_eq!(normalized.schema_version, 1); + assert_eq!( + normalized + .methods + .iter() + .map(|method| (method.range.start_line, method.main_class.as_str())) + .collect::>(), + vec![(1, "demo.InstanceArgs"), (9, "demo.Outer$Inner")] + ); + assert_eq!(normalized.methods[0].project_name.as_deref(), Some("app")); + assert_eq!(normalized.methods[0].range.start_utf16_column, 33); + } + + #[test] + fn null_result_means_no_methods() { + let normalized = normalize_java_main_methods(&Value::Null).expect("null result"); + assert!(normalized.methods.is_empty()); + assert!(normalized.diagnostics.is_empty()); + } + + #[test] + fn malformed_entries_are_diagnosed_without_hiding_valid_peers() { + let result = json!([ + "not-a-method", + { "range": range(0, 0, 4) }, + { "mainClass": "demo.NoRange" }, + { "range": range(2, 4, 8), "mainClass": "demo.App", "projectName": " " }, + { "range": range(2, 4, 8), "mainClass": "demo.App" } + ]); + let normalized = normalize_java_main_methods(&result).expect("array result"); + assert_eq!(normalized.methods.len(), 1); + assert_eq!(normalized.methods[0].main_class, "demo.App"); + assert_eq!(normalized.methods[0].project_name, None); + assert_eq!( + normalized + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code) + .collect::>(), + vec!["invalidMethod", "missingMainClass", "missingRange"] + ); + } + + #[test] + fn other_shapes_are_not_misreported_as_an_empty_file() { + assert_eq!(normalize_java_main_methods(&json!({ "methods": [] })), None); + } +} diff --git a/crates/java-domain/src/lsp/languages/java_navigation_syntax.rs b/crates/java-domain/src/lsp/languages/java_navigation_syntax.rs new file mode 100644 index 0000000..bc0132f --- /dev/null +++ b/crates/java-domain/src/lsp/languages/java_navigation_syntax.rs @@ -0,0 +1,264 @@ +//! Java syntax candidates for semantic navigation-marker verification. + +use tree_sitter::{Node, Parser}; + +/// One declaration whose semantic parent or implementation relation must be verified by JDT LS. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct JavaNavigationCandidate { + pub line: i64, + pub utf16_column: i64, + pub direction: &'static str, + pub relation: &'static str, +} + +/// Extracts real Java method declarations without guessing across statement text. +/// +/// Syntax only selects bounded candidates. A candidate never becomes a visible +/// marker until JDT LS confirms at least one target, so incomplete or erroneous +/// source cannot create a false gutter icon by itself. +pub fn navigation_candidates(source: &str) -> Vec { + let mut parser = Parser::new(); + if parser + .set_language(&tree_sitter_java::LANGUAGE.into()) + .is_err() + { + return Vec::new(); + } + let Some(tree) = parser.parse(source, None) else { + return Vec::new(); + }; + let mut candidates = Vec::new(); + collect_candidates(tree.root_node(), source.as_bytes(), &mut candidates); + candidates.sort_by(|left, right| { + left.line + .cmp(&right.line) + .then_with(|| left.utf16_column.cmp(&right.utf16_column)) + .then_with(|| left.direction.cmp(right.direction)) + .then_with(|| left.relation.cmp(right.relation)) + }); + candidates.dedup(); + candidates +} + +fn collect_candidates( + node: Node<'_>, + source: &[u8], + candidates: &mut Vec, +) { + if node.kind() == "method_declaration" { + collect_method_candidates(node, source, candidates); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_candidates(child, source, candidates); + } +} + +fn collect_method_candidates( + method: Node<'_>, + source: &[u8], + candidates: &mut Vec, +) { + let Some(name) = method.child_by_field_name("name") else { + return; + }; + let modifiers = method + .children(&mut method.walk()) + .find(|child| child.kind() == "modifiers"); + let modifier_text = modifiers + .and_then(|value| value.utf8_text(source).ok()) + .unwrap_or_default(); + let enclosing_type = enclosing_type(method); + let relation = if enclosing_type.is_some_and(|value| value.kind() == "interface_declaration") { + "interface" + } else { + "inheritance" + }; + let point = name.start_position(); + let position = JavaNavigationCandidate { + line: i64::try_from(point.row).unwrap_or(i64::MAX), + utf16_column: utf16_column(source, point.row, point.column), + direction: "down", + relation, + }; + + if is_possible_override_target(modifier_text, enclosing_type, source) { + candidates.push(position.clone()); + } + if has_override_annotation(modifiers, source) { + candidates.push(JavaNavigationCandidate { + direction: "up", + relation: super_relation(enclosing_type, source), + ..position + }); + } +} + +fn enclosing_type(mut node: Node<'_>) -> Option> { + while let Some(parent) = node.parent() { + if matches!( + parent.kind(), + "class_declaration" + | "interface_declaration" + | "enum_declaration" + | "record_declaration" + ) { + return Some(parent); + } + node = parent; + } + None +} + +fn is_possible_override_target( + modifier_text: &str, + enclosing_type: Option>, + source: &[u8], +) -> bool { + if modifier_text + .split_whitespace() + .any(|modifier| matches!(modifier, "private" | "static" | "final")) + { + return false; + } + let Some(enclosing_type) = enclosing_type else { + return false; + }; + if enclosing_type.kind() == "interface_declaration" { + return true; + } + let type_modifiers = enclosing_type + .children(&mut enclosing_type.walk()) + .find(|child| child.kind() == "modifiers") + .and_then(|value| value.utf8_text(source).ok()) + .unwrap_or_default(); + !type_modifiers + .split_whitespace() + .any(|value| value == "final") +} + +fn has_override_annotation(modifiers: Option>, source: &[u8]) -> bool { + let Some(modifiers) = modifiers else { + return false; + }; + let mut cursor = modifiers.walk(); + let found = modifiers.children(&mut cursor).any(|child| { + matches!(child.kind(), "marker_annotation" | "annotation") + && child + .utf8_text(source) + .is_ok_and(|text| text.trim() == "@Override") + }); + found +} + +fn super_relation(enclosing_type: Option>, source: &[u8]) -> &'static str { + let Some(enclosing_type) = enclosing_type else { + return "inheritance"; + }; + if enclosing_type.kind() == "interface_declaration" { + return "interface"; + } + let header_end = enclosing_type + .child_by_field_name("body") + .map(|body| body.start_byte()) + .unwrap_or(enclosing_type.end_byte()); + let header = source + .get(enclosing_type.start_byte()..header_end) + .and_then(|value| std::str::from_utf8(value).ok()) + .unwrap_or_default(); + // JDT LS 1.38 findLinks identifies the parent method but does not report + // whether its declaring type is an interface. Prefer the interface visual + // for implementing classes; ordinary subclass overrides remain inheritance. + // A class that both extends and implements can be ambiguous until JDT LS + // exposes the declaring-type kind in this response. + if header + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .any(|word| word == "implements") + { + "interface" + } else { + "inheritance" + } +} + +fn utf16_column(source: &[u8], row: usize, byte_column: usize) -> i64 { + let line = source + .split(|value| *value == b'\n') + .nth(row) + .unwrap_or_default(); + let prefix = line.get(..byte_column).unwrap_or(line); + let units = String::from_utf8_lossy(prefix).encode_utf16().count(); + i64::try_from(units).unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn multiline_declarations_are_candidates_but_constructor_calls_are_not() { + let source = r#" +interface UploadService { + UploadResponse createDirectUploadSession( + UploadRequest request, + String objectKey + ); +} + +class Response { + Response upload() { + return new Response( + ); + } +} +"#; + + let candidates = navigation_candidates(source); + + assert!(candidates.iter().any(|candidate| { + candidate.line == 2 + && candidate.direction == "down" + && candidate.relation == "interface" + })); + assert_eq!( + candidates + .iter() + .filter(|candidate| candidate.line >= 9) + .count(), + 1 + ); + } + + #[test] + fn override_methods_get_up_and_possible_down_candidates() { + let source = r#" +class ServiceImpl implements Service { + @Override + public void run() {} +} +"#; + + let candidates = navigation_candidates(source); + + assert!(candidates + .iter() + .any(|candidate| { candidate.direction == "up" && candidate.relation == "interface" })); + assert!(candidates + .iter() + .any(|candidate| candidate.direction == "down")); + } + + #[test] + fn private_static_final_and_final_class_methods_are_not_down_candidates() { + let source = r#" +final class Closed { + void ordinary() {} + private void hidden() {} + static void utility() {} + final void fixed() {} +} +"#; + + assert!(navigation_candidates(source).is_empty()); + } +} diff --git a/crates/java-domain/src/lsp/languages/java_run_markers.rs b/crates/java-domain/src/lsp/languages/java_run_markers.rs new file mode 100644 index 0000000..0dc53b9 --- /dev/null +++ b/crates/java-domain/src/lsp/languages/java_run_markers.rs @@ -0,0 +1,647 @@ +//! Projects JDT facts and recorded test outcomes into editor Run markers. +//! +//! Both products show IDEA-style gutter markers: a Run marker beside each +//! launchable `main` method, a Run-all marker beside each test class, and a +//! Run marker beside each test method, the latter two switching to a passed or +//! failed icon once a run recorded an outcome. Which declarations qualify is +//! decided upstream by JDT (`javaMainMethods`) and the Java Test extension +//! (`javaTestItems`); outcomes come from canonical build-provider reports. This +//! module only combines those answers, so the two platforms cannot drift on +//! placement, labels, or how method outcomes roll up into a class outcome. +//! +//! Note: the design is recorded in .agents/notes/implemented/architecture/2026-09-22-editor-run-markers-and-test-outcomes.md + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Java Test extension level of a test class item. +const TEST_LEVEL_CLASS: i64 = 5; +/// Java Test extension level of a test method item. +const TEST_LEVEL_METHOD: i64 = 6; +/// Upper bound on markers returned for one file. +const MAX_MARKERS: usize = 5_000; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Upstream answers for one open Java file. +pub struct JavaRunMarkersRequest { + /// `methods` from the file's `javaMainMethods` answer. + #[serde(default)] + pub main_methods: Vec, + /// `items` from the file's `javaTestItems` answer. Platforms pass an empty + /// list when they cannot run tests for this file. + #[serde(default)] + pub test_items: Vec, + /// Canonical report cases (`suite`, `case`, `status`); entries for other + /// classes are ignored. Legacy `className`/`method` remain accepted. + #[serde(default)] + pub test_cases: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One `javaMainMethods` entry. +pub struct MainMethodInput { + pub main_class: String, + #[serde(default)] + pub project_name: Option, + pub range: RangeInput, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One `javaTestItems` node, including its children. +pub struct TestItemInput { + pub id: String, + pub label: String, + pub full_name: String, + pub test_level: i64, + #[serde(default)] + pub range: Option, + #[serde(default)] + pub children: Vec, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Zero-based UTF-16 range as returned by the upstream operations. +pub struct RangeInput { + pub start_line: i64, + /// Java Test ranges run to the end of the declaration body; Java Debug + /// main-method ranges cover only the method name. + #[serde(default)] + pub end_line: Option, +} + +impl RangeInput { + fn end_line(self) -> i64 { + self.end_line + .filter(|end| *end >= self.start_line) + .unwrap_or(self.start_line) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One canonical test case, with legacy field aliases for older consumers. +pub struct TestCaseInput { + #[serde(alias = "suite")] + pub class_name: String, + #[serde(alias = "case")] + pub method: String, + pub status: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// What a marker launches. The declaration order is the order of markers +/// sharing one line. +pub enum JavaRunMarkerKind { + /// A launchable `main` method. + Main, + /// A test class; running it runs every test the class contains. + TestClass, + /// One test method. + TestMethod, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Last recorded outcome shown by a marker. +pub enum JavaRunMarkerStatus { + /// Not run yet, or no outcome was recorded; shows the plain Run icon. + None, + /// Every recorded invocation passed. + Passed, + /// A failure or an error. + Failed, + /// Every recorded invocation was skipped. + Skipped, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// One gutter marker. +pub struct JavaRunMarker { + /// Zero-based line of the declaration name. + pub line: i64, + /// Last zero-based line of the declaration as JDT reports it: the end of + /// the body for test classes and methods, the name line for `main`. + pub end_line: i64, + pub kind: JavaRunMarkerKind, + /// Target named in menus, such as `App.main()`, `OrderTest`, or + /// `OrderTest.creates`; platforms wrap it in their localized verbs. + pub label: String, + /// `main` launch target, matching a `javaEntrypoints` entry. + #[serde(skip_serializing_if = "Option::is_none")] + pub main_class: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_name: Option, + /// Fully qualified test class for test markers, as the Java Test + /// extension names it. + #[serde(skip_serializing_if = "Option::is_none")] + pub test_class: Option, + /// Java method name for test method markers, without parameters. + #[serde(skip_serializing_if = "Option::is_none")] + pub test_method: Option, + /// Java Test extension item identity, for platforms that launch or debug + /// through the extension. + #[serde(skip_serializing_if = "Option::is_none")] + pub test_item_id: Option, + pub status: JavaRunMarkerStatus, +} + +impl JavaRunMarker { + /// Identifier the test workflow expects for this marker. + /// + /// Runs build a build-tool selector (`Class#method`). Debugging resolves + /// the Java Test item again, which matches its JDT item id but not a + /// parameterless name, because JDT names methods `Class#method()` or + /// `Class#method(Type)`. `main` markers have no test identifier. + /// + /// Ported from the Swift `JavaRunMarker.testIdentifier(forDebugging:)`. + pub fn test_identifier(&self, for_debugging: bool) -> Option { + if self.kind == JavaRunMarkerKind::Main { + return None; + } + let test_class = self.test_class.as_ref()?; + if for_debugging { + if let Some(item_id) = &self.test_item_id { + return Some(item_id.clone()); + } + } + if self.kind == JavaRunMarkerKind::TestMethod { + if let Some(method) = &self.test_method { + return Some(format!("{test_class}#{method}")); + } + } + Some(test_class.clone()) + } + + /// The marker a caret line refers to, following IntelliJ IDEA. + /// + /// The innermost test method or class whose declaration contains the line + /// wins; otherwise the `main` on that line; otherwise the file's first + /// `main`. Mirrors `frontend/editor/src/run-markers.ts` in the Lithe + /// repository and the Swift `JavaRunMarker.forLine(_:in:)`. + pub fn for_line(line: i64, markers: &[JavaRunMarker]) -> Option<&JavaRunMarker> { + let mut enclosing: Option<&JavaRunMarker> = None; + for marker in markers.iter().filter(|marker| { + marker.kind != JavaRunMarkerKind::Main && marker.line <= line && line <= marker.end_line + }) { + if let Some(current) = enclosing { + if marker.line < current.line + || (marker.line == current.line && marker.kind != JavaRunMarkerKind::TestMethod) + { + continue; + } + } + enclosing = Some(marker); + } + if enclosing.is_some() { + return enclosing; + } + let mut mains = markers + .iter() + .filter(|marker| marker.kind == JavaRunMarkerKind::Main); + let first = mains.clone().next(); + mains.find(|marker| marker.line == line).or(first) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Markers for one file, ordered by line and then [`JavaRunMarkerKind`]. +pub struct JavaRunMarkersResponse { + pub markers: Vec, +} + +/// Builds the markers for one file (`java.runMarkers`). +pub fn java_run_markers( + request: JavaRunMarkersRequest, +) -> Result { + let outcomes = recorded_outcomes(&request.test_cases); + let mut markers = Vec::new(); + for method in &request.main_methods { + let main_class = method.main_class.trim(); + if main_class.is_empty() || method.range.start_line < 0 { + continue; + } + markers.push(JavaRunMarker { + line: method.range.start_line, + end_line: method.range.end_line(), + kind: JavaRunMarkerKind::Main, + label: format!("{}.main()", simple_class_name(main_class)), + main_class: Some(main_class.to_string()), + project_name: method + .project_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string), + test_class: None, + test_method: None, + test_item_id: None, + status: JavaRunMarkerStatus::None, + }); + } + for item in &request.test_items { + collect_test_markers(item, None, &outcomes, &mut markers)?; + } + if markers.len() > MAX_MARKERS { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Too many Run markers for one file", + ) + .with_details(format!("maximumMarkers={MAX_MARKERS}"))); + } + markers.sort_by(|left, right| { + (left.line, left.kind, &left.label).cmp(&(right.line, right.kind, &right.label)) + }); + markers.dedup_by(|left, right| { + left.line == right.line && left.kind == right.kind && left.label == right.label + }); + Ok(JavaRunMarkersResponse { markers }) +} + +/// Outcomes keyed by `(class, method)`, with `$` nested-class separators +/// folded to `.` so report binary names match Java Test's source names. +fn recorded_outcomes(cases: &[TestCaseInput]) -> HashMap<(String, String), JavaRunMarkerStatus> { + let mut outcomes = HashMap::new(); + for case in cases { + let status = match case.status.as_str() { + "passed" => JavaRunMarkerStatus::Passed, + "failed" | "error" => JavaRunMarkerStatus::Failed, + "skipped" => JavaRunMarkerStatus::Skipped, + _ => continue, + }; + outcomes.insert( + ( + source_class_name(&case.class_name), + case.method.trim().to_string(), + ), + status, + ); + } + outcomes +} + +fn collect_test_markers( + item: &TestItemInput, + owning_class: Option<&str>, + outcomes: &HashMap<(String, String), JavaRunMarkerStatus>, + markers: &mut Vec, +) -> Result, CoreError> { + crate::protocol::cancellation::check()?; + match item.test_level { + TEST_LEVEL_CLASS => { + let class_name = item.full_name.trim(); + let mut statuses = Vec::new(); + for child in &item.children { + statuses.extend(collect_test_markers( + child, + Some(class_name), + outcomes, + markers, + )?); + } + if let Some(range) = item.range.filter(|range| range.start_line >= 0) { + markers.push(JavaRunMarker { + line: range.start_line, + end_line: range.end_line(), + kind: JavaRunMarkerKind::TestClass, + label: simple_class_name(class_name).to_string(), + main_class: None, + project_name: None, + test_class: Some(class_name.to_string()), + test_method: None, + test_item_id: Some(item.id.clone()), + status: class_status(&statuses), + }); + } + Ok(statuses) + } + TEST_LEVEL_METHOD => { + let Some(class_name) = owning_class.or_else(|| declaring_class(&item.full_name)) else { + return Ok(Vec::new()); + }; + let Some(method) = method_name(item) else { + return Ok(Vec::new()); + }; + let status = outcomes + .get(&(source_class_name(class_name), method.clone())) + .copied() + .unwrap_or(JavaRunMarkerStatus::None); + if let Some(range) = item.range.filter(|range| range.start_line >= 0) { + markers.push(JavaRunMarker { + line: range.start_line, + end_line: range.end_line(), + kind: JavaRunMarkerKind::TestMethod, + label: format!("{}.{}", simple_class_name(class_name), method), + main_class: None, + project_name: None, + test_class: Some(class_name.to_string()), + test_method: Some(method), + test_item_id: Some(item.id.clone()), + status, + }); + } + Ok(vec![status]) + } + // Package, project, and workspace levels only group classes. + _ => { + let mut statuses = Vec::new(); + for child in &item.children { + statuses.extend(collect_test_markers( + child, + owning_class, + outcomes, + markers, + )?); + } + Ok(statuses) + } + } +} + +/// A class shows failed when any recorded method failed and passed when at +/// least one passed and none failed; unrun methods do not hide a failure. +fn class_status(statuses: &[JavaRunMarkerStatus]) -> JavaRunMarkerStatus { + if statuses.contains(&JavaRunMarkerStatus::Failed) { + JavaRunMarkerStatus::Failed + } else if statuses.contains(&JavaRunMarkerStatus::Passed) { + JavaRunMarkerStatus::Passed + } else if statuses.contains(&JavaRunMarkerStatus::Skipped) { + JavaRunMarkerStatus::Skipped + } else { + JavaRunMarkerStatus::None + } +} + +/// Method name without its parameter list, from `Class#method(Args)` or the label. +fn method_name(item: &TestItemInput) -> Option { + let semantic = item + .full_name + .rsplit_once('#') + .map(|(_, method)| method) + .unwrap_or(&item.label); + let name = semantic.split('(').next().unwrap_or_default().trim(); + (!name.is_empty()).then(|| name.to_string()) +} + +fn declaring_class(full_name: &str) -> Option<&str> { + full_name + .split_once('#') + .map(|(class_name, _)| class_name.trim()) + .filter(|class_name| !class_name.is_empty()) +} + +fn source_class_name(class_name: &str) -> String { + class_name.trim().replace('$', ".") +} + +fn simple_class_name(class_name: &str) -> &str { + let without_module = class_name + .rsplit_once('/') + .map_or(class_name, |(_, name)| name); + without_module + .rsplit(['.', '$']) + .next() + .filter(|name| !name.is_empty()) + .unwrap_or(without_module) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn markers(request: serde_json::Value) -> Vec { + let request = serde_json::from_value(request).expect("valid request"); + let response = java_run_markers(request).expect("markers"); + serde_json::to_value(response).expect("encodes")["markers"] + .as_array() + .cloned() + .unwrap_or_default() + } + + fn range(line: i64) -> serde_json::Value { + json!({ "startLine": line, "startUtf16Column": 0, "endLine": line, "endUtf16Column": 4 }) + } + + #[test] + fn main_methods_become_run_markers_named_like_idea() { + let markers = markers(json!({ + "mainMethods": [ + { "mainClass": "demo.App", "projectName": "app", "range": range(4) }, + { "mainClass": "app/demo.Modular", "range": range(9) } + ] + })); + assert_eq!( + markers, + vec![ + json!({ "line": 4, "endLine": 4, "kind": "main", "label": "App.main()", "mainClass": "demo.App", "projectName": "app", "status": "none" }), + json!({ "line": 9, "endLine": 9, "kind": "main", "label": "Modular.main()", "mainClass": "app/demo.Modular", "status": "none" }), + ] + ); + } + + #[test] + fn test_classes_and_methods_carry_their_recorded_outcomes() { + let markers = markers(json!({ + "testItems": [{ + "id": "app@demo.OrderTest", "label": "OrderTest", "fullName": "demo.OrderTest", + "testLevel": 5, "range": range(3), + "children": [ + { "id": "m1", "label": "creates()", "fullName": "demo.OrderTest#creates()", "testLevel": 6, "range": range(5), "children": [] }, + { "id": "m2", "label": "priced(int)", "fullName": "demo.OrderTest#priced(int)", "testLevel": 6, "range": range(8), "children": [] }, + { "id": "m3", "label": "later()", "fullName": "demo.OrderTest#later()", "testLevel": 6, "range": range(11), "children": [] } + ] + }], + "testCases": [ + { "className": "demo.OrderTest", "method": "creates", "status": "passed" }, + { "className": "demo.OrderTest", "method": "priced", "status": "error" }, + { "className": "demo.OtherTest", "method": "later", "status": "passed" } + ] + })); + let summary = markers + .iter() + .map(|marker| { + ( + marker["line"].as_i64().unwrap(), + marker["kind"].as_str().unwrap().to_string(), + marker["label"].as_str().unwrap().to_string(), + marker["status"].as_str().unwrap().to_string(), + ) + }) + .collect::>(); + assert_eq!( + summary, + vec![ + (3, "testClass".into(), "OrderTest".into(), "failed".into()), + ( + 5, + "testMethod".into(), + "OrderTest.creates".into(), + "passed".into() + ), + ( + 8, + "testMethod".into(), + "OrderTest.priced".into(), + "failed".into() + ), + ( + 11, + "testMethod".into(), + "OrderTest.later".into(), + "none".into() + ), + ] + ); + assert_eq!(markers[2]["testMethod"], json!("priced")); + assert_eq!(markers[2]["testClass"], json!("demo.OrderTest")); + assert_eq!(markers[2]["testItemId"], json!("m2")); + } + + // Java Test nests inner test classes under their outer class and names + // them by binary name (`Outer$Inner`), as JDT LS 1.61.0 does for + // `@Nested`; source spelling (`Outer.Inner`) must match reports too. + #[test] + fn nested_class_outcomes_match_either_spelling() { + let markers = markers(json!({ + "testItems": [{ + "id": "outer", "label": "OrderTest", "fullName": "demo.OrderTest", "testLevel": 5, "range": range(2), + "children": [{ + "id": "inner", "label": "Refunds", "fullName": "demo.OrderTest$Refunds", "testLevel": 5, "range": range(4), + "children": [{ "id": "m", "label": "refunds()", "fullName": "demo.OrderTest$Refunds#refunds()", "testLevel": 6, "range": range(5), "children": [] }] + }] + }], + "testCases": [{ "className": "demo.OrderTest.Refunds", "method": "refunds", "status": "passed" }] + })); + assert_eq!( + markers + .iter() + .map(|marker| marker["status"].as_str().unwrap()) + .collect::>(), + vec!["passed", "passed", "passed"] + ); + assert_eq!(markers[1]["label"], json!("Refunds")); + } + + #[test] + fn items_without_a_source_range_get_no_marker() { + let markers = markers(json!({ + "testItems": [{ "id": "c", "label": "InheritedSuite", "fullName": "demo.InheritedSuite", "testLevel": 5, "children": [ + { "id": "m", "label": "inherited()", "fullName": "demo.BaseTests#inherited()", "testLevel": 6, "children": [] } + ] }] + })); + assert!(markers.is_empty()); + } + + fn marker( + line: i64, + end_line: i64, + kind: JavaRunMarkerKind, + label: &str, + test_class: Option<&str>, + ) -> JavaRunMarker { + JavaRunMarker { + line, + end_line, + kind, + label: label.into(), + main_class: (kind == JavaRunMarkerKind::Main).then(|| "demo.App".into()), + project_name: None, + test_class: test_class.map(Into::into), + test_method: None, + test_item_id: None, + status: JavaRunMarkerStatus::None, + } + } + + // Ported from macos/Tests/LitheLanguageIntelligenceModuleTests/JavaRunMarkerTests.swift. + fn caret_markers() -> Vec { + let mut creates = marker( + 7, + 7, + JavaRunMarkerKind::TestMethod, + "OrderTest.creates", + Some("demo.OrderTest"), + ); + creates.test_method = Some("creates".into()); + vec![ + marker(2, 2, JavaRunMarkerKind::Main, "App.main()", None), + marker( + 5, + 14, + JavaRunMarkerKind::TestClass, + "OrderTest", + Some("demo.OrderTest"), + ), + creates, + marker( + 10, + 13, + JavaRunMarkerKind::TestClass, + "Refunds", + Some("demo.OrderTest$Refunds"), + ), + ] + } + + #[test] + fn caret_picks_the_innermost_test_declaration() { + let markers = caret_markers(); + let label = |line| JavaRunMarker::for_line(line, &markers).map(|m| m.label.as_str()); + assert_eq!(label(7), Some("OrderTest.creates")); + assert_eq!(label(11), Some("Refunds")); + assert_eq!(label(14), Some("OrderTest")); + } + + #[test] + fn caret_outside_tests_falls_back_to_the_files_main() { + let markers = caret_markers(); + assert_eq!( + JavaRunMarker::for_line(0, &markers).map(|m| m.label.as_str()), + Some("App.main()") + ); + assert_eq!(JavaRunMarker::for_line(1, &[]), None); + } + + #[test] + fn test_methods_run_by_selector_and_debug_by_java_test_item() { + let mut method = caret_markers()[2].clone(); + method.test_item_id = Some("app@demo.OrderTest#creates()".into()); + assert_eq!( + method.test_identifier(false).as_deref(), + Some("demo.OrderTest#creates") + ); + assert_eq!( + method.test_identifier(true).as_deref(), + Some("app@demo.OrderTest#creates()") + ); + let nested = &caret_markers()[3]; + assert_eq!( + nested.test_identifier(false).as_deref(), + Some("demo.OrderTest$Refunds") + ); + assert_eq!( + nested.test_identifier(true).as_deref(), + Some("demo.OrderTest$Refunds") + ); + assert_eq!(caret_markers()[0].test_identifier(false), None); + } + + #[test] + fn marker_payload_round_trips() { + let marker: JavaRunMarker = serde_json::from_str( + r#"{"line":3,"endLine":3,"kind":"main","label":"App.main()","mainClass":"demo.App","status":"none"}"#, + ) + .unwrap(); + assert_eq!(marker.kind, JavaRunMarkerKind::Main); + assert_eq!(marker.main_class.as_deref(), Some("demo.App")); + assert_eq!(marker.test_class, None); + } +} diff --git a/crates/java-domain/src/lsp/languages/java_tests.rs b/crates/java-domain/src/lsp/languages/java_tests.rs new file mode 100644 index 0000000..0efb5f5 --- /dev/null +++ b/crates/java-domain/src/lsp/languages/java_tests.rs @@ -0,0 +1,293 @@ +//! Normalizes Java Test extension discovery into Lithe's shared contract. +//! +//! The Java Test extension and JDT decide which declarations are tests. This +//! module deliberately has no annotation or naming rules: it only validates +//! the extension result and exposes stable, typed fields to platform code. +//! +//! Note: semantic ownership is recorded in .agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md + +use serde::Serialize; +use serde_json::{json, Value}; + +/// Java Test extension command that discovers test types and methods in a file. +pub const JAVA_FIND_TEST_ITEMS_COMMAND: &str = "vscode.java.test.findTestTypesAndMethods"; + +/// Version of the serialized [`JavaTestItems`] shape. +pub const JAVA_TEST_ITEMS_SCHEMA_VERSION: u32 = 1; + +/// `workspace/executeCommand` params for semantic test discovery in one file. +pub fn java_test_items_command(uri: &str) -> Value { + json!({ + "command": JAVA_FIND_TEST_ITEMS_COMMAND, + "arguments": [uri], + }) +} + +/// Java test declarations reported for one source file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaTestItems { + pub schema_version: u32, + pub items: Vec, + /// Invalid extension nodes that were ignored, in traversal order. + pub diagnostics: Vec, +} + +/// One test class or method identified by the Java Test extension. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaTestItem { + pub id: String, + pub label: String, + pub full_name: String, + pub project_name: String, + pub test_kind: i64, + pub test_level: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub jdt_handler: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub range: Option, + pub children: Vec, +} + +/// Zero-based UTF-16 source range from JDT. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaTestRange { + pub start_line: i64, + pub start_utf16_column: i64, + pub end_line: i64, + pub end_utf16_column: i64, +} + +/// One raw extension node Core could not safely expose. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaTestItemDiagnostic { + pub code: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +/// Converts `findTestTypesAndMethods` output into the shared typed contract. +/// +/// An array is a valid answer, including an empty one. `null` is also “no +/// tests”: vscode-java-test returns its root's `children`, which stays `null` +/// until a first test is added. Any other shape is a broken server contract and +/// returns `None` so callers do not mistake it for “no tests”. Malformed child +/// nodes are diagnosed without discarding valid peers. +pub fn normalize_java_test_items(result: &Value) -> Option { + let candidates = match result { + Value::Null => &Vec::new(), + Value::Array(candidates) => candidates, + _ => return None, + }; + let mut diagnostics = Vec::new(); + let items = candidates + .iter() + .filter_map(|candidate| normalize_item(candidate, &mut diagnostics)) + .collect(); + Some(JavaTestItems { + schema_version: JAVA_TEST_ITEMS_SCHEMA_VERSION, + items, + diagnostics, + }) +} + +fn normalize_item( + candidate: &Value, + diagnostics: &mut Vec, +) -> Option { + let Some(object) = candidate.as_object() else { + diagnostics.push(JavaTestItemDiagnostic { + code: "invalidItem", + detail: Some("item is not an object".to_string()), + }); + return None; + }; + let required_string = |name: &str| { + object + .get(name) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }; + let id = required_string("id"); + let label = required_string("label"); + let full_name = required_string("fullName"); + let project_name = required_string("projectName"); + let test_kind = integer_value(object.get("testKind")); + let test_level = integer_value(object.get("testLevel")); + let ( + Some(id), + Some(label), + Some(full_name), + Some(project_name), + Some(test_kind), + Some(test_level), + ) = (id, label, full_name, project_name, test_kind, test_level) + else { + diagnostics.push(JavaTestItemDiagnostic { + code: "missingRequiredField", + detail: object + .get("label") + .and_then(Value::as_str) + .map(str::to_string), + }); + return None; + }; + let children = object + .get("children") + .and_then(Value::as_array) + .map(|children| { + children + .iter() + .filter_map(|child| normalize_item(child, diagnostics)) + .collect() + }) + .unwrap_or_default(); + Some(JavaTestItem { + id, + label, + full_name, + project_name, + test_kind, + test_level, + jdt_handler: optional_string(object.get("jdtHandler")), + sort_text: optional_string(object.get("sortText")), + range: object.get("range").and_then(normalize_range), + children, + }) +} + +fn optional_string(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn integer_value(value: Option<&Value>) -> Option { + value.and_then(|value| { + value + .as_i64() + .or_else(|| value.as_str().and_then(|text| text.parse().ok())) + }) +} + +fn normalize_range(value: &Value) -> Option { + let start = value.get("start")?; + let end = value.get("end")?; + Some(JavaTestRange { + start_line: integer_value(start.get("line"))?, + start_utf16_column: integer_value(start.get("character"))?, + end_line: integer_value(end.get("line"))?, + end_utf16_column: integer_value(end.get("character"))?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_passes_the_document_uri_to_java_test() { + assert_eq!( + java_test_items_command("file:///work/src/Composed.java"), + json!({ + "command": "vscode.java.test.findTestTypesAndMethods", + "arguments": ["file:///work/src/Composed.java"] + }) + ); + } + + #[test] + fn normalizes_classes_methods_and_utf16_ranges_without_semantic_guessing() { + let result = json!([{ + "id": "class-id", + "label": "OddlyNamedSpec", + "fullName": "demo.OddlyNamedSpec", + "projectName": "app", + "testKind": 0, + "testLevel": "5", + "jdtHandler": "=app/src, + /// Files added, changed, or removed since the previous observation. + #[serde(default)] + pub changed_paths: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Shared Java workspace activation and change plan. +pub struct JavaWorkspacePolicyResponse { + /// Whether the workspace is a Java project that JDT LS should be started for. + /// + /// Requires a Java source *and* evidence that Java is what the workspace is + /// for: a build descriptor near the root, or a Java source near the root for + /// projects that have no build system at all. A lone Java file deep inside a + /// repository of another ecosystem does not qualify. + pub should_start: bool, + /// Deterministically selected Java source used only to attach the session. + #[serde(skip_serializing_if = "Option::is_none")] + pub representative_java_path: Option, + /// Deterministically ordered classifications for the supplied changes. + pub changes: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// One normalized changed path and the action category it belongs to. +pub struct JavaWorkspaceChange { + /// Normalized workspace-relative path with `/` separators. + pub path: String, + /// Shared semantic category consumed by platform workflow adapters. + pub kind: JavaWorkspaceChangeKind, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Java workspace reaction required for one changed path. +pub enum JavaWorkspaceChangeKind { + /// An ignored output, dependency, metadata, or linked-worktree path. + Ignored, + /// A Java document synchronized through versioned LSP document events. + Source, + /// Maven or Gradle structure that requires a debounced project refresh. + BuildConfiguration, + /// A workspace file that does not affect the Java project model. + Other, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Filesystem metadata used to select expired JDT LS workspace directories. +pub struct JdtCacheRetentionRequest { + /// Unix timestamp supplied by the platform clock. + pub now_unix_seconds: u64, + /// Workspace key currently owned by a running session and never removable. + #[serde(default)] + pub active_workspace_key: Option, + /// Candidate directories discovered by the platform filesystem adapter. + #[serde(default)] + pub entries: Vec, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Modification metadata for one candidate JDT LS workspace directory. +pub struct JdtCacheEntry { + /// SHA-256 workspace directory name produced by `lsp.jdtWorkspaceKey`. + pub workspace_key: String, + /// Last modification time reported by the host filesystem. + pub last_modified_unix_seconds: u64, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deterministically ordered JDT LS directories that a platform may remove. +pub struct JdtCacheRetentionResponse { + /// Stable product retention period exposed for diagnostics and tests. + pub retention_days: u64, + /// Valid inactive workspace keys older than the retention period. + pub expired_workspace_keys: Vec, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Platform-observed inputs used to derive one portable JDT LS workspace fingerprint. +pub struct JdtWorkspaceFingerprintRequest { + /// Root build descriptors observed without recursive filesystem traversal. + #[serde(default)] + pub build_files: Vec, + /// Names of direct child directories that contain a Maven descriptor. + #[serde(default)] + pub direct_maven_modules: Vec, + /// `distributionUrl` of the root `gradle/wrapper/gradle-wrapper.properties`, + /// when present. Unlike edits to build scripts, which JDT LS notices on + /// restart, a Gradle version change is invisible to its import check, so + /// the version itself selects a fresh state directory. + #[serde(default)] + pub gradle_wrapper_distribution: Option, + /// JDT LS version resolved by the platform package adapter. + pub jdtls_version: String, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One root-level Java build descriptor that exists in the workspace. +/// +/// Only its presence counts. Edits to a descriptor are JDT LS's to detect: on a +/// restart it compares each POM and Gradle file's timestamp with the state it +/// saved and updates only the projects that changed, and while running it +/// receives the change through `workspace/didChangeWatchedFiles`. Mixing the +/// timestamp into the state-directory name would discard the whole index after +/// any touch of the file, a `git checkout` included. +pub struct JdtBuildFileObservation { + /// Workspace-relative build descriptor path with `/` separators. + pub path: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Stable opaque fingerprint consumed by JDT LS workspace-key derivation. +pub struct JdtWorkspaceFingerprintResponse { + /// Opaque deterministic value that platforms pass back without parsing. + pub workspace_fingerprint: String, +} + +/// Builds the cross-platform Java activation and change-classification plan. +pub fn java_workspace_policy( + request: JavaWorkspacePolicyRequest, +) -> Result { + let workspace_paths = normalize_paths(request.workspace_paths)?; + let changed_paths = normalize_paths(request.changed_paths)?; + let representative_java_path = workspace_paths + .iter() + .find(|path| !is_ignored(path) && is_java_source(path)) + .cloned(); + // A stray `.java` file does not make a Java project. Fixtures, samples, and + // vendored snippets appear routinely in repositories of other ecosystems, and + // starting JDT LS for one costs a JVM, a full workspace import, and a cache + // directory retained for `JDT_CACHE_RETENTION_DAYS`. Require positive evidence + // instead. Opening a Java file in the editor stays extension-based on the host + // side, so an unrecognized project still gets language support on demand. + let describes_java_project = workspace_paths.iter().any(|path| { + is_activating_build_file(path) + || (!is_ignored(path) + && is_java_source(path) + && directory_depth(path) <= MAX_ACTIVATION_DEPTH) + }); + let changes = changed_paths + .into_iter() + .map(|path| JavaWorkspaceChange { + kind: classify_change(&path), + path, + }) + .collect(); + + Ok(JavaWorkspacePolicyResponse { + should_start: representative_java_path.is_some() && describes_java_project, + representative_java_path, + changes, + }) +} + +/// Reduces platform filesystem observations to the shared JDT LS fingerprint format. +pub fn jdt_workspace_fingerprint( + request: JdtWorkspaceFingerprintRequest, +) -> Result { + let version = request.jdtls_version.trim(); + if version.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The JDT LS version must not be empty.", + )); + } + + let build_files = request + .build_files + .into_iter() + .map(|observation| normalize_relative_path(&observation.path)) + .collect::, _>>()?; + let modules = request + .direct_maven_modules + .into_iter() + .map(|module| normalize_module_name(&module)) + .collect::, _>>()?; + let build_files = build_files.into_iter().collect::>().join(","); + let modules = modules.into_iter().collect::>().join(","); + let mut workspace_fingerprint = + format!("build={build_files}|modules={modules}|jdtls={version}"); + if let Some(distribution) = request.gradle_wrapper_distribution { + let distribution = distribution.trim(); + if distribution.is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The Gradle wrapper distribution must not be empty.", + )); + } + // Appended only when present, so workspaces without a wrapper keep + // the state directory they already have. + workspace_fingerprint.push_str("|gradle="); + workspace_fingerprint.push_str(distribution); + } + + Ok(JdtWorkspaceFingerprintResponse { + workspace_fingerprint, + }) +} + +/// Selects inactive JDT LS workspace caches older than the shared retention period. +pub fn jdt_cache_retention( + request: JdtCacheRetentionRequest, +) -> Result { + if let Some(active_key) = request.active_workspace_key.as_deref() { + validate_workspace_key(active_key)?; + } + + // Duplicate observations are reduced to the newest timestamp so a stale + // filesystem event cannot delete a directory that was subsequently reused. + let mut newest_by_key = BTreeMap::new(); + for entry in request.entries { + if !is_workspace_key(&entry.workspace_key) { + continue; + } + newest_by_key + .entry(entry.workspace_key) + .and_modify(|timestamp: &mut u64| { + *timestamp = (*timestamp).max(entry.last_modified_unix_seconds) + }) + .or_insert(entry.last_modified_unix_seconds); + } + let retention_seconds = JDT_CACHE_RETENTION_DAYS * SECONDS_PER_DAY; + let expired_workspace_keys = newest_by_key + .into_iter() + .filter_map(|(workspace_key, last_modified)| { + let is_active = request.active_workspace_key.as_deref() == Some(workspace_key.as_str()); + let age = request.now_unix_seconds.saturating_sub(last_modified); + (!is_active && age > retention_seconds).then_some(workspace_key) + }) + .collect(); + + Ok(JdtCacheRetentionResponse { + retention_days: JDT_CACHE_RETENTION_DAYS, + expired_workspace_keys, + }) +} + +fn validate_workspace_key(value: &str) -> Result<(), CoreError> { + if is_workspace_key(value) { + Ok(()) + } else { + Err(CoreError::new( + ErrorCode::InvalidRequest, + "The active JDT LS workspace key must be a lowercase SHA-256 value.", + )) + } +} + +fn is_workspace_key(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn normalize_paths(paths: Vec) -> Result, CoreError> { + paths + .into_iter() + .map(|path| normalize_relative_path(&path)) + .collect::, _>>() + .map(BTreeSet::into_iter) + .map(Iterator::collect) +} + +fn normalize_relative_path(path: &str) -> Result { + let normalized = path.trim().replace('\\', "/"); + let normalized = normalized.trim_start_matches("./"); + let is_windows_absolute = normalized + .as_bytes() + .get(1) + .is_some_and(|separator| *separator == b':'); + if normalized.is_empty() + || normalized.starts_with('/') + || is_windows_absolute + || normalized + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Java workspace paths must be workspace-relative.", + ) + .with_details(path.to_string())); + } + Ok(normalized.to_string()) +} + +fn normalize_module_name(value: &str) -> Result { + let normalized = value.trim(); + if normalized.is_empty() || normalized.contains(['/', '\\']) || matches!(normalized, "." | "..") + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Direct Maven module names must be single path components.", + ) + .with_details(value.to_string())); + } + Ok(normalized.to_string()) +} + +fn classify_change(path: &str) -> JavaWorkspaceChangeKind { + if is_ignored(path) { + JavaWorkspaceChangeKind::Ignored + } else if is_java_source(path) { + JavaWorkspaceChangeKind::Source + } else if is_build_configuration(path) { + JavaWorkspaceChangeKind::BuildConfiguration + } else { + JavaWorkspaceChangeKind::Other + } +} + +fn is_ignored(path: &str) -> bool { + path.split('/').any(|component| { + let component = component.to_ascii_lowercase(); + IGNORED_DIRECTORIES.contains(&component.as_str()) + }) +} + +fn is_java_source(path: &str) -> bool { + path.rsplit_once('.') + .is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("java")) +} + +/// Number of directories between the workspace root and `path`. +/// +/// Root-level files are depth zero, matching the workspace-relative path format +/// that every host normalizes to before calling in. +fn directory_depth(path: &str) -> usize { + path.matches('/').count() +} + +/// Whether `path` is a build descriptor close enough to the root to describe the +/// opened project. +/// +/// Only file names count. `is_build_configuration` additionally treats any `.mvn` +/// or `gradle` directory component as build configuration, which is correct for +/// invalidating a cached project model but too loose for activation: an unrelated +/// `docs/gradle/` directory would otherwise start JDT LS. +fn is_activating_build_file(path: &str) -> bool { + if is_ignored(path) || directory_depth(path) > MAX_ACTIVATION_DEPTH { + return false; + } + path.rsplit('/') + .next() + .map(|name| name.to_ascii_lowercase()) + .is_some_and(|name| BUILD_FILE_NAMES.contains(&name.as_str())) +} + +fn is_build_configuration(path: &str) -> bool { + let components: Vec<_> = path.split('/').collect(); + let file_name = components + .last() + .map(|component| component.to_ascii_lowercase()) + .unwrap_or_default(); + BUILD_FILE_NAMES.contains(&file_name.as_str()) + || components.iter().any(|component| { + component.eq_ignore_ascii_case(".mvn") || component.eq_ignore_ascii_case("gradle") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn should_start(paths: &[&str]) -> bool { + java_workspace_policy(JavaWorkspacePolicyRequest { + workspace_paths: paths.iter().map(|path| path.to_string()).collect(), + changed_paths: Vec::new(), + }) + .expect("valid workspace paths must produce a policy") + .should_start + } + + #[test] + fn starts_for_projects_that_declare_a_java_build_near_the_root() { + // Conventional source layouts put sources well below MAX_ACTIVATION_DEPTH, + // so activation has to come from the descriptor rather than the sources. + assert!(should_start(&[ + "pom.xml", + "src/main/java/com/example/demo/DemoApplication.java", + ])); + assert!(should_start(&[ + "services/backend/build.gradle.kts", + "services/backend/src/main/java/com/example/App.java", + ])); + } + + #[test] + fn does_not_start_for_java_fixtures_checked_into_another_ecosystem() { + // Regression for this repository: a Swift, Rust, and TypeScript product + // that checks a sample Maven project into `shared/fixtures/`. Every such + // workspace used to start JDT LS and import the fixture as a real project. + assert!(!should_start(&[ + "Package.swift", + "Cargo.toml", + "shared/fixtures/projects/demo/pom.xml", + "shared/fixtures/projects/demo/src/main/java/com/example/demo/DemoApplication.java", + ])); + } + + #[test] + fn starts_for_java_sources_that_have_no_build_system() { + // Single-file programs and standalone `main` classes are supported by the + // run configuration layer, so language support must not require Maven or + // Gradle to be present. + assert!(should_start(&["Main.java"])); + assert!(should_start(&["src/Main.java"])); + } + + #[test] + fn ignores_build_descriptors_and_sources_under_ignored_directories() { + // Build output carries copies of both descriptors and sources. Treating + // them as evidence would reactivate on exactly the trees the ignore list + // exists to exclude. + assert!(!should_start(&[ + "target/pom.xml", + "target/classes/Main.java" + ])); + assert!(!should_start(&["node_modules/pkg/pom.xml"])); + } + + #[test] + fn requires_a_java_source_even_when_a_build_descriptor_exists() { + // A Gradle or Maven build in a Kotlin-only or resources-only repository + // has nothing for JDT LS to open. + assert!(!should_start(&["pom.xml", "src/main/kotlin/App.kt"])); + } + + #[test] + fn rejects_paths_that_escape_or_replace_the_workspace_root() { + for path in ["../Main.java", "/tmp/Main.java", "C:/work/Main.java"] { + let error = java_workspace_policy(JavaWorkspacePolicyRequest { + workspace_paths: vec![path.to_string()], + changed_paths: Vec::new(), + }) + .expect_err("absolute and escaping paths must be rejected"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + } + } + + #[test] + fn rejects_invalid_workspace_fingerprint_observations() { + for request in [ + JdtWorkspaceFingerprintRequest { + build_files: vec![JdtBuildFileObservation { + path: "../pom.xml".to_string(), + }], + direct_maven_modules: Vec::new(), + gradle_wrapper_distribution: None, + jdtls_version: "1.38.0".to_string(), + }, + JdtWorkspaceFingerprintRequest { + build_files: Vec::new(), + direct_maven_modules: vec!["nested/core".to_string()], + gradle_wrapper_distribution: None, + jdtls_version: "1.38.0".to_string(), + }, + JdtWorkspaceFingerprintRequest { + build_files: Vec::new(), + direct_maven_modules: Vec::new(), + gradle_wrapper_distribution: None, + jdtls_version: " ".to_string(), + }, + JdtWorkspaceFingerprintRequest { + build_files: Vec::new(), + direct_maven_modules: Vec::new(), + gradle_wrapper_distribution: Some(" ".to_string()), + jdtls_version: "1.38.0".to_string(), + }, + ] { + let error = jdt_workspace_fingerprint(request) + .expect_err("invalid fingerprint observations must be rejected"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + } + } +} diff --git a/crates/java-domain/src/lsp/languages/jdt.rs b/crates/java-domain/src/lsp/languages/jdt.rs new file mode 100644 index 0000000..68a859c --- /dev/null +++ b/crates/java-domain/src/lsp/languages/jdt.rs @@ -0,0 +1,2532 @@ +//! JDT LS-specific policy kept outside the generic LSP client and transport. +//! +//! The adapter is deliberately pure: it describes launch arguments, provider +//! notifications, configuration responses, and virtual-source requests. The +//! process engine remains responsible for creating directories and performing +//! all I/O. + +#![allow(dead_code)] // This module is an engine adapter seam; integration is intentionally separate. + +use crate::lsp::JavaRuntimeCandidate; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use url::Url; + +const JAVA_PROVIDER_ID: &str = "java"; +const JDT_URI_SCHEME: &str = "jdt"; +const JDTLS_DATA_DIRECTORY: &str = "jdtls"; +const JAVA_DECOMPILE_COMMAND: &str = "java.decompile"; +const DID_CHANGE_CONFIGURATION_METHOD: &str = "workspace/didChangeConfiguration"; +/// JDT LS extension that force-updates the listed projects' build configuration. +const PROJECT_CONFIGURATIONS_UPDATE_METHOD: &str = "java/projectConfigurationsUpdate"; +const LANGUAGE_STATUS_METHOD: &str = "language/status"; +const WORK_DONE_PROGRESS_METHOD: &str = "$/progress"; +const SERVICE_READY_STATUS: &str = "ServiceReady"; +const ERROR_STATUS: &str = "Error"; +/// JVM system property read by JDT LS's `org.eclipse.jdt.ls.filesystem` bundle. +/// +/// When the property is absent, JDT LS writes `.project`, `.classpath`, +/// `.factorypath`, and `.settings/*.prefs` into every imported module directory. +/// `false` redirects them to the `-data` metadata area, so opening a project +/// never adds Eclipse files to the user's tree. Files that already exist at a +/// module root still take precedence; `jdt_project_metadata` removes those. +const METADATA_AT_PROJECT_ROOT_PROPERTY: &str = "-Djava.import.generatesMetadataFilesAtProjectRoot"; +/// Heap ceiling passed to the JDT LS JVM on both launch paths. +/// +/// JDT LS switches to a batched, memory-frugal Maven import as soon as its +/// maximum heap is at most 1.5 GB. That path hands a `null` project to +/// `IProject.open` when several modules share `groupId` and `artifactId`, which +/// fails the whole Java service instead of the affected modules. Keep the +/// ceiling above that threshold, and at the 2 GB upstream documents for the +/// language server, so a normal multi-module workspace imports through the +/// regular path. +const JDTLS_MAX_HEAP_ARGUMENT: &str = "-Xmx2048m"; + +/// JDT LS readiness transition conveyed through its `language/status` +/// extension after the standard LSP initialize handshake. +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum JdtReadinessSignal { + /// Project import and Java service initialization have completed. + Ready, + /// JDT LS reported that its Java service could not become usable. + Failed(String), +} + +/// Maven artifact transfer details extracted from JDT LS work-done progress. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct JdtDownloadProgress { + /// File name only; repository URLs can contain credentials or private hosts. + pub artifact: String, + /// Repository host without path, query, or credentials. + pub repository_host: Option, + /// Bytes transferred according to JDT LS, when its progress includes a size pair. + pub downloaded_bytes: Option, + /// Expected artifact size according to JDT LS, when the server reports it. + pub total_bytes: Option, +} + +/// Observable Java workspace-import activity derived from standard progress events. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct JdtImportProgress { + /// Stable signature used to ignore duplicate progress notifications. + pub activity_signature: String, + /// JDT LS work phase such as Maven import or classpath setup. + pub phase: Option, + /// Outer workspace-import percentage, which can stay fixed during downloads. + pub percentage: Option, + /// Current Maven project when JDT LS includes it in the progress message. + pub current_project: Option, + /// Current artifact transfer, if the progress message contains a repository URL. + pub download: Option, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JdtStartContext { + pub provider_id: String, + pub workspace_root: PathBuf, + /// Engine-owned cache/state root. The adapter only derives a child path. + pub data_root: PathBuf, + #[serde(default)] + pub selected_java_executable: Option, + #[serde(default)] + pub direct_launch_resources: Option, + #[serde(default)] + pub arguments: Vec, + /// Opaque platform-provided digest of the build-system inputs that decide + /// how JDT LS will import the workspace: root build files and the set of + /// module directories, plus the bundled JDT LS version. + /// + /// JDT LS never invalidates its own `-data` state, so a workspace that + /// gains or loses a module keeps resolving against a stale project model + /// and silently loses cross-module navigation. Mixing this digest into the + /// state-directory name gives structurally different workspaces different + /// directories while keeping the previous one intact for a later switch + /// back. `None` preserves the pre-fingerprint directory name so existing + /// caches stay valid. + #[serde(default)] + pub workspace_fingerprint: Option, +} + +/// JDT LS settings keys that other plugins may contribute to this plugin's +/// language server. +/// +/// JDT LS imports Maven and Gradle projects itself, but the values it imports +/// with (settings files, local repository, offline mode, Gradle user home, +/// source folders read from build files) belong to the build-system plugins +/// that own those projects. This plugin only declares which part of JDT LS's +/// own settings namespace it accepts from them; it never computes those +/// values. An entry ending in `.` accepts every key below it; any other entry +/// accepts exactly that key. +/// +/// `java.project.sourcePaths` is listed because Rust Core sent the source +/// folders it read from Maven POMs under that key. +// The contributions arrive through cross-plugin function calls that the host +// relays (this plugin, Core, the build-system plugin); the interface is in +// `jdt_contributions` and the calls are in the backend's `plugin_functions` +// module. +// +// TODO(host-api: API-35 cross-plugin function): the relay does not exist in +// the host yet, so no provider can be discovered or called. When it does, +// the backend discovers every enabled provider of `languageImport.settings` +// before JDT LS starts, merges the answers and passes them in +// `JdtSettings::contributed`. Later changes work the other way round: the +// build-system plugin calls the change function this plugin exports and +// passes the new values in the call. The backend then sends +// `workspace/didChangeConfiguration` through the language-server channel, +// which needs the API-25 language-server API. See `MIGRATION.md`. +// +// TODO(host-api: API-35 cross-plugin function): JDT LS has no settings key +// for Maven profiles. Rust Core applied them per project with the +// `java.project.updateSettings` command and +// `org.eclipse.m2e.core.selectedProfiles`, which a settings fragment cannot +// express. The Maven plugin computes the profiles; delivering them needs a +// per-project entry in the same change function (see `MIGRATION.md`). +pub const CONTRIBUTED_SETTINGS_KEYS: &[&str] = &[ + "java.configuration.maven.", + "java.import.maven.", + "java.import.gradle.", + "java.project.sourcePaths", +]; + +/// One contributed settings fragment, split into dotted leaf keys. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ContributedSettings { + /// Leaves inside [`CONTRIBUTED_SETTINGS_KEYS`], such as + /// `java.import.gradle.offline.enabled`, in key order. + pub accepted: BTreeMap, + /// Leaf keys outside [`CONTRIBUTED_SETTINGS_KEYS`]; they never reach + /// JDT LS. + pub rejected: Vec, +} + +/// Splits a contributed fragment into the leaves this plugin accepts and the +/// ones it drops. +/// +/// A fragment may nest objects (`{"java": {"import": {...}}}`), use dotted +/// keys (`{"java.import.gradle.offline.enabled": true}`) or mix both: objects +/// are walked down to their leaves, and arrays and scalars are leaves. A key +/// with an empty segment is rejected. A fragment that is not an object +/// contributes nothing. The host checks the declared keys before it forwards +/// a fragment; this check repeats it so a value outside them can never reach +/// JDT LS. +pub fn accept_contributed_settings(fragment: &Value) -> ContributedSettings { + let mut contributed = ContributedSettings::default(); + if let Value::Object(entries) = fragment { + collect_contributed_leaves(entries, "", &mut contributed); + } + contributed +} + +fn collect_contributed_leaves( + entries: &Map, + prefix: &str, + contributed: &mut ContributedSettings, +) { + for (key, value) in entries { + let key = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + match value { + Value::Object(children) => collect_contributed_leaves(children, &key, contributed), + leaf if is_contributable_key(&key) => { + contributed.accepted.insert(key, leaf.clone()); + } + _ => contributed.rejected.push(key), + } + } +} + +fn is_contributable_key(key: &str) -> bool { + !key.split('.').any(str::is_empty) + && CONTRIBUTED_SETTINGS_KEYS + .iter() + .any(|accepted| match accepted.strip_suffix('.') { + Some(_) => key.starts_with(accepted), + None => key == *accepted, + }) +} + +/// Where the settings contributed by associated plugins stand for the +/// current session. +/// +/// Rust Core tracked the Maven profiles it applied after the import with a +/// richer task status; the plugin only needs to know whether a contribution +/// is still arriving and whether one failed. +// `jdt_contributions::Contributions::status` derives `Settled` and `Failed` +// from the provider calls: a call that failed or timed out marks its provider +// as failed, and the user-visible message names that plugin. +// +// TODO(host-api: API-35 cross-plugin function): `Pending` is reported by the +// backend while provider calls are outstanding, once the host relays them. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ContributedSettingsStatus { + /// Every associated plugin answered, or none is associated. + #[default] + Settled, + /// The host is still collecting or applying a contribution. + Pending, + /// An associated plugin failed or timed out; the session runs with the + /// settings that did arrive. + Failed, +} + +/// A JDK JDT LS may bind a project to, keyed by its execution environment. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct JdtJavaRuntime { + /// Selected project VM; at most one runtime is the default. + pub is_default: bool, + /// Execution-environment name such as `JavaSE-25` or `JavaSE-1.8`; JDT LS + /// matches it against the release a project compiles for. + pub name: String, + /// JDK home directory. + pub path: String, +} + +/// Values Lithe owns in JDT LS's `settings.java`, borrowed from one session. +#[derive(Debug, Clone, Copy, Default)] +pub struct JdtSettings<'a> { + /// Workspace indexing policy, when the host has installed one. + pub indexing_root_uri: Option<&'a str>, + /// Plugin-declared completion options from this session's initialization; + /// all leaves survive except the provider-owned guessMethodArguments. + pub completion: Option<&'a Value>, + /// Original plugin import exclusions, before Lithe overlays the workspace policy. + pub import_exclusions: Option<&'a Value>, + /// Settings fragments from associated plugins, applied in order; only + /// leaves inside [`CONTRIBUTED_SETTINGS_KEYS`] are used. Empty for a + /// plain Java workspace, which JDT LS opens without an import step. + pub contributed: &'a [Value], + /// JDKs installed on this machine, at most one per execution environment. + pub java_runtimes: &'a [JdtJavaRuntime], +} + +/// Picks one JDK per Java major version for `java.configuration.runtimes`. +/// +/// Without these, JDT LS knows only the JDK it runs on, so a project that +/// compiles for a newer release fails with "release N is not found in the +/// system" even though that JDK is installed. The first valid default wins its +/// major; otherwise, the first JDK listed for that major wins. Platforms order +/// candidates by preference. Versions that do not parse are skipped, not guessed. +pub fn jdt_java_runtimes(candidates: &[JavaRuntimeCandidate]) -> Vec { + let selected = candidates.iter().find(|candidate| { + candidate.is_default + && !candidate.home_path.trim().is_empty() + && java_major_version(&candidate.version).is_some() + }); + let mut by_major = BTreeMap::new(); + // The explicit selection wins even over another installation of the same major. + for candidate in selected.into_iter().chain(candidates) { + let Some(major) = java_major_version(&candidate.version) else { + continue; + }; + if candidate.home_path.trim().is_empty() { + continue; + } + by_major.entry(major).or_insert(candidate); + } + by_major + .into_iter() + .map(|(major, candidate)| JdtJavaRuntime { + name: if major == 8 { + "JavaSE-1.8".to_string() + } else { + format!("JavaSE-{major}") + }, + path: candidate.home_path.trim().to_string(), + is_default: selected.is_some_and(|chosen| std::ptr::eq(chosen, candidate)), + }) + .collect() +} + +/// Major version of a `java -version` string: `25.0.4` → 25, `1.8.0_402` → 8. +/// Versions before 8 have no JDT execution environment Lithe supports. +fn java_major_version(version: &str) -> Option { + let mut parts = version + .trim() + .split(|character: char| !character.is_ascii_digit()); + let first = parts.next()?.parse::().ok()?; + let major = if first == 1 { + parts.next()?.parse::().ok()? + } else { + first + }; + (major >= 8).then_some(major) +} + +/// Platform-resolved files required to launch JDT LS without a shell wrapper. +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JdtDirectLaunchResources { + pub launcher_jar_path: PathBuf, + /// Writable Equinox configuration area passed as `-configuration`. The + /// engine supplies the cache copy from `jdt_configuration`, never the + /// packaged directory, because Equinox writes its state here. + pub configuration_directory: PathBuf, + pub lombok_agent_path: PathBuf, + #[serde(default)] + pub java_debug_bundle_path: Option, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// Request for the deterministic directory key used by JDT LS state. +pub struct JdtWorkspaceKeyRequest { + /// Absolute platform workspace path used only to derive a stable local key. + pub workspace_root: String, + /// Optional platform-computed build-structure digest mixed into the key. + #[serde(default)] + pub workspace_fingerprint: Option, +} + +#[derive(Debug, Clone, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// Stable key returned to platform cache-maintenance adapters. +pub struct JdtWorkspaceKeyResponse { + /// Lowercase SHA-256 key naming the workspace's JDT LS state directory. + pub workspace_key: String, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JdtStartAdaptation { + #[serde(skip_serializing_if = "Option::is_none")] + pub executable: Option, + pub arguments: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub data_directory: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceConfigurationItem { + #[serde(default)] + pub scope_uri: Option, + #[serde(default)] + pub section: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderNotification { + pub method: String, + pub params: Value, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderLocation { + pub uri: String, + #[serde(default)] + pub is_read_only: bool, + #[serde(default)] + pub display_path: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecuteCommandParams { + pub command: String, + pub arguments: Vec, +} + +/// Applies the JDT LS-owned part of a provider start plan. +/// +/// `data_directory` is returned to the engine as a directory requirement; this +/// function never creates it. Arguments owned by this adapter are replaced so +/// repeated adaptation is deterministic and cannot leave two `-data` roots. +pub fn adapt_start(context: &JdtStartContext) -> JdtStartAdaptation { + if !is_java_provider(&context.provider_id) { + return JdtStartAdaptation { + executable: None, + arguments: context.arguments.clone(), + data_directory: None, + }; + } + + let data_directory = context + .data_root + .join(JDTLS_DATA_DIRECTORY) + .join(workspace_key( + &context.workspace_root, + context.workspace_fingerprint.as_deref(), + )); + let (executable, arguments) = match ( + &context.selected_java_executable, + &context.direct_launch_resources, + ) { + (Some(java_executable), Some(resources)) => ( + Some(java_executable.clone()), + direct_java_arguments(&context.arguments, resources, &data_directory), + ), + _ => ( + None, + wrapper_arguments( + &context.arguments, + context.selected_java_executable.as_deref(), + &data_directory, + ), + ), + }; + + JdtStartAdaptation { + executable, + arguments, + data_directory: Some(data_directory), + } +} + +/// Adds the JDT LS client extensions required for Java tooling. +/// +/// Catalog-provided options are preserved, while the provider-owned capability +/// is authoritative because virtual class files cannot be opened without it. +/// Extension bundles are appended in caller order without duplicating catalog +/// entries, which keeps Debug and Test plugin activation deterministic. +pub fn adapt_initialization_options( + provider_id: &str, + initialization_options: Option, + java_extension_bundle_paths: &[PathBuf], + settings: JdtSettings<'_>, +) -> Option { + if !is_java_provider(provider_id) { + return initialization_options; + } + + let mut options = match initialization_options { + Some(Value::Object(options)) => options, + _ => Map::new(), + }; + let extended = options + .entry("extendedClientCapabilities") + .or_insert_with(|| json!({})); + if !extended.is_object() { + *extended = json!({}); + } + extended + .as_object_mut() + .expect("the extended capabilities were normalized to an object") + .insert("classFileContentsSupport".to_string(), Value::Bool(true)); + + if !java_extension_bundle_paths.is_empty() { + let bundles = options + .entry("bundles") + .or_insert_with(|| Value::Array(Vec::new())); + if !bundles.is_array() { + *bundles = Value::Array(Vec::new()); + } + let bundles = bundles + .as_array_mut() + .expect("the Java extension bundles were normalized to an array"); + for bundle_path in java_extension_bundle_paths { + let bundle = Value::String(bundle_path.to_string_lossy().into_owned()); + if !bundles.contains(&bundle) { + bundles.push(bundle); + } + } + } + + // JDT LS reads its build-import settings from `initializationOptions` + // while it handles `initialize`, and the project import starts right + // after. Contributed values sent only through `didChangeConfiguration` + // would arrive after that import already resolved the projects with JDT + // LS's defaults, so they must be present here as well. + merge_java_settings(&mut options, java_settings(settings)); + + Some(Value::Object(options)) +} + +/// Overlays the provider-owned `settings.java` values onto catalog-provided +/// initialization options. +/// +/// Catalog keys that the provider does not own are preserved, while the +/// build, completion, code-lens and contributed values stay authoritative. +fn merge_java_settings(options: &mut Map, provider_settings: Value) { + let settings = options.entry("settings").or_insert_with(|| json!({})); + if !settings.is_object() { + *settings = json!({}); + } + let settings = settings + .as_object_mut() + .expect("the settings value was normalized to an object"); + let java = settings.entry("java").or_insert_with(|| json!({})); + if !java.is_object() { + *java = json!({}); + } + let java = java + .as_object_mut() + .expect("the java settings were normalized to an object"); + let Some(provider_java) = provider_settings + .get("java") + .and_then(Value::as_object) + .cloned() + else { + return; + }; + for (key, value) in provider_java { + if key == "completion" { + let completion = java.entry(key).or_insert_with(|| json!({})); + if !completion.is_object() { + *completion = json!({}); + } + completion.as_object_mut().unwrap().extend( + value + .as_object() + .expect("completion settings are an object") + .clone(), + ); + continue; + } + // `java.import` holds both values the provider owns (the exclusions + // and the contributed `maven`/`gradle` leaves) and options a catalog + // may declare that nobody else sets (`gradle.version`, + // `gradle.java.home`, ...). The owned values merge into the declared + // object instead of replacing it, and the exclusions are combined. + if key == "import" { + let import = java + .entry("import".to_string()) + .or_insert_with(|| json!({})); + if !import.is_object() { + *import = json!({}); + } + let import = import + .as_object_mut() + .expect("the import value was normalized to an object"); + let Value::Object(owned) = value else { + continue; + }; + for (import_key, import_value) in owned { + if import_key == "exclusions" { + let mut combined = import + .get("exclusions") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for exclusion in import_value.as_array().into_iter().flatten() { + if !combined.contains(exclusion) { + combined.push(exclusion.clone()); + } + } + import.insert(import_key, json!(combined)); + } else { + merge_value( + import.entry(import_key).or_insert(Value::Null), + import_value, + ); + } + } + continue; + } + java.insert(key, value); + } +} + +/// Merges `value` into `target`: objects merge key by key, and any other +/// value replaces what `target` held. +fn merge_value(target: &mut Value, value: Value) { + match (target, value) { + (Value::Object(target), Value::Object(value)) => { + for (key, child) in value { + merge_value(target.entry(key).or_insert(Value::Null), child); + } + } + (target, value) => *target = value, + } +} + +/// Returns JDT LS configuration values in the same order as the requested +/// `workspace/configuration` items. `None` delegates non-Java providers to the +/// generic engine behavior. +pub fn workspace_configuration( + provider_id: &str, + items: &[WorkspaceConfigurationItem], + settings: JdtSettings<'_>, +) -> Option> { + if !is_java_provider(provider_id) { + return None; + } + Some( + items + .iter() + .map(|item| java_configuration_for_section(item.section.as_deref(), settings)) + .collect(), + ) +} + +/// Notification the engine sends after the generic `initialized` handshake. +pub fn initialized_notification( + provider_id: &str, + settings: JdtSettings<'_>, +) -> Option { + is_java_provider(provider_id).then(|| settings_notification(settings)) +} + +/// `workspace/didChangeConfiguration` carrying every Java setting Lithe owns, +/// including the contributed ones. +/// +/// When a contributed Maven settings path differs from the one JDT LS holds, +/// JDT LS reloads it and force-updates every Maven project itself +/// (`StandardPreferenceManager.update`), including an import still in +/// progress. The backend sends this notification again whenever a +/// build-system plugin reports that its contribution changed. +pub fn settings_notification(settings: JdtSettings<'_>) -> ProviderNotification { + ProviderNotification { + method: DID_CHANGE_CONFIGURATION_METHOD.to_string(), + params: json!({ + "settings": java_settings(settings) + }), + } +} + +/// Forces JDT LS to re-resolve the given projects even though neither their +/// build files nor the contributed settings changed. +/// +/// A JDT LS restart does not do this: its importer skips projects whose build +/// file is older than the saved workspace state, so dependencies that failed +/// to resolve stay missing. `java/projectConfigurationsUpdate` is the +/// upstream "update project" action and ignores that check. The URIs are the +/// projects JDT LS itself reports (`java.project.getAll`); repeated URIs are +/// sent once, in first-seen order. +pub fn project_update_notification(project_uris: &[String]) -> Option { + let mut seen = std::collections::BTreeSet::new(); + let identifiers = project_uris + .iter() + .filter(|uri| seen.insert(uri.as_str())) + .map(|uri| json!({ "uri": uri })) + .collect::>(); + (!identifiers.is_empty()).then(|| ProviderNotification { + method: PROJECT_CONFIGURATIONS_UPDATE_METHOD.to_string(), + params: json!({ "identifiers": identifiers }), + }) +} + +/// Returns whether the provider has a readiness phase after standard LSP +/// initialization. JDT LS cannot safely serve semantic requests until its Java +/// project import publishes `ServiceReady`. +pub fn waits_for_service_ready(provider_id: &str) -> bool { + is_java_provider(provider_id) +} + +/// Reduces one JDT LS status notification to the readiness transition owned by +/// the generic process engine. Other providers and informational statuses do +/// not affect lifecycle state. +pub fn readiness_signal( + provider_id: &str, + method: Option<&str>, + params: Option<&Value>, +) -> Option { + if !is_java_provider(provider_id) || method != Some(LANGUAGE_STATUS_METHOD) { + return None; + } + let params = params?.as_object()?; + match params.get("type").and_then(Value::as_str) { + Some(SERVICE_READY_STATUS) => Some(JdtReadinessSignal::Ready), + Some(ERROR_STATUS) => Some(JdtReadinessSignal::Failed( + params + .get("message") + .and_then(Value::as_str) + .filter(|message| !message.trim().is_empty()) + .unwrap_or("JDT LS reported an unknown Java service error.") + .to_string(), + )), + _ => None, + } +} + +/// Extracts observability-only Maven import details from JDT LS `$/progress`. +/// +/// Lifecycle correctness must never depend on this parser because progress text +/// is owned by JDT LS and can change between releases. Readiness continues to +/// depend exclusively on `language/status: ServiceReady`. +pub fn import_progress( + provider_id: &str, + method: Option<&str>, + params: Option<&Value>, +) -> Option { + if !is_java_provider(provider_id) || method != Some(WORK_DONE_PROGRESS_METHOD) { + return None; + } + let value = params?.get("value")?; + let kind = value + .get("kind") + .and_then(Value::as_str) + .unwrap_or("report"); + let message = value + .get("message") + .and_then(Value::as_str) + .or_else(|| value.get("title").and_then(Value::as_str)) + .unwrap_or("") + .trim(); + let percentage = value.get("percentage").and_then(Value::as_u64); + if message.is_empty() && percentage.is_none() { + return None; + } + + Some(JdtImportProgress { + activity_signature: format!("{kind}|{}|{message}", percentage.unwrap_or(u64::MAX)), + phase: progress_phase(message), + percentage, + current_project: progress_project(message), + download: progress_download(message), + }) +} + +/// Returns whether a Java notification is replaced by structured import logs. +pub fn is_structured_import_notification(provider_id: &str, method: Option<&str>) -> bool { + is_java_provider(provider_id) + && matches!( + method, + Some(WORK_DONE_PROGRESS_METHOD | LANGUAGE_STATUS_METHOD) + ) +} + +fn progress_phase(message: &str) -> Option { + let phase = message + .split_once(" - ") + .map_or(message, |(phase, _)| phase) + .trim(); + (!phase.is_empty()).then(|| phase.to_string()) +} + +fn progress_project(message: &str) -> Option { + if let Some((_, project)) = message.split_once("Importing project ") { + let project = project.trim().trim_matches('\''); + return (!project.is_empty()).then(|| project.to_string()); + } + let (_, quoted) = message.split_once("Project '")?; + let (project, _) = quoted.split_once('\'')?; + let project = project.trim(); + (!project.is_empty()).then(|| project.to_string()) +} + +fn progress_download(message: &str) -> Option { + let url_text = message + .split_whitespace() + .find(|part| part.starts_with("https://") || part.starts_with("http://"))? + .trim_end_matches([',', ';', ')', ']']); + let url = Url::parse(url_text).ok()?; + let artifact = url + .path_segments() + .and_then(|mut segments| segments.next_back()) + .filter(|segment| !segment.is_empty())? + .to_string(); + let sizes = message.split_whitespace().find_map(parse_size_pair); + Some(JdtDownloadProgress { + artifact, + repository_host: url.host_str().map(ToString::to_string), + downloaded_bytes: sizes.map(|(downloaded, _)| downloaded), + total_bytes: sizes.map(|(_, total)| total), + }) +} + +fn parse_size_pair(value: &str) -> Option<(u64, u64)> { + if value.starts_with("http://") || value.starts_with("https://") { + return None; + } + let value = value.trim_matches(|character: char| matches!(character, '(' | ')' | ',' | ';')); + let (downloaded, total) = value.split_once('/')?; + Some((parse_byte_count(downloaded)?, parse_byte_count(total)?)) +} + +fn parse_byte_count(value: &str) -> Option { + let number_end = value + .find(|character: char| !character.is_ascii_digit() && character != '.') + .unwrap_or(value.len()); + let number = value[..number_end].parse::().ok()?; + let multiplier = match value[number_end..].to_ascii_uppercase().as_str() { + "B" => 1_f64, + "KB" | "KIB" => 1024_f64, + "MB" | "MIB" => 1024_f64 * 1024_f64, + "GB" | "GIB" => 1024_f64 * 1024_f64 * 1024_f64, + _ => return None, + }; + Some((number * multiplier).round() as u64) +} + +/// Marks JDT virtual locations as read-only and gives them a source-like path. +/// File locations and locations from other providers pass through unchanged. +pub fn normalize_location(provider_id: &str, mut location: ProviderLocation) -> ProviderLocation { + if is_java_provider(provider_id) && has_uri_scheme(&location.uri, JDT_URI_SCHEME) { + location.is_read_only = true; + location.display_path = jdt_display_path(&location.uri); + } + location +} + +/// Converts a JDT virtual URI into `workspace/executeCommand` parameters. The +/// generic engine owns request IDs and JSON-RPC framing. +pub fn virtual_source_resolve_params(provider_id: &str, uri: &str) -> Option { + if !is_virtual_source_uri(provider_id, uri) { + return None; + } + Some(ExecuteCommandParams { + command: JAVA_DECOMPILE_COMMAND.to_string(), + arguments: vec![json!(uri)], + }) +} + +/// Returns whether a URI names a virtual source document owned by JDT LS. +pub fn is_virtual_source_uri(provider_id: &str, uri: &str) -> bool { + is_java_provider(provider_id) && has_uri_scheme(uri, JDT_URI_SCHEME) +} + +/// Extracts the text shape returned by JDT LS for `java.decompile`. +/// Different JDT LS versions return either the string directly or wrap it in +/// a `content` member. +pub fn virtual_source_content(provider_id: &str, result: &Value) -> Option { + if !is_java_provider(provider_id) { + return None; + } + result + .as_str() + .or_else(|| result.get("content").and_then(Value::as_str)) + .filter(|content| !content.is_empty()) + .map(str::to_string) +} + +fn is_java_provider(provider_id: &str) -> bool { + provider_id.trim().eq_ignore_ascii_case(JAVA_PROVIDER_ID) +} + +/// Maximum heap used when the plugin does not choose one, or chooses one the +/// host cannot accept. See `JDTLS_MAX_HEAP_ARGUMENT` for why it is 2 GB. +const DEFAULT_MAX_HEAP_ARGUMENT: &str = JDTLS_MAX_HEAP_ARGUMENT; +/// JDT LS runs its constrained-memory import at or below this heap, so no +/// plugin choice at or below it is accepted. +const CONSTRAINED_HEAP_MEBIBYTES: u64 = 1536; +const MAX_HEAP_PREFIX: &str = "-Xmx"; +/// Language-server log level used when the plugin does not choose one. +const DEFAULT_LOG_LEVEL_ARGUMENT: &str = "-Dlog.level=WARNING"; +const LOG_LEVEL_PREFIX: &str = "-Dlog.level="; +/// JVM defaults vscode-java and the JDT LS CI launch the language server with. +/// +/// The throughput collector with a 20% GC-time budget and a slowly adapting +/// size policy returns heap to the OS once an import settles, instead of +/// holding the 2 GB ceiling for the life of the session. `-Xlog:disable` keeps +/// JVM warnings off stdout, which carries the LSP stream. The installed-VM +/// scan is redundant because the host sends `java.configuration.runtimes`. +/// A plugin option with the same key replaces the default; see +/// `tuning_arguments`. +const JDTLS_TUNING_ARGUMENTS: [&str; 5] = [ + "-XX:+UseParallelGC", + "-XX:GCTimeRatio=4", + "-XX:AdaptiveSizePolicyWeight=90", + "-Xlog:disable", + "-DDetectVMInstallationsJob.disabled=true", +]; + +/// Removes every argument starting with `prefix` and returns the last one +/// that is acceptable. The JVM honors the last occurrence, so that is the one +/// the plugin meant; an unacceptable value is dropped so the host default +/// stays in force. +fn take_plugin_choice( + arguments: &mut Vec, + prefix: &str, + acceptable: fn(&str) -> bool, +) -> Option { + let choice = arguments + .iter() + .rev() + .find(|argument| argument.strip_prefix(prefix).is_some_and(acceptable)) + .cloned(); + arguments.retain(|argument| !argument.starts_with(prefix)); + choice +} + +/// A JVM heap size (digits and an optional `k`, `m`, or `g` unit) above the +/// size at which JDT LS switches to its constrained-memory import. A smaller +/// choice is dropped so the host default stays in force. +fn is_heap_size(value: &str) -> bool { + let digits = value.trim_end_matches(['k', 'K', 'm', 'M', 'g', 'G']); + if digits.is_empty() + || value.len() - digits.len() > 1 + || !digits.chars().all(|character| character.is_ascii_digit()) + { + return false; + } + let Ok(amount) = digits.parse::() else { + return false; + }; + let mebibytes = match value[digits.len()..].to_ascii_lowercase().as_str() { + "k" => amount / 1024, + "m" => amount, + "g" => amount.saturating_mul(1024), + _ => amount / (1024 * 1024), + }; + mebibytes > CONSTRAINED_HEAP_MEBIBYTES +} + +/// Returns the tuning defaults the plugin's JVM options leave unset. An option +/// with the same key replaces a default, and any garbage-collector selection +/// replaces the default collector, since the JVM refuses two collectors. +fn tuning_arguments(custom_jvm_arguments: &[String]) -> Vec { + let chooses_collector = custom_jvm_arguments + .iter() + .any(|argument| is_collector_selection(argument)); + JDTLS_TUNING_ARGUMENTS + .iter() + .filter(|default| !(chooses_collector && is_collector_selection(default))) + .filter(|default| { + let key = jvm_option_key(default); + !custom_jvm_arguments + .iter() + .any(|argument| jvm_option_key(argument) == key) + }) + .map(|default| default.to_string()) + .collect() +} + +/// A tuning default exactly as the host writes it. It is host-owned, so a +/// relaunch rebuilds it instead of reading it back as a plugin choice. +fn is_tuning_default(argument: &str) -> bool { + JDTLS_TUNING_ARGUMENTS.contains(&argument) +} + +fn is_collector_selection(argument: &str) -> bool { + argument + .strip_prefix("-XX:") + .and_then(|option| option.strip_prefix(['+', '-'])) + .is_some_and(|option| option.starts_with("Use") && option.ends_with("GC")) +} + +/// The part of a JVM option that identifies it regardless of its value: +/// `-XX:` options by name without the `+`/`-` toggle, every `-Xlog` option as +/// one key because they combine into a single logging configuration, and other +/// options by the text before `=`. +fn jvm_option_key(argument: &str) -> String { + if let Some(option) = argument.strip_prefix("-XX:") { + let option = option.trim_start_matches(['+', '-']); + let name = option.split_once('=').map_or(option, |(name, _)| name); + return format!("-XX:{name}"); + } + if argument.starts_with("-Xlog") { + return "-Xlog".to_string(); + } + argument + .split_once('=') + .map_or(argument, |(name, _)| name) + .to_string() +} + +fn is_log_level(value: &str) -> bool { + !value.is_empty() + && value + .chars() + .all(|character| character.is_ascii_alphanumeric()) +} + +fn wrapper_arguments( + arguments: &[String], + java_executable: Option<&Path>, + data_directory: &Path, +) -> Vec { + let mut jvm_arguments: Vec = arguments + .iter() + .filter_map(|argument| argument.strip_prefix("--jvm-arg=")) + .map(str::to_string) + .collect(); + let max_heap = take_plugin_choice(&mut jvm_arguments, MAX_HEAP_PREFIX, is_heap_size) + .unwrap_or_else(|| DEFAULT_MAX_HEAP_ARGUMENT.to_string()); + jvm_arguments.retain(|argument| !is_tuning_default(argument)); + let tuning = tuning_arguments(&jvm_arguments); + let mut arguments = without_wrapper_owned_arguments(arguments); + if let Some(java_executable) = java_executable { + arguments.push("--java-executable".to_string()); + arguments.push(java_executable.to_string_lossy().into_owned()); + } + arguments.extend([ + "--jvm-arg=-Xms256m".to_string(), + format!("--jvm-arg={max_heap}"), + ]); + arguments.extend( + tuning + .into_iter() + .map(|argument| format!("--jvm-arg={argument}")), + ); + arguments.extend([ + format!("--jvm-arg={METADATA_AT_PROJECT_ROOT_PROPERTY}=false"), + "-data".to_string(), + data_directory.to_string_lossy().into_owned(), + ]); + arguments +} + +fn direct_java_arguments( + arguments: &[String], + resources: &JdtDirectLaunchResources, + data_directory: &Path, +) -> Vec { + let (mut custom_jvm_arguments, server_arguments) = + split_direct_launch_arguments(arguments, &resources.launcher_jar_path); + let max_heap = take_plugin_choice(&mut custom_jvm_arguments, MAX_HEAP_PREFIX, is_heap_size) + .unwrap_or_else(|| DEFAULT_MAX_HEAP_ARGUMENT.to_string()); + let log_level = take_plugin_choice(&mut custom_jvm_arguments, LOG_LEVEL_PREFIX, is_log_level) + .unwrap_or_else(|| DEFAULT_LOG_LEVEL_ARGUMENT.to_string()); + let mut adapted = vec![ + format!( + "-javaagent:{}", + resources.lombok_agent_path.to_string_lossy() + ), + "-Xms256m".to_string(), + max_heap, + ]; + adapted.extend(tuning_arguments(&custom_jvm_arguments)); + adapted.extend([ + "--add-modules=ALL-SYSTEM".to_string(), + "--add-opens=java.base/java.util=ALL-UNNAMED".to_string(), + "--add-opens=java.base/java.lang=ALL-UNNAMED".to_string(), + "-Declipse.application=org.eclipse.jdt.ls.core.id1".to_string(), + "-Declipse.product=org.eclipse.jdt.ls.core.product".to_string(), + "-Dosgi.bundles.defaultStartLevel=4".to_string(), + "-Dlog.protocol=true".to_string(), + log_level, + format!("{METADATA_AT_PROJECT_ROOT_PROPERTY}=false"), + ]); + adapted.extend(custom_jvm_arguments); + adapted.extend([ + "-jar".to_string(), + resources.launcher_jar_path.to_string_lossy().into_owned(), + "-configuration".to_string(), + resources + .configuration_directory + .to_string_lossy() + .into_owned(), + ]); + adapted.extend(server_arguments); + adapted.extend([ + "-data".to_string(), + data_directory.to_string_lossy().into_owned(), + ]); + adapted +} + +fn split_direct_launch_arguments( + arguments: &[String], + launcher_jar_path: &Path, +) -> (Vec, Vec) { + let launcher_jar_path = launcher_jar_path.to_string_lossy(); + let owned_launcher_index = arguments + .windows(2) + .position(|pair| pair[0] == "-jar" && pair[1] == launcher_jar_path.as_ref()); + let has_direct_layout = owned_launcher_index.is_some(); + let mut custom_jvm_arguments = Vec::new(); + let mut server_arguments = Vec::new(); + let mut after_launcher = false; + let mut index = 0; + while index < arguments.len() { + let argument = &arguments[index]; + if argument == "--java-executable" || argument == "-data" { + index += usize::from(index + 1 < arguments.len()) + 1; + continue; + } + if argument == "--jvm-arg" { + if let Some(value) = arguments.get(index + 1) { + if !is_jdt_owned_jvm_argument(value) { + custom_jvm_arguments.push(value.clone()); + } + } + index += usize::from(index + 1 < arguments.len()) + 1; + continue; + } + if let Some(value) = argument.strip_prefix("--jvm-arg=") { + if !is_jdt_owned_jvm_argument(value) { + custom_jvm_arguments.push(value.to_string()); + } + index += 1; + continue; + } + if owned_launcher_index == Some(index) { + after_launcher = true; + index += usize::from(index + 1 < arguments.len()) + 1; + continue; + } + if argument == "-configuration" && has_direct_layout && after_launcher { + index += usize::from(index + 1 < arguments.len()) + 1; + continue; + } + if has_direct_layout + && !after_launcher + && ((argument == "--add-modules" + && arguments + .get(index + 1) + .is_some_and(|value| value == "ALL-SYSTEM")) + || (argument == "--add-opens" + && arguments.get(index + 1).is_some_and(|value| { + value == "java.base/java.util=ALL-UNNAMED" + || value == "java.base/java.lang=ALL-UNNAMED" + }))) + { + index += 2; + continue; + } + if argument.starts_with("--java-executable=") || argument.starts_with("-data=") { + index += 1; + continue; + } + if has_direct_layout && !after_launcher { + if !is_jdt_owned_jvm_argument(argument) { + custom_jvm_arguments.push(argument.clone()); + } + } else if !is_jdt_owned_server_argument(argument) { + server_arguments.push(argument.clone()); + } + index += 1; + } + (custom_jvm_arguments, server_arguments) +} + +fn is_jdt_owned_jvm_argument(argument: &str) -> bool { + // `-Xmx` and `-Dlog.level=` are the plugin's to choose, so they stay in + // the custom arguments, where `direct_java_arguments` reads them. + argument.starts_with("-Xms") + || argument == "--add-modules=ALL-SYSTEM" + || argument == "--add-opens=java.base/java.util=ALL-UNNAMED" + || argument == "--add-opens=java.base/java.lang=ALL-UNNAMED" + || argument.starts_with("-Declipse.application=") + || argument.starts_with("-Declipse.product=") + || argument.starts_with("-Dosgi.bundles.defaultStartLevel=") + || argument.starts_with("-Dlog.protocol=") + || is_tuning_default(argument) + || is_metadata_location_argument(argument) + || is_lombok_agent_argument(argument) +} + +fn is_metadata_location_argument(argument: &str) -> bool { + argument + .strip_prefix(METADATA_AT_PROJECT_ROOT_PROPERTY) + .is_some_and(|rest| rest.starts_with('=')) +} + +fn is_lombok_agent_argument(argument: &str) -> bool { + let Some(path) = argument.strip_prefix("-javaagent:") else { + return false; + }; + path.split_once('=') + .map_or(path, |(path, _)| path) + .replace('\\', "/") + .rsplit('/') + .next() + .is_some_and(|name| name.eq_ignore_ascii_case("lombok.jar")) +} + +fn is_jdt_owned_server_argument(argument: &str) -> bool { + argument == "-data" || argument.starts_with("-data=") +} + +fn without_wrapper_owned_arguments(arguments: &[String]) -> Vec { + let mut retained = Vec::with_capacity(arguments.len()); + let mut index = 0; + while index < arguments.len() { + let argument = &arguments[index]; + let owns_following_value = argument == "--java-executable" || argument == "-data"; + let owns_inline_value = argument.starts_with("--java-executable=") + || argument.starts_with("-data=") + || argument.starts_with("--jvm-arg=-Xms") + || argument.starts_with("--jvm-arg=-Xmx") + || argument.strip_prefix("--jvm-arg=").is_some_and(|value| { + is_metadata_location_argument(value) || is_tuning_default(value) + }); + if owns_following_value { + index += usize::from(index + 1 < arguments.len()) + 1; + } else { + if !owns_inline_value { + retained.push(argument.clone()); + } + index += 1; + } + } + retained +} + +fn java_settings(settings: JdtSettings<'_>) -> Value { + let mut java = json!({ + "java": { + "eclipse": { + "downloadSources": false + }, + "maven": { + "downloadSources": false + }, + "configuration": { + "updateBuildConfiguration": "automatic" + }, + // JDT LS otherwise inserts parameter names into calls. Its "off" + // mode keeps the argument list empty and the snippet cursor inside. + "completion": java_completion_settings(settings.completion), + "inlayHints": { + "parameterNames": { + "enabled": "all" + } + }, + // Show "N implementations" code lens above interface methods and classes. + "implementationsCodeLens": { + "enabled": true + }, + // Show "N references" code lens above declarations. + "referencesCodeLens": { + "enabled": true + } + } + }); + // Contributed keys never overlap the keys set above or below: none of + // them lies inside `CONTRIBUTED_SETTINGS_KEYS`. + for fragment in settings.contributed { + for (key, value) in accept_contributed_settings(fragment).accepted { + insert_dotted(&mut java, &key, value); + } + } + if settings.indexing_root_uri.is_some() || settings.import_exclusions.is_some() { + let mut exclusions = settings + .import_exclusions + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if let Some(root_uri) = settings.indexing_root_uri { + for path in crate::host::indexing::java_import_exclusions(root_uri) { + let value = Value::String(path); + if !exclusions.contains(&value) { + exclusions.push(value); + } + } + } + java["java"]["import"]["exclusions"] = json!(exclusions); + } + if !settings.java_runtimes.is_empty() { + java["java"]["configuration"]["runtimes"] = java_runtimes_value(settings.java_runtimes); + } + java +} + +/// Stores `value` at a dotted key such as `java.import.gradle.offline.enabled`, +/// creating objects along the way and replacing non-object values in the path. +fn insert_dotted(root: &mut Value, key: &str, value: Value) { + let mut target = root; + for segment in key.split('.') { + if !target.is_object() { + *target = json!({}); + } + target = target + .as_object_mut() + .expect("the path value was normalized to an object") + .entry(segment.to_string()) + .or_insert(Value::Null); + } + *target = value; +} + +/// Retains the plugin's completion leaves while applying Lithe's argument policy. +/// Invalid section shapes are normalized without mutating the session input. +fn java_completion_settings(declared: Option<&Value>) -> Value { + let mut completion = declared + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + completion.insert("guessMethodArguments".to_string(), json!("off")); + Value::Object(completion) +} + +fn java_runtimes_value(runtimes: &[JdtJavaRuntime]) -> Value { + Value::Array( + runtimes + .iter() + .map(|runtime| { + let mut value = json!({ "name": runtime.name, "path": runtime.path }); + if runtime.is_default { + value["default"] = json!(true); + } + value + }) + .collect(), + ) +} + +/// Answers a `workspace/configuration` item from the same settings sent at +/// startup, so a section request and the initialization options never +/// disagree. Unknown sections answer `null`, which JDT LS treats as "use the +/// default". +fn java_configuration_for_section(section: Option<&str>, settings: JdtSettings<'_>) -> Value { + let Some(section) = section else { + return Value::Null; + }; + if section == "java.configuration.runtimes" { + // Answered with a list even when no runtime is registered; the + // initialization options omit an empty list instead. + return java_runtimes_value(settings.java_runtimes); + } + let settings = java_settings(settings); + section + .split('.') + .try_fold(&settings, |value, key| value.get(key)) + .cloned() + .unwrap_or(Value::Null) +} + +/// Resolves a platform request through the same key algorithm used at startup. +pub fn resolve_workspace_key(request: JdtWorkspaceKeyRequest) -> JdtWorkspaceKeyResponse { + JdtWorkspaceKeyResponse { + workspace_key: workspace_key( + Path::new(&request.workspace_root), + request.workspace_fingerprint.as_deref(), + ), + } +} + +/// Hashes the normalized workspace identity and optional structural fingerprint. +pub fn workspace_key(workspace_root: &Path, fingerprint: Option<&str>) -> String { + let mut identity = normalized_workspace_identity(workspace_root); + // Mix the structural fingerprint in as a null-delimited suffix so changes + // to build files or the module set produce a fresh state directory without + // touching the previous one. Absent fingerprint keeps the legacy key. + if let Some(fp) = fingerprint { + identity.push('\x00'); + identity.push_str(fp); + } + let digest = Sha256::digest(identity.as_bytes()); + let mut key = String::with_capacity(digest.len() * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + key.push(HEX[(byte >> 4) as usize] as char); + key.push(HEX[(byte & 0x0f) as usize] as char); + } + key +} + +fn normalized_workspace_identity(workspace_root: &Path) -> String { + let raw = workspace_root.to_string_lossy().replace('\\', "/"); + let bytes = raw.as_bytes(); + let has_drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; + let is_unc = raw.starts_with("//"); + let (prefix, remainder, is_absolute, protected_components, fold_case) = if has_drive { + let drive = (bytes[0] as char).to_ascii_lowercase(); + let remainder = &raw[2..]; + ( + format!("{drive}:"), + remainder, + remainder.starts_with('/'), + 0, + true, + ) + } else if is_unc { + ("//".to_string(), raw.trim_start_matches('/'), true, 2, true) + } else if raw.starts_with('/') { + ("/".to_string(), raw.trim_start_matches('/'), true, 0, false) + } else { + (String::new(), raw.as_str(), false, 0, false) + }; + + let mut components: Vec = Vec::new(); + for component in remainder.split('/') { + match component { + "" | "." => {} + ".." => { + if components.len() > protected_components + && components.last().is_some_and(|value| value != "..") + { + components.pop(); + } else if !is_absolute { + components.push("..".to_string()); + } + } + value => components.push(if fold_case { + value.to_lowercase() + } else { + value.to_string() + }), + } + } + + let joined = components.join("/"); + match (prefix.as_str(), joined.is_empty(), is_absolute) { + ("", true, _) => ".".to_string(), + ("/", true, _) => "/".to_string(), + ("//", true, _) => "//".to_string(), + (prefix, true, _) => prefix.to_string(), + ("", false, _) => joined, + ("/", false, _) => format!("/{joined}"), + ("//", false, _) => format!("//{joined}"), + (prefix, false, true) => format!("{prefix}/{joined}"), + (prefix, false, false) => format!("{prefix}{joined}"), + } +} + +fn has_uri_scheme(uri: &str, expected: &str) -> bool { + uri.split_once(':') + .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case(expected)) +} + +fn jdt_display_path(uri: &str) -> Option { + let (_, remainder) = uri.split_once(':')?; + let path = if let Some(with_authority) = remainder.strip_prefix("//") { + with_authority + .find('/') + .map(|index| &with_authority[index + 1..])? + } else { + remainder.trim_start_matches('/') + }; + let path = path.split_once(['?', '#']).map_or(path, |(value, _)| value); + let decoded = percent_decode(path); + let mut components: Vec<_> = decoded + .split('/') + .filter(|component| !component.is_empty()) + .map(str::to_string) + .collect(); + let last = components.last_mut()?; + if let Some(class_name) = last.strip_suffix(".class") { + *last = format!("{class_name}.java"); + } + Some(components.join("/")) +} + +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let (Some(high), Some(low)) = + (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) + { + decoded.push((high << 4) | low); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&decoded).into_owned() +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn runtime(home_path: &str, version: &str) -> JavaRuntimeCandidate { + JavaRuntimeCandidate { + is_default: false, + home_path: home_path.to_string(), + version: version.to_string(), + } + } + + #[test] + fn one_jdk_per_major_version_becomes_a_jdt_execution_environment() { + // A Java 25 project only builds when JDT LS knows a JavaSE-25 JDK; the + // bundled JDK 21 it runs on is not enough (#769). + let runtimes = jdt_java_runtimes(&[ + runtime("/jdks/temurin-25", "25.0.4.1"), + runtime("/jdks/corretto-25", "25.0.1"), + runtime("/jdks/jdk8", "1.8.0_402"), + runtime("/jdks/unknown", "not-a-version"), + runtime("", "17.0.9"), + runtime("/jdks/temurin-21", "21"), + ]); + assert_eq!( + runtimes, + vec![ + JdtJavaRuntime { + is_default: false, + name: "JavaSE-1.8".to_string(), + path: "/jdks/jdk8".to_string() + }, + JdtJavaRuntime { + is_default: false, + name: "JavaSE-21".to_string(), + path: "/jdks/temurin-21".to_string() + }, + JdtJavaRuntime { + is_default: false, + name: "JavaSE-25".to_string(), + path: "/jdks/temurin-25".to_string() + }, + ] + ); + } + + #[test] + fn selected_project_vm_wins_over_discovery_order_and_duplicate_majors() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../fixtures/lsp/java-project-runtimes-v1.json" + ))) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let candidates: Vec = + serde_json::from_value(case["candidates"].clone()).unwrap(); + assert_eq!( + java_runtimes_value(&jdt_java_runtimes(&candidates)), + case["expected"], + "{}", + case["name"] + ); + } + } + + #[test] + fn java_runtimes_reach_jdt_at_initialize_and_on_configuration_requests() { + let runtimes = [JdtJavaRuntime { + is_default: true, + name: "JavaSE-25".to_string(), + path: "C:/Program Files/Java/jdk-25".to_string(), + }]; + let settings = JdtSettings { + java_runtimes: &runtimes, + ..Default::default() + }; + let expected = json!([{ "name": "JavaSE-25", "path": "C:/Program Files/Java/jdk-25", "default": true }]); + let options = adapt_initialization_options("java", None, &[], settings).unwrap(); + assert_eq!( + options["settings"]["java"]["configuration"]["runtimes"], + expected + ); + let items = ["java", "java.configuration", "java.configuration.runtimes"].map(|section| { + WorkspaceConfigurationItem { + scope_uri: None, + section: Some(section.to_string()), + } + }); + let values = workspace_configuration("java", &items, settings).unwrap(); + assert_eq!(values[0]["configuration"]["runtimes"], expected); + assert_eq!(values[1]["runtimes"], expected); + assert_eq!(values[2], expected); + let notification = initialized_notification("java", settings).unwrap(); + assert_eq!( + notification.params["settings"]["java"]["configuration"]["runtimes"], + expected + ); + } + + #[test] + fn no_known_jdks_leaves_jdt_on_its_own_runtime() { + let options = + adapt_initialization_options("java", None, &[], JdtSettings::default()).unwrap(); + assert!(options["settings"]["java"]["configuration"] + .get("runtimes") + .is_none()); + } + + fn contributed_settings(fragments: &[Value]) -> JdtSettings<'_> { + JdtSettings { + contributed: fragments, + ..Default::default() + } + } + + #[test] + fn contributed_fragments_keep_only_the_declared_jdtls_keys() { + // Nested objects, dotted keys and a mix of both reach the same leaves. + let fragment = json!({ + "java": { + "configuration": { + "maven": { "globalSettings": "/state/maven/settings.xml" }, + "runtimes": [] + }, + "import.gradle": { "offline": { "enabled": true } }, + "project": { "sourcePaths": ["src/main/java"], "outputPath": "bin" }, + "maven": { "downloadSources": true } + }, + "java.import.maven.enabled": true, + "java.import.gradle": false, + "java.import.gradle..home": "/empty-segment", + "java.project.sourcePaths.extra": 1, + "editor": { "tabSize": 2 } + }); + + let contributed = accept_contributed_settings(&fragment); + + assert_eq!( + contributed.accepted, + BTreeMap::from([ + ( + "java.configuration.maven.globalSettings".to_string(), + json!("/state/maven/settings.xml") + ), + ( + "java.import.gradle.offline.enabled".to_string(), + json!(true) + ), + ("java.import.maven.enabled".to_string(), json!(true)), + ( + "java.project.sourcePaths".to_string(), + json!(["src/main/java"]) + ), + ]) + ); + let mut rejected = contributed.rejected; + rejected.sort(); + // A prefix entry accepts keys below it, never the namespace itself; + // an exact entry accepts nothing below it. + assert_eq!( + rejected, + [ + "editor.tabSize", + "java.configuration.runtimes", + "java.import.gradle", + "java.import.gradle..home", + "java.maven.downloadSources", + "java.project.outputPath", + "java.project.sourcePaths.extra", + ] + ); + for fragment in [json!(null), json!("java.import.maven.enabled"), json!([{}])] { + assert_eq!( + accept_contributed_settings(&fragment), + ContributedSettings::default() + ); + } + } + + #[test] + fn contributed_settings_reach_jdt_before_the_first_import() { + // JDT LS configures its Maven embedder and Buildship while handling + // `initialize`, and the project import starts right after. Settings + // sent only through `didChangeConfiguration` would arrive once that + // import had already resolved every artifact against the defaults. + let fragments = [ + json!({ + "java.configuration.maven.globalSettings": "/state/maven/settings.xml", + "java.project.sourcePaths": ["src/main/java"], + // Owned by this plugin, so a contribution cannot change it. + "java.maven.downloadSources": true + }), + json!({ + "java": { "import": { "gradle": { "enabled": true, "wrapper": { "enabled": true } } } } + }), + ]; + + let options = adapt_initialization_options( + "java", + Some(json!({ + "workspace": { "custom": true }, + "settings": { "java": { "custom": true } } + })), + &[], + contributed_settings(&fragments), + ) + .unwrap(); + + let java = &options["settings"]["java"]; + assert_eq!( + java["configuration"]["maven"]["globalSettings"], + "/state/maven/settings.xml" + ); + assert_eq!( + java["configuration"]["updateBuildConfiguration"], + "automatic" + ); + assert_eq!(java["project"]["sourcePaths"], json!(["src/main/java"])); + assert_eq!(java["import"]["gradle"]["enabled"], true); + assert_eq!(java["import"]["gradle"]["wrapper"]["enabled"], true); + assert_eq!(java["maven"]["downloadSources"], false); + // Catalog values outside the provider-owned keys survive the overlay. + assert_eq!(java["custom"], true); + assert_eq!(options["workspace"]["custom"], true); + } + + #[test] + fn later_contributions_replace_earlier_ones_leaf_by_leaf() { + let fragments = [ + json!({ + "java.import.gradle.offline.enabled": true, + "java.import.gradle.user.home": "/caches/first" + }), + json!({ "java": { "import": { "gradle": { "user": { "home": "/caches/second" } } } } }), + ]; + + let options = + adapt_initialization_options("java", None, &[], contributed_settings(&fragments)) + .unwrap(); + + let gradle = &options["settings"]["java"]["import"]["gradle"]; + assert_eq!(gradle["offline"]["enabled"], true); + assert_eq!(gradle["user"]["home"], "/caches/second"); + } + + #[test] + fn contributed_import_settings_keep_the_catalog_import_options() { + // A contribution states only the leaves its plugin knows; a declared + // `import.gradle` subtree has to survive the overlay instead of being + // replaced by those leaves. + let fragments = [json!({ + "java.import.gradle.enabled": true, + "java.import.gradle.wrapper.enabled": true + })]; + + let options = adapt_initialization_options( + "java", + Some(json!({ + "settings": { + "java": { + "import": { + "gradle": { + "offline": { "enabled": true }, + "version": "8.10" + } + } + } + } + })), + &[], + contributed_settings(&fragments), + ) + .unwrap(); + + let gradle = &options["settings"]["java"]["import"]["gradle"]; + assert_eq!(gradle["enabled"], true); + assert_eq!(gradle["wrapper"]["enabled"], true); + assert_eq!(gradle["offline"]["enabled"], true); + assert_eq!(gradle["version"], "8.10"); + } + + #[test] + fn workspace_configuration_and_notifications_answer_contributed_sections() { + let fragments = [ + json!({ + "java.configuration.maven.userSettings": "/local/settings.xml", + "java.configuration.maven.globalSettings": "/state/maven/settings.xml", + "java.project.sourcePaths": ["src/main/java", "module-a/src/main/java"] + }), + json!({ "java.import.gradle.enabled": true, "java.import.gradle.wrapper.enabled": true }), + ]; + let settings = contributed_settings(&fragments); + let items = [ + "java.configuration.maven", + "java.configuration.maven.userSettings", + "java.configuration.maven.globalSettings", + "java.import", + "java.import.gradle.wrapper", + "java.import.gradle.wrapper.enabled", + "java.import.gradle.version", + "java.project", + "java.project.sourcePaths", + ] + .map(|section| WorkspaceConfigurationItem { + scope_uri: Some("file:///workspace/reactor/".to_string()), + section: Some(section.to_string()), + }); + + let values = workspace_configuration("java", &items, settings).unwrap(); + assert_eq!( + values[0], + json!({ + "userSettings": "/local/settings.xml", + "globalSettings": "/state/maven/settings.xml" + }) + ); + assert_eq!(values[1], "/local/settings.xml"); + assert_eq!(values[2], "/state/maven/settings.xml"); + assert_eq!(values[3]["gradle"]["enabled"], true); + assert_eq!(values[4], json!({ "enabled": true })); + assert_eq!(values[5], true); + // A key no plugin contributed stays on JDT LS's own default. + assert_eq!(values[6], Value::Null); + let source_paths = json!(["src/main/java", "module-a/src/main/java"]); + assert_eq!(values[7], json!({ "sourcePaths": source_paths })); + assert_eq!(values[8], source_paths); + + for notification in [ + initialized_notification("java", settings).unwrap(), + settings_notification(settings), + ] { + let java = ¬ification.params["settings"]["java"]; + assert_eq!( + java["configuration"]["maven"]["userSettings"], + "/local/settings.xml" + ); + assert_eq!(java["import"]["gradle"]["wrapper"]["enabled"], true); + assert_eq!(java["project"]["sourcePaths"], source_paths); + } + } + + #[test] + fn project_updates_name_each_project_once_in_first_seen_order() { + let uris = [ + "file:///workspace/reactor/", + "file:///workspace/reactor/module-a/", + "file:///workspace/reactor/", + "file:///workspace/reactor/module-a/nested/", + ] + .map(str::to_string); + + let notification = project_update_notification(&uris).unwrap(); + + assert_eq!(notification.method, "java/projectConfigurationsUpdate"); + assert_eq!( + notification.params, + json!({ + "identifiers": [ + { "uri": "file:///workspace/reactor/" }, + { "uri": "file:///workspace/reactor/module-a/" }, + { "uri": "file:///workspace/reactor/module-a/nested/" } + ] + }) + ); + assert!(project_update_notification(&[]).is_none()); + } + + fn java_start_context() -> JdtStartContext { + JdtStartContext { + provider_id: "java".to_string(), + workspace_root: PathBuf::from("/workspace/project"), + data_root: PathBuf::from("/cache/Lithe"), + selected_java_executable: Some(PathBuf::from("/jdk/bin/java")), + direct_launch_resources: None, + arguments: vec!["--stdio".to_string()], + workspace_fingerprint: None, + } + } + + #[test] + fn import_progress_extracts_module_and_download_diagnostics() { + let module = import_progress( + "java", + Some("$/progress"), + Some(&json!({ + "value": { + "kind": "report", + "message": "Importing Maven project(s) - Importing project module-a", + "percentage": 24 + } + })), + ) + .expect("Java work-done progress should be observed"); + assert_eq!(module.phase.as_deref(), Some("Importing Maven project(s)")); + assert_eq!(module.current_project.as_deref(), Some("module-a")); + assert_eq!(module.percentage, Some(24)); + + let download = import_progress( + "java", + Some("$/progress"), + Some(&json!({ + "value": { + "kind": "report", + "message": "Importing Maven project(s) - 117KB/7MB (1%) https://repo.maven.apache.org/maven2/org/apache/poi/poi.jar", + "percentage": 49 + } + })), + ) + .and_then(|progress| progress.download) + .expect("artifact transfer details should be parsed"); + assert_eq!(download.artifact, "poi.jar"); + assert_eq!( + download.repository_host.as_deref(), + Some("repo.maven.apache.org") + ); + assert_eq!(download.downloaded_bytes, Some(117 * 1024)); + assert_eq!(download.total_bytes, Some(7 * 1024 * 1024)); + } + + #[test] + fn import_progress_ignores_other_providers_and_status_text() { + let params = json!({ + "value": { + "kind": "report", + "message": "Importing Maven project(s) - 50%", + "percentage": 50 + } + }); + assert!(import_progress("gopls", Some("$/progress"), Some(¶ms)).is_none()); + assert!(import_progress("java", Some("language/status"), Some(¶ms)).is_none()); + } + + #[test] + fn java_start_adds_runtime_memory_and_unique_data_arguments() { + let context = java_start_context(); + let adapted = adapt_start(&context); + let data_directory = adapted.data_directory.as_ref().unwrap(); + + assert_eq!( + data_directory.parent().unwrap(), + Path::new("/cache/Lithe/jdtls") + ); + assert_eq!( + data_directory.file_name().unwrap().to_string_lossy().len(), + 64 + ); + assert_eq!( + adapted.arguments, + vec![ + "--stdio", + "--java-executable", + "/jdk/bin/java", + "--jvm-arg=-Xms256m", + "--jvm-arg=-Xmx2048m", + "--jvm-arg=-XX:+UseParallelGC", + "--jvm-arg=-XX:GCTimeRatio=4", + "--jvm-arg=-XX:AdaptiveSizePolicyWeight=90", + "--jvm-arg=-Xlog:disable", + "--jvm-arg=-DDetectVMInstallationsJob.disabled=true", + "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false", + "-data", + data_directory.to_string_lossy().as_ref() + ] + ); + } + + #[test] + fn java_start_lets_plugin_jvm_options_replace_the_tuning_defaults() { + let tuning_of = |arguments: &[String]| { + arguments + .iter() + .filter_map(|argument| argument.strip_prefix("--jvm-arg=")) + .filter(|argument| argument.starts_with("-XX:") || argument.starts_with("-Xlog")) + .map(str::to_string) + .collect::>() + }; + let mut context = java_start_context(); + context.arguments = vec![ + "--jvm-arg=-XX:+UseG1GC".to_string(), + "--jvm-arg=-XX:GCTimeRatio=9".to_string(), + "--jvm-arg=-Xlog:gc*:file=/tmp/gc.log".to_string(), + ]; + let first = adapt_start(&context); + context.arguments = first.arguments.clone(); + let second = adapt_start(&context); + + assert_eq!(first, second); + // The plugin's collector replaces ParallelGC, and its own GC-time + // ratio and logging replace the host's; the untouched default stays. + assert_eq!( + tuning_of(&first.arguments), + vec![ + "-XX:+UseG1GC", + "-XX:GCTimeRatio=9", + "-Xlog:gc*:file=/tmp/gc.log", + "-XX:AdaptiveSizePolicyWeight=90", + ] + ); + assert!(first + .arguments + .contains(&"--jvm-arg=-DDetectVMInstallationsJob.disabled=true".to_string())); + } + + #[test] + fn java_start_uses_the_plugins_heap_choice_and_is_stable() { + let mut context = java_start_context(); + context.arguments = vec![ + "--java-executable".to_string(), + "/old/java".to_string(), + "--jvm-arg=-Xms2g".to_string(), + "--jvm-arg=-Xmx4g".to_string(), + "--jvm-arg=-Duser.language=en".to_string(), + // A catalog or user override must not put metadata back into the project. + "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=true".to_string(), + "-data".to_string(), + "/old/data".to_string(), + ]; + let first = adapt_start(&context); + context.arguments = first.arguments.clone(); + let second = adapt_start(&context); + + assert_eq!(first, second); + assert!(first + .arguments + .contains(&"--jvm-arg=-Duser.language=en".to_string())); + // The plugin chooses the maximum heap; the initial heap stays the host's. + assert_eq!( + first + .arguments + .iter() + .filter(|argument| argument.starts_with("--jvm-arg=-Xm")) + .collect::>(), + vec!["--jvm-arg=-Xms256m", "--jvm-arg=-Xmx4g"] + ); + assert!(!first.arguments.contains(&"/old/java".to_string())); + assert!(!first.arguments.contains(&"/old/data".to_string())); + assert_eq!( + first + .arguments + .iter() + .filter(|argument| argument.contains("generatesMetadataFilesAtProjectRoot")) + .collect::>(), + vec!["--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false"] + ); + } + + #[test] + fn java_start_keeps_the_heap_above_the_jdtls_constrained_memory_threshold() { + // JDT LS switches to a batched Maven import whose `updateProjects` path + // dereferences a `null` project for modules that share a build + // coordinate (eclipse-jdtls/eclipse.jdt.ls#3893). Staying above 1.5 GB + // keeps a normal multi-module workspace on the regular import path. + const CONSTRAINED_MEMORY_THRESHOLD_MEBIBYTES: u64 = 1536; + + let mut direct_context = java_start_context(); + direct_context.direct_launch_resources = Some(JdtDirectLaunchResources { + launcher_jar_path: PathBuf::from("/jdtls/plugins/equinox.jar"), + configuration_directory: PathBuf::from("/jdtls/config_mac"), + lombok_agent_path: PathBuf::from("/jdtls/lombok/lombok.jar"), + java_debug_bundle_path: None, + }); + let wrapper = adapt_start(&java_start_context()); + let direct = adapt_start(&direct_context); + + for arguments in [&wrapper.arguments, &direct.arguments] { + let heap = arguments + .iter() + .find_map(|argument| { + argument + .strip_prefix("--jvm-arg=") + .unwrap_or(argument) + .strip_prefix("-Xmx") + }) + .expect("both JDT LS launch paths set a maximum heap"); + let mebibytes = heap + .strip_suffix('m') + .and_then(|value| value.parse::().ok()) + .expect("the JDT LS maximum heap uses a mebibyte suffix"); + assert!( + mebibytes > CONSTRAINED_MEMORY_THRESHOLD_MEBIBYTES, + "JDT LS needs more than {CONSTRAINED_MEMORY_THRESHOLD_MEBIBYTES} MiB of maximum heap, found {heap}" + ); + } + } + + #[test] + fn java_direct_start_builds_complete_shell_free_arguments_and_is_stable() { + let mut context = java_start_context(); + context.direct_launch_resources = Some(JdtDirectLaunchResources { + launcher_jar_path: PathBuf::from("/jdtls/plugins/equinox.jar"), + configuration_directory: PathBuf::from("/jdtls/config_mac"), + lombok_agent_path: PathBuf::from("/jdtls/lombok/lombok.jar"), + java_debug_bundle_path: Some(PathBuf::from( + "/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + )), + }); + context.arguments = vec![ + "--stdio".to_string(), + "-jar".to_string(), + "/server/extension.jar".to_string(), + "--java-executable=/old/java".to_string(), + "--jvm-arg=-Xmx4g".to_string(), + "--jvm-arg=-Dlog.level=INFO".to_string(), + "--jvm-arg=-Duser.language=en".to_string(), + "--jvm-arg=-Dosgi.logfile=/session/plugins/java/files/jdt.log".to_string(), + "--jvm-arg=-Declipse.log.include.commandline=false".to_string(), + "--jvm-arg=-Declipse.log.size.max=1024".to_string(), + "--jvm-arg=-Declipse.log.backup.max=2".to_string(), + "--jvm-arg=-Declipse.log.level=WARNING".to_string(), + "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=true".to_string(), + "-data=/old/data".to_string(), + ]; + + let first = adapt_start(&context); + context.arguments = first.arguments.clone(); + let second = adapt_start(&context); + let data_directory = first.data_directory.as_ref().unwrap(); + + assert_eq!(first, second); + assert_eq!(first.executable, Some(PathBuf::from("/jdk/bin/java"))); + assert_eq!( + first.arguments, + vec![ + "-javaagent:/jdtls/lombok/lombok.jar", + "-Xms256m", + "-Xmx4g", + "-XX:+UseParallelGC", + "-XX:GCTimeRatio=4", + "-XX:AdaptiveSizePolicyWeight=90", + "-Xlog:disable", + "-DDetectVMInstallationsJob.disabled=true", + "--add-modules=ALL-SYSTEM", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "-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=INFO", + "-Djava.import.generatesMetadataFilesAtProjectRoot=false", + "-Duser.language=en", + "-Dosgi.logfile=/session/plugins/java/files/jdt.log", + "-Declipse.log.include.commandline=false", + "-Declipse.log.size.max=1024", + "-Declipse.log.backup.max=2", + "-Declipse.log.level=WARNING", + "-jar", + "/jdtls/plugins/equinox.jar", + "-configuration", + "/jdtls/config_mac", + "--stdio", + "-jar", + "/server/extension.jar", + "-data", + data_directory.to_string_lossy().as_ref(), + ] + ); + assert!(!first.arguments.contains(&"/old/java".to_string())); + assert!(!first.arguments.contains(&"/old/data".to_string())); + assert!(!first.arguments.contains(&"-Dlog.level=ALL".to_string())); + } + + #[test] + fn direct_start_keeps_host_defaults_without_a_choice_and_ignores_malformed_ones() { + let resources = JdtDirectLaunchResources { + launcher_jar_path: PathBuf::from("/jdtls/plugins/equinox.jar"), + configuration_directory: PathBuf::from("/jdtls/config_mac"), + lombok_agent_path: PathBuf::from("/jdtls/lombok/lombok.jar"), + java_debug_bundle_path: None, + }; + let data = Path::new("/data"); + let heap_and_level = |arguments: &[&str]| { + let arguments: Vec = arguments.iter().map(|value| value.to_string()).collect(); + let adapted = direct_java_arguments(&arguments, &resources, data); + ( + adapted + .iter() + .filter(|argument| argument.starts_with("-Xmx")) + .cloned() + .collect::>(), + adapted + .iter() + .filter(|argument| argument.starts_with("-Dlog.level=")) + .cloned() + .collect::>(), + ) + }; + + assert_eq!( + heap_and_level(&[]), + ( + vec!["-Xmx2048m".to_string()], + vec!["-Dlog.level=WARNING".to_string()] + ) + ); + // The last acceptable choice wins; a malformed one never reaches the JVM. + assert_eq!( + heap_and_level(&[ + "--jvm-arg=-Xmx2g", + "--jvm-arg=-Xmx3072m", + "--jvm-arg=-Dlog.level=WARNING", + "--jvm-arg=-Dlog.level=SEV ERE", + ]), + ( + vec!["-Xmx3072m".to_string()], + vec!["-Dlog.level=WARNING".to_string()] + ) + ); + assert_eq!( + heap_and_level(&[ + "--jvm-arg=-Xmx", + "--jvm-arg=-Xmxlots", + "--jvm-arg=-Xmx1024m", + "--jvm-arg=-Xmx1536m", + "--jvm-arg=-Dlog.level=", + ]), + ( + vec!["-Xmx2048m".to_string()], + vec!["-Dlog.level=WARNING".to_string()] + ) + ); + // A plugin that launches the server as `java -jar ` + // puts its choices before the launcher, where they are read the same way. + assert_eq!( + heap_and_level(&[ + "-Xmx2048m", + "-Dlog.level=SEVERE", + "-jar", + "/jdtls/plugins/equinox.jar", + ]), + ( + vec!["-Xmx2048m".to_string()], + vec!["-Dlog.level=SEVERE".to_string()] + ) + ); + } + + #[test] + fn workspace_identity_is_lexical_cross_platform_and_unique() { + assert_eq!( + workspace_key(Path::new(r"C:\Users\Ada\Project\.\src\.."), None), + workspace_key(Path::new("c:/users/ada/project"), None) + ); + assert_eq!( + workspace_key(Path::new("/workspace/project/./src/.."), None), + workspace_key(Path::new("/workspace/project"), None) + ); + assert_ne!( + workspace_key(Path::new("/workspace/project"), None), + workspace_key(Path::new("/workspace/other"), None) + ); + } + + #[test] + fn workspace_fingerprint_changes_data_directory() { + let mut context = java_start_context(); + let without = adapt_start(&context).data_directory.unwrap(); + context.workspace_fingerprint = Some("pom=111|modules=core,web|jdtls=1.38.0".to_string()); + let first = adapt_start(&context).data_directory.unwrap(); + context.workspace_fingerprint = Some("pom=222|modules=core,web|jdtls=1.38.0".to_string()); + let changed = adapt_start(&context).data_directory.unwrap(); + + assert_ne!(without, first); + assert_ne!(first, changed); + assert_eq!(without.parent(), first.parent()); + assert_eq!(first.parent(), changed.parent()); + assert_eq!(without.file_name().unwrap().to_string_lossy().len(), 64); + assert_eq!(first.file_name().unwrap().to_string_lossy().len(), 64); + assert_eq!(changed.file_name().unwrap().to_string_lossy().len(), 64); + + context.workspace_fingerprint = Some("pom=111|modules=core,web|jdtls=1.38.0".to_string()); + let repeated = adapt_start(&context).data_directory.unwrap(); + assert_eq!(first, repeated); + } + + #[test] + fn workspace_key_request_uses_the_same_compatibility_algorithm() { + let response = resolve_workspace_key(JdtWorkspaceKeyRequest { + workspace_root: "/workspace/project".to_string(), + workspace_fingerprint: Some("pom=111|jdtls=1.38.0".to_string()), + }); + + assert_eq!( + response.workspace_key, + workspace_key( + Path::new("/workspace/project"), + Some("pom=111|jdtls=1.38.0") + ) + ); + } + + #[test] + fn non_java_start_is_a_generic_noop() { + let mut context = java_start_context(); + context.provider_id = "rust".to_string(); + let adapted = adapt_start(&context); + + assert_eq!(adapted.arguments, context.arguments); + assert_eq!(adapted.data_directory, None); + assert!(workspace_configuration("rust", &[], JdtSettings::default()).is_none()); + assert!(initialized_notification("rust", JdtSettings::default()).is_none()); + assert!(virtual_source_resolve_params("rust", "jdt://contents/A.class").is_none()); + assert_eq!( + adapt_initialization_options( + "rust", + Some(json!({ "custom": true })), + &[], + JdtSettings::default() + ), + Some(json!({ "custom": true })) + ); + let location = ProviderLocation { + uri: "jdt://contents/A.class".to_string(), + is_read_only: false, + display_path: None, + }; + assert_eq!(normalize_location("rust", location.clone()), location); + } + + #[test] + fn java_initialization_enables_class_file_content_without_losing_catalog_options() { + let options = adapt_initialization_options( + "JAVA", + Some(json!({ + "workspace": { "custom": true }, + "bundles": ["/plugins/custom.jar"], + "extendedClientCapabilities": { + "customCapability": true, + "classFileContentsSupport": false + } + })), + &[ + PathBuf::from("/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar"), + PathBuf::from( + "/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar", + ), + ], + JdtSettings::default(), + ) + .unwrap(); + + assert_eq!(options["workspace"]["custom"], true); + assert_eq!( + options["extendedClientCapabilities"]["customCapability"], + true + ); + assert_eq!( + options["extendedClientCapabilities"]["classFileContentsSupport"], + true + ); + assert_eq!( + options["bundles"], + json!([ + "/plugins/custom.jar", + "/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + "/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar" + ]) + ); + } + + #[test] + fn java_completion_leaves_arguments_empty_across_configuration_channels() { + // Initialization, notifications and pull configuration must agree, or + // JDT LS can restore parameter placeholders after the first completion. + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../fixtures/lsp/java-completion-v1.json" + )) + .expect("Java completion fixture should be valid"); + let expected = &fixture["javaSettings"]["completion"]; + let settings = JdtSettings { + completion: Some(&fixture["pluginCompletion"]), + ..Default::default() + }; + let options = adapt_initialization_options( + "java", + Some(json!({ "settings": { "java": { "completion": fixture["pluginCompletion"] } } })), + &[], + settings, + ) + .unwrap(); + assert_eq!( + options["settings"]["java"]["completion"]["guessMethodArguments"], + expected["guessMethodArguments"] + ); + assert_eq!( + options["settings"]["java"]["completion"]["favoriteStaticMembers"], + json!(["java.util.Objects.*"]) + ); + + assert_eq!(options["settings"]["java"]["completion"], *expected); + let notification = initialized_notification("java", settings).unwrap(); + assert_eq!( + notification.params["settings"]["java"]["completion"], + *expected + ); + let items = fixture["workspaceConfigurationSections"] + .as_array() + .unwrap() + .iter() + .map(|section| WorkspaceConfigurationItem { + scope_uri: None, + section: Some(section.as_str().unwrap().to_string()), + }) + .collect::>(); + let values = workspace_configuration("java", &items, settings).unwrap(); + assert_eq!(values[0]["completion"], *expected); + assert_eq!(values[1], *expected); + assert_eq!(values[2], expected["guessMethodArguments"]); + assert_eq!(values[3], expected["favoriteStaticMembers"]); + assert_eq!(values[4], expected["filteredTypes"]); + assert_eq!(values[5], Value::Null); + assert_eq!( + fixture["pluginCompletion"]["guessMethodArguments"], + "insertParameterNames" + ); + } + + #[test] + fn java_initialization_options_carry_settings_without_contributions() { + let options = + adapt_initialization_options("java", None, &[], JdtSettings::default()).unwrap(); + + assert_eq!( + options["settings"]["java"]["maven"]["downloadSources"], + false + ); + // Without a contribution JDT LS keeps its own Maven defaults rather + // than receiving an empty or null settings section. + assert!(options["settings"]["java"]["configuration"] + .get("maven") + .is_none()); + } + + #[test] + fn java_initialization_options_omit_import_without_contributions() { + let options = + adapt_initialization_options("java", None, &[], JdtSettings::default()).unwrap(); + + assert!(options["settings"]["java"].get("import").is_none()); + } + + #[test] + fn java_workspace_configuration_matches_each_section_shape() { + let items = [ + "java", + "java.eclipse.downloadSources", + "java.maven.downloadSources", + "java.inlayHints", + "java.inlayHints.parameterNames", + "java.inlayHints.parameterNames.enabled", + "java.implementationsCodeLens", + "java.implementationsCodeLens.enabled", + "java.referencesCodeLens", + "java.referencesCodeLens.enabled", + "java.unknown", + ] + .map(|section| WorkspaceConfigurationItem { + scope_uri: Some("file:///workspace/project".to_string()), + section: Some(section.to_string()), + }); + let values = workspace_configuration("java", &items, JdtSettings::default()).unwrap(); + + assert_eq!(values[0]["inlayHints"]["parameterNames"]["enabled"], "all"); + assert_eq!(values[0]["eclipse"]["downloadSources"], false); + assert_eq!(values[0]["maven"]["downloadSources"], false); + assert_eq!(values[0]["implementationsCodeLens"]["enabled"], true); + assert_eq!(values[0]["referencesCodeLens"]["enabled"], true); + assert_eq!(values[1], false); // java.eclipse.downloadSources + assert_eq!(values[2], false); // java.maven.downloadSources + assert_eq!(values[3]["parameterNames"]["enabled"], "all"); // java.inlayHints + assert_eq!(values[4]["enabled"], "all"); // java.inlayHints.parameterNames + assert_eq!(values[5], "all"); // java.inlayHints.parameterNames.enabled + assert_eq!(values[6]["enabled"], true); // java.implementationsCodeLens + assert_eq!(values[7], true); // java.implementationsCodeLens.enabled + assert_eq!(values[8]["enabled"], true); // java.referencesCodeLens + assert_eq!(values[9], true); // java.referencesCodeLens.enabled + assert_eq!(values[10], Value::Null); // java.unknown + } + + #[test] + fn java_initialized_notification_publishes_inlay_settings() { + let notification = initialized_notification("JAVA", JdtSettings::default()).unwrap(); + + assert_eq!(notification.method, "workspace/didChangeConfiguration"); + assert_eq!( + notification.params["settings"]["java"]["inlayHints"]["parameterNames"]["enabled"], + "all" + ); + assert_eq!( + notification.params["settings"]["java"]["eclipse"]["downloadSources"], + false + ); + assert_eq!( + notification.params["settings"]["java"]["maven"]["downloadSources"], + false + ); + assert_eq!( + notification.params["settings"]["java"]["implementationsCodeLens"]["enabled"], + true + ); + assert_eq!( + notification.params["settings"]["java"]["referencesCodeLens"]["enabled"], + true + ); + } + + #[test] + fn jdt_location_is_read_only_with_a_source_display_path() { + let location = normalize_location( + "java", + ProviderLocation { + uri: "jdt://contents/java.base/java/util/Map%24Entry.class?=demo".to_string(), + is_read_only: false, + display_path: None, + }, + ); + + assert!(location.is_read_only); + assert_eq!( + location.display_path.as_deref(), + Some("java.base/java/util/Map$Entry.java") + ); + + let unchanged = normalize_location( + "java", + ProviderLocation { + uri: "file:///workspace/Main.java".to_string(), + is_read_only: false, + display_path: Some("Main.java".to_string()), + }, + ); + assert!(!unchanged.is_read_only); + assert_eq!(unchanged.display_path.as_deref(), Some("Main.java")); + } + + #[test] + fn virtual_source_uses_java_decompile_execute_command_params() { + let uri = "jdt://contents/java.base/java/lang/String.class"; + let params = virtual_source_resolve_params("java", uri).unwrap(); + let encoded = serde_json::to_value(params).unwrap(); + + assert_eq!(encoded["command"], "java.decompile"); + assert_eq!(encoded["arguments"], json!([uri])); + assert!(virtual_source_resolve_params("java", "file:///tmp/String.java").is_none()); + } + + #[test] + fn virtual_source_content_accepts_supported_jdt_result_shapes() { + assert_eq!( + virtual_source_content("java", &json!("class String {}")), + Some("class String {}".to_string()) + ); + assert_eq!( + virtual_source_content("java", &json!({ "content": "class Object {}" })), + Some("class Object {}".to_string()) + ); + assert!(virtual_source_content("go", &json!("class String {}")).is_none()); + assert!(virtual_source_content("java", &Value::Null).is_none()); + } +} diff --git a/crates/java-domain/src/lsp/languages/jdt_build.rs b/crates/java-domain/src/lsp/languages/jdt_build.rs new file mode 100644 index 0000000..00a3a7b --- /dev/null +++ b/crates/java-domain/src/lsp/languages/jdt_build.rs @@ -0,0 +1,1227 @@ +//! Coordination of JDT LS project builds requested by Run and Debug preparation. +//! +//! Java Debug Server's `vscode.java.buildWorkspace` builds every Java project in +//! the JDT workspace. Three upstream behaviors shape this coordinator: +//! +//! - Applying contributed build settings or changing build files schedules +//! asynchronous JDT project-update jobs, and the command that scheduled them +//! returns before the jobs finish. A build started meanwhile races JDT's own +//! reconfiguration of classpaths, annotation processing, and output folders, +//! so builds wait until those jobs report completion through work-done +//! progress. +//! - Concurrent workspace builds in one JDT session contend for the same Eclipse +//! workspace rule and share builder state. Core therefore runs at most one build +//! per session and lets identical waiting requests share the next build. +//! - `$/cancelRequest` is advisory: JDT may keep building until an Eclipse job +//! observes cancellation. A build stays tracked until JDT answers, even after +//! every caller left, and the next build is not dispatched before that answer. +//! +//! The coordinator is a deterministic state machine. The engine supplies the +//! clock, allocates JSON-RPC IDs, and publishes the resulting events. + +use serde::Serialize; +use serde_json::Value; +use std::collections::{BTreeMap, VecDeque}; +use std::time::{Duration, Instant}; + +/// Java Debug Server command that builds the Java projects before a launch. +pub const JAVA_BUILD_WORKSPACE_COMMAND: &str = "vscode.java.buildWorkspace"; + +/// Default bound for one caller, covering the wait for project configuration, +/// the wait for an earlier build, and the build itself. Cold builds of large +/// multi-module projects with annotation processors take minutes, so the +/// ordinary 30-second semantic-request deadline does not apply. +pub const DEFAULT_JAVA_BUILD_TIMEOUT_MS: u64 = 10 * 60_000; + +/// A project-configuration job that reports no progress for this long no longer +/// blocks builds. JDT reports long update jobs every few seconds, but one +/// Maven step can stay silent for over a minute, so the bound stays generous +/// while still releasing builds if JDT never sends the matching `end`. +const PROJECT_CONFIGURATION_IDLE_TIMEOUT: Duration = Duration::from_secs(120); + +/// Job names JDT LS 1.38 reports while it rewrites project models. They come +/// from `ProjectsManager`: `updateProject`, `UpdateProjectsWorkspaceJob`, +/// `ImportProjectsFromSelectionJob`, and the workspace-folder update job. +const PROJECT_CONFIGURATION_JOB_PREFIXES: &[&str] = &[ + "Update project ", + "Updating project configurations", + "Applying the selected build files", + "Updating workspace folders", +]; + +const WORK_DONE_PROGRESS_METHOD: &str = "$/progress"; + +/// Why a queued build has not been sent to JDT LS yet. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum JavaBuildBlock { + /// An earlier build has not received its terminal response. + PreviousBuild, + /// The backend is still collecting or applying settings that + /// build-system plugins contribute to JDT LS. + ContributedSettings, + /// JDT reported a project-configuration job without its `end`. + ProjectConfiguration, +} + +impl JavaBuildBlock { + /// Stable identifier used in log details and timeout diagnostics. + pub fn as_str(self) -> &'static str { + match self { + Self::PreviousBuild => "waitingForPreviousBuild", + Self::ContributedSettings => "waitingForContributedSettings", + Self::ProjectConfiguration => "waitingForProjectConfiguration", + } + } +} + +/// One work-done progress notification that may affect build gating. +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum ProjectJobProgress { + /// A job began; `label` is its task name or title. + Begin { token: String, label: String }, + /// A job reported progress, proving that it is still alive. + Report { token: String }, + /// A job finished. + End { token: String }, +} + +/// Parses a JDT work-done progress notification for build gating. +pub fn project_job_progress( + provider_id: &str, + method: Option<&str>, + params: Option<&Value>, +) -> Option { + if provider_id != "java" || method != Some(WORK_DONE_PROGRESS_METHOD) { + return None; + } + let params = params?; + let token = match params.get("token")? { + Value::String(token) => token.clone(), + Value::Number(token) => token.to_string(), + _ => return None, + }; + let value = params.get("value")?; + match value.get("kind").and_then(Value::as_str)? { + "begin" => { + // JDT puts the job name in `message` and may replace `title` with + // a subtask, so both are candidates for the job label. + let label = [value.get("message"), value.get("title")] + .into_iter() + .flatten() + .filter_map(Value::as_str) + .find(|label| is_project_configuration_job(label)) + .or_else(|| value.get("message").and_then(Value::as_str)) + .or_else(|| value.get("title").and_then(Value::as_str)) + .unwrap_or("") + .trim() + .to_string(); + Some(ProjectJobProgress::Begin { token, label }) + } + "report" => Some(ProjectJobProgress::Report { token }), + "end" => Some(ProjectJobProgress::End { token }), + _ => None, + } +} + +fn is_project_configuration_job(label: &str) -> bool { + let label = label.trim(); + PROJECT_CONFIGURATION_JOB_PREFIXES + .iter() + .any(|prefix| label.starts_with(prefix)) +} + +/// Returns whether an `executeCommand` payload asks Java Debug Server to build. +pub fn is_java_build_command(provider_id: &str, command: &Value) -> bool { + provider_id == "java" + && command.get("command").and_then(Value::as_str) == Some(JAVA_BUILD_WORKSPACE_COMMAND) +} + +/// Terminal result of one `vscode.java.buildWorkspace` call. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum JavaBuildOutcome { + /// The build finished without error markers in the launched project. + Succeeded, + /// The build finished, but the launched project or a dependency has errors. + CompilationErrors, + /// JDT could not complete the build, for example after a builder exception. + Failed, + /// JDT observed cancellation before the build finished. + Cancelled, + /// The response did not contain a known build status. + Unrecognized, +} + +/// Which projects' error markers were requested for an unsuccessful build. +/// +/// Java Debug Server judges a build from the markers on the project owning the +/// main class plus the projects on its classpath. When it cannot identify that +/// owner it falls back to every project in the workspace, and an unrelated +/// module can then decide the verdict. Core cannot observe the project Java +/// Debug Server ultimately resolves, so this is evidence inferred from the +/// request rather than an authoritative account of the upstream marker query. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum JavaBuildMarkerScope { + /// The request named the owning project and asked upstream to scope to it. + LaunchTarget, + /// No owning project was named, so every project could decide the verdict. + Workspace, +} + +/// Recovery action a host should offer for an unsuccessful build. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum JavaBuildRecovery { + /// Nothing beyond fixing the reported code is indicated. + None, + /// The build reached a verdict about the code -- a builder failure or + /// error markers -- that may not describe the current sources: a failed + /// builder leaves markers that later builds neither refresh nor clear, and + /// error markers can also be decided by unrelated projects when the marker + /// scope widened. Resetting the Java index is the way back to a + /// trustworthy verdict, so it is offered alongside fixing the reported + /// errors. + RebuildJavaIndex, +} + +/// Evidence behind one unsuccessful Java launch build. +/// +/// A host must be able to explain a blocked launch and offer a way forward, so +/// this carries facts rather than a verdict: nothing here gates the launch by +/// itself, and the host decides what to show and which actions to enable. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaBuildReport { + /// Marker scope inferred from the command arguments sent upstream. + pub marker_scope: JavaBuildMarkerScope, + /// An earlier build in this session ended with a builder failure, so the + /// markers behind this verdict may predate the current sources. + pub builder_failed_earlier: bool, + /// Wall-clock duration of this build, measured from dispatch to response. + /// A near-zero value can accompany an empty incremental build, but fast + /// successful builds and real stored errors are possible too; this value + /// is evidence only and never decides whether the verdict is trustworthy. + pub elapsed_milliseconds: u64, + /// Recovery action the host should offer alongside the reported errors. + pub recovery: JavaBuildRecovery, +} + +/// Derives the marker scope from the arguments sent to Java Debug Server. +/// +/// The command carries one JSON-encoded string argument. A missing, blank, or +/// unparsable `projectName` all reach Java Debug Server as blank, because it +/// applies `isNotBlank` before falling back to resolving the main class. +pub fn java_build_marker_scope(command: &Value) -> JavaBuildMarkerScope { + let named_project = command + .get("arguments") + .and_then(Value::as_array) + .and_then(|arguments| arguments.first()) + .and_then(Value::as_str) + .and_then(|encoded| serde_json::from_str::(encoded).ok()) + .as_ref() + .and_then(|payload| payload.get("projectName")) + .and_then(Value::as_str) + .is_some_and(|name| !name.trim().is_empty()); + if named_project { + JavaBuildMarkerScope::LaunchTarget + } else { + JavaBuildMarkerScope::Workspace + } +} + +/// Builds the report that accompanies an unsuccessful build. +/// +/// Any verdict about the code carries the rebuild option: a builder failure +/// leaves markers that later builds neither refresh nor clear, error markers +/// can be decided by unrelated projects when the marker scope widened, and a +/// project opened for the first time can still be mid-import while its +/// markers already read as errors. Cancellation and unrecognized results +/// reached no verdict about the code, so there the only useful action is a +/// retry. +pub fn java_build_report( + outcome: JavaBuildOutcome, + command: &Value, + builder_failed_earlier: bool, + elapsed: Duration, +) -> JavaBuildReport { + let recovery = if matches!( + outcome, + JavaBuildOutcome::Failed | JavaBuildOutcome::CompilationErrors + ) || builder_failed_earlier + { + JavaBuildRecovery::RebuildJavaIndex + } else { + JavaBuildRecovery::None + }; + JavaBuildReport { + marker_scope: java_build_marker_scope(command), + builder_failed_earlier, + elapsed_milliseconds: elapsed.as_millis() as u64, + recovery, + } +} + +/// Status returned by a Gradle build server when compilation fails. +const GRADLE_BUILD_SERVER_COMPILATION_ERROR: i64 = 100; + +/// Interprets the raw command result using JDT LS `BuildWorkspaceStatus` +/// (`FAILED`, `SUCCEED`, `WITH_ERROR`, `CANCELLED`, serialized by ordinal). +pub fn java_build_outcome(result: Option<&Value>) -> JavaBuildOutcome { + let numeric = match result { + Some(Value::Number(number)) => number.as_i64(), + Some(Value::String(text)) => match text.trim() { + "FAILED" => Some(0), + "SUCCEED" => Some(1), + "WITH_ERROR" => Some(2), + "CANCELLED" => Some(3), + other => other.parse().ok(), + }, + _ => None, + }; + match numeric { + Some(0) => JavaBuildOutcome::Failed, + Some(1) => JavaBuildOutcome::Succeeded, + Some(2) | Some(GRADLE_BUILD_SERVER_COMPILATION_ERROR) => { + JavaBuildOutcome::CompilationErrors + } + Some(3) => JavaBuildOutcome::Cancelled, + _ => JavaBuildOutcome::Unrecognized, + } +} + +/// Stable error code and user-facing message for an unsuccessful build. +pub fn java_build_failure(outcome: JavaBuildOutcome) -> Option<(&'static str, &'static str)> { + match outcome { + JavaBuildOutcome::Succeeded => None, + JavaBuildOutcome::CompilationErrors => Some(( + "javaBuildCompilationErrors", + "The Java project build reported compilation errors.", + )), + JavaBuildOutcome::Failed => Some(( + "javaBuildFailed", + "The Java language service could not complete the project build. \ + Check the Java language server log for the build error.", + )), + JavaBuildOutcome::Cancelled => Some(( + "javaBuildCancelled", + "The Java project build was cancelled before it finished.", + )), + JavaBuildOutcome::Unrecognized => Some(( + "invalidServerResult", + "The Java language service returned an unknown project build status.", + )), + } +} + +#[derive(Debug, Clone)] +struct BuildWaiter { + operation_id: String, + created_at: Instant, + deadline: Instant, +} + +/// Callers that share one dispatch because they asked for the same build. +#[derive(Debug)] +struct BuildBatch { + /// Complete `workspace/executeCommand` params sent to JDT LS. + command: Value, + waiters: Vec, + queued_at: Instant, +} + +impl BuildBatch { + fn same_build(&self, command: &Value) -> bool { + self.command.get("command") == command.get("command") + && self.command.get("arguments") == command.get("arguments") + } + + fn remove_waiter(&mut self, operation_id: &str) -> bool { + let before = self.waiters.len(); + self.waiters + .retain(|waiter| waiter.operation_id != operation_id); + self.waiters.len() != before + } +} + +#[derive(Debug)] +struct InFlightBuild { + /// JSON-RPC ID of the request, retained until JDT answers. + request_id: String, + batch: BuildBatch, + started_at: Instant, + cancel_sent: bool, +} + +#[derive(Debug)] +struct ConfigurationJob { + last_activity: Instant, +} + +/// Result of trying to start the next queued build. +#[derive(Debug, Eq, PartialEq)] +pub enum JavaBuildDispatch { + /// No caller is waiting for a build. + Idle, + /// A caller is waiting; `newly` is true when the reason changed. + Blocked { block: JavaBuildBlock, newly: bool }, + /// The build was sent to JDT LS. + Started { + request_id: String, + waiter_count: usize, + waited: Duration, + }, +} + +/// Callers whose shared build received a terminal response. +#[derive(Debug, Eq, PartialEq)] +pub struct CompletedJavaBuild { + pub operation_ids: Vec, + pub elapsed: Duration, + /// The `executeCommand` params this build was dispatched with, so the + /// completion can report the marker scope requested upstream. + pub command: Value, +} + +/// Caller whose deadline elapsed before its build answered. +#[derive(Debug, Eq, PartialEq)] +pub struct ExpiredJavaBuildWaiter { + pub operation_id: String, + pub elapsed: Duration, + /// Stable phase reached before the deadline, for diagnostics. + pub phase: &'static str, +} + +/// Waiters that left because of cancellation, timeout, or session teardown. +#[derive(Debug, Default, Eq, PartialEq)] +pub struct JavaBuildDeparture { + pub expired: Vec, + /// Advisory cancellation to send when no caller still needs the running build. + pub cancel_request_id: Option, +} + +/// Per-session state that serializes and gates Java project builds. +#[derive(Debug, Default)] +pub struct JavaBuildCoordinator { + in_flight: Option, + queued: VecDeque, + configuration_jobs: BTreeMap, + /// Last block reason reported, so the monitor logs a reason once per change. + reported_block: Option, + /// A build in this session ended with a builder failure. JDT keeps the + /// error markers that failure left behind, and later incremental builds + /// neither refresh nor clear them, so every subsequent verdict is suspect + /// until the Java index is rebuilt. + builder_failed: bool, +} + +impl JavaBuildCoordinator { + /// Whether a builder failure was already observed in this session. + pub fn builder_failed_earlier(&self) -> bool { + self.builder_failed + } + + /// Records a terminal outcome so later builds can report that their markers + /// may predate the current sources. + pub fn observe_outcome(&mut self, outcome: JavaBuildOutcome) { + if outcome == JavaBuildOutcome::Failed { + self.builder_failed = true; + } + } + + /// Adds a caller. An identical queued build is shared; a running build + /// never is, because it may have started before the caller saved files. + pub fn enqueue( + &mut self, + operation_id: String, + command: Value, + now: Instant, + timeout: Duration, + ) { + let waiter = BuildWaiter { + operation_id, + created_at: now, + deadline: now + timeout, + }; + if let Some(batch) = self + .queued + .iter_mut() + .find(|batch| batch.same_build(&command)) + { + batch.waiters.push(waiter); + return; + } + self.queued.push_back(BuildBatch { + command, + waiters: vec![waiter], + queued_at: now, + }); + } + + /// Returns whether a caller is waiting for a build that has not started. + pub fn has_queued(&self) -> bool { + self.queued.iter().any(|batch| !batch.waiters.is_empty()) + } + + /// Returns whether `operation_id` is waiting for a build. + pub fn contains(&self, operation_id: &str) -> bool { + self.batches() + .flat_map(|batch| batch.waiters.iter()) + .any(|waiter| waiter.operation_id == operation_id) + } + + /// Operation IDs of every waiting caller, sorted for deterministic output. + #[cfg(test)] + pub fn operation_ids(&self) -> Vec { + let mut ids: Vec = self + .batches() + .flat_map(|batch| batch.waiters.iter()) + .map(|waiter| waiter.operation_id.clone()) + .collect(); + ids.sort(); + ids + } + + /// JSON-RPC ID of the build JDT is still running, if any. + pub fn in_flight_request_id(&self) -> Option<&str> { + self.in_flight + .as_ref() + .map(|build| build.request_id.as_str()) + } + + /// Reports the same bounded project-update gate used by build dispatch. + pub fn project_configuration_running(&mut self, now: Instant) -> bool { + self.configuration_jobs.retain(|_, job| { + now.saturating_duration_since(job.last_activity) < PROJECT_CONFIGURATION_IDLE_TIMEOUT + }); + !self.configuration_jobs.is_empty() + } + + /// Records a project-configuration job's lifecycle from work-done progress. + pub fn observe_progress(&mut self, progress: ProjectJobProgress, now: Instant) { + match progress { + ProjectJobProgress::Begin { token, label } => { + if is_project_configuration_job(&label) { + self.configuration_jobs + .insert(token, ConfigurationJob { last_activity: now }); + } + } + ProjectJobProgress::Report { token } => { + if let Some(job) = self.configuration_jobs.get_mut(&token) { + job.last_activity = now; + } + } + ProjectJobProgress::End { token } => { + self.configuration_jobs.remove(&token); + } + } + } + + /// Sends the next build when nothing blocks it. `allocate` writes the + /// JSON-RPC request for the command and returns its ID. If allocation + /// fails, the batch is discarded and its callers are returned with the + /// error so they fail once instead of being retried on every tick. + pub fn dispatch( + &mut self, + settings_pending: bool, + now: Instant, + allocate: impl FnOnce(&Value) -> Result, + ) -> Result)> { + self.queued.retain(|batch| !batch.waiters.is_empty()); + if self.queued.is_empty() { + self.reported_block = None; + return Ok(JavaBuildDispatch::Idle); + } + if let Some(block) = self.block(settings_pending, now) { + let newly = self.reported_block != Some(block); + self.reported_block = Some(block); + return Ok(JavaBuildDispatch::Blocked { block, newly }); + } + let Some(batch) = self.queued.pop_front() else { + return Ok(JavaBuildDispatch::Idle); + }; + let request_id = match allocate(&batch.command) { + Ok(request_id) => request_id, + Err(error) => { + self.reported_block = None; + let operation_ids = batch + .waiters + .into_iter() + .map(|waiter| waiter.operation_id) + .collect(); + return Err((error, operation_ids)); + } + }; + self.reported_block = None; + let waiter_count = batch.waiters.len(); + let waited = now.saturating_duration_since(batch.queued_at); + self.in_flight = Some(InFlightBuild { + request_id: request_id.clone(), + batch, + started_at: now, + cancel_sent: false, + }); + Ok(JavaBuildDispatch::Started { + request_id, + waiter_count, + waited, + }) + } + + /// Releases the running build after JDT answered `request_id`. + pub fn complete(&mut self, request_id: &str, now: Instant) -> Option { + if self.in_flight_request_id() != Some(request_id) { + return None; + } + let build = self.in_flight.take()?; + Some(CompletedJavaBuild { + operation_ids: build + .batch + .waiters + .into_iter() + .map(|waiter| waiter.operation_id) + .collect(), + elapsed: now.saturating_duration_since(build.started_at), + command: build.batch.command, + }) + } + + /// Removes one caller. Returns `None` when the operation is not a build. + pub fn cancel(&mut self, operation_id: &str) -> Option { + let removed = self + .in_flight + .as_mut() + .is_some_and(|build| build.batch.remove_waiter(operation_id)) + || self + .queued + .iter_mut() + .any(|batch| batch.remove_waiter(operation_id)); + if !removed { + return None; + } + self.queued.retain(|batch| !batch.waiters.is_empty()); + Some(JavaBuildDeparture { + expired: Vec::new(), + cancel_request_id: self.cancel_unneeded_build(), + }) + } + + /// Removes every caller whose deadline elapsed. + pub fn expire(&mut self, settings_pending: bool, now: Instant) -> JavaBuildDeparture { + let queued_phase = self + .block(settings_pending, now) + .map(JavaBuildBlock::as_str) + .unwrap_or("queued"); + let mut expired = Vec::new(); + let mut take_expired = |waiters: &mut Vec, phase: &'static str| { + waiters.retain(|waiter| { + if now < waiter.deadline { + return true; + } + expired.push(ExpiredJavaBuildWaiter { + operation_id: waiter.operation_id.clone(), + elapsed: now.saturating_duration_since(waiter.created_at), + phase, + }); + false + }); + }; + if let Some(build) = self.in_flight.as_mut() { + take_expired(&mut build.batch.waiters, "building"); + } + for batch in &mut self.queued { + take_expired(&mut batch.waiters, queued_phase); + } + if expired.is_empty() { + return JavaBuildDeparture::default(); + } + self.queued.retain(|batch| !batch.waiters.is_empty()); + JavaBuildDeparture { + expired, + cancel_request_id: self.cancel_unneeded_build(), + } + } + + /// Clears all state when the session stops or fails, returning the callers + /// to fail and the request ID of a build JDT had not answered. + pub fn drain(&mut self) -> (Vec, Option) { + let in_flight = self.in_flight.take(); + let request_id = in_flight.as_ref().map(|build| build.request_id.clone()); + let mut operation_ids: Vec = in_flight + .into_iter() + .map(|build| build.batch) + .chain(self.queued.drain(..)) + .flat_map(|batch| batch.waiters) + .map(|waiter| waiter.operation_id) + .collect(); + operation_ids.sort(); + self.configuration_jobs.clear(); + self.reported_block = None; + (operation_ids, request_id) + } + + fn batches(&self) -> impl Iterator { + self.in_flight + .iter() + .map(|build| &build.batch) + .chain(self.queued.iter()) + } + + fn block(&mut self, settings_pending: bool, now: Instant) -> Option { + self.configuration_jobs.retain(|_, job| { + now.saturating_duration_since(job.last_activity) < PROJECT_CONFIGURATION_IDLE_TIMEOUT + }); + if self.in_flight.is_some() { + Some(JavaBuildBlock::PreviousBuild) + } else if settings_pending { + Some(JavaBuildBlock::ContributedSettings) + } else if !self.configuration_jobs.is_empty() { + Some(JavaBuildBlock::ProjectConfiguration) + } else { + None + } + } + + /// Requests cancellation only when no running or queued caller still needs + /// a build: a queued caller benefits from the running build's output, and + /// interrupting JDT while it acquires workspace rules is itself risky. + fn cancel_unneeded_build(&mut self) -> Option { + let build = self.in_flight.as_mut()?; + if build.cancel_sent || !build.batch.waiters.is_empty() || !self.queued.is_empty() { + return None; + } + build.cancel_sent = true; + Some(build.request_id.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const TIMEOUT: Duration = Duration::from_secs(600); + + fn build_command(main_class: &str) -> Value { + json!({ + "title": "Build Java Workspace", + "command": JAVA_BUILD_WORKSPACE_COMMAND, + "arguments": [format!("{{\"mainClass\":\"{main_class}\",\"isFullBuild\":false}}")] + }) + } + + fn start_next( + coordinator: &mut JavaBuildCoordinator, + now: Instant, + request_id: &str, + ) -> JavaBuildDispatch { + coordinator + .dispatch(false, now, |_| Ok::<_, ()>(request_id.to_string())) + .unwrap_or_else(|_| panic!("allocation succeeds")) + } + + fn begin(token: &str, label: &str) -> ProjectJobProgress { + ProjectJobProgress::Begin { + token: token.to_string(), + label: label.to_string(), + } + } + + #[test] + fn marker_scope_follows_the_project_name_java_debug_would_use() { + let command = |arguments: Value| { + json!({ + "command": JAVA_BUILD_WORKSPACE_COMMAND, + "arguments": arguments, + }) + }; + let encoded = |payload: Value| json!([payload.to_string()]); + + assert_eq!( + java_build_marker_scope(&command(encoded( + json!({ "mainClass": "a.Main", "projectName": "service" }) + ))), + JavaBuildMarkerScope::LaunchTarget + ); + // Java Debug Server applies `isNotBlank`, so a missing field, an empty + // string, and whitespace all take the workspace-wide fallback. + assert_eq!( + java_build_marker_scope(&command(encoded(json!({ "mainClass": "a.Main" })))), + JavaBuildMarkerScope::Workspace + ); + assert_eq!( + java_build_marker_scope(&command(encoded( + json!({ "mainClass": "a.Main", "projectName": "" }) + ))), + JavaBuildMarkerScope::Workspace + ); + assert_eq!( + java_build_marker_scope(&command(encoded( + json!({ "mainClass": "a.Main", "projectName": " " }) + ))), + JavaBuildMarkerScope::Workspace + ); + // A payload Core cannot parse must not be reported as scoped. + assert_eq!( + java_build_marker_scope(&command(json!(["not json"]))), + JavaBuildMarkerScope::Workspace + ); + assert_eq!( + java_build_marker_scope(&command(json!([]))), + JavaBuildMarkerScope::Workspace + ); + } + + #[test] + fn a_builder_failure_marks_every_later_verdict_as_suspect() { + let mut coordinator = JavaBuildCoordinator::default(); + let command = json!({ + "command": JAVA_BUILD_WORKSPACE_COMMAND, + "arguments": [json!({ "projectName": "service" }).to_string()], + }); + + // The failure itself is the origin, not a casualty of an earlier one. + assert!(!coordinator.builder_failed_earlier()); + let failure = java_build_report( + JavaBuildOutcome::Failed, + &command, + coordinator.builder_failed_earlier(), + Duration::from_millis(5012), + ); + coordinator.observe_outcome(JavaBuildOutcome::Failed); + assert!(!failure.builder_failed_earlier); + assert_eq!(failure.recovery, JavaBuildRecovery::RebuildJavaIndex); + + // A later verdict stays suspect because the earlier builder failure may + // have left markers behind. Its short duration remains evidence only. + let leftover = java_build_report( + JavaBuildOutcome::CompilationErrors, + &command, + coordinator.builder_failed_earlier(), + Duration::from_millis(7), + ); + assert!(leftover.builder_failed_earlier); + assert_eq!(leftover.elapsed_milliseconds, 7); + assert_eq!(leftover.recovery, JavaBuildRecovery::RebuildJavaIndex); + assert_eq!(leftover.marker_scope, JavaBuildMarkerScope::LaunchTarget); + } + + #[test] + fn every_code_verdict_offers_the_workspace_reset() { + let mut coordinator = JavaBuildCoordinator::default(); + coordinator.observe_outcome(JavaBuildOutcome::Succeeded); + coordinator.observe_outcome(JavaBuildOutcome::Cancelled); + assert!(!coordinator.builder_failed_earlier()); + + // Error markers can predate or misdescribe the current sources even + // without a builder failure -- a first import can still be running, + // and a widened marker scope lets unrelated projects decide the + // verdict -- so the rebuild option stays available for every verdict + // about the code. + let report = java_build_report( + JavaBuildOutcome::CompilationErrors, + &json!({ "command": JAVA_BUILD_WORKSPACE_COMMAND, "arguments": [] }), + coordinator.builder_failed_earlier(), + Duration::from_millis(12511), + ); + assert!(!report.builder_failed_earlier); + assert_eq!(report.recovery, JavaBuildRecovery::RebuildJavaIndex); + assert_eq!(report.marker_scope, JavaBuildMarkerScope::Workspace); + + // A cancelled build reached no verdict about the code, so resetting + // the workspace is not indicated. + let cancelled = java_build_report( + JavaBuildOutcome::Cancelled, + &json!({ "command": JAVA_BUILD_WORKSPACE_COMMAND, "arguments": [] }), + coordinator.builder_failed_earlier(), + Duration::from_millis(4), + ); + assert_eq!(cancelled.recovery, JavaBuildRecovery::None); + } + + #[test] + fn build_results_follow_jdt_build_workspace_status() { + assert_eq!( + java_build_outcome(Some(&json!(1))), + JavaBuildOutcome::Succeeded + ); + assert_eq!( + java_build_outcome(Some(&json!(0))), + JavaBuildOutcome::Failed + ); + assert_eq!( + java_build_outcome(Some(&json!(2))), + JavaBuildOutcome::CompilationErrors + ); + assert_eq!( + java_build_outcome(Some(&json!(3))), + JavaBuildOutcome::Cancelled + ); + assert_eq!( + java_build_outcome(Some(&json!(100))), + JavaBuildOutcome::CompilationErrors + ); + assert_eq!( + java_build_outcome(Some(&json!("WITH_ERROR"))), + JavaBuildOutcome::CompilationErrors + ); + assert_eq!( + java_build_outcome(Some(&json!("1"))), + JavaBuildOutcome::Succeeded + ); + assert_eq!(java_build_outcome(None), JavaBuildOutcome::Unrecognized); + assert_eq!(java_build_failure(JavaBuildOutcome::Succeeded), None); + let codes: Vec<_> = [ + JavaBuildOutcome::CompilationErrors, + JavaBuildOutcome::Failed, + JavaBuildOutcome::Cancelled, + JavaBuildOutcome::Unrecognized, + ] + .into_iter() + .filter_map(java_build_failure) + .map(|(code, _)| code) + .collect(); + // Each unsuccessful outcome keeps a distinct code so hosts never + // present an internal failure or cancellation as a source error. + assert_eq!( + codes, + [ + "javaBuildCompilationErrors", + "javaBuildFailed", + "javaBuildCancelled", + "invalidServerResult" + ] + ); + } + + #[test] + fn only_java_debug_build_commands_are_coordinated() { + assert!(is_java_build_command("java", &build_command("demo.App"))); + assert!(!is_java_build_command("gopls", &build_command("demo.App"))); + assert!(!is_java_build_command( + "java", + &json!({ "command": "vscode.java.resolveClasspath", "arguments": [] }) + )); + } + + #[test] + fn progress_parser_uses_job_names_and_numeric_tokens() { + let parsed = project_job_progress( + "java", + Some("$/progress"), + Some(&json!({ + "token": 7, + "value": { + "kind": "begin", + "message": "Update project ruoyi-vue-plus", + "title": "Refreshing '/ruoyi-vue-plus'." + } + })), + ); + assert_eq!(parsed, Some(begin("7", "Update project ruoyi-vue-plus"))); + assert_eq!( + project_job_progress( + "java", + Some("$/progress"), + Some(&json!({ "token": "a", "value": { "kind": "end" } })), + ), + Some(ProjectJobProgress::End { + token: "a".to_string() + }) + ); + assert_eq!( + project_job_progress( + "gopls", + Some("$/progress"), + Some(&json!({ "token": "a", "value": { "kind": "end" } })), + ), + None + ); + } + + #[test] + fn builds_wait_for_contributed_settings_and_project_updates() { + let now = Instant::now(); + let mut coordinator = JavaBuildCoordinator::default(); + coordinator.observe_progress(begin("update", "Update project ruoyi-vue-plus"), now); + coordinator.observe_progress(begin("build", "Building"), now); + coordinator.enqueue("run-1".into(), build_command("demo.App"), now, TIMEOUT); + + let blocked = coordinator + .dispatch(true, now, |_| Ok::<_, ()>("never".to_string())) + .unwrap(); + assert_eq!( + blocked, + JavaBuildDispatch::Blocked { + block: JavaBuildBlock::ContributedSettings, + newly: true + } + ); + // The settings arrived, but the update job they scheduled is still running. + let blocked = coordinator + .dispatch(false, now, |_| Ok::<_, ()>("never".to_string())) + .unwrap(); + assert_eq!( + blocked, + JavaBuildDispatch::Blocked { + block: JavaBuildBlock::ProjectConfiguration, + newly: true + } + ); + let unchanged = coordinator + .dispatch(false, now, |_| Ok::<_, ()>("never".to_string())) + .unwrap(); + assert_eq!( + unchanged, + JavaBuildDispatch::Blocked { + block: JavaBuildBlock::ProjectConfiguration, + newly: false + } + ); + + coordinator.observe_progress( + ProjectJobProgress::End { + token: "update".into(), + }, + now, + ); + let later = now + Duration::from_secs(3); + assert_eq!( + start_next(&mut coordinator, later, "41"), + JavaBuildDispatch::Started { + request_id: "41".to_string(), + waiter_count: 1, + waited: Duration::from_secs(3), + } + ); + } + + #[test] + fn silent_configuration_jobs_stop_blocking_after_the_idle_bound() { + let now = Instant::now(); + let mut coordinator = JavaBuildCoordinator::default(); + coordinator.observe_progress(begin("update", "Updating project configurations"), now); + coordinator.enqueue("run-1".into(), build_command("demo.App"), now, TIMEOUT); + + let reported = now + PROJECT_CONFIGURATION_IDLE_TIMEOUT - Duration::from_secs(1); + coordinator.observe_progress( + ProjectJobProgress::Report { + token: "update".into(), + }, + reported, + ); + assert!(matches!( + start_next( + &mut coordinator, + now + PROJECT_CONFIGURATION_IDLE_TIMEOUT, + "1" + ), + JavaBuildDispatch::Blocked { .. } + )); + assert!(matches!( + start_next( + &mut coordinator, + reported + PROJECT_CONFIGURATION_IDLE_TIMEOUT, + "1" + ), + JavaBuildDispatch::Started { .. } + )); + } + + #[test] + fn one_build_runs_at_a_time_and_identical_queued_requests_share_the_next() { + let now = Instant::now(); + let mut coordinator = JavaBuildCoordinator::default(); + coordinator.enqueue("run-1".into(), build_command("demo.App"), now, TIMEOUT); + assert!(matches!( + start_next(&mut coordinator, now, "10"), + JavaBuildDispatch::Started { + waiter_count: 1, + .. + } + )); + // Repeated Run clicks while the first build is running: the running + // build is not shared because it may predate the latest save. + coordinator.enqueue("run-2".into(), build_command("demo.App"), now, TIMEOUT); + coordinator.enqueue("run-3".into(), build_command("demo.App"), now, TIMEOUT); + coordinator.enqueue("other".into(), build_command("demo.Other"), now, TIMEOUT); + assert_eq!( + start_next(&mut coordinator, now, "11"), + JavaBuildDispatch::Blocked { + block: JavaBuildBlock::PreviousBuild, + newly: true + } + ); + + let first = coordinator + .complete("10", now + Duration::from_secs(32)) + .unwrap(); + assert_eq!(first.operation_ids, ["run-1"]); + assert_eq!(first.elapsed, Duration::from_secs(32)); + assert!(matches!( + start_next(&mut coordinator, now, "11"), + JavaBuildDispatch::Started { + waiter_count: 2, + .. + } + )); + assert_eq!(coordinator.complete("unknown", now), None); + let second = coordinator.complete("11", now).unwrap(); + assert_eq!(second.operation_ids, ["run-2", "run-3"]); + assert!(matches!( + start_next(&mut coordinator, now, "12"), + JavaBuildDispatch::Started { + waiter_count: 1, + .. + } + )); + assert_eq!( + coordinator.complete("12", now).unwrap().operation_ids, + ["other"] + ); + assert_eq!( + start_next(&mut coordinator, now, "13"), + JavaBuildDispatch::Idle + ); + } + + #[test] + fn cancelling_the_only_caller_cancels_but_keeps_tracking_the_running_build() { + let now = Instant::now(); + let mut coordinator = JavaBuildCoordinator::default(); + coordinator.enqueue("run-1".into(), build_command("demo.App"), now, TIMEOUT); + start_next(&mut coordinator, now, "10"); + + let departure = coordinator.cancel("run-1").unwrap(); + assert_eq!(departure.cancel_request_id.as_deref(), Some("10")); + assert_eq!(coordinator.cancel("run-1"), None); + // JDT may keep building after advisory cancellation, so a new caller + // waits for that build's response instead of overlapping it. + coordinator.enqueue("run-2".into(), build_command("demo.App"), now, TIMEOUT); + assert!(matches!( + start_next(&mut coordinator, now, "11"), + JavaBuildDispatch::Blocked { + block: JavaBuildBlock::PreviousBuild, + .. + } + )); + let abandoned = coordinator.complete("10", now).unwrap(); + assert!(abandoned.operation_ids.is_empty()); + assert!(matches!( + start_next(&mut coordinator, now, "11"), + JavaBuildDispatch::Started { .. } + )); + } + + #[test] + fn a_running_build_is_not_cancelled_while_another_caller_needs_a_build() { + let now = Instant::now(); + let mut coordinator = JavaBuildCoordinator::default(); + coordinator.enqueue("run-1".into(), build_command("demo.App"), now, TIMEOUT); + coordinator.enqueue("run-1b".into(), build_command("demo.App"), now, TIMEOUT); + start_next(&mut coordinator, now, "10"); + coordinator.enqueue("run-2".into(), build_command("demo.App"), now, TIMEOUT); + + assert_eq!(coordinator.cancel("run-1").unwrap().cancel_request_id, None); + assert_eq!( + coordinator.cancel("run-1b").unwrap().cancel_request_id, + None + ); + // The last caller leaving the queue makes the running build unneeded. + assert_eq!( + coordinator + .cancel("run-2") + .unwrap() + .cancel_request_id + .as_deref(), + Some("10") + ); + assert!(coordinator.operation_ids().is_empty()); + assert_eq!(coordinator.in_flight_request_id(), Some("10")); + } + + #[test] + fn deadlines_report_the_phase_each_caller_reached() { + let now = Instant::now(); + let mut coordinator = JavaBuildCoordinator::default(); + let short = Duration::from_secs(30); + coordinator.enqueue("building".into(), build_command("demo.App"), now, short); + start_next(&mut coordinator, now, "10"); + coordinator.enqueue("queued".into(), build_command("demo.App"), now, short); + coordinator.enqueue("patient".into(), build_command("demo.App"), now, TIMEOUT); + + assert_eq!( + coordinator.expire(false, now + Duration::from_secs(29)), + JavaBuildDeparture::default() + ); + let departure = coordinator.expire(false, now + short); + assert_eq!( + departure.expired, + [ + ExpiredJavaBuildWaiter { + operation_id: "building".to_string(), + elapsed: short, + phase: "building", + }, + ExpiredJavaBuildWaiter { + operation_id: "queued".to_string(), + elapsed: short, + phase: "waitingForPreviousBuild", + }, + ] + ); + // `patient` still needs a build, so the running one is not cancelled. + assert_eq!(departure.cancel_request_id, None); + assert_eq!(coordinator.operation_ids(), ["patient"]); + } + + #[test] + fn draining_returns_every_caller_and_the_unanswered_build() { + let now = Instant::now(); + let mut coordinator = JavaBuildCoordinator::default(); + coordinator.observe_progress(begin("update", "Update project app"), now); + coordinator.enqueue("b".into(), build_command("demo.App"), now, TIMEOUT); + coordinator.observe_progress( + ProjectJobProgress::End { + token: "update".into(), + }, + now, + ); + start_next(&mut coordinator, now, "10"); + coordinator.enqueue("a".into(), build_command("demo.Other"), now, TIMEOUT); + + let (operation_ids, request_id) = coordinator.drain(); + assert_eq!(operation_ids, ["a", "b"]); + assert_eq!(request_id.as_deref(), Some("10")); + assert!(!coordinator.contains("a")); + assert_eq!( + start_next(&mut coordinator, now, "11"), + JavaBuildDispatch::Idle + ); + } + + #[test] + fn allocation_failure_fails_the_batch_once_and_keeps_later_builds() { + let now = Instant::now(); + let mut coordinator = JavaBuildCoordinator::default(); + coordinator.enqueue("run-1".into(), build_command("demo.App"), now, TIMEOUT); + coordinator.enqueue("run-1b".into(), build_command("demo.App"), now, TIMEOUT); + coordinator.enqueue("other".into(), build_command("demo.Other"), now, TIMEOUT); + let failed = coordinator.dispatch(false, now, |_| Err::("encode failed")); + assert_eq!( + failed, + Err(( + "encode failed", + vec!["run-1".to_string(), "run-1b".to_string()] + )) + ); + assert!(!coordinator.contains("run-1")); + assert_eq!(coordinator.in_flight_request_id(), None); + assert!(matches!( + start_next(&mut coordinator, now, "10"), + JavaBuildDispatch::Started { + waiter_count: 1, + .. + } + )); + } +} diff --git a/crates/java-domain/src/lsp/languages/jdt_configuration.rs b/crates/java-domain/src/lsp/languages/jdt_configuration.rs new file mode 100644 index 0000000..3d520d8 --- /dev/null +++ b/crates/java-domain/src/lsp/languages/jdt_configuration.rs @@ -0,0 +1,453 @@ +//! Writable Equinox configuration areas for packaged JDT LS installations. +//! +//! Equinox writes its framework state (bundle caches, resolver state, locks, +//! and p2 data) into the directory passed as `-configuration`. Packaged JDT LS +//! ships its `config_` directory inside the installed product: the +//! signed macOS app bundle, whose exact bytes are the base of Sparkle delta +//! updates, and the Windows installation directory. Handing that directory to +//! Equinox turns every launch into a modification of the release. +//! +//! As vscode-java does, the only shipped input, `config.ini`, is copied into +//! the host cache under a directory named by the file's SHA-256. The file lists +//! every bundle with its version, so a JDT LS upgrade receives a fresh area +//! instead of reusing framework state recorded for other bundles, and Equinox +//! never reads or writes the installation's configuration directory. + +use super::java_workspace::{jdt_cache_retention, JdtCacheEntry, JdtCacheRetentionRequest}; +use crate::protocol::{CoreError, ErrorCode}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Cache subdirectory holding one configuration area per `config.ini` digest. +/// +/// It is a sibling of the `-data` directories under `jdtls/` so that the +/// workspace retention adapters, which scan `jdtls/`, never treat an area in +/// use by another installation as an expired workspace. +pub const CONFIGURATION_AREAS_DIRECTORY: &str = "jdtls-configuration"; +/// The only file JDT LS ships in a platform configuration directory. +const CONFIG_INI: &str = "config.ini"; +/// Directory passed as `-configuration`, one level below the area root because +/// JDT LS's `config.ini` sets `eclipse.p2.data.area=@config.dir/../p2`, which +/// must resolve inside the same area. +const EQUINOX_CONFIGURATION_DIRECTORY: &str = "configuration"; +/// File in an area root recording the last launch that used it, as Unix seconds. +const LAST_USED_MARKER: &str = ".lithe-last-used"; + +static NEXT_TEMPORARY_FILE: AtomicU64 = AtomicU64::new(0); + +/// Configuration area selected for one JDT LS launch. +#[derive(Debug, Eq, PartialEq)] +pub struct PreparedConfigurationArea { + /// Writable directory passed to JDT LS as `-configuration`. + pub directory: PathBuf, + /// Digests of other areas removed because no launch used them within the + /// shared JDT cache retention period, in ascending order. + pub removed_keys: Vec, + /// Why expired areas could not be removed. Only disk space is at stake, + /// so the launch proceeds and the engine reports this as a warning. + pub cleanup_failure: Option, +} + +/// Returns the writable configuration area for the packaged configuration +/// directory `packaged_configuration`, creating or repairing it as needed. +/// +/// The area lives under `cache_directory`, which must resolve outside the JDT +/// LS installation; a cache inside it, including through a symbolic link, is +/// rejected before anything is written. Areas of other `config.ini` digests +/// that no launch used within the retention period are removed afterwards. +pub fn prepare_configuration_area( + cache_directory: &Path, + packaged_configuration: &Path, + now_unix_seconds: u64, +) -> Result { + let packaged_configuration = fs::canonicalize(packaged_configuration).map_err(|error| { + start_failure("The Java language-server configuration directory is unavailable.") + .with_details(error.to_string()) + })?; + let installation_root = packaged_configuration + .parent() + .unwrap_or(&packaged_configuration) + .to_path_buf(); + let config_ini = fs::read(packaged_configuration.join(CONFIG_INI)).map_err(|error| { + start_failure("The Java language-server configuration has no config.ini.") + .with_details(error.to_string()) + })?; + let key = format!("{:x}", Sha256::digest(&config_ini)); + let areas_root = cache_directory.join(CONFIGURATION_AREAS_DIRECTORY); + let area_root = areas_root.join(&key); + let directory = area_root.join(EQUINOX_CONFIGURATION_DIRECTORY); + + // Resolve through the deepest existing ancestor so that a symbolic link + // anywhere in the cache path is followed before the first write. + ensure_outside_installation(&directory, &installation_root)?; + fs::create_dir_all(&directory).map_err(|error| { + start_failure("Could not create the Java language-server configuration area.") + .with_details(error.to_string()) + })?; + ensure_outside_installation(&directory, &installation_root)?; + write_if_changed(&directory.join(CONFIG_INI), &config_ini)?; + fs::write( + area_root.join(LAST_USED_MARKER), + now_unix_seconds.to_string(), + ) + .map_err(|error| { + start_failure("Could not record the Java language-server configuration area use.") + .with_details(error.to_string()) + })?; + + let (removed_keys, cleanup_failure) = remove_expired_areas(&areas_root, &key, now_unix_seconds); + Ok(PreparedConfigurationArea { + directory, + removed_keys, + cleanup_failure, + }) +} + +fn start_failure(message: &str) -> CoreError { + CoreError::new(ErrorCode::ProcessStartFailed, message) +} + +fn ensure_outside_installation(path: &Path, installation_root: &Path) -> Result<(), CoreError> { + let resolved = resolve_existing_prefix(path).map_err(|error| { + start_failure("Could not resolve the Java language-server cache directory.") + .with_details(error.to_string()) + })?; + if resolved.starts_with(installation_root) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The Java language-server cache must be outside the JDT LS installation.", + )); + } + Ok(()) +} + +/// Canonicalizes the deepest existing ancestor of `path` and appends the +/// components that do not exist yet, which cannot be symbolic links. +fn resolve_existing_prefix(path: &Path) -> std::io::Result { + let mut missing = Vec::new(); + let mut current = path; + loop { + match fs::canonicalize(current) { + Ok(resolved) => { + return Ok(missing + .iter() + .rev() + .fold(resolved, |resolved, component| resolved.join(component))) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let (Some(parent), Some(name)) = (current.parent(), current.file_name()) else { + return Err(error); + }; + missing.push(name.to_os_string()); + current = parent; + } + Err(error) => return Err(error), + } + } +} + +/// Replaces `path` atomically unless it already holds `contents`, so a damaged +/// or truncated copy is repaired and concurrent launches never observe a +/// partially written file. +fn write_if_changed(path: &Path, contents: &[u8]) -> Result<(), CoreError> { + if fs::read(path).is_ok_and(|current| current == contents) { + return Ok(()); + } + let temporary = path.with_file_name(format!( + ".{CONFIG_INI}.{}-{}.tmp", + std::process::id(), + NEXT_TEMPORARY_FILE.fetch_add(1, Ordering::Relaxed) + )); + let result = fs::write(&temporary, contents).and_then(|()| fs::rename(&temporary, path)); + if result.is_err() { + // This boundary keeps the primary result; WARN exposes leftover staging. + let mut cleanup = crate::host::storage::StorageCleanup::default(); + cleanup.observe(fs::remove_file(&temporary)); + cleanup.finish("jdt-configuration"); + } + result.map_err(|error| { + start_failure("Could not write the Java language-server configuration.") + .with_details(error.to_string()) + }) +} + +fn remove_expired_areas( + areas_root: &Path, + active_key: &str, + now_unix_seconds: u64, +) -> (Vec, Option) { + let entries = match fs::read_dir(areas_root) { + Ok(entries) => entries, + Err(error) => return (Vec::new(), Some(error.to_string())), + }; + let mut candidates = Vec::new(); + for entry in entries.flatten() { + let Ok(metadata) = fs::symlink_metadata(entry.path()) else { + continue; + }; + // A link is never followed: removing through it could delete data + // that does not belong to the cache. + if !metadata.is_dir() { + continue; + } + let Some(key) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + let last_used = fs::read_to_string(entry.path().join(LAST_USED_MARKER)) + .ok() + .and_then(|marker| marker.trim().parse::().ok()) + .or_else(|| { + metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|elapsed| elapsed.as_secs()) + }) + .unwrap_or(0); + candidates.push(JdtCacheEntry { + workspace_key: key, + last_modified_unix_seconds: last_used, + }); + } + // The workspace retention policy only accepts SHA-256 names and never + // selects the active one, which are exactly the rules for areas too. + let plan = match jdt_cache_retention(JdtCacheRetentionRequest { + now_unix_seconds, + active_workspace_key: Some(active_key.to_string()), + entries: candidates, + }) { + Ok(plan) => plan, + Err(error) => return (Vec::new(), Some(error.message)), + }; + let mut removed = Vec::new(); + let mut failures = Vec::new(); + for key in plan.expired_workspace_keys { + match fs::remove_dir_all(areas_root.join(&key)) { + Ok(()) => removed.push(key), + Err(error) => failures.push(format!("{key}: {error}")), + } + } + let failure = (!failures.is_empty()).then(|| failures.join("; ")); + (removed, failure) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + static NEXT_SCRATCH: AtomicU64 = AtomicU64::new(0); + const DAY: u64 = 24 * 60 * 60; + const NOW: u64 = 1_800_000_000; + const CONFIG: &[u8] = + b"osgi.bundles=reference\\:file\\:org.eclipse.jdt.ls.core_1.61.0.jar@4\\:start\n"; + + /// A packaged JDT LS installation and a separate host cache, removed on + /// drop so a failed assertion does not leak them. + struct Scratch { + root: PathBuf, + } + + impl Scratch { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "lithe-jdt-configuration-{}-{}", + std::process::id(), + NEXT_SCRATCH.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&root); + let configuration = root.join("installation/jdtls/config_mac"); + fs::create_dir_all(&configuration).expect("configuration should be created"); + fs::write(configuration.join(CONFIG_INI), CONFIG) + .expect("config.ini should be written"); + fs::create_dir_all(root.join("installation/jdtls/plugins")).expect("plugins"); + fs::write(root.join("installation/jdtls/plugins/equinox.jar"), b"jar").expect("jar"); + Self { root } + } + + fn installation(&self) -> PathBuf { + self.root.join("installation") + } + + fn packaged_configuration(&self) -> PathBuf { + self.installation().join("jdtls/config_mac") + } + + fn cache(&self) -> PathBuf { + self.root.join("cache") + } + + fn prepare(&self, now: u64) -> Result { + prepare_configuration_area(&self.cache(), &self.packaged_configuration(), now) + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + /// Relative path and contents of every file below `root`. + fn snapshot(root: &Path) -> BTreeMap> { + let mut files = BTreeMap::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(&directory).expect("directory should be readable") { + let path = entry.expect("entry should be readable").path(); + if path.is_dir() { + pending.push(path); + } else { + let contents = fs::read(&path).expect("file should be readable"); + files.insert(path.strip_prefix(root).unwrap().to_path_buf(), contents); + } + } + } + files + } + + fn key() -> String { + format!("{:x}", Sha256::digest(CONFIG)) + } + + #[test] + fn equinox_state_lands_in_the_cache_and_the_installation_stays_byte_identical() { + let scratch = Scratch::new(); + let before = snapshot(&scratch.installation()); + + let area = scratch.prepare(NOW).expect("the area should be prepared"); + // Equinox writes its framework state into the -configuration directory. + fs::create_dir_all(area.directory.join("org.eclipse.osgi")).expect("state directory"); + fs::write(area.directory.join("org.eclipse.osgi/.manager"), b"lock").expect("state"); + + assert_eq!( + area.directory, + scratch + .cache() + .join(CONFIGURATION_AREAS_DIRECTORY) + .join(key()) + .join(EQUINOX_CONFIGURATION_DIRECTORY) + ); + assert_eq!(fs::read(area.directory.join(CONFIG_INI)).unwrap(), CONFIG); + assert_eq!(snapshot(&scratch.installation()), before); + } + + #[test] + fn a_damaged_copy_is_repaired_and_equinox_state_is_kept() { + let scratch = Scratch::new(); + let first = scratch.prepare(NOW).expect("the area should be prepared"); + fs::create_dir_all(first.directory.join("org.eclipse.osgi")).expect("state directory"); + fs::write(first.directory.join(CONFIG_INI), b"truncat").expect("damage"); + + let second = scratch + .prepare(NOW) + .expect("a damaged copy should be repaired"); + assert_eq!(second.directory, first.directory); + assert_eq!(fs::read(second.directory.join(CONFIG_INI)).unwrap(), CONFIG); + assert!(second.directory.join("org.eclipse.osgi").is_dir()); + + fs::remove_file(second.directory.join(CONFIG_INI)).expect("delete the copy"); + let third = scratch + .prepare(NOW) + .expect("a missing copy should be restored"); + assert_eq!(fs::read(third.directory.join(CONFIG_INI)).unwrap(), CONFIG); + } + + #[test] + fn a_new_config_ini_selects_a_fresh_area() { + let scratch = Scratch::new(); + let first = scratch.prepare(NOW).expect("the area should be prepared"); + fs::write( + scratch.packaged_configuration().join(CONFIG_INI), + b"osgi.bundles=reference\\:file\\:org.eclipse.jdt.ls.core_1.62.0.jar@4\\:start\n", + ) + .expect("upgrade"); + + let upgraded = scratch + .prepare(NOW) + .expect("the upgraded area should be prepared"); + assert_ne!(upgraded.directory, first.directory); + assert!(first.directory.is_dir(), "a recently used area is retained"); + } + + #[test] + fn a_cache_inside_the_installation_is_rejected_before_anything_is_written() { + let scratch = Scratch::new(); + let before = snapshot(&scratch.installation()); + + let error = prepare_configuration_area( + &scratch.installation().join("jdtls/cache"), + &scratch.packaged_configuration(), + NOW, + ) + .expect_err("a cache inside the installation must be rejected"); + + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + assert_eq!(snapshot(&scratch.installation()), before); + assert!(!scratch.installation().join("jdtls/cache").exists()); + } + + #[cfg(unix)] + #[test] + fn a_cache_linked_into_the_installation_is_rejected_before_anything_is_written() { + let scratch = Scratch::new(); + let before = snapshot(&scratch.installation()); + fs::create_dir_all(scratch.cache()).expect("cache"); + std::os::unix::fs::symlink( + scratch.installation().join("jdtls"), + scratch.cache().join(CONFIGURATION_AREAS_DIRECTORY), + ) + .expect("link"); + + let error = scratch + .prepare(NOW) + .expect_err("a cache linked into the installation must be rejected"); + + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + assert_eq!(snapshot(&scratch.installation()), before); + } + + #[test] + fn a_missing_config_ini_fails_the_start() { + let scratch = Scratch::new(); + fs::remove_file(scratch.packaged_configuration().join(CONFIG_INI)).expect("remove"); + + let error = scratch.prepare(NOW).expect_err("config.ini is required"); + + assert!(matches!(error.code, ErrorCode::ProcessStartFailed)); + assert!(!scratch.cache().exists()); + } + + #[test] + fn areas_unused_for_the_retention_period_are_removed() { + let scratch = Scratch::new(); + let areas = scratch.cache().join(CONFIGURATION_AREAS_DIRECTORY); + let expired = "a".repeat(64); + let recent = "b".repeat(64); + for (name, last_used) in [(&expired, NOW - 31 * DAY), (&recent, NOW - 29 * DAY)] { + fs::create_dir_all(areas.join(name).join(EQUINOX_CONFIGURATION_DIRECTORY)) + .expect("area"); + fs::write( + areas.join(name).join(LAST_USED_MARKER), + last_used.to_string(), + ) + .expect("marker"); + } + // A directory that is not an area is never removed. + fs::create_dir_all(areas.join("p2")).expect("foreign directory"); + + let area = scratch.prepare(NOW).expect("the area should be prepared"); + + assert_eq!(area.removed_keys, vec![expired.clone()]); + assert_eq!(area.cleanup_failure, None); + assert!(!areas.join(&expired).exists()); + assert!(areas.join(&recent).is_dir()); + assert!(areas.join("p2").is_dir()); + assert_eq!( + fs::read_to_string(areas.join(key()).join(LAST_USED_MARKER)).unwrap(), + NOW.to_string() + ); + } +} diff --git a/crates/java-domain/src/lsp/languages/jdt_contributions.rs b/crates/java-domain/src/lsp/languages/jdt_contributions.rs new file mode 100644 index 0000000..d7632c7 --- /dev/null +++ b/crates/java-domain/src/lsp/languages/jdt_contributions.rs @@ -0,0 +1,352 @@ +//! Settings that other plugins contribute to JDT LS. +//! +//! JDT LS imports Maven and Gradle projects on its own. The values it imports +//! with (settings file, local repository, offline mode, Gradle user home and +//! so on) belong to the plugins that own those build systems, so this plugin +//! defines a small function interface and never computes them: +//! +//! - [`SETTINGS_FUNCTION`] (`languageImport.settings`) is implemented by a +//! build-system plugin. Before JDT LS starts, the backend discovers every +//! enabled provider through the host, calls each one with +//! [`settings_request`] and records the answers in [`Contributions`]. +//! - [`SETTINGS_CHANGED_FUNCTION`] (`languageImport.settingsChanged`) is +//! exported by this plugin. When the user changes the build system's +//! settings, the build-system plugin calls it and passes the complete new +//! settings in the call. This plugin never calls back into a plugin that is +//! in the middle of calling it, because the host rejects such a loop. +//! +//! The host relays both directions (this plugin, Core, the other plugin) and +//! passes no trust along. A provider can only set keys inside +//! [`CONTRIBUTED_SETTINGS_KEYS`]; everything else is dropped here before it +//! can reach JDT LS. +//! +//! This module is the pure part of that design: contract constants, answer +//! validation and bookkeeping. The calls themselves are in the backend's +//! `plugin_functions` module. +//! +//! Both answers and change calls carry the same object: +//! +//! ```json +//! { "settings": { "java.import.maven.enabled": true } } +//! ``` +//! +//! `settings` may nest objects or use dotted keys. Unknown sibling fields are +//! ignored, so a later version can add fields without breaking this one. + +use super::jdt::{accept_contributed_settings, ContributedSettingsStatus}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +/// One function of the interface this plugin defines. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FunctionContract { + /// Function ID, dot-separated lowerCamelCase segments. + pub id: &'static str, + /// Version of the params and result this function exchanges. An + /// incompatible change uses a new version; callers and exporters must + /// agree on it. + pub version: u32, +} + +/// Implemented by build-system plugins: answers with the JDT LS settings +/// their projects need. Params are built by [`settings_request`]. +pub const SETTINGS_FUNCTION: FunctionContract = FunctionContract { + id: "languageImport.settings", + version: 1, +}; + +/// Exported by this plugin: a build-system plugin reports that its settings +/// changed and passes the complete new settings. +pub const SETTINGS_CHANGED_FUNCTION: FunctionContract = FunctionContract { + id: "languageImport.settingsChanged", + version: 1, +}; + +/// Most providers consulted for one workspace. Further ones are ignored, so a +/// crowded plugin list cannot delay the start of JDT LS. +pub const MAX_PROVIDERS: usize = 16; + +/// Params of [`SETTINGS_FUNCTION`]. +pub fn settings_request(workspace_root: &str) -> Value { + json!({ "workspaceRoot": workspace_root }) +} + +/// The settings of one answer after validation. +#[derive(Debug, Clone, PartialEq)] +pub struct AcceptedSettings { + /// Flat object of dotted keys, all inside the accepted namespace. + pub fragment: Value, + /// Leaf keys outside the accepted namespace. They never reach JDT LS. + pub rejected: Vec, +} + +/// Validates an answer of [`SETTINGS_FUNCTION`] or the params of +/// [`SETTINGS_CHANGED_FUNCTION`]. +/// +/// The error is a diagnostic sentence for the log and for the status line; +/// it contains no values from the answer. +pub fn accept_answer(answer: &Value) -> Result { + let settings = match answer.get("settings") { + Some(settings) if settings.is_object() => settings, + Some(_) => return Err("`settings` is not an object.".to_string()), + None => return Err("The answer has no `settings` object.".to_string()), + }; + let contributed = accept_contributed_settings(settings); + Ok(AcceptedSettings { + fragment: Value::Object(contributed.accepted.into_iter().collect()), + rejected: contributed.rejected, + }) +} + +/// What one provider last told this plugin. +#[derive(Debug, Clone, PartialEq)] +enum Slot { + Settings(Value), + Failed(String), +} + +/// The latest contribution of every provider of one workspace. +/// +/// Each provider owns one slot, so a change call replaces exactly what that +/// provider sent before and a removed setting disappears. Providers are +/// ordered by plugin ID, which keeps the merged result independent of the +/// order in which they answered. +#[derive(Debug, Clone, Default)] +pub struct Contributions { + slots: BTreeMap, + discovery_failure: Option, +} + +impl Contributions { + /// Stores the settings a provider answered with, replacing its slot. + pub fn record_settings(&mut self, provider: &str, accepted: AcceptedSettings) { + self.slots + .insert(provider.to_string(), Slot::Settings(accepted.fragment)); + } + + /// Remembers that a provider failed or timed out, replacing its slot. + /// `reason` is a short diagnostic that names no machine path. + pub fn record_failure(&mut self, provider: &str, reason: impl Into) { + self.slots + .insert(provider.to_string(), Slot::Failed(reason.into())); + } + + /// Remembers that the host could not list the providers at all, so it is + /// unknown which plugins should have contributed. + pub fn record_discovery_failure(&mut self, reason: impl Into) { + self.discovery_failure = Some(reason.into()); + } + + /// Drops what a provider contributed, for example when its plugin is + /// disabled. + pub fn forget(&mut self, provider: &str) { + self.slots.remove(provider); + } + + /// Fragments to put into `JdtSettings::contributed`, in plugin ID order. + /// When two providers set the same key, the one with the later plugin ID + /// wins. + pub fn fragments(&self) -> Vec { + self.slots + .values() + .filter_map(|slot| match slot { + Slot::Settings(fragment) => Some(fragment.clone()), + Slot::Failed(_) => None, + }) + .collect() + } + + /// Providers that failed, with the reason, in plugin ID order. + pub fn failures(&self) -> Vec<(&str, &str)> { + self.slots + .iter() + .filter_map(|(provider, slot)| match slot { + Slot::Failed(reason) => Some((provider.as_str(), reason.as_str())), + Slot::Settings(_) => None, + }) + .collect() + } + + /// Why the providers could not be listed, if they could not. + pub fn discovery_failure(&self) -> Option<&str> { + self.discovery_failure.as_deref() + } + + /// [`Failed`](ContributedSettingsStatus::Failed) while the providers + /// could not be listed or any provider's last answer failed, otherwise + /// [`Settled`](ContributedSettingsStatus::Settled). The collector reports + /// [`Pending`](ContributedSettingsStatus::Pending) itself while calls are + /// still outstanding. + pub fn status(&self) -> ContributedSettingsStatus { + if self.failures().is_empty() && self.discovery_failure.is_none() { + ContributedSettingsStatus::Settled + } else { + ContributedSettingsStatus::Failed + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn accepted(settings: Value) -> AcceptedSettings { + accept_answer(&json!({ "settings": settings })).expect("a valid answer") + } + + #[test] + fn the_request_names_the_workspace_root() { + assert_eq!( + settings_request("/work/app"), + json!({ "workspaceRoot": "/work/app" }) + ); + } + + #[test] + fn an_answer_keeps_only_the_declared_jdt_keys() { + let answer = accepted(json!({ + "java": { "import": { "maven": { "enabled": true } } }, + "java.import.gradle.offline.enabled": true, + "java.home": "/opt/jdk", + "unrelated": 1 + })); + assert_eq!( + answer.fragment, + json!({ + "java.import.gradle.offline.enabled": true, + "java.import.maven.enabled": true + }) + ); + let mut rejected = answer.rejected; + rejected.sort(); + assert_eq!(rejected, ["java.home", "unrelated"]); + } + + #[test] + fn a_stored_fragment_survives_the_second_filter_unchanged() { + let answer = accepted(json!({ + "java.configuration.maven.userSettings": "/home/dev/.m2/settings.xml", + "java.project.sourcePaths": ["src/main/java"] + })); + let again = accept_contributed_settings(&answer.fragment); + assert!(again.rejected.is_empty()); + assert_eq!( + Value::Object(again.accepted.into_iter().collect()), + answer.fragment + ); + } + + #[test] + fn an_answer_without_a_settings_object_is_refused() { + for answer in [ + json!({}), + json!(null), + json!({ "settings": null }), + json!({ "settings": ["java.import.maven.enabled"] }), + ] { + assert!(accept_answer(&answer).is_err(), "{answer}"); + } + } + + #[test] + fn unknown_sibling_fields_are_ignored() { + let answer = accept_answer(&json!({ + "settings": { "java.import.maven.enabled": false }, + "futureField": { "any": "thing" } + })) + .expect("a valid answer"); + assert_eq!( + answer.fragment, + json!({ "java.import.maven.enabled": false }) + ); + } + + #[test] + fn fragments_follow_plugin_id_order_not_arrival_order() { + let mut contributions = Contributions::default(); + contributions.record_settings( + "dev.lithe.plugin.maven", + accepted(json!({ "java.import.maven.enabled": true })), + ); + contributions.record_settings( + "dev.lithe.plugin.gradle", + accepted(json!({ "java.import.gradle.offline.enabled": true })), + ); + assert_eq!( + contributions.fragments(), + [ + json!({ "java.import.gradle.offline.enabled": true }), + json!({ "java.import.maven.enabled": true }), + ] + ); + } + + #[test] + fn a_change_replaces_the_providers_slot_and_removes_old_settings() { + let mut contributions = Contributions::default(); + let maven = "dev.lithe.plugin.maven"; + contributions.record_settings( + maven, + accepted(json!({ + "java.configuration.maven.userSettings": "/a/settings.xml", + "java.import.maven.offline.enabled": true + })), + ); + contributions.record_settings( + maven, + accepted(json!({ "java.configuration.maven.userSettings": "/b/settings.xml" })), + ); + assert_eq!( + contributions.fragments(), + [json!({ "java.configuration.maven.userSettings": "/b/settings.xml" })] + ); + } + + #[test] + fn a_failed_provider_marks_the_status_until_it_answers_again() { + let mut contributions = Contributions::default(); + assert_eq!(contributions.status(), ContributedSettingsStatus::Settled); + + contributions.record_failure("dev.lithe.plugin.maven", "The call timed out."); + assert_eq!(contributions.status(), ContributedSettingsStatus::Failed); + assert_eq!( + contributions.failures(), + [("dev.lithe.plugin.maven", "The call timed out.")] + ); + assert!(contributions.fragments().is_empty()); + + contributions.record_settings( + "dev.lithe.plugin.maven", + accepted(json!({ "java.import.maven.enabled": true })), + ); + assert_eq!(contributions.status(), ContributedSettingsStatus::Settled); + assert!(contributions.failures().is_empty()); + } + + #[test] + fn a_discovery_failure_marks_the_status_without_naming_a_provider() { + let mut contributions = Contributions::default(); + contributions.record_discovery_failure("The host could not list providers."); + assert_eq!(contributions.status(), ContributedSettingsStatus::Failed); + assert_eq!( + contributions.discovery_failure(), + Some("The host could not list providers.") + ); + assert!(contributions.failures().is_empty()); + assert!(contributions.fragments().is_empty()); + } + + #[test] + fn forgetting_a_provider_drops_its_settings_and_its_failure() { + let mut contributions = Contributions::default(); + contributions.record_settings( + "dev.lithe.plugin.gradle", + accepted(json!({ "java.import.gradle.offline.enabled": true })), + ); + contributions.record_failure("dev.lithe.plugin.maven", "The call failed."); + contributions.forget("dev.lithe.plugin.gradle"); + contributions.forget("dev.lithe.plugin.maven"); + assert!(contributions.fragments().is_empty()); + assert_eq!(contributions.status(), ContributedSettingsStatus::Settled); + } +} diff --git a/crates/java-domain/src/lsp/languages/jdt_navigation.rs b/crates/java-domain/src/lsp/languages/jdt_navigation.rs new file mode 100644 index 0000000..186f1e8 --- /dev/null +++ b/crates/java-domain/src/lsp/languages/jdt_navigation.rs @@ -0,0 +1,350 @@ +//! JDT LS navigation-marker planning and normalization shared by every platform frontend. + +use super::java_navigation_syntax::navigation_candidates; +use crate::protocol::JavaNavigationMarkerResponse; +use serde_json::json; +use serde_json::Value; +use std::collections::VecDeque; +use std::time::Instant; + +/// Maximum semantic verification work accepted for one document version. +pub const MAX_JAVA_NAVIGATION_TASKS: usize = 64; + +/// Bounded multi-request projection of JDT LS data into visible gutter markers. +pub struct JavaNavigationMarkerBatch { + tasks: VecDeque, + active_task: Option, + resolved_lenses: Vec, + semantic_markers: Vec, + total_tasks: usize, + created_at: Instant, + deadline: Instant, +} + +#[derive(Clone)] +/// One provider request needed to verify a possible Java navigation relation. +pub enum JavaNavigationMarkerTask { + ResolveCodeLens(Value), + FindImplementations(super::java_navigation_syntax::JavaNavigationCandidate), + FindSuperImplementation(super::java_navigation_syntax::JavaNavigationCandidate), +} + +impl JavaNavigationMarkerBatch { + /// Plans a deterministic, bounded batch from initial CodeLens data and synchronized source. + pub fn new(raw_lenses: &[Value], source: &str, created_at: Instant, deadline: Instant) -> Self { + let mut implementation_lenses = implementation_code_lenses(raw_lenses); + let (resolved_lenses, unresolved_lenses): (Vec<_>, Vec<_>) = implementation_lenses + .drain(..) + .partition(is_resolved_implementation_lens); + let candidates = navigation_candidates(source); + let mut tasks = unresolved_lenses + .into_iter() + .map(JavaNavigationMarkerTask::ResolveCodeLens) + .collect::>(); + // Parent checks run before child checks so an override arrow can appear + // even when the fixed task budget is exhausted by a very large type. + tasks.extend( + candidates + .iter() + .filter(|candidate| candidate.direction == "up") + .cloned() + .map(JavaNavigationMarkerTask::FindSuperImplementation), + ); + tasks.extend( + candidates + .into_iter() + .filter(|candidate| candidate.direction == "down") + .map(JavaNavigationMarkerTask::FindImplementations), + ); + let total_tasks = tasks.len(); + tasks.truncate(MAX_JAVA_NAVIGATION_TASKS); + Self { + tasks, + active_task: None, + resolved_lenses, + semantic_markers: Vec::new(), + total_tasks, + created_at, + deadline, + } + } + + /// Removes and tracks the next semantic verification request. + pub fn take_next(&mut self) -> Option { + let task = self.tasks.pop_front()?; + self.active_task = Some(task.clone()); + Some(task) + } + + /// Merges a successful provider response into the partial projection. + pub fn record_active_result(&mut self, result: Option<&Value>) { + let Some(task) = self.active_task.take() else { + return; + }; + match task { + JavaNavigationMarkerTask::ResolveCodeLens(_) => { + if let Some(lens) = result.cloned().filter(is_resolved_implementation_lens) { + self.resolved_lenses.push(lens); + } + } + JavaNavigationMarkerTask::FindImplementations(candidate) + | JavaNavigationMarkerTask::FindSuperImplementation(candidate) => { + let count = raw_location_count(result); + if count > 0 { + self.semantic_markers.push(JavaNavigationMarkerResponse { + line: usize::try_from(candidate.line).unwrap_or_default(), + utf16_column: usize::try_from(candidate.utf16_column).unwrap_or_default(), + implementation_count: count, + direction: candidate.direction.to_string(), + relation: candidate.relation.to_string(), + }); + } + } + } + } + + /// Produces the deterministic final marker list, including partial successes. + pub fn finish(self, source: &str) -> Vec { + let mut markers = markers_from_resolved_code_lenses(&self.resolved_lenses, source); + markers.extend(self.semantic_markers); + normalized_markers(markers) + } + + pub fn total_tasks(&self) -> usize { + self.total_tasks + } + + pub fn resolved_lens_count(&self) -> usize { + self.resolved_lenses.len() + } + + pub fn created_at(&self) -> Instant { + self.created_at + } + + pub fn deadline(&self) -> Instant { + self.deadline + } +} + +impl JavaNavigationMarkerTask { + /// Returns the provider method and params for this verification step. + pub fn request(&self, uri: &str) -> (&'static str, Value) { + match self { + Self::ResolveCodeLens(lens) => ("codeLens/resolve", lens.clone()), + Self::FindImplementations(candidate) => ( + "textDocument/implementation", + json!({ + "textDocument": { "uri": uri }, + "position": { + "line": candidate.line, + "character": candidate.utf16_column + } + }), + ), + Self::FindSuperImplementation(candidate) => ( + "java/findLinks", + json!({ + "type": "superImplementation", + "position": { + "textDocument": { "uri": uri }, + "position": { + "line": candidate.line, + "character": candidate.utf16_column + } + } + }), + ), + } + } +} + +fn raw_location_count(result: Option<&Value>) -> usize { + match result { + Some(Value::Array(locations)) => locations.len(), + Some(Value::Object(_)) => 1, + _ => 0, + } +} + +/// Keeps only JDT LS implementation lenses; reference lenses never drive gutter icons. +pub fn implementation_code_lenses(values: &[Value]) -> Vec { + values + .iter() + .filter(|lens| { + resolved_implementation_title(lens).is_some() + || lens + .get("data") + .and_then(Value::as_array) + .and_then(|data| data.get(2)) + .and_then(Value::as_str) + == Some("implementations") + }) + .cloned() + .collect() +} + +/// Returns whether JDT LS has already attached the target count to a lens. +pub fn is_resolved_implementation_lens(lens: &Value) -> bool { + resolved_implementation_title(lens).is_some() +} + +/// Converts resolved JDT LS implementation CodeLens entries into gutter markers. +/// +/// JDT LS 1.38 only emits implementation lenses for interfaces and abstract +/// classes. It does not encode the declaration kind in the lens, so the +/// synchronized source is used solely to distinguish those two relationships. +/// Zero-target lenses are intentionally omitted to match IDEA gutter behavior. +pub fn markers_from_resolved_code_lenses( + code_lenses: &[Value], + source: &str, +) -> Vec { + let markers = code_lenses + .iter() + .filter_map(|lens| marker_from_resolved_code_lens(lens, source)) + .collect::>(); + normalized_markers(markers) +} + +/// Applies the deterministic ordering and identity used by the shared contract. +pub fn normalized_markers( + mut markers: Vec, +) -> Vec { + markers.sort_by(|left, right| { + left.line + .cmp(&right.line) + .then_with(|| left.utf16_column.cmp(&right.utf16_column)) + .then_with(|| left.relation.cmp(&right.relation)) + }); + markers.dedup_by(|left, right| { + left.line == right.line + && left.utf16_column == right.utf16_column + && left.direction == right.direction + && left.relation == right.relation + }); + markers +} + +fn marker_from_resolved_code_lens( + lens: &Value, + source: &str, +) -> Option { + let count = implementation_count(resolved_implementation_title(lens)?)?; + if count == 0 { + return None; + } + let start = lens.get("range")?.get("start")?; + let line = usize::try_from(start.get("line")?.as_i64()?).ok()?; + let utf16_column = start + .get("utf16Column") + .or_else(|| start.get("character")) + .and_then(Value::as_i64) + .and_then(|column| usize::try_from(column).ok())?; + let relation = if declaration_prefix(source, line, utf16_column) + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .any(|word| word == "interface") + { + "interface" + } else { + "inheritance" + }; + Some(JavaNavigationMarkerResponse { + line, + utf16_column, + implementation_count: count, + direction: "down".to_string(), + relation: relation.to_string(), + }) +} + +fn resolved_implementation_title(lens: &Value) -> Option<&str> { + let title = lens.get("command")?.get("title")?.as_str()?.trim(); + implementation_count(title).map(|_| title) +} + +fn implementation_count(title: &str) -> Option { + let mut parts = title.split_whitespace(); + let count = parts.next()?.parse::().ok()?; + let noun = parts.next()?; + if matches!(noun, "implementation" | "implementations") && parts.next().is_none() { + Some(count) + } else { + None + } +} + +fn declaration_prefix(source: &str, line: usize, utf16_column: usize) -> &str { + let Some(line_text) = source.lines().nth(line) else { + return ""; + }; + let byte_column = line_text + .char_indices() + .scan(0usize, |units, (offset, character)| { + let current = *units; + *units += character.len_utf16(); + Some((offset, current)) + }) + .find_map(|(offset, units)| (units >= utf16_column).then_some(offset)) + .unwrap_or(line_text.len()); + &line_text[..byte_column] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn resolved_lenses_distinguish_interface_and_abstract_class_declarations() { + let source = "public interface Service {}\npublic abstract class Base {}\n"; + let lenses = vec![ + json!({ + "range": { "start": { "line": 0, "utf16Column": 17 } }, + "command": { "title": "2 implementations" } + }), + json!({ + "range": { "start": { "line": 1, "utf16Column": 22 } }, + "command": { "title": "1 implementation" } + }), + ]; + + let markers = markers_from_resolved_code_lenses(&lenses, source); + + assert_eq!(markers.len(), 2); + assert_eq!(markers[0].relation, "interface"); + assert_eq!(markers[0].implementation_count, 2); + assert_eq!(markers[1].relation, "inheritance"); + } + + #[test] + fn unresolved_zero_target_and_reference_lenses_do_not_create_markers() { + let lenses = vec![ + json!({ "range": { "start": { "line": 0, "utf16Column": 0 } } }), + json!({ + "range": { "start": { "line": 0, "utf16Column": 0 } }, + "command": { "title": "0 implementations" } + }), + json!({ + "range": { "start": { "line": 0, "utf16Column": 0 } }, + "command": { "title": "3 references" } + }), + ]; + + assert!(markers_from_resolved_code_lenses(&lenses, "interface Service {}").is_empty()); + } + + #[test] + fn implementation_filter_uses_jdt_data_before_lenses_are_resolved() { + let lenses = vec![ + json!({ "data": ["file:///Service.java", { "line": 0 }, "implementations"] }), + json!({ "data": ["file:///Service.java", { "line": 0 }, "references"] }), + json!({ "command": { "title": "1 implementation" } }), + ]; + + let filtered = implementation_code_lenses(&lenses); + + assert_eq!(filtered.len(), 2); + assert!(!is_resolved_implementation_lens(&filtered[0])); + assert!(is_resolved_implementation_lens(&filtered[1])); + } +} diff --git a/crates/java-domain/src/lsp/languages/jdt_progress.rs b/crates/java-domain/src/lsp/languages/jdt_progress.rs new file mode 100644 index 0000000..880af7f --- /dev/null +++ b/crates/java-domain/src/lsp/languages/jdt_progress.rs @@ -0,0 +1,527 @@ +//! Stateful, throttled diagnostics for JDT LS workspace-import progress. + +use super::jdt::JdtImportProgress; +use serde_json::json; +use std::collections::{BTreeMap, BTreeSet}; +use std::time::{Duration, Instant}; + +const JAVA_PROGRESS_LOG_INTERVAL_MS: u64 = 2_000; +const JAVA_PROGRESS_PERCENTAGE_STEP: u64 = 5; + +struct JavaDownloadDiagnostics { + artifact: String, + repository_host: Option, + started_at: Instant, + downloaded_bytes: Option, + total_bytes: Option, +} + +/// Java-only preparation observations retained until `ServiceReady` or timeout. +pub struct JavaPreparationDiagnostics { + startup_started_at: Instant, + process_start_elapsed: Duration, + cache_disposition: &'static str, + workspace_fingerprint_present: bool, + readiness_started_at: Option, + last_progress_at: Option, + absolute_deadline: Option, + idle_timeout: Duration, + idle_warning_reported: bool, + last_activity_signature: Option, + phase: Option, + progress_percentage: Option, + current_project: Option, + observed_projects: BTreeSet, + observed_downloads: BTreeMap, + active_download: Option, + last_log_at: Option, + last_logged_phase: Option, + last_logged_project: Option, + last_logged_percentage: Option, + last_logged_download_bytes: Option, + initialize_elapsed: Option, +} + +impl JavaPreparationDiagnostics { + /// Starts diagnostics with the process-launch and durable-cache observations. + pub fn new( + startup_started_at: Instant, + process_start_elapsed: Duration, + cache_disposition: &'static str, + workspace_fingerprint_present: bool, + ) -> Self { + Self { + startup_started_at, + process_start_elapsed, + cache_disposition, + workspace_fingerprint_present, + readiness_started_at: None, + last_progress_at: None, + absolute_deadline: None, + idle_timeout: Duration::ZERO, + idle_warning_reported: false, + last_activity_signature: None, + phase: None, + progress_percentage: None, + current_project: None, + observed_projects: BTreeSet::new(), + observed_downloads: BTreeMap::new(), + active_download: None, + last_log_at: None, + last_logged_phase: None, + last_logged_project: None, + last_logged_percentage: None, + last_logged_download_bytes: None, + initialize_elapsed: None, + } + } + + /// Serializes the process-start observation for platform log adapters. + pub fn startup_detail(&self) -> String { + json!({ + "stage": "processStart", + "processStartMilliseconds": self.process_start_elapsed.as_millis(), + "cacheDisposition": self.cache_disposition, + "workspaceFingerprintPresent": self.workspace_fingerprint_present, + }) + .to_string() + } + + /// Switches from the standard initialize handshake to post-initialize readiness. + pub fn begin_readiness( + &mut self, + now: Instant, + initialize_elapsed: Duration, + idle_timeout: Duration, + absolute_timeout: Duration, + ) -> String { + self.initialize_elapsed = Some(initialize_elapsed); + self.readiness_started_at = Some(now); + self.last_progress_at = Some(now); + self.absolute_deadline = Some(now + absolute_timeout); + self.idle_timeout = idle_timeout; + self.idle_warning_reported = false; + json!({ + "stage": "serviceReady", + "initializeMilliseconds": initialize_elapsed.as_millis(), + "idleTimeoutMilliseconds": idle_timeout.as_millis(), + "absoluteTimeoutMilliseconds": absolute_timeout.as_millis(), + "cacheDisposition": self.cache_disposition, + }) + .to_string() + } + + /// Records changed work-done progress and returns a throttled JSON log detail. + pub fn record_progress(&mut self, progress: JdtImportProgress, now: Instant) -> Option { + self.readiness_started_at?; + if self.last_activity_signature.as_deref() == Some(&progress.activity_signature) { + return None; + } + self.last_activity_signature = Some(progress.activity_signature); + self.last_progress_at = Some(now); + self.idle_warning_reported = false; + + let phase_changed = progress + .phase + .as_ref() + .is_some_and(|phase| self.last_logged_phase.as_deref() != Some(phase.as_str())); + let project_changed = progress + .current_project + .as_ref() + .is_some_and(|project| self.last_logged_project.as_deref() != Some(project.as_str())); + let percentage_advanced = progress.percentage.is_some_and(|percentage| { + self.last_logged_percentage.is_none_or(|logged| { + percentage >= logged.saturating_add(JAVA_PROGRESS_PERCENTAGE_STEP) + }) + }); + let download_finished = progress.download.is_none() && self.active_download.is_some(); + + if let Some(phase) = progress.phase { + self.phase = Some(phase); + } + if let Some(percentage) = progress.percentage { + self.progress_percentage = Some(percentage); + } + if let Some(project) = progress.current_project { + self.observed_projects.insert(project.clone()); + self.current_project = Some(project); + } + + let mut download_advanced = false; + if let Some(download) = progress.download { + let download_key = format!( + "{}/{}", + download.repository_host.as_deref().unwrap_or("unknown"), + download.artifact + ); + let observed = self.observed_downloads.entry(download_key).or_default(); + if let Some(downloaded_bytes) = download.downloaded_bytes { + *observed = (*observed).max(downloaded_bytes); + download_advanced = self + .last_logged_download_bytes + .is_none_or(|logged| downloaded_bytes > logged); + } + let same_download = self.active_download.as_ref().is_some_and(|active| { + active.artifact == download.artifact + && active.repository_host == download.repository_host + }); + if same_download { + if let Some(active) = self.active_download.as_mut() { + active.downloaded_bytes = download.downloaded_bytes; + active.total_bytes = download.total_bytes; + } + } else { + self.active_download = Some(JavaDownloadDiagnostics { + artifact: download.artifact, + repository_host: download.repository_host, + started_at: now, + downloaded_bytes: download.downloaded_bytes, + total_bytes: download.total_bytes, + }); + self.last_logged_download_bytes = None; + download_advanced = true; + } + } else { + self.active_download = None; + self.last_logged_download_bytes = None; + } + + let log_interval_elapsed = self.last_log_at.is_none_or(|logged_at| { + now.saturating_duration_since(logged_at) + >= Duration::from_millis(JAVA_PROGRESS_LOG_INTERVAL_MS) + }); + let should_log = phase_changed + || project_changed + || percentage_advanced + || download_finished + || (download_advanced && log_interval_elapsed); + if !should_log { + return None; + } + + self.last_log_at = Some(now); + self.last_logged_phase = self.phase.clone(); + self.last_logged_project = self.current_project.clone(); + self.last_logged_percentage = self.progress_percentage; + self.last_logged_download_bytes = self + .active_download + .as_ref() + .and_then(|download| download.downloaded_bytes); + Some(self.progress_detail(now)) + } + + fn progress_detail(&self, now: Instant) -> String { + let (artifact, repository_host, downloaded_bytes, total_bytes, bytes_per_second) = self + .active_download + .as_ref() + .map(|download| { + let elapsed = now.saturating_duration_since(download.started_at); + let throughput = download.downloaded_bytes.and_then(|bytes| { + (elapsed > Duration::ZERO) + .then(|| (bytes as f64 / elapsed.as_secs_f64()).round() as u64) + }); + ( + Some(download.artifact.clone()), + download.repository_host.clone(), + download.downloaded_bytes, + download.total_bytes, + throughput, + ) + }) + .unwrap_or((None, None, None, None, None)); + json!({ + "stage": "serviceReady", + "phase": self.phase, + "progressPercent": self.progress_percentage, + "currentProject": self.current_project, + "observedProjectCount": self.observed_projects.len(), + "downloadArtifact": artifact, + "repositoryHost": repository_host, + "downloadedBytes": downloaded_bytes, + "totalBytes": total_bytes, + "bytesPerSecond": bytes_per_second, + "elapsedMilliseconds": self.readiness_started_at + .map(|started| now.saturating_duration_since(started).as_millis()), + "idleMilliseconds": self.last_progress_at + .map(|progress| now.saturating_duration_since(progress).as_millis()), + "cacheDisposition": self.cache_disposition, + }) + .to_string() + } + + /// Scalar observation for catalog logging, separate from v1 JSON presentation. + pub fn log_progress(&self, now: Instant) -> serde_json::Value { + json!({"progressPercent": self.progress_percentage, "idleMilliseconds": self.last_progress_at.map(|at| now.saturating_duration_since(at).as_millis() as u64).unwrap_or(0)}) + } + + /// Elapsed import time before ready_detail consumes the readiness state. + pub fn log_import(&self, now: Instant) -> serde_json::Value { + json!({"importMilliseconds": self.readiness_started_at.map(|at| now.saturating_duration_since(at).as_millis() as u64)}) + } + + /// Finishes preparation and serializes one aggregate startup summary. + pub fn ready_detail(&mut self, now: Instant) -> String { + let import_elapsed = self + .readiness_started_at + .map(|started| now.saturating_duration_since(started)); + self.readiness_started_at = None; + self.absolute_deadline = None; + let downloaded_bytes: u64 = self.observed_downloads.values().copied().sum(); + json!({ + "stage": "serviceReady", + "outcome": "ready", + "totalMilliseconds": now.saturating_duration_since(self.startup_started_at).as_millis(), + "processStartMilliseconds": self.process_start_elapsed.as_millis(), + "initializeMilliseconds": self.initialize_elapsed.map(|elapsed| elapsed.as_millis()), + "importMilliseconds": import_elapsed.map(|elapsed| elapsed.as_millis()), + "progressPercent": self.progress_percentage, + "observedProjectCount": self.observed_projects.len(), + "downloadedArtifactCount": self.observed_downloads.len(), + "downloadedBytes": downloaded_bytes, + "cacheDisposition": self.cache_disposition, + }) + .to_string() + } + + /// Reports each quiet interval once without treating optional progress as a heartbeat. + pub fn take_idle_warning(&mut self, now: Instant) -> Option { + let started_at = self.readiness_started_at?; + let idle = now.saturating_duration_since(self.last_progress_at.unwrap_or(started_at)); + if self.idle_warning_reported || idle < self.idle_timeout { + return None; + } + self.idle_warning_reported = true; + Some(self.progress_detail(now)) + } + + /// Takes the absolute timeout once and returns its diagnostic snapshot. + pub fn take_timeout(&mut self, now: Instant) -> Option { + let started_at = self.readiness_started_at?; + if now < self.absolute_deadline? { + return None; + } + let last_progress_at = self.last_progress_at.unwrap_or(started_at); + let idle = now.saturating_duration_since(last_progress_at); + // JDT reports work milestones, not liveness. Silence is diagnostic only; + // even a quiet Maven import retains the full, bounded readiness budget. + let classification = match (idle >= self.idle_timeout, self.active_download.is_some()) { + (true, true) => "networkDownloadStalled", + (true, false) => "noProgressStall", + (false, true) => "networkTransferActive", + _ => "projectImportActive", + }; + self.readiness_started_at = None; + self.absolute_deadline = None; + Some( + json!({ + "stage": "serviceReady", + "timeoutKind": "absolute", + "classification": classification, + "elapsedMilliseconds": now.saturating_duration_since(started_at).as_millis(), + "idleMilliseconds": idle.as_millis(), + "progressPercent": self.progress_percentage, + "phase": self.phase, + "currentProject": self.current_project, + "observedProjectCount": self.observed_projects.len(), + "downloadArtifact": self.active_download.as_ref().map(|download| &download.artifact), + "downloadedBytes": self.active_download.as_ref().and_then(|download| download.downloaded_bytes), + "totalBytes": self.active_download.as_ref().and_then(|download| download.total_bytes), + "cacheDisposition": self.cache_disposition, + }) + .to_string(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn progress(signature: &str, percentage: u64) -> JdtImportProgress { + JdtImportProgress { + activity_signature: signature.to_string(), + phase: Some("Importing Maven project(s)".to_string()), + percentage: Some(percentage), + current_project: Some("module-a".to_string()), + download: None, + } + } + + #[test] + fn shared_readiness_examples_preserve_warnings_and_the_absolute_cap() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../../fixtures/lsp/jdt-readiness-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let started = Instant::now(); + let mut diagnostics = + JavaPreparationDiagnostics::new(started, Duration::ZERO, "reused", true); + diagnostics.begin_readiness( + started, + Duration::ZERO, + Duration::from_millis(fixture["idleWarningMilliseconds"].as_u64().unwrap()), + Duration::from_millis(fixture["absoluteTimeoutMilliseconds"].as_u64().unwrap()), + ); + for step in case["steps"].as_array().unwrap() { + let now = started + Duration::from_millis(step["atMilliseconds"].as_u64().unwrap()); + match step["action"].as_str().unwrap() { + "progress" => { + diagnostics.record_progress( + progress( + &step["atMilliseconds"].to_string(), + step["percentage"].as_u64().unwrap(), + ), + now, + ); + } + "ready" => { + let result: serde_json::Value = + serde_json::from_str(&diagnostics.ready_detail(now)).unwrap(); + assert_eq!(result["outcome"], "ready"); + } + "poll" => { + let timeout = diagnostics.take_timeout(now); + assert_eq!( + timeout.is_some(), + step["timeout"].as_bool().unwrap(), + "{}", + case["name"] + ); + let warning = diagnostics.take_idle_warning(now); + assert_eq!( + warning.is_some(), + step["warning"].as_bool().unwrap(), + "{}", + case["name"] + ); + if let Some(timeout) = timeout { + let detail: serde_json::Value = serde_json::from_str(&timeout).unwrap(); + assert_eq!(detail["timeoutKind"], "absolute"); + assert_eq!(detail["classification"], step["classification"]); + } + } + action => panic!("unknown fixture action: {action}"), + } + } + } + } + + #[test] + fn quiet_import_warns_once_and_can_still_become_ready() { + let started = Instant::now(); + let mut diagnostics = + JavaPreparationDiagnostics::new(started, Duration::ZERO, "reused", true); + diagnostics.begin_readiness( + started, + Duration::ZERO, + Duration::from_secs(45), + Duration::from_secs(600), + ); + diagnostics.record_progress(progress("maven", 23), started); + let quiet = started + Duration::from_secs(46); + assert!(diagnostics.take_timeout(quiet).is_none()); + let warning = diagnostics + .take_idle_warning(quiet) + .expect("quiet import warning"); + let warning: serde_json::Value = serde_json::from_str(&warning).unwrap(); + assert_eq!(warning["idleMilliseconds"], 46_000); + assert_eq!(warning["progressPercent"], 23); + assert!(diagnostics + .take_idle_warning(quiet + Duration::from_secs(1)) + .is_none()); + + let ready: serde_json::Value = + serde_json::from_str(&diagnostics.ready_detail(started + Duration::from_secs(90))) + .unwrap(); + assert_eq!(ready["outcome"], "ready"); + assert!(diagnostics + .take_timeout(started + Duration::from_secs(601)) + .is_none()); + assert!(diagnostics + .take_idle_warning(started + Duration::from_secs(601)) + .is_none()); + } + + #[test] + fn meaningful_progress_refreshes_the_idle_warning() { + let started = Instant::now(); + let mut diagnostics = JavaPreparationDiagnostics::new(started, Duration::ZERO, "new", true); + diagnostics.begin_readiness( + started, + Duration::ZERO, + Duration::from_millis(100), + Duration::from_secs(1), + ); + diagnostics.record_progress(progress("first", 20), started + Duration::from_millis(80)); + assert!(diagnostics + .take_idle_warning(started + Duration::from_millis(150)) + .is_none()); + assert!(diagnostics + .take_idle_warning(started + Duration::from_millis(181)) + .is_some()); + diagnostics.record_progress(progress("second", 25), started + Duration::from_millis(200)); + assert!(diagnostics + .take_idle_warning(started + Duration::from_millis(250)) + .is_none()); + assert!(diagnostics + .take_idle_warning(started + Duration::from_millis(301)) + .is_some()); + assert!(diagnostics + .take_timeout(started + Duration::from_millis(301)) + .is_none()); + } + + #[test] + fn duplicate_progress_does_not_refresh_the_idle_warning() { + let started = Instant::now(); + let mut diagnostics = JavaPreparationDiagnostics::new(started, Duration::ZERO, "new", true); + diagnostics.begin_readiness( + started, + Duration::ZERO, + Duration::from_millis(45), + Duration::from_secs(10), + ); + diagnostics.record_progress(progress("same", 20), started + Duration::from_millis(10)); + diagnostics.record_progress(progress("same", 20), started + Duration::from_millis(30)); + let quiet = started + Duration::from_millis(56); + assert!(diagnostics.take_idle_warning(quiet).is_some()); + diagnostics.record_progress(progress("same", 20), started + Duration::from_millis(70)); + assert!(diagnostics + .take_idle_warning(started + Duration::from_millis(120)) + .is_none()); + assert!(diagnostics.take_timeout(quiet).is_none()); + let timeout: serde_json::Value = serde_json::from_str( + &diagnostics + .take_timeout(started + Duration::from_secs(10)) + .unwrap(), + ) + .unwrap(); + assert_eq!(timeout["timeoutKind"], "absolute"); + assert_eq!(timeout["classification"], "noProgressStall"); + assert!(diagnostics + .take_timeout(started + Duration::from_secs(11)) + .is_none()); + } + + #[test] + fn absolute_deadline_still_caps_continuously_active_imports() { + let started = Instant::now(); + let mut diagnostics = + JavaPreparationDiagnostics::new(started, Duration::from_millis(5), "reused", true); + diagnostics.begin_readiness( + started, + Duration::from_millis(10), + Duration::from_secs(1), + Duration::from_millis(100), + ); + diagnostics.record_progress(progress("first", 20), started + Duration::from_millis(40)); + diagnostics.record_progress(progress("second", 25), started + Duration::from_millis(90)); + + let timeout = diagnostics + .take_timeout(started + Duration::from_millis(100)) + .expect("the absolute deadline must remain a final safety cap"); + assert!(timeout.contains("\"timeoutKind\":\"absolute\"")); + assert!(timeout.contains("\"classification\":\"projectImportActive\"")); + } +} diff --git a/crates/java-domain/src/lsp/languages/jdt_project_metadata.rs b/crates/java-domain/src/lsp/languages/jdt_project_metadata.rs new file mode 100644 index 0000000..1be75d0 --- /dev/null +++ b/crates/java-domain/src/lsp/languages/jdt_project_metadata.rs @@ -0,0 +1,499 @@ +//! Removal of Eclipse project metadata that earlier JDT LS launches wrote into +//! the user's module directories. +//! +//! JDT LS now keeps `.project`, `.classpath`, `.factorypath`, and +//! `.settings/*.prefs` in its `-data` metadata area (see `jdt::adapt_start`). +//! A file that already exists at a module root still wins over that area, so +//! files written by earlier Lithe versions would otherwise stay in the project +//! forever. Only files Git does not track are removed: a tracked file is a +//! team decision, and outside a Git work tree ownership cannot be established. + +use crate::lsp::languages::java_workspace::IGNORED_DIRECTORIES; +use crate::protocol::{CoreError, ErrorCode}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Build descriptors for which JDT LS's Maven and Gradle importers create an +/// Eclipse project, and therefore the only directories they write metadata to. +const MODULE_DESCRIPTOR_NAMES: &[&str] = &["pom.xml", "build.gradle", "build.gradle.kts"]; +/// Metadata files JDT LS writes directly into a module directory. +const METADATA_FILE_NAMES: &[&str] = &[".classpath", ".factorypath", ".project"]; +/// Directory holding the per-project Eclipse preference files. +const SETTINGS_DIRECTORY_NAME: &str = ".settings"; +/// Extension of the preference files JDT LS redirects together with the files above. +const PREFERENCES_EXTENSION: &str = "prefs"; + +/// Outcome of removing legacy metadata before one JDT LS launch. +#[derive(Debug, Default, Eq, PartialEq)] +pub struct LegacyMetadataCleanup { + /// Removed files as sorted workspace-relative paths with `/` separators. + pub removed_files: Vec, + /// Whether an existing JDT LS state directory was deleted so the launch + /// imports the workspace afresh. + pub state_reset: bool, +} + +/// Removes legacy metadata before JDT LS starts for `workspace_root`. +/// +/// When any file was removed, the JDT LS state in `data_directory` still +/// describes those projects at their old location, so it is deleted and the +/// launch imports the workspace afresh. +pub fn prepare_workspace( + workspace_root: &Path, + data_directory: &Path, +) -> Result { + prepare_workspace_with( + workspace_root, + data_directory, + crate::host::git::untracked_candidates, + ) +} + +/// [`prepare_workspace`] with an explicit tracking query. +/// +/// `untracked_candidates(repository, paths)` returns the entries of `paths` +/// (relative to `repository`, `/` separators) that the repository does not +/// track, or `None` when it cannot answer. Production passes the host's +/// read-only VCS query; tests pass an in-memory index. +pub fn prepare_workspace_with( + workspace_root: &Path, + data_directory: &Path, + untracked_candidates: impl Fn(&Path, &[String]) -> Option>, +) -> Result { + let removed_files = remove_untracked_metadata(workspace_root, &untracked_candidates); + let state_reset = !removed_files.is_empty() && data_directory.exists(); + if state_reset { + fs::remove_dir_all(data_directory).map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not reset the Java language-server state after moving its project files out of the workspace.", + ) + .with_details(error.to_string()) + })?; + } + Ok(LegacyMetadataCleanup { + removed_files, + state_reset, + }) +} + +/// One metadata file of one module, keyed for the repository that owns it. +struct Candidate { + module: PathBuf, + /// Path relative to `module` with `/` separators. + name: String, + /// Path relative to the owning repository root with `/` separators, the + /// form `git ls-files` reports. + repository_path: String, +} + +/// Returns the entries of `paths` in `repository` that Git does not track, or +/// `None` when Git cannot answer. +type UntrackedCandidates<'a> = &'a dyn Fn(&Path, &[String]) -> Option>; + +fn remove_untracked_metadata( + workspace_root: &Path, + untracked_candidates: UntrackedCandidates<'_>, +) -> Vec { + // Group candidates by repository so each repository answers with one Git + // query. The repository is located from its `.git` entry without a query, + // so modules outside Git — whose files are kept anyway — cost no Git + // invocation on any launch. + let mut repositories: BTreeMap> = BTreeMap::new(); + for module in module_directories(workspace_root) { + let names = metadata_candidates(&module); + if names.is_empty() { + continue; + } + let Some(repository) = enclosing_repository(&module) else { + continue; + }; + let module_path = relative_path(&repository, &module); + let candidates = repositories.entry(repository).or_default(); + for name in names { + let repository_path = if module_path.is_empty() { + name.clone() + } else { + format!("{module_path}/{name}") + }; + candidates.push(Candidate { + module: module.clone(), + name, + repository_path, + }); + } + } + + let mut removed = Vec::new(); + let mut emptied_settings = BTreeSet::new(); + for (repository, candidates) in repositories { + let paths = candidates + .iter() + .map(|candidate| candidate.repository_path.clone()) + .collect::>(); + // Removal is best effort: a file that cannot be queried or deleted keeps + // working for JDT LS exactly as before, so it never blocks the launch. + let Some(untracked) = untracked_candidates(&repository, &paths) else { + continue; + }; + let untracked = untracked.into_iter().collect::>(); + for candidate in candidates { + if !untracked.contains(&candidate.repository_path) + || fs::remove_file(candidate.module.join(&candidate.name)).is_err() + { + continue; + } + if candidate.name.starts_with(SETTINGS_DIRECTORY_NAME) { + emptied_settings.insert(candidate.module.join(SETTINGS_DIRECTORY_NAME)); + } + let module_path = relative_path(workspace_root, &candidate.module); + removed.push(if module_path.is_empty() { + candidate.name + } else { + format!("{module_path}/{}", candidate.name) + }); + } + } + // Summarize cleanup at the launch boundary; retained settings are normal. + let mut cleanup = crate::host::storage::StorageCleanup::default(); + for settings in emptied_settings { + cleanup.empty_directory(&settings); + } + cleanup.finish("jdt-settings"); + removed.sort(); + removed +} + +/// Nearest ancestor of `directory` (itself included) holding a `.git` entry. +/// +/// `.git` is a directory in a regular checkout and a file in linked worktrees +/// and submodules; both mark the repository whose index decides tracking, so a +/// module inside a nested repository is answered by that nested repository. +fn enclosing_repository(directory: &Path) -> Option { + directory + .ancestors() + .find(|ancestor| fs::symlink_metadata(ancestor.join(".git")).is_ok()) + .map(Path::to_path_buf) +} + +/// Directories below `workspace_root` that declare a Maven or Gradle module. +/// +/// Hidden, ignored, and symbolically linked directories are not entered, so the +/// walk stays inside the project's own sources and cannot leave the workspace. +fn module_directories(workspace_root: &Path) -> Vec { + let mut modules = Vec::new(); + let mut pending = vec![workspace_root.to_path_buf()]; + while let Some(directory) = pending.pop() { + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + let mut is_module = false; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if file_type.is_file() && MODULE_DESCRIPTOR_NAMES.contains(&name.as_ref()) { + is_module = true; + } else if file_type.is_dir() + && !name.starts_with('.') + && !IGNORED_DIRECTORIES.contains(&name.as_ref()) + { + pending.push(entry.path()); + } + } + if is_module { + modules.push(directory); + } + } + modules.sort(); + modules +} + +/// Existing metadata files of one module, relative to it with `/` separators. +fn metadata_candidates(module: &Path) -> Vec { + let mut candidates = METADATA_FILE_NAMES + .iter() + .filter(|name| is_regular_file(&module.join(name))) + .map(|name| name.to_string()) + .collect::>(); + let settings = module.join(SETTINGS_DIRECTORY_NAME); + let is_settings_directory = + fs::symlink_metadata(&settings).is_ok_and(|metadata| metadata.file_type().is_dir()); + if is_settings_directory { + if let Ok(entries) = fs::read_dir(&settings) { + let mut preferences = entries + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file())) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| { + Path::new(name) + .extension() + .is_some_and(|extension| extension == PREFERENCES_EXTENSION) + }) + .map(|name| format!("{SETTINGS_DIRECTORY_NAME}/{name}")) + .collect::>(); + preferences.sort(); + candidates.extend(preferences); + } + } + candidates +} + +fn is_regular_file(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file()) +} + +/// `path` relative to `base` with `/` separators; empty when they are equal. +fn relative_path(base: &Path, path: &Path) -> String { + path.strip_prefix(base) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_WORKSPACE: AtomicU64 = AtomicU64::new(0); + + /// A scratch directory holding a workspace and a JDT data directory, removed + /// on drop so a failed assertion does not leak it. + struct Scratch { + root: PathBuf, + } + + impl Scratch { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "lithe-jdt-metadata-{}-{}", + std::process::id(), + NEXT_WORKSPACE.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("workspace")).expect("workspace should be created"); + Self { root } + } + + fn workspace(&self) -> PathBuf { + self.root.join("workspace") + } + + fn data_directory(&self) -> PathBuf { + let directory = self.root.join("data"); + fs::create_dir_all(directory.join(".metadata")).expect("data should be created"); + directory + } + + fn write(&self, relative: &str) { + let path = self.workspace().join(relative); + fs::create_dir_all(path.parent().unwrap()).expect("parent should be created"); + fs::write(path, relative).expect("file should be written"); + } + + fn exists(&self, relative: &str) -> bool { + self.workspace().join(relative).exists() + } + + /// Marks `relative` (empty for the workspace) as a repository root the + /// way a checkout does, with a `.git` directory. + fn init_repository(&self, relative: &str) { + fs::create_dir_all(self.workspace().join(relative).join(".git")) + .expect("repository marker should be created"); + } + } + + /// In-memory stand-in for the host's read-only VCS query: every path is + /// untracked unless it was added to the index of its repository. + #[derive(Default)] + struct Index { + tracked: BTreeSet<(PathBuf, String)>, + queried: RefCell>, + } + + impl Index { + fn track(&mut self, repository: PathBuf, path: &str) { + self.tracked.insert((repository, path.to_string())); + } + + fn untracked(&self, repository: &Path, paths: &[String]) -> Option> { + self.queried.borrow_mut().push(repository.to_path_buf()); + Some( + paths + .iter() + .filter(|path| { + !self + .tracked + .contains(&(repository.to_path_buf(), (*path).clone())) + }) + .cloned() + .collect(), + ) + } + } + + fn prepare(scratch: &Scratch, index: &Index, data_directory: &Path) -> LegacyMetadataCleanup { + prepare_workspace_with(&scratch.workspace(), data_directory, |repository, paths| { + index.untracked(repository, paths) + }) + .expect("preparation should succeed") + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + /// The reported layout: a Maven reactor whose modules sit one and two + /// levels deep, each carrying the files JDT LS used to write in place. + fn write_reactor_with_legacy_metadata(scratch: &Scratch) { + for module in ["", "domain/", "infrastructure/sdk/"] { + scratch.write(&format!("{module}pom.xml")); + scratch.write(&format!("{module}.project")); + scratch.write(&format!("{module}.classpath")); + scratch.write(&format!("{module}.factorypath")); + scratch.write(&format!("{module}.settings/org.eclipse.jdt.core.prefs")); + scratch.write(&format!("{module}.settings/org.eclipse.m2e.core.prefs")); + } + } + + #[test] + fn removes_untracked_module_metadata_and_resets_the_jdt_state() { + let scratch = Scratch::new(); + write_reactor_with_legacy_metadata(&scratch); + scratch.init_repository(""); + let data_directory = scratch.data_directory(); + + let cleanup = prepare(&scratch, &Index::default(), &data_directory); + let removed = &cleanup.removed_files; + + assert!(cleanup.state_reset); + assert_eq!(removed.len(), 15, "{removed:?}"); + assert_eq!(removed.first().map(String::as_str), Some(".classpath")); + assert!(removed + .contains(&"infrastructure/sdk/.settings/org.eclipse.m2e.core.prefs".to_string())); + for module in ["", "domain/", "infrastructure/sdk/"] { + assert!(scratch.exists(&format!("{module}pom.xml"))); + assert!(!scratch.exists(&format!("{module}.project"))); + assert!(!scratch.exists(&format!("{module}.settings"))); + } + assert!(!data_directory.exists(), "stale JDT state must not survive"); + } + + #[test] + fn keeps_tracked_metadata_and_user_files_in_settings() { + let scratch = Scratch::new(); + write_reactor_with_legacy_metadata(&scratch); + scratch.write("domain/.settings/team-notes.txt"); + scratch.init_repository(""); + // A team that commits Eclipse files for its Eclipse users owns them. + let mut index = Index::default(); + index.track(scratch.workspace(), "domain/.classpath"); + index.track( + scratch.workspace(), + "domain/.settings/org.eclipse.jdt.core.prefs", + ); + + prepare(&scratch, &index, &scratch.data_directory()); + + assert!(scratch.exists("domain/.classpath")); + assert!(scratch.exists("domain/.settings/org.eclipse.jdt.core.prefs")); + assert!(!scratch.exists("domain/.settings/org.eclipse.m2e.core.prefs")); + assert!(scratch.exists("domain/.settings/team-notes.txt")); + assert!(!scratch.exists("domain/.project")); + } + + #[test] + fn asks_the_nested_repository_that_owns_a_module() { + let scratch = Scratch::new(); + scratch.write("pom.xml"); + scratch.write(".project"); + scratch.write("vendor-lib/pom.xml"); + scratch.write("vendor-lib/.classpath"); + scratch.write("vendor-lib/.project"); + scratch.init_repository(""); + // The outer index never lists files of a nested repository, so asking the + // outer repository would report the inner tracked `.classpath` as untracked. + scratch.init_repository("vendor-lib"); + let mut index = Index::default(); + index.track(scratch.workspace().join("vendor-lib"), ".classpath"); + + let cleanup = prepare(&scratch, &index, &scratch.data_directory()); + + assert_eq!( + cleanup.removed_files, + vec![".project", "vendor-lib/.project"] + ); + assert!(scratch.exists("vendor-lib/.classpath")); + assert_eq!( + *index.queried.borrow(), + vec![scratch.workspace(), scratch.workspace().join("vendor-lib")] + ); + } + + #[test] + fn leaves_workspaces_outside_git_and_their_jdt_state_untouched() { + let scratch = Scratch::new(); + write_reactor_with_legacy_metadata(&scratch); + let data_directory = scratch.data_directory(); + let index = Index::default(); + + let cleanup = prepare(&scratch, &index, &data_directory); + + assert_eq!(cleanup, LegacyMetadataCleanup::default()); + assert!(index.queried.borrow().is_empty(), "no repository, no query"); + assert!(scratch.exists(".project")); + assert!(scratch.exists("infrastructure/sdk/.settings/org.eclipse.m2e.core.prefs")); + assert!(data_directory.join(".metadata").is_dir()); + } + + #[test] + fn ignores_directories_that_are_not_modules_or_not_project_sources() { + let scratch = Scratch::new(); + scratch.write("pom.xml"); + // No build descriptor: an Eclipse project JDT LS did not generate. + scratch.write("tools/.project"); + // Vendored and build-output copies are not the opened project's modules. + scratch.write("node_modules/dep/pom.xml"); + scratch.write("node_modules/dep/.project"); + scratch.write("target/nested/pom.xml"); + scratch.write("target/nested/.classpath"); + scratch.init_repository(""); + let data_directory = scratch.data_directory(); + + let cleanup = prepare(&scratch, &Index::default(), &data_directory); + + assert_eq!(cleanup, LegacyMetadataCleanup::default()); + assert!(scratch.exists("tools/.project")); + assert!(scratch.exists("node_modules/dep/.project")); + assert!(scratch.exists("target/nested/.classpath")); + assert!( + data_directory.join(".metadata").is_dir(), + "nothing moved, so the state is kept" + ); + } + + #[test] + fn without_a_host_vcs_answer_every_file_is_kept() { + // TODO(host-api: API-42 built-in Git read-only): read-only VCS query. + // Until the host answers, the plugin cannot prove a file is untracked, + // so it never deletes one. + let scratch = Scratch::new(); + write_reactor_with_legacy_metadata(&scratch); + scratch.init_repository(""); + let data_directory = scratch.data_directory(); + + let cleanup = prepare_workspace(&scratch.workspace(), &data_directory) + .expect("preparation should succeed"); + + assert_eq!(cleanup, LegacyMetadataCleanup::default()); + assert!(scratch.exists("domain/.project")); + assert!(data_directory.join(".metadata").is_dir()); + } +} diff --git a/crates/java-domain/src/lsp/languages/mod.rs b/crates/java-domain/src/lsp/languages/mod.rs new file mode 100644 index 0000000..8a20302 --- /dev/null +++ b/crates/java-domain/src/lsp/languages/mod.rs @@ -0,0 +1,35 @@ +//! Java-specific language-server rules moved from Rust Core's +//! `lsp/languages` module. + +pub mod java_entrypoints; +pub mod java_launch; +pub mod java_main_methods; +pub mod java_navigation_syntax; +mod java_run_markers; +pub mod java_tests; +mod java_workspace; +pub mod jdt; +pub mod jdt_build; +mod jdt_configuration; +pub mod jdt_contributions; +pub mod jdt_navigation; +pub mod jdt_progress; +mod jdt_project_metadata; +pub mod project_preparation; + +pub use java_run_markers::{ + java_run_markers, JavaRunMarker, JavaRunMarkerKind, JavaRunMarkerStatus, JavaRunMarkersRequest, + JavaRunMarkersResponse, +}; +pub use java_workspace::{ + java_workspace_policy, jdt_cache_retention, jdt_workspace_fingerprint, + JavaWorkspacePolicyRequest, JdtBuildFileObservation, JdtCacheEntry, JdtCacheRetentionRequest, + JdtCacheRetentionResponse, JdtWorkspaceFingerprintRequest, JdtWorkspaceFingerprintResponse, +}; +pub use jdt::{resolve_workspace_key, workspace_key, JdtWorkspaceKeyRequest}; +pub use jdt_build::JavaBuildReport; +pub use jdt_configuration::prepare_configuration_area as prepare_jdt_configuration_area; +pub use jdt_project_metadata::{ + prepare_workspace as prepare_jdt_workspace, + prepare_workspace_with as prepare_jdt_workspace_with, +}; diff --git a/crates/java-domain/src/lsp/languages/project_preparation.rs b/crates/java-domain/src/lsp/languages/project_preparation.rs new file mode 100644 index 0000000..441fe58 --- /dev/null +++ b/crates/java-domain/src/lsp/languages/project_preparation.rs @@ -0,0 +1,114 @@ +//! User-facing Java preparation state derived from the existing session and build owner. + +use super::jdt::ContributedSettingsStatus; +use crate::lsp::LspLifecycleState; +use serde::Serialize; + +/// Stable presentation state. Ready means project preparation, not compilation, completed. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPreparation { + /// Current stage: starting, importing, configuring, building, ready, or stopped. + pub phase: &'static str, + /// Presentation outcome: idle, loading, ready, or failed. + pub status: &'static str, + /// Whether Java launch must wait for the session-wide preparation gate. + pub blocks_run: bool, +} + +/// Projects upstream lifecycle facts without starting work or treating indexing as a gate. +/// +/// `contributed` is the state of the settings that build-system plugins +/// contribute; while it is pending the projects are still being configured. +pub fn snapshot( + lifecycle: LspLifecycleState, + contributed: ContributedSettingsStatus, + configuring: bool, + building: bool, +) -> ProjectPreparation { + use LspLifecycleState::*; + let (phase, status) = match lifecycle { + Created | ProcessStarting => ("starting", "loading"), + Initializing => ("importing", "loading"), + Failed => ("starting", "failed"), + Stopping | Stopped => ("stopped", "idle"), + Ready => match contributed { + ContributedSettingsStatus::Pending => ("configuring", "loading"), + _ if configuring => ("configuring", "loading"), + _ if building => ("building", "loading"), + ContributedSettingsStatus::Failed => ("configuring", "failed"), + ContributedSettingsStatus::Settled => ("ready", "ready"), + }, + }; + ProjectPreparation { + phase, + status, + // A missing contribution leaves the session on default settings. + // Keep the failure visible, but let JDT validate the selected target + // rather than blocking modules the contribution did not affect. + blocks_run: lifecycle != LspLifecycleState::Ready || status == "loading", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ContributedSettingsStatus::{Failed, Pending, Settled}; + + #[test] + fn serialized_snapshots_match_the_shared_fixture() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../../fixtures/lsp/project-preparation-v1.json" + )) + .unwrap(); + let cases = [ + (LspLifecycleState::Created, Settled, false, false), + (LspLifecycleState::Initializing, Settled, false, false), + (LspLifecycleState::Ready, Pending, false, false), + (LspLifecycleState::Ready, Settled, false, true), + (LspLifecycleState::Ready, Settled, false, false), + (LspLifecycleState::Ready, Failed, false, false), + (LspLifecycleState::Stopped, Settled, false, false), + ]; + let snapshots = fixture["snapshots"].as_array().unwrap(); + assert_eq!(snapshots.len(), cases.len()); + for ((lifecycle, contributed, configuring, building), expected) in + cases.into_iter().zip(snapshots) + { + assert_eq!( + &serde_json::to_value(snapshot(lifecycle, contributed, configuring, building)) + .unwrap(), + expected + ); + } + } + + #[test] + fn service_ready_does_not_bypass_project_configuration() { + let ready = LspLifecycleState::Ready; + assert_eq!(snapshot(ready, Pending, false, false).phase, "configuring"); + assert!(snapshot(ready, Pending, false, false).blocks_run); + assert!(snapshot(ready, Settled, true, false).blocks_run); + assert_eq!(snapshot(ready, Settled, false, true).phase, "building"); + assert!(!snapshot(ready, Settled, false, false).blocks_run); + } + + #[test] + fn failures_and_stopped_sessions_never_report_ready() { + let ready = LspLifecycleState::Ready; + assert!(snapshot(ready, Failed, true, false).blocks_run); + assert!(snapshot(ready, Failed, false, true).blocks_run); + let failed = snapshot(ready, Failed, false, false); + assert_eq!((failed.phase, failed.status), ("configuring", "failed")); + assert!(!failed.blocks_run); + assert!(snapshot(LspLifecycleState::Stopped, Settled, false, false).blocks_run); + assert_eq!( + snapshot(LspLifecycleState::Initializing, Settled, false, false).phase, + "importing" + ); + assert_eq!( + snapshot(LspLifecycleState::Failed, Settled, false, false).status, + "failed" + ); + } +} diff --git a/crates/java-domain/src/lsp/mod.rs b/crates/java-domain/src/lsp/mod.rs new file mode 100644 index 0000000..d458208 --- /dev/null +++ b/crates/java-domain/src/lsp/mod.rs @@ -0,0 +1,112 @@ +//! JDT LS integration rules: workspace keys and policy, launch arguments and +//! settings, readiness, build reports, entry points, tests and navigation. +//! +//! The types defined here are the parts of Rust Core's language-server engine +//! that the Java rules referenced. The engine itself (sessions, JSON-RPC, +//! queues) stays in the host; see `MIGRATION.md`. + +pub mod languages; + +pub use languages::*; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Observable lifecycle of one managed language-server session. +pub enum LspLifecycleState { + /// Session state exists, but the child process has not started. + Created, + /// The child process and its standard streams are being created. + ProcessStarting, + /// The process is running and the initialize handshake is pending. + Initializing, + /// Initialization completed and semantic requests may be sent. + Ready, + /// A graceful shutdown is pending or the process is being terminated. + Stopping, + /// The session ended normally and will produce no further events. + Stopped, + /// Startup, protocol handling, or the child process failed terminally. + Failed, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// A JDK installation the platform discovered for the Java language service. +pub struct JavaRuntimeCandidate { + /// The platform-selected project JDK, independent of the server JVM. + #[serde(default)] + pub is_default: bool, + /// JDK home directory. + pub home_path: String, + /// Version reported by `java -version`, such as `25.0.4` or `1.8.0_402`. + pub version: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Structured runtime failure with session and protocol-stage context. +pub struct LspRuntimeError { + /// Stable error code. + pub code: String, + /// Language-server provider that failed. + pub provider_id: String, + /// Session that failed. + pub session_id: String, + /// Lifecycle or protocol phase in which the error occurred. + pub stage: String, + /// JSON-RPC method being handled, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + /// Document the request concerned, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub document_uri: Option, + /// Human-readable summary. + pub message: String, + /// Upstream message, kept verbatim. + #[serde(skip_serializing_if = "Option::is_none")] + pub underlying_message: Option, + /// Exit code of the language-server process, when it exited. + #[serde(skip_serializing_if = "Option::is_none")] + pub process_exit_code: Option, + /// Evidence behind an unsuccessful Java launch build. Present only for + /// `javaBuild` failures; hosts that do not read it are unaffected. + #[serde(skip_serializing_if = "Option::is_none")] + pub java_build_report: Option, + /// Plugin-defined code of a failed plugin function (`error.data.code`), + /// passed through for the plugin's own UI; Lithe never branches on it. + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_error_code: Option, +} + +/// Decodes `%XX` escapes in a URI component, keeping the input when the +/// decoded bytes are not UTF-8. +pub fn percent_decode(value: &str) -> String { + let mut decoded = Vec::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let (Some(high), Some(low)) = + (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) + { + decoded.push((high << 4) | low); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + String::from_utf8(decoded).unwrap_or_else(|_| value.to_string()) +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} diff --git a/crates/java-domain/src/protocol/cancellation.rs b/crates/java-domain/src/protocol/cancellation.rs new file mode 100644 index 0000000..b28bda4 --- /dev/null +++ b/crates/java-domain/src/protocol/cancellation.rs @@ -0,0 +1,275 @@ +//! Cooperative operation cancellation and per-thread deadline tracking. + +use crate::protocol::{CoreError, ErrorCode}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +#[derive(Clone)] +/// Cancellation flag and absolute deadline installed for the current command. +struct State { + cancelled: Arc, + deadline: Option, +} + +// Each live scope owns a token. A nested request with the same ID temporarily +// becomes the cancellation target without unregistering its still-live caller. +type Registrations = HashMap>>; +static OPERATIONS: OnceLock> = OnceLock::new(); + +thread_local! { + static CURRENT: RefCell> = const { RefCell::new(None) }; +} + +/// Guard that installs cancellation and timeout state for the current thread. +/// +/// Dropping the scope unregisters the operation and restores the previous +/// thread-local state, including when a command exits through an error path. +pub struct Scope { + operation_id: Option, + /// A synchronous callback may execute another Core request on this thread. + previous_state: Option, + /// Remove only this registration, including when same-ID requests overlap. + cancelled: Arc, +} + +impl Scope { + /// Begins a cancellable operation with an optional relative timeout. + pub fn begin(operation_id: Option, timeout_milliseconds: Option) -> Self { + let cancelled = Arc::new(AtomicBool::new(false)); + if let Some(operation_id) = operation_id.as_deref() { + registry() + .lock() + .expect("operation registry should not be poisoned") + .entry(operation_id.to_string()) + .or_default() + .push(Arc::clone(&cancelled)); + } + let previous_state = CURRENT.with(|current| { + current.replace(Some(State { + cancelled: Arc::clone(&cancelled), + deadline: timeout_milliseconds + .filter(|milliseconds| *milliseconds > 0) + .map(|milliseconds| Instant::now() + Duration::from_millis(milliseconds)), + })) + }); + Self { + operation_id, + previous_state, + cancelled, + } + } +} + +impl Drop for Scope { + fn drop(&mut self) { + CURRENT.with(|current| current.replace(self.previous_state.take())); + if let Some(operation_id) = self.operation_id.take() { + let mut registry = registry() + .lock() + .expect("operation registry should not be poisoned"); + if let Some(tokens) = registry.get_mut(&operation_id) { + tokens.retain(|token| !Arc::ptr_eq(token, &self.cancelled)); + if tokens.is_empty() { + registry.remove(&operation_id); + } + } + } + } +} + +pub fn cancel(operation_id: &str) -> bool { + registry() + .lock() + .expect("operation registry should not be poisoned") + .get(operation_id) + .and_then(|tokens| tokens.last()) + .map(|token| { + token.store(true, Ordering::Release); + true + }) + .unwrap_or(false) +} + +pub fn check() -> Result<(), CoreError> { + CURRENT.with(|current| { + let Some(state) = current.borrow().as_ref().cloned() else { + return Ok(()); + }; + if state.cancelled.load(Ordering::Acquire) { + return Err(CoreError::new( + ErrorCode::Cancelled, + "Operation was cancelled", + )); + } + if state + .deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + state.cancelled.store(true, Ordering::Release); + return Err(CoreError::new(ErrorCode::TimedOut, "Operation timed out")); + } + Ok(()) + }) +} + +/// Runs bounded outcome inspection after a mutation's cancellation or deadline. +/// +/// Cleanup must determine whether a ref already moved without inheriting the +/// cancelled request token. The original thread state is restored on every exit. +pub fn with_cleanup_deadline(timeout: Duration, operation: impl FnOnce() -> T) -> T { + struct RestoreState(Option); + impl Drop for RestoreState { + fn drop(&mut self) { + CURRENT.with(|current| *current.borrow_mut() = self.0.take()); + } + } + let previous = CURRENT.with(|current| { + current.replace(Some(State { + cancelled: Arc::new(AtomicBool::new(false)), + deadline: Some(Instant::now() + timeout), + })) + }); + let _restore = RestoreState(previous); + operation() +} + +/// Inspects a mutation using the request budget, or bounded cleanup if it ended. +/// +/// Active requests retain their absolute deadline and cancellation token. Only +/// an already cancelled or timed-out request needs an independent cleanup scope. +pub fn with_mutation_inspection(cleanup_timeout: Duration, operation: impl FnOnce() -> T) -> T { + if check().is_ok() { + operation() + } else { + with_cleanup_deadline(cleanup_timeout, operation) + } +} + +fn registry() -> &'static Mutex { + OPERATIONS.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +mod tests { + use super::{cancel, check, with_mutation_inspection, Scope, CURRENT}; + use crate::protocol::ErrorCode; + use std::thread; + use std::time::{Duration, Instant}; + + #[test] + fn mutation_inspection_preserves_active_deadline_and_cancellation() { + let _scope = Scope::begin(Some("active-mutation-inspection".into()), Some(20_000)); + let deadline = CURRENT.with(|current| current.borrow().as_ref().unwrap().deadline); + with_mutation_inspection(Duration::from_secs(2), || { + // A successful rebase must not lose its remaining request budget or + // become immune to cancellation while its session is inspected. + assert_eq!( + CURRENT.with(|current| current.borrow().as_ref().unwrap().deadline), + deadline + ); + assert!(cancel("active-mutation-inspection")); + assert!(matches!(check().unwrap_err().code, ErrorCode::Cancelled)); + }); + } + + #[test] + fn mutation_inspection_after_cancellation_is_bounded_and_restores_request() { + let _scope = Scope::begin(Some("cancelled-mutation-inspection".into()), None); + assert!(cancel("cancelled-mutation-inspection")); + let earliest_cleanup_deadline = Instant::now() + Duration::from_secs(2); + let result = with_mutation_inspection(Duration::from_secs(2), || { + assert!(check().is_ok()); + let deadline = CURRENT.with(|current| current.borrow().as_ref().unwrap().deadline); + assert!(deadline.is_some_and(|value| { + value >= earliest_cleanup_deadline + && value <= Instant::now() + Duration::from_secs(2) + })); + // Reach the installed cleanup deadline deterministically, with no + // sleeping or dependency on Git subprocess speed. + CURRENT.with(|current| { + current.borrow_mut().as_mut().unwrap().deadline = Some(Instant::now()) + }); + check() + }); + assert!(matches!(result.unwrap_err().code, ErrorCode::TimedOut)); + assert!(matches!(check().unwrap_err().code, ErrorCode::Cancelled)); + assert!(CURRENT.with(|current| current.borrow().as_ref().unwrap().deadline.is_none())); + } + + #[test] + fn mutation_inspection_after_deadline_restores_expired_request() { + let _scope = Scope::begin(None, None); + let expired = Instant::now(); + CURRENT.with(|current| current.borrow_mut().as_mut().unwrap().deadline = Some(expired)); + with_mutation_inspection(Duration::from_secs(2), || assert!(check().is_ok())); + assert_eq!( + CURRENT.with(|current| current.borrow().as_ref().unwrap().deadline), + Some(expired) + ); + assert!(check().is_err()); + } + + #[test] + fn mutation_inspection_restores_cancelled_request_during_unwind() { + let _scope = Scope::begin(Some("unwinding-mutation-inspection".into()), None); + assert!(cancel("unwinding-mutation-inspection")); + let result = std::panic::catch_unwind(|| { + with_mutation_inspection(Duration::from_secs(2), || { + assert!(check().is_ok()); + panic!("inspection failed"); + }) + }); + assert!(result.is_err()); + assert!(matches!(check().unwrap_err().code, ErrorCode::Cancelled)); + } + + #[test] + fn nested_scopes_restore_the_outer_cancellation_registration() { + let outer = Scope::begin(Some("nested-cancellation".into()), None); + { + let _inner = Scope::begin(Some("nested-cancellation".into()), None); + assert!(cancel("nested-cancellation")); + assert!(matches!(check().unwrap_err().code, ErrorCode::Cancelled)); + } + assert!( + check().is_ok(), + "The inner cancellation must not replace the outer token" + ); + assert!(cancel("nested-cancellation")); + assert!(matches!(check().unwrap_err().code, ErrorCode::Cancelled)); + drop(outer); + assert!(!cancel("nested-cancellation")); + } + + #[test] + fn nested_scopes_restore_the_outer_deadline() { + let _outer = Scope::begin(None, None); + // Install an already reached monotonic deadline, avoiding sleeps or a + // machine-speed dependency while testing restoration across a callback. + super::CURRENT.with(|state| { + state.borrow_mut().as_mut().unwrap().deadline = Some(std::time::Instant::now()) + }); + { + let _inner = Scope::begin(None, None); + assert!(check().is_ok()); + } + assert!(matches!(check().unwrap_err().code, ErrorCode::TimedOut)); + } + + #[test] + fn cancellation_is_visible_to_the_active_scope() { + let _scope = Scope::begin(Some("cancellation-test".to_string()), None); + assert!(cancel("cancellation-test")); + assert!(matches!(check().unwrap_err().code, ErrorCode::Cancelled)); + } + + #[test] + fn deadline_returns_a_stable_timeout_error() { + let _scope = Scope::begin(Some("timeout-test".to_string()), Some(1)); + thread::sleep(Duration::from_millis(3)); + assert!(matches!(check().unwrap_err().code, ErrorCode::TimedOut)); + } +} diff --git a/crates/java-domain/src/protocol/contracts.rs b/crates/java-domain/src/protocol/contracts.rs new file mode 100644 index 0000000..b61941d --- /dev/null +++ b/crates/java-domain/src/protocol/contracts.rs @@ -0,0 +1,288 @@ +//! Serializable response models of the Java commands. +//! +//! Copied from `rust/lithe-core/src/protocol/contracts.rs` in the Lithe +//! repository. Only the Java, Spring and MyBatis models move here; the field +//! names and JSON shapes stay identical so the plugin backend can answer the +//! same requests Rust Core answered before the split. + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Java class containing a runnable main method. +pub struct JavaMainClassResponse { + pub path: String, + pub qualified_name: String, + pub simple_name: String, + pub is_spring_boot: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Source-set classification attached to one discovered Java run entry. +pub enum JavaSourceSetResponse { + /// Production source compiled into the main project output. + Main, + /// Test source compiled into the test project output. + Test, + /// Java source outside the conventional Maven main and test layouts. + Other, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// UI-facing Java or Spring Boot run entry. +pub struct JavaRunConfigurationResponse { + pub id: String, + pub name: String, + /// UI category such as `javaMain`, `springBoot`, or `mavenModule`. + pub kind: String, + pub module_path: Option, + pub main_class: Option, + /// Workspace-relative source that produced this exact run entry. + pub source_path: String, + /// Build output that must be present on the Java launch classpath. + pub source_set: JavaSourceSetResponse, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Discovered Java main classes and their stable run entries. +pub struct JavaRunConfigurationsResponse { + pub main_classes: Vec, + pub configurations: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Usage count rendered above one Java declaration. +pub struct JavaCodeVisionHintResponse { + pub line: usize, + pub utf16_column: usize, + pub symbol: String, + pub usage_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Code Vision hints in source order. +pub struct JavaCodeVisionResponse { + pub hints: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Package-qualified Java class name. +pub struct JavaClassNameResponse { + pub class_name: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Zero-based UTF-16 location of a Java declaration. +pub struct JavaSourceDefinitionResponse { + pub line: usize, + pub utf16_column: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Server port declared by Spring configuration, when one is present. +pub struct JavaServerPortResponse { + pub port: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Foldable Java source region and the text span hidden by folding. +pub struct JavaFoldRegionResponse { + /// Fold category such as imports, declaration, or comment. + pub kind: String, + pub start_line: usize, + pub end_line: usize, + pub hidden_start: usize, + pub hidden_length: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Lightweight Java parameter-name inlay hint. +pub struct JavaInlayHintResponse { + pub line: usize, + pub utf16_column: usize, + pub label: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// One non-overlapping Java token range with a portable editor-theme role. +pub struct JavaSyntaxHighlightResponse { + /// Document-relative start measured in UTF-16 code units. + pub utf16_start: usize, + /// Token length measured in UTF-16 code units. + pub utf16_length: usize, + /// Semantic role defined by the shared editor syntax-theme contract. + pub role: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Lightweight structural features derived from one Java source document. +pub struct JavaStructureResponse { + pub fold_regions: Vec, + pub inlay_hints: Vec, + pub syntax_highlights: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One Java navigation gutter marker normalized from provider semantic data. +pub struct JavaNavigationMarkerResponse { + pub line: usize, + pub utf16_column: usize, + pub implementation_count: usize, + pub direction: String, + pub relation: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One Spring configuration property and its optional Java declaration. +pub struct SpringPropertyResponse { + pub name: String, + pub type_name: Option, + pub description: Option, + pub default_value: Option, + pub source_path: Option, + pub source_line: Option, + pub source_column: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One key/value occurrence from a Spring application configuration document. +pub struct SpringConfigurationValueResponse { + pub key: String, + pub value: String, + pub path: String, + pub line: usize, + pub column: usize, + pub profile: Option, + pub overrides_base_value: bool, + pub target_path: Option, + pub target_line: Option, + pub target_column: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Java `@Value` reference to a Spring configuration property. +pub struct SpringPropertyReferenceResponse { + pub key: String, + pub path: String, + pub line: usize, + pub column: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Spring configuration problem projected onto one source location. +pub struct SpringDiagnosticResponse { + pub path: String, + pub line: usize, + pub column: usize, + pub severity: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Component or `@Bean` declaration available for dependency injection. +pub struct SpringBeanResponse { + pub id: String, + pub name: String, + pub type_name: String, + pub path: String, + pub line: usize, + pub column: usize, + pub kind: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Injection point and the bean declarations that satisfy it. +pub struct SpringInjectionResponse { + pub path: String, + pub line: usize, + pub column: usize, + pub type_name: String, + pub qualifier: Option, + pub bean_ids: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// HTTP endpoint declared by a Spring MVC controller method. +pub struct SpringEndpointResponse { + pub id: String, + pub http_methods: Vec, + pub route: String, + pub controller: String, + pub method: String, + pub path: String, + pub line: usize, + pub column: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Complete deterministic Spring semantic index for one workspace snapshot. +pub struct SpringIndexResponse { + pub properties: Vec, + pub values: Vec, + pub property_references: Vec, + pub diagnostics: Vec, + pub beans: Vec, + pub injections: Vec, + pub endpoints: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One MyBatis statement that has both a Java mapper method and an XML `id`. +pub struct MybatisStatementResponse { + /// Stable identity: namespace, statement id, XML path, and XML line. + pub id: String, + /// Fully qualified mapper type from the XML `namespace`. + pub namespace: String, + /// XML statement `id`, which matches the Java method name. + pub statement_id: String, + /// MyBatis statement kind: `select`, `insert`, `update`, or `delete`. + pub kind: String, + /// Workspace-relative Java mapper path. + pub java_path: String, + /// One-based line of the Java method name. + pub java_line: usize, + /// One-based UTF-16 column of the Java method name. + pub java_column: usize, + /// One-based line of the Java method signature terminator. + pub java_end_line: usize, + /// Exclusive one-based UTF-16 column after the Java method name. + pub java_end_column: usize, + /// Workspace-relative mapper XML path. + pub xml_path: String, + /// One-based line of the XML statement `id` value. + pub xml_line: usize, + /// One-based UTF-16 column of the XML statement `id` value. + pub xml_column: usize, + /// Exclusive one-based UTF-16 column after the XML statement `id` value. + pub xml_end_column: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Complete deterministic MyBatis mapper/XML index for one workspace snapshot. +pub struct MybatisIndexResponse { + /// Paired Java methods and XML statements, ordered by namespace and id. + pub statements: Vec, +} diff --git a/crates/java-domain/src/protocol/error.rs b/crates/java-domain/src/protocol/error.rs new file mode 100644 index 0000000..6fe3709 --- /dev/null +++ b/crates/java-domain/src/protocol/error.rs @@ -0,0 +1,90 @@ +//! Stable error categories and safe cross-boundary error serialization. + +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +/// Stable failure categories understood by all Core consumers. +/// +/// Keep these categories independent of Rust libraries and host operating +/// systems; implementation-specific context belongs in [`CoreError::details`]. +pub enum ErrorCode { + /// The request envelope, payload, path, or operation name is invalid. + InvalidRequest, + /// The requested workspace or repository root does not exist. + WorkspaceNotFound, + /// The operation would escape an allowed root or lacks filesystem access. + PermissionDenied, + /// The requested behavior is valid but unavailable in this Core build. + NotSupported, + /// A required host-discovered executable or runtime is unavailable. + RuntimeMissing, + /// A required child process could not be created. + ProcessStartFailed, + /// A child process started but failed while serving the operation. + ProcessFailed, + /// Input or tool output could not be decoded into the stable contract. + ParseFailed, + /// The caller cooperatively cancelled the operation. + Cancelled, + /// The operation exceeded its declared deadline. + TimedOut, + /// A long-lived connection the request names is closed or was lost; the + /// caller can reconnect and retry. + ConnectionClosed, + /// A failure does not fit a more stable cross-platform category. + Unknown, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Cross-boundary failure containing a stable category and actionable message. +pub struct CoreError { + /// Machine-readable category used by application error handling. + pub code: ErrorCode, + /// User-facing summary that is safe to display. + pub message: String, + /// Optional diagnostic context that must not contain secrets. + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl CoreError { + /// Creates an error without implementation-specific details. + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + details: None, + } + } + + /// Adds safe diagnostic context while preserving the stable error category. + pub fn with_details(mut self, details: impl Into) -> Self { + self.details = Some(details.into()); + self + } +} + +impl From for CoreError { + fn from(error: std::io::Error) -> Self { + let code = match error.kind() { + std::io::ErrorKind::NotFound => ErrorCode::WorkspaceNotFound, + std::io::ErrorKind::PermissionDenied => ErrorCode::PermissionDenied, + _ => ErrorCode::Unknown, + }; + Self::new(code, error.to_string()) + } +} + +/// Workspace-relative paths use `/` in the shared contract even when the +/// caller is running on Windows. Validate both separator conventions so a +/// Windows-shaped path cannot bypass checks on another host. +pub fn invalid_relative_path(value: &str) -> bool { + let normalized = value.replace('\\', "/"); + normalized.is_empty() + || normalized.starts_with('/') + || normalized.contains(':') + || normalized.contains('\0') + || normalized.split('/').any(|component| component == "..") +} diff --git a/crates/java-domain/src/protocol/mod.rs b/crates/java-domain/src/protocol/mod.rs new file mode 100644 index 0000000..0fa02c0 --- /dev/null +++ b/crates/java-domain/src/protocol/mod.rs @@ -0,0 +1,12 @@ +//! Error, cancellation and response models shared by the Java commands. +//! +//! These are the minimal parts of Rust Core's `protocol` module that the Java +//! domain logic used. They keep the same JSON shapes, so a request that Rust +//! Core answered before the split gets the same answer from the plugin. + +pub mod cancellation; +mod contracts; +mod error; + +pub use contracts::*; +pub use error::*; diff --git a/crates/java-domain/src/testing/debug_launch.rs b/crates/java-domain/src/testing/debug_launch.rs new file mode 100644 index 0000000..30f7351 --- /dev/null +++ b/crates/java-domain/src/testing/debug_launch.rs @@ -0,0 +1,233 @@ +//! Preparation of a Java test debug launch. +//! +//! Ported from the Swift `JavaTestDebugLaunchService`. Preparation resolves +//! the selected tests through JDT LS, starts the result channel, and builds +//! the generic debug configuration. A result channel that was started is +//! always stopped when a later step fails or the launch is cancelled, so a +//! failed launch never leaves a listening port behind. + +use super::jdt_test_items::JavaTestDebugLaunchTarget; +use super::workflow::{JavaTestDebugRequest, StopHandle}; +use crate::debug::{java_test_launch, DebugLaunchConfiguration}; +use crate::protocol::{CoreError, ErrorCode}; + +/// Resolves the selected tests into runner arguments. +/// +/// TODO(host-api: API-25 language server): production resolution sends the Java +/// Test extension's `vscode.java.test.findTestTypesAndMethods` and +/// `vscode.java.test.junit.argument` commands through the host language-server +/// request channel. +pub trait TargetResolver { + /// Resolves `request` or explains why it cannot be debugged. + fn resolve( + &mut self, + request: &JavaTestDebugRequest, + ) -> Result; +} + +/// A started result channel. +pub trait ResultServer: StopHandle { + /// Starts listening and returns the loopback port. + fn start(&mut self) -> Result; +} + +/// A debug configuration with the result channel it reports to. +#[derive(Debug)] +pub struct PreparedJavaTestDebugLaunch { + /// Configuration for the generic debugger. + pub configuration: DebugLaunchConfiguration, + /// Result channel the runner connects to. + pub result_server: S, +} + +impl PreparedJavaTestDebugLaunch { + /// Releases the result channel. + pub fn stop(&mut self) { + self.result_server.stop(); + } +} + +/// Resolves, starts the result channel and configures the debug launch. +/// +/// `is_cancelled` is checked after resolution and after the channel started. +pub fn prepare( + request: &JavaTestDebugRequest, + resolver: &mut impl TargetResolver, + make_server: impl FnOnce() -> S, + is_cancelled: impl Fn() -> bool, +) -> Result, CoreError> { + let target = resolver.resolve(request)?; + if is_cancelled() { + return Err(cancelled()); + } + let mut server = make_server(); + let port = match server.start() { + Ok(port) => port, + Err(error) => { + server.stop(); + return Err(error); + } + }; + if is_cancelled() { + server.stop(); + return Err(cancelled()); + } + match java_test_launch(target.launch_request(port)) { + Ok(configuration) => Ok(PreparedJavaTestDebugLaunch { + configuration, + result_server: server, + }), + Err(error) => { + server.stop(); + Err(error) + } + } +} + +fn cancelled() -> CoreError { + CoreError::new( + ErrorCode::Cancelled, + "The Java test debug launch was cancelled.", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::debug::JavaTestFramework; + use std::cell::Cell; + use std::path::PathBuf; + use std::rc::Rc; + + #[derive(Default)] + struct Resolver { + requests: Vec, + working_directory: String, + } + + impl TargetResolver for Resolver { + fn resolve( + &mut self, + request: &JavaTestDebugRequest, + ) -> Result { + self.requests.push(request.clone()); + Ok(JavaTestDebugLaunchTarget { + file_path: request.file_path.display().to_string(), + name: "UserServiceTest".into(), + framework: JavaTestFramework::Junit, + working_directory: self.working_directory.clone(), + main_class: "org.eclipse.jdt.internal.junit.runner.RemoteTestRunner".into(), + project_name: Some("service".into()), + class_paths: vec!["/w/target/classes".into()], + module_paths: Vec::new(), + vm_arguments: vec!["-ea".into()], + program_arguments: vec!["-port".into(), "${resultPort}".into()], + testng_runner_path: None, + testng_test_names: Vec::new(), + }) + } + } + + #[derive(Debug, Default, Clone)] + struct Counts { + starts: Rc>, + stops: Rc>, + } + + #[derive(Debug)] + struct Server(Counts); + + impl StopHandle for Server { + fn stop(&mut self) { + self.0.stops.set(self.0.stops.get() + 1); + } + } + + impl ResultServer for Server { + fn start(&mut self) -> Result { + self.0.starts.set(self.0.starts.get() + 1); + Ok(43_128) + } + } + + fn request() -> JavaTestDebugRequest { + JavaTestDebugRequest { + file_path: PathBuf::from("/w/src/test/java/UserServiceTest.java"), + test_identifier: None, + } + } + + #[test] + fn file_selection_resolves_the_target_starts_results_and_builds_the_configuration() { + let counts = Counts::default(); + let mut resolver = Resolver { + working_directory: "/w".into(), + ..Default::default() + }; + let mut prepared = prepare( + &request(), + &mut resolver, + || Server(counts.clone()), + || false, + ) + .unwrap(); + assert_eq!(resolver.requests, vec![request()]); + assert_eq!(counts.starts.get(), 1); + assert_eq!(counts.stops.get(), 0); + assert_eq!(prepared.configuration.name, "UserServiceTest"); + let encoded = serde_json::to_string(&prepared.configuration.arguments).unwrap(); + assert!(encoded.contains("43128"), "{encoded}"); + prepared.stop(); + assert_eq!(counts.stops.get(), 1); + } + + #[test] + fn a_configuration_failure_stops_the_result_server() { + let counts = Counts::default(); + let mut resolver = Resolver::default(); + let error = prepare( + &request(), + &mut resolver, + || Server(counts.clone()), + || false, + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::InvalidRequest); + assert_eq!((counts.starts.get(), counts.stops.get()), (1, 1)); + } + + #[test] + fn cancellation_after_start_stops_the_result_server() { + let counts = Counts::default(); + let mut resolver = Resolver { + working_directory: "/w".into(), + ..Default::default() + }; + let checks = Cell::new(0); + let error = prepare( + &request(), + &mut resolver, + || Server(counts.clone()), + || { + checks.set(checks.get() + 1); + checks.get() == 2 + }, + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::Cancelled); + assert_eq!((counts.starts.get(), counts.stops.get()), (1, 1)); + } + + #[test] + fn cancellation_before_start_never_creates_a_server() { + let mut resolver = Resolver::default(); + let error = prepare( + &request(), + &mut resolver, + || -> Server { unreachable!("cancelled before the server") }, + || true, + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::Cancelled); + } +} diff --git a/crates/java-domain/src/testing/jdt_test_items.rs b/crates/java-domain/src/testing/jdt_test_items.rs new file mode 100644 index 0000000..6435696 --- /dev/null +++ b/crates/java-domain/src/testing/jdt_test_items.rs @@ -0,0 +1,804 @@ +//! Java Test extension items and the launch plan derived from them. +//! +//! JDT LS's Java Test extension owns the semantic identity of every test class +//! and method. This module turns its answers into the generic Tests tree, +//! build-tool selectors and the Java test debug launch target, without ever +//! deriving an identity from a file name, package declaration or method +//! spelling. +//! +//! Ported from the Java branches of the Swift `LanguageToolingSessionManager` +//! (`discoverJavaTestItems`, `javaBuildTestSelectors`, +//! `resolveJavaTestDebugLaunchTarget`, `javaTestLaunchArguments`). + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; + +use crate::debug::{JavaTestFramework, JavaTestLaunchRequest}; +use crate::protocol::{CoreError, ErrorCode}; + +/// Java Test extension test level of a test class. +pub const CLASS_LEVEL: i64 = 5; +/// Java Test extension test level of a test method. +pub const METHOD_LEVEL: i64 = 6; +/// Largest number of selectors handed to a build tool in one run. +pub const MAX_BUILD_SELECTORS: usize = 64; +/// JDT LS command that resolves the runner arguments of selected tests. +pub const LAUNCH_ARGUMENTS_COMMAND: &str = "vscode.java.test.junit.argument"; +/// Main class of the packaged TestNG runner. +pub const TESTNG_RUNNER_MAIN_CLASS: &str = "com.microsoft.java.test.runner.Launcher"; + +/// Source range of a test item, in zero-based lines and UTF-16 columns. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaTestRange { + /// First line of the declaration. + pub start_line: i64, + /// UTF-16 column on the first line. + pub start_utf16_column: i64, + /// Last line of the declaration. + pub end_line: i64, + /// UTF-16 column on the last line. + pub end_utf16_column: i64, +} + +/// One test class or method reported by the Java Test extension. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaTestItem { + /// Extension identifier, stable within one session. + pub id: String, + /// Display label. + pub label: String, + /// `Class` or `Class#method(...)` name used by runners and reports. + pub full_name: String, + /// JDT project that owns the test. + pub project_name: String, + /// Framework kind: 0 JUnit 4, 1 JUnit 5, 2 TestNG. + #[serde(rename = "testKind")] + pub kind: i64, + /// Item level: [`CLASS_LEVEL`] or [`METHOD_LEVEL`]. + #[serde(rename = "testLevel")] + pub level: i64, + /// JDT handle identifier, required to launch a single JUnit method. + #[serde(default)] + pub jdt_handler: Option, + /// Sort key chosen by the extension. + #[serde(default)] + pub sort_text: Option, + /// Declaration range in the source file. + #[serde(default)] + pub range: Option, + /// Nested classes and methods. + #[serde(default)] + pub children: Vec, +} + +impl JavaTestItem { + /// Whether `identifier` names this item by any identity the host may hold. + pub fn matches(&self, identifier: &str) -> bool { + self.id == identifier + || self.label == identifier + || self.full_name == identifier + || self.jdt_handler.as_deref() == Some(identifier) + } +} + +/// Kind of a row in the generic Tests tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LanguageTestItemKind { + /// The provider's workspace row. + Workspace, + /// A source file. + File, + /// A class or method inside a file. + TestCase, +} + +/// Canonical report names a test row is associated with. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportIdentity { + /// Suite (class) name in test reports. + pub suite: String, + /// Test case name inside the suite, `None` for the whole suite. + #[serde(default)] + pub name: Option, +} + +/// One row of the generic Tests tree. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageTestItem { + /// Row identifier. + pub id: String, + /// Provider that owns the row; Java rows use `"java"`. + pub provider_id: String, + /// Display label. + pub label: String, + /// Row kind. + pub kind: LanguageTestItemKind, + /// Absolute source file path. + #[serde(default)] + pub file_path: Option, + /// Identifier forwarded unchanged to run and debug. + #[serde(default)] + pub test_identifier: Option, + /// Nesting below the provider section; files start at zero. + pub depth: usize, + /// Report association used to match results to rows. + #[serde(default)] + pub report_identity: Option, + /// Host-owned fields (project path, build reference, ...) kept verbatim. + #[serde(flatten)] + pub host_fields: Map, +} + +/// All items depth first, parents before their children. +pub fn flatten(items: &[JavaTestItem]) -> Vec<&JavaTestItem> { + let mut flattened = Vec::new(); + for item in items { + flattened.push(item); + flattened.extend(flatten(&item.children)); + } + flattened +} + +/// Items in the extension's order: sort text, then label, then identifier. +pub fn sorted(items: &[JavaTestItem]) -> Vec<&JavaTestItem> { + let mut sorted = items.iter().collect::>(); + sorted.sort_by(|left, right| { + let key = |item: &JavaTestItem| { + ( + item.sort_text.clone().unwrap_or_else(|| item.label.clone()), + item.label.clone(), + item.id.clone(), + ) + }; + key(left).cmp(&key(right)) + }); + sorted +} + +/// Tests tree rows for the items of one source file, starting at `depth`. +pub fn project_language_test_items( + items: &[JavaTestItem], + file_path: &str, + depth: usize, +) -> Vec { + sorted(items) + .into_iter() + .flat_map(|item| { + std::iter::once(LanguageTestItem { + id: item.id.clone(), + provider_id: "java".to_string(), + label: item.label.clone(), + kind: LanguageTestItemKind::TestCase, + file_path: Some(file_path.to_string()), + test_identifier: Some(item.full_name.clone()), + depth, + report_identity: report_identity(item), + host_fields: Map::new(), + }) + .chain(project_language_test_items( + &item.children, + file_path, + depth + 1, + )) + .collect::>() + }) + .collect() +} + +/// Report names of one item, following JDT's display-name convention. +/// +/// A class reports as its full name; a method `Class#name(params)` reports as +/// suite `Class` and case `name`. +pub fn report_identity(item: &JavaTestItem) -> Option { + if item.level == CLASS_LEVEL { + return Some(ReportIdentity { + suite: item.full_name.clone(), + name: None, + }); + } + if item.level != METHOD_LEVEL { + return None; + } + let (suite, name) = item.full_name.split_once('#')?; + let name = name.split('(').next().unwrap_or(name); + Some(ReportIdentity { + suite: suite.to_string(), + name: Some(name.to_string()), + }) +} + +/// Build-tool selectors for a file or one identified test. +/// +/// Values are the extension's full names, unique and sorted. A file yields its +/// top-level items; an identifier must select exactly one item. More than +/// [`MAX_BUILD_SELECTORS`] selectors, or none, is unsupported. +/// +/// TODO(host-api: API-35 cross-plugin function): this plugin only produces the +/// selectors. Running them is the job of the build plugin that owns the +/// project (Maven or Gradle). Once the host relays cross-plugin calls, the +/// backend asks the host for the enabled plugins that export the test-run +/// function ID this plugin declares and sends the selectors there; it never +/// names Maven or Gradle. Where the result appears in the native test panel +/// is decided with the editor-action API (API-27). See `MIGRATION.md`. +pub fn build_test_selectors( + items: &[JavaTestItem], + identifier: Option<&str>, +) -> Result, CoreError> { + let selected = match identifier { + Some(identifier) => flatten(items) + .into_iter() + .filter(|item| item.matches(identifier)) + .collect::>(), + None => items.iter().collect(), + }; + let mut values = selected + .into_iter() + .map(|item| item.full_name.clone()) + .filter(|name| !name.is_empty()) + .collect::>(); + values.sort(); + values.dedup(); + if values.is_empty() + || values.len() > MAX_BUILD_SELECTORS + || (identifier.is_some() && values.len() != 1) + { + return Err(unavailable("Java")); + } + Ok(values) +} + +/// Selected tests and the request that asks JDT LS for their runner arguments. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JavaTestLaunchPlan { + /// Session name: the item label for one item, else the file name. + pub name: String, + /// JDT project shared by every selected item. + pub project_name: String, + /// Framework shared by every selected item. + pub framework: JavaTestFramework, + /// Level shared by every selected item. + pub level: i64, + /// Framework kind shared by every selected item. + pub kind: i64, + /// Names sent in `testNames` of the launch-arguments request. + pub launch_test_names: Vec, + /// TestNG method names the runner executes; empty for JUnit. + pub testng_test_names: Vec, +} + +impl JavaTestLaunchPlan { + /// Single JSON-string argument of [`LAUNCH_ARGUMENTS_COMMAND`], with sorted keys. + pub fn launch_arguments_request(&self) -> String { + json!({ + "projectName": self.project_name, + "testKind": self.kind, + "testLevel": self.level, + "testNames": self.launch_test_names, + }) + .to_string() + } +} + +/// Chooses the tests to debug from one file's items. +/// +/// Without an identifier every top-level class is selected. All selected items +/// must share one project, framework kind and level, because one runner +/// process debugs them. +pub fn plan_debug_launch( + items: &[JavaTestItem], + file_name: &str, + test_identifier: Option<&str>, +) -> Result { + let identifier = test_identifier.filter(|value| !value.is_empty()); + let selected = match identifier { + Some(identifier) => flatten(items) + .into_iter() + .filter(|item| item.matches(identifier)) + .collect::>(), + None => items + .iter() + .filter(|item| item.level == CLASS_LEVEL) + .collect(), + }; + let Some(first) = selected.first() else { + return Err(unavailable(&format!( + "No Java test was found for {}.", + test_identifier.unwrap_or(file_name) + ))); + }; + if selected.iter().any(|item| { + item.project_name != first.project_name + || item.kind != first.kind + || item.level != first.level + }) { + return Err(unavailable( + "Debug one Java test framework and project at a time.", + )); + } + let framework = match first.kind { + 0 | 1 => JavaTestFramework::Junit, + 2 => JavaTestFramework::Testng, + _ => { + return Err(unavailable( + "The selected Java test framework is not supported.", + )) + } + }; + let launch_test_names = if framework == JavaTestFramework::Junit && first.level == METHOD_LEVEL + { + selected + .iter() + .filter_map(|item| item.jdt_handler.clone()) + .collect::>() + } else { + selected.iter().map(|item| item.full_name.clone()).collect() + }; + if launch_test_names.len() != selected.len() { + return Err(unavailable( + "The Java language service returned incomplete test identifiers.", + )); + } + let testng_test_names = if framework == JavaTestFramework::Testng { + selected + .iter() + .flat_map(|item| testng_method_names(item)) + .collect() + } else { + Vec::new() + }; + Ok(JavaTestLaunchPlan { + name: if selected.len() == 1 { + first.label.clone() + } else { + file_name.to_string() + }, + project_name: first.project_name.clone(), + framework, + level: first.level, + kind: first.kind, + launch_test_names, + testng_test_names, + }) +} + +fn testng_method_names(item: &JavaTestItem) -> Vec { + if item.level == METHOD_LEVEL { + return vec![item.full_name.clone()]; + } + item.children.iter().flat_map(testng_method_names).collect() +} + +/// Runner arguments JDT LS resolved for a launch plan. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ResolvedJavaTestLaunchArguments { + /// Runner working directory. + pub working_directory: String, + /// JUnit runner main class; absent for TestNG. + pub main_class: Option, + /// JDT project name echoed by the extension. + pub project_name: Option, + /// Class path entries. + pub class_paths: Vec, + /// Module path entries. + pub module_paths: Vec, + /// JVM arguments. + pub vm_arguments: Vec, + /// Runner program arguments. + pub program_arguments: Vec, +} + +/// Reads the answer of [`LAUNCH_ARGUMENTS_COMMAND`]. +pub fn parse_launch_arguments(value: &Value) -> Result { + let Some(response) = value.as_object() else { + return Err(unavailable( + "The Java language service returned invalid test launch arguments.", + )); + }; + if let Some(message) = response + .get("errorMessage") + .and_then(Value::as_str) + .filter(|message| !message.is_empty()) + { + return Err(unavailable(message)); + } + let body = response.get("body").and_then(Value::as_object); + let Some((body, working_directory)) = body.and_then(|body| { + body.get("workingDirectory") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(|directory| (body, directory.to_string())) + }) else { + return Err(unavailable( + "The Java language service returned incomplete test launch arguments.", + )); + }; + let text = |key: &str| { + body.get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }; + let strings = |key: &str| { + body.get(key) + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .collect() + }) + .unwrap_or_default() + }; + Ok(ResolvedJavaTestLaunchArguments { + working_directory, + main_class: text("mainClass"), + project_name: text("projectName"), + class_paths: strings("classpath"), + module_paths: strings("modulepath"), + vm_arguments: strings("vmArguments"), + program_arguments: strings("programArguments"), + }) +} + +/// Everything the debug launch needs about the selected tests. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaTestDebugLaunchTarget { + /// Source file the tests were selected from. + pub file_path: String, + /// Session name. + pub name: String, + /// Runner framework. + pub framework: JavaTestFramework, + /// Runner working directory. + pub working_directory: String, + /// Runner main class. + pub main_class: String, + /// JDT project name. + #[serde(default)] + pub project_name: Option, + /// Class path entries. + #[serde(default)] + pub class_paths: Vec, + /// Module path entries. + #[serde(default)] + pub module_paths: Vec, + /// JVM arguments. + #[serde(default)] + pub vm_arguments: Vec, + /// Runner program arguments. + #[serde(default)] + pub program_arguments: Vec, + /// Packaged TestNG runner jar. + #[serde(default)] + pub testng_runner_path: Option, + /// TestNG methods to run. + #[serde(default)] + pub testng_test_names: Vec, +} + +impl JavaTestDebugLaunchTarget { + /// Request for [`crate::debug::java_test_launch`] with the result port. + pub fn launch_request(&self, result_port: u16) -> JavaTestLaunchRequest { + JavaTestLaunchRequest { + name: self.name.clone(), + framework: self.framework, + working_directory: self.working_directory.clone(), + main_class: self.main_class.clone(), + project_name: self.project_name.clone(), + class_paths: self.class_paths.clone(), + module_paths: self.module_paths.clone(), + vm_arguments: self.vm_arguments.clone(), + program_arguments: self.program_arguments.clone(), + result_port, + testng_runner_path: self.testng_runner_path.clone(), + testng_test_names: self.testng_test_names.clone(), + } + } +} + +/// Combines a plan with JDT LS's runner arguments. +/// +/// TestNG runs through the packaged runner, which the plugin package declares +/// as the `testRunner` resource; JUnit uses the main class JDT LS resolved. +pub fn debug_launch_target( + file_path: &str, + file_name: &str, + plan: JavaTestLaunchPlan, + launch: ResolvedJavaTestLaunchArguments, + testng_runner_path: Option<&str>, +) -> Result { + let (main_class, runner_path) = match plan.framework { + JavaTestFramework::Testng => { + let Some(runner) = testng_runner_path else { + return Err(unavailable( + "The packaged Java TestNG runner is unavailable. Reinstall the Java Support plugin.", + )); + }; + if plan.testng_test_names.is_empty() { + return Err(unavailable(&format!( + "No TestNG test method was found in {file_name}." + ))); + } + ( + TESTNG_RUNNER_MAIN_CLASS.to_string(), + Some(runner.to_string()), + ) + } + JavaTestFramework::Junit => { + let Some(main_class) = launch.main_class.clone() else { + return Err(unavailable( + "The Java language service returned no JUnit runner main class.", + )); + }; + (main_class, None) + } + }; + Ok(JavaTestDebugLaunchTarget { + file_path: file_path.to_string(), + name: plan.name, + framework: plan.framework, + working_directory: launch.working_directory, + main_class, + project_name: launch.project_name, + class_paths: launch.class_paths, + module_paths: launch.module_paths, + vm_arguments: launch.vm_arguments, + program_arguments: launch.program_arguments, + testng_runner_path: runner_path, + testng_test_names: plan.testng_test_names, + }) +} + +fn unavailable(message: &str) -> CoreError { + CoreError::new(ErrorCode::NotSupported, message) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(id: &str, full_name: &str, level: i64, children: Vec) -> JavaTestItem { + JavaTestItem { + id: id.to_string(), + label: full_name + .rsplit(['.', '#']) + .next() + .unwrap_or(full_name) + .to_string(), + full_name: full_name.to_string(), + project_name: "service".to_string(), + kind: 1, + level, + jdt_handler: Some(format!("=service/{id}")), + sort_text: None, + range: None, + children, + } + } + + fn user_service_test() -> Vec { + vec![item( + "class", + "example.UserServiceTest", + CLASS_LEVEL, + vec![ + item( + "b", + "example.UserServiceTest#logsOut()", + METHOD_LEVEL, + vec![], + ), + item( + "a", + "example.UserServiceTest#logsIn()", + METHOD_LEVEL, + vec![], + ), + ], + )] + } + + #[test] + fn extension_json_uses_test_kind_and_level_keys() { + let item: JavaTestItem = serde_json::from_value(json!({ + "id": "1", "label": "UserServiceTest", "fullName": "example.UserServiceTest", + "projectName": "service", "testKind": 1, "testLevel": 5 + })) + .unwrap(); + assert_eq!((item.kind, item.level), (1, CLASS_LEVEL)); + assert!(item.children.is_empty()); + } + + #[test] + fn items_match_every_identity_the_host_holds() { + let item = &user_service_test()[0]; + for identity in [ + "class", + "UserServiceTest", + "example.UserServiceTest", + "=service/class", + ] { + assert!(item.matches(identity), "{identity}"); + } + assert!(!item.matches("UserService")); + } + + #[test] + fn tests_tree_rows_are_sorted_nested_and_carry_report_names() { + let rows = project_language_test_items(&user_service_test(), "/w/UserServiceTest.java", 1); + let labels = rows + .iter() + .map(|row| (row.label.as_str(), row.depth)) + .collect::>(); + assert_eq!( + labels, + vec![("UserServiceTest", 1), ("logsIn()", 2), ("logsOut()", 2)] + ); + assert_eq!( + rows[0].report_identity, + Some(ReportIdentity { + suite: "example.UserServiceTest".into(), + name: None + }) + ); + assert_eq!( + rows[1].report_identity, + Some(ReportIdentity { + suite: "example.UserServiceTest".into(), + name: Some("logsIn".into()) + }) + ); + assert_eq!( + rows[1].test_identifier.as_deref(), + Some("example.UserServiceTest#logsIn()") + ); + assert!(rows + .iter() + .all(|row| row.kind == LanguageTestItemKind::TestCase && row.provider_id == "java")); + } + + #[test] + fn host_fields_survive_a_round_trip() { + let row: LanguageTestItem = serde_json::from_value(json!({ + "id": "1", "providerId": "java", "label": "A", "kind": "file", "depth": 0, + "projectPath": "service", "buildReference": {"tool": "maven"} + })) + .unwrap(); + let encoded = serde_json::to_value(&row).unwrap(); + assert_eq!(encoded["projectPath"], "service"); + assert_eq!(encoded["buildReference"]["tool"], "maven"); + } + + #[test] + fn build_selectors_cover_the_file_or_exactly_one_test() { + let items = user_service_test(); + assert_eq!( + build_test_selectors(&items, None).unwrap(), + vec!["example.UserServiceTest"] + ); + assert_eq!( + build_test_selectors(&items, Some("a")).unwrap(), + vec!["example.UserServiceTest#logsIn()"] + ); + assert!(build_test_selectors(&items, Some("missing")).is_err()); + assert!(build_test_selectors(&[], None).is_err()); + } + + #[test] + fn a_junit_method_launch_uses_jdt_handles() { + let plan = + plan_debug_launch(&user_service_test(), "UserServiceTest.java", Some("a")).unwrap(); + assert_eq!(plan.framework, JavaTestFramework::Junit); + assert_eq!(plan.name, "logsIn()"); + assert_eq!(plan.launch_test_names, vec!["=service/a"]); + assert_eq!( + plan.launch_arguments_request(), + r#"{"projectName":"service","testKind":1,"testLevel":6,"testNames":["=service/a"]}"# + ); + } + + #[test] + fn a_file_launch_selects_its_classes_by_full_name() { + let plan = plan_debug_launch(&user_service_test(), "UserServiceTest.java", None).unwrap(); + assert_eq!(plan.level, CLASS_LEVEL); + assert_eq!(plan.launch_test_names, vec!["example.UserServiceTest"]); + assert_eq!(plan.name, "UserServiceTest"); + } + + #[test] + fn mixed_projects_or_frameworks_are_rejected() { + let mut items = user_service_test(); + let mut other = items[0].clone(); + other.id = "other".into(); + other.project_name = "web".into(); + items.push(other); + let error = plan_debug_launch(&items, "Mixed.java", None).unwrap_err(); + assert_eq!( + error.message, + "Debug one Java test framework and project at a time." + ); + let error = plan_debug_launch(&items, "Mixed.java", Some("missing")).unwrap_err(); + assert_eq!(error.message, "No Java test was found for missing."); + } + + #[test] + fn a_junit_method_without_a_handle_is_incomplete() { + let mut items = user_service_test(); + items[0].children[1].jdt_handler = None; + let error = plan_debug_launch(&items, "UserServiceTest.java", Some("a")).unwrap_err(); + assert_eq!( + error.message, + "The Java language service returned incomplete test identifiers." + ); + } + + #[test] + fn testng_runs_through_the_packaged_runner() { + let mut items = user_service_test(); + items[0].kind = 2; + for child in &mut items[0].children { + child.kind = 2; + } + let plan = plan_debug_launch(&items, "UserServiceTest.java", None).unwrap(); + assert_eq!( + plan.testng_test_names, + vec![ + "example.UserServiceTest#logsOut()", + "example.UserServiceTest#logsIn()" + ] + ); + let launch = ResolvedJavaTestLaunchArguments { + working_directory: "/w".into(), + ..Default::default() + }; + assert!( + debug_launch_target("/w/T.java", "T.java", plan.clone(), launch.clone(), None).is_err() + ); + let target = + debug_launch_target("/w/T.java", "T.java", plan, launch, Some("/p/runner.jar")) + .unwrap(); + assert_eq!(target.main_class, TESTNG_RUNNER_MAIN_CLASS); + assert_eq!(target.testng_runner_path.as_deref(), Some("/p/runner.jar")); + let request = target.launch_request(43_128); + assert_eq!(request.result_port, 43_128); + } + + #[test] + fn launch_arguments_require_a_working_directory_and_surface_errors() { + assert!(parse_launch_arguments(&json!([])).is_err()); + let error = parse_launch_arguments(&json!({"errorMessage": "no project"})).unwrap_err(); + assert_eq!(error.message, "no project"); + assert!(parse_launch_arguments(&json!({"body": {"workingDirectory": ""}})).is_err()); + let parsed = parse_launch_arguments(&json!({"body": { + "workingDirectory": "/w", "mainClass": "Runner", "projectName": "", + "classpath": ["/a", "", 3], "vmArguments": ["-ea"] + }})) + .unwrap(); + assert_eq!(parsed.main_class.as_deref(), Some("Runner")); + assert_eq!(parsed.project_name, None); + assert_eq!(parsed.class_paths, vec!["/a"]); + assert_eq!(parsed.vm_arguments, vec!["-ea"]); + } + + #[test] + fn junit_needs_the_runner_main_class_from_jdt() { + let plan = plan_debug_launch(&user_service_test(), "UserServiceTest.java", None).unwrap(); + let launch = ResolvedJavaTestLaunchArguments { + working_directory: "/w".into(), + ..Default::default() + }; + let error = debug_launch_target("/w/T.java", "T.java", plan, launch, None).unwrap_err(); + assert_eq!( + error.message, + "The Java language service returned no JUnit runner main class." + ); + } +} diff --git a/crates/java-domain/src/testing/mod.rs b/crates/java-domain/src/testing/mod.rs new file mode 100644 index 0000000..bab4d21 --- /dev/null +++ b/crates/java-domain/src/testing/mod.rs @@ -0,0 +1,12 @@ +//! Java test discovery, debug launch and result-channel rules. +//! +//! These modules replace the Java branches of the Swift host's test workflow: +//! [`jdt_test_items`] interprets the Java Test extension's answers, +//! [`workflow`] decides which operation is current, [`debug_launch`] prepares +//! a debug session and [`result_channel`] fixes the result listener's +//! lifecycle. + +pub mod debug_launch; +pub mod jdt_test_items; +pub mod result_channel; +pub mod workflow; diff --git a/crates/java-domain/src/testing/result_channel.rs b/crates/java-domain/src/testing/result_channel.rs new file mode 100644 index 0000000..cc95008 --- /dev/null +++ b/crates/java-domain/src/testing/result_channel.rs @@ -0,0 +1,374 @@ +//! Lifecycle of the loopback channel that receives Java test results. +//! +//! The Java test runner connects back to a loopback port and streams its +//! results. Ported from the Swift `JavaTestResultServer` as a sans-IO state +//! machine: the driver owns the socket and the clock, feeds listener events +//! in, and executes the returned [`Action`]s. Every listener gets a +//! generation, so events from a listener that was already replaced are +//! ignored, and late connections to it are closed. +//! +//! TODO(host-api: API-29 build and test): a plugin backend cannot open +//! listening sockets today. The driver needs either a host-owned loopback +//! channel that forwards the runner connection to the backend, or a backend +//! `network.loopbackListen` permission. Until then the host keeps its listener +//! and this module only fixes the rules it must follow. + +use std::time::{Duration, Instant}; + +/// How long the listener may take to report its port. +pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(5); + +/// Identifier of one listener start. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Generation(u64); + +/// Identifier the driver gives an accepted connection. +pub type ConnectionId = u64; + +/// Something the listener of one generation reported. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ListenerEvent { + /// The listener is bound; `port` is `None` when the system assigned none. + Ready { + /// Assigned loopback port. + port: Option, + }, + /// The listener failed. + Failed { + /// System error description. + message: String, + }, + /// The listener was cancelled. + Cancelled, + /// The runner connected. + Connection { + /// Driver-assigned connection identifier. + id: ConnectionId, + }, + /// A tracked connection closed. + ConnectionEnded { + /// Driver-assigned connection identifier. + id: ConnectionId, + }, +} + +/// Why startup did not produce a port. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResultChannelError { + /// The listener failed before it was ready. + StartupFailed(String), + /// The listener did not report a port in time. + StartupTimedOut, + /// Startup was abandoned by [`ResultChannel::stop`]. + Cancelled, +} + +impl std::fmt::Display for ResultChannelError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::StartupFailed(message) => write!( + formatter, + "Could not start the Java test result listener: {message}" + ), + Self::StartupTimedOut => { + formatter.write_str("The Java test result listener did not become ready in time.") + } + Self::Cancelled => formatter.write_str("The Java test result listener was cancelled."), + } + } +} + +impl std::error::Error for ResultChannelError {} + +/// Work the driver performs on behalf of the state machine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Bind a new loopback listener on an ephemeral port for `generation`. + StartListener { + /// Generation the listener reports events for. + generation: Generation, + }, + /// Stop accepting connections on the current listener. + CancelListener, + /// Close one connection. + CloseConnection { + /// Connection to close. + id: ConnectionId, + }, + /// Resolve the pending [`ResultChannel::start`] call. + CompleteStartup(Result), +} + +#[derive(Debug)] +struct Pending { + deadline: Instant, +} + +/// Single-connection result channel state. +#[derive(Debug)] +pub struct ResultChannel { + startup_timeout: Duration, + generation: u64, + listening: bool, + pending: Option, + connections: Vec, + port: Option, +} + +impl Default for ResultChannel { + fn default() -> Self { + Self::new(DEFAULT_STARTUP_TIMEOUT) + } +} + +impl ResultChannel { + /// Creates a stopped channel with the given startup timeout. + pub fn new(startup_timeout: Duration) -> Self { + Self { + startup_timeout, + generation: 0, + listening: false, + pending: None, + connections: Vec::new(), + port: None, + } + } + + /// Port of the ready listener. + pub fn port(&self) -> Option { + self.port + } + + /// Connections currently tracked. + pub fn connections(&self) -> &[ConnectionId] { + &self.connections + } + + /// Stops any previous listener and starts a new one at `now`. + pub fn start(&mut self, now: Instant) -> (Generation, Vec) { + let mut actions = self.stop(); + self.generation += 1; + let generation = Generation(self.generation); + self.listening = true; + self.pending = Some(Pending { + deadline: now + self.startup_timeout, + }); + actions.push(Action::StartListener { generation }); + (generation, actions) + } + + /// Cancels the listener, closes every connection and abandons startup. + pub fn stop(&mut self) -> Vec { + let mut actions = Vec::new(); + if self.listening { + self.listening = false; + actions.push(Action::CancelListener); + } + actions.extend( + self.connections + .drain(..) + .map(|id| Action::CloseConnection { id }), + ); + self.port = None; + if self.pending.take().is_some() { + actions.push(Action::CompleteStartup(Err(ResultChannelError::Cancelled))); + } + actions + } + + /// Applies one listener event. + pub fn handle(&mut self, generation: Generation, event: ListenerEvent) -> Vec { + if generation.0 != self.generation || !self.is_active() { + return match event { + ListenerEvent::Connection { id } => vec![Action::CloseConnection { id }], + _ => Vec::new(), + }; + } + match event { + ListenerEvent::Ready { port: Some(port) } => { + self.port = Some(port); + match self.pending.take() { + Some(_) => vec![Action::CompleteStartup(Ok(port))], + None => Vec::new(), + } + } + ListenerEvent::Ready { port: None } => self.fail_startup( + ResultChannelError::StartupFailed("No listening port was assigned.".into()), + ), + ListenerEvent::Failed { message } => { + self.fail_startup(ResultChannelError::StartupFailed(message)) + } + ListenerEvent::Cancelled => self.fail_startup(ResultChannelError::Cancelled), + ListenerEvent::Connection { id } => { + // The runner opens exactly one connection; refuse any other. + let mut actions = Vec::new(); + if self.listening { + self.listening = false; + actions.push(Action::CancelListener); + } + self.connections.push(id); + actions + } + ListenerEvent::ConnectionEnded { id } => { + self.connections.retain(|tracked| *tracked != id); + Vec::new() + } + } + } + + /// Times out a startup whose deadline passed. + pub fn poll(&mut self, now: Instant) -> Vec { + match &self.pending { + Some(pending) if now >= pending.deadline => { + self.fail_startup(ResultChannelError::StartupTimedOut) + } + _ => Vec::new(), + } + } + + fn is_active(&self) -> bool { + self.listening || self.pending.is_some() || !self.connections.is_empty() + } + + fn fail_startup(&mut self, error: ResultChannelError) -> Vec { + let Some(_) = self.pending.take() else { + return Vec::new(); + }; + let mut actions = self.stop(); + actions.push(Action::CompleteStartup(Err(error))); + actions + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn started(channel: &mut ResultChannel, now: Instant) -> Generation { + let (generation, actions) = channel.start(now); + assert_eq!(actions, vec![Action::StartListener { generation }]); + generation + } + + #[test] + fn start_returns_the_ready_port_and_stop_cancels_the_listener() { + let now = Instant::now(); + let mut channel = ResultChannel::default(); + let generation = started(&mut channel, now); + assert_eq!( + channel.handle(generation, ListenerEvent::Ready { port: Some(43_128) }), + vec![Action::CompleteStartup(Ok(43_128))] + ); + assert_eq!(channel.port(), Some(43_128)); + assert_eq!(channel.stop(), vec![Action::CancelListener]); + assert_eq!(channel.port(), None); + } + + #[test] + fn cancelling_startup_stops_the_pending_listener() { + let now = Instant::now(); + let mut channel = ResultChannel::default(); + started(&mut channel, now); + assert_eq!( + channel.stop(), + vec![ + Action::CancelListener, + Action::CompleteStartup(Err(ResultChannelError::Cancelled)) + ] + ); + assert!(channel.stop().is_empty()); + } + + #[test] + fn startup_times_out_at_the_deadline_only() { + let now = Instant::now(); + let mut channel = ResultChannel::new(Duration::from_millis(100)); + started(&mut channel, now); + assert!(channel.poll(now + Duration::from_millis(99)).is_empty()); + let actions = channel.poll(now + Duration::from_millis(100)); + assert_eq!( + actions, + vec![ + Action::CancelListener, + Action::CompleteStartup(Err(ResultChannelError::StartupTimedOut)) + ] + ); + assert_eq!( + ResultChannelError::StartupTimedOut.to_string(), + "The Java test result listener did not become ready in time." + ); + } + + #[test] + fn listener_failures_resolve_startup_with_the_host_message() { + let now = Instant::now(); + let mut channel = ResultChannel::default(); + let generation = started(&mut channel, now); + let actions = channel.handle(generation, ListenerEvent::Ready { port: None }); + let Some(Action::CompleteStartup(Err(error))) = actions.last() else { + panic!("startup should fail: {actions:?}"); + }; + assert_eq!( + error.to_string(), + "Could not start the Java test result listener: No listening port was assigned." + ); + } + + #[test] + fn events_from_a_replaced_listener_are_ignored_and_its_connections_closed() { + let now = Instant::now(); + let mut channel = ResultChannel::default(); + let old = started(&mut channel, now); + let (new, actions) = channel.start(now); + assert_eq!( + actions, + vec![ + Action::CancelListener, + Action::CompleteStartup(Err(ResultChannelError::Cancelled)), + Action::StartListener { generation: new } + ] + ); + assert!(channel + .handle(old, ListenerEvent::Ready { port: Some(1) }) + .is_empty()); + assert_eq!( + channel.handle(old, ListenerEvent::Connection { id: 7 }), + vec![Action::CloseConnection { id: 7 }] + ); + assert_eq!( + channel.handle(new, ListenerEvent::Ready { port: Some(2) }), + vec![Action::CompleteStartup(Ok(2))] + ); + } + + #[test] + fn the_first_connection_closes_the_listener_to_further_runners() { + let now = Instant::now(); + let mut channel = ResultChannel::default(); + let generation = started(&mut channel, now); + channel.handle(generation, ListenerEvent::Ready { port: Some(9) }); + assert_eq!( + channel.handle(generation, ListenerEvent::Connection { id: 1 }), + vec![Action::CancelListener] + ); + assert_eq!(channel.connections(), &[1]); + assert!(channel + .handle(generation, ListenerEvent::ConnectionEnded { id: 1 }) + .is_empty()); + assert!(channel.connections().is_empty()); + assert_eq!( + channel.handle(generation, ListenerEvent::Connection { id: 2 }), + vec![Action::CloseConnection { id: 2 }] + ); + } + + #[test] + fn stop_closes_open_connections() { + let now = Instant::now(); + let mut channel = ResultChannel::default(); + let generation = started(&mut channel, now); + channel.handle(generation, ListenerEvent::Ready { port: Some(9) }); + channel.handle(generation, ListenerEvent::Connection { id: 4 }); + assert_eq!(channel.stop(), vec![Action::CloseConnection { id: 4 }]); + } +} diff --git a/crates/java-domain/src/testing/workflow.rs b/crates/java-domain/src/testing/workflow.rs new file mode 100644 index 0000000..13b8cb6 --- /dev/null +++ b/crates/java-domain/src/testing/workflow.rs @@ -0,0 +1,515 @@ +//! Java test discovery and debug workflow rules. +//! +//! Ported from the Swift `JavaTestWorkflowState` and +//! `JavaTestDebugWorkflowCoordinator`. The host keeps the tasks and the editor; +//! this module decides which selection can be debugged, which operation is +//! still current, and how per-file discovery results become Tests tree rows. + +use std::path::{Component, Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::jdt_test_items::{LanguageTestItem, LanguageTestItemKind}; + +/// Selection the user asked to debug. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum TestScope { + /// The whole workspace. + Workspace, + /// Every test in one source file. + File { + /// Absolute source path. + path: String, + }, + /// One discovered class or method. + TestCase { + /// Identifier forwarded unchanged to the Java Test extension. + identifier: String, + /// Source file of the test, when known. + #[serde(default)] + path: Option, + }, +} + +/// Resolved Java test target passed into debug preparation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaTestDebugRequest { + /// Lexically normalized source file. + pub file_path: PathBuf, + /// Selected test, or `None` to debug the whole file. + pub test_identifier: Option, +} + +/// Accepts a debug selection or explains why it cannot be debugged. +pub fn resolve_debug_request( + provider_id: &str, + scope: &TestScope, +) -> Result { + if provider_id != "java" { + return Err("Java test debugging is currently available for Java projects only".into()); + } + match scope { + TestScope::Workspace => Err("Select a Java test file or test case to debug".into()), + TestScope::File { path } => Ok(JavaTestDebugRequest { + file_path: normalize(Path::new(path)), + test_identifier: None, + }), + TestScope::TestCase { identifier, path } => { + let Some(path) = path else { + return Err("The selected Java test has no source file".into()); + }; + Ok(JavaTestDebugRequest { + file_path: normalize(Path::new(path)), + test_identifier: Some(identifier.clone()), + }) + } + } +} + +/// Removes `.` components and folds `..` into its parent without touching +/// the file system, like Foundation's `standardizedFileURL`. +pub fn normalize(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + normalized.push(component); + } + } + other => normalized.push(other), + } + } + normalized +} + +/// Discovery answer for one source file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileDiscovery { + /// The file's test rows, possibly empty. + Items(Vec), + /// Discovery failed; the file row is kept so the user can still run it. + Failed, +} + +/// Why a discovery projection stopped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Superseded; + +/// Expands the file rows of `base_items` with their discovered tests. +/// +/// Workspace rows are kept. A file stays when discovery found tests or failed; +/// a file without tests is dropped. When every file answered and none has +/// tests, the result is empty so the Tests view shows its empty state. +/// `is_current` is checked before each file and once at the end; a stale +/// operation stops with [`Superseded`]. +pub fn project_discovered_items( + base_items: &[LanguageTestItem], + mut discover: impl FnMut(&str) -> Result, + is_current: impl Fn() -> bool, +) -> Result, Superseded> { + let files = base_items + .iter() + .filter(|item| item.kind == LanguageTestItemKind::File && item.file_path.is_some()) + .collect::>(); + if files.is_empty() { + return Ok(base_items.to_vec()); + } + let mut projected = base_items + .iter() + .filter(|item| item.kind == LanguageTestItemKind::Workspace) + .cloned() + .collect::>(); + let mut completed_file_count = 0; + for file in files { + if !is_current() { + return Err(Superseded); + } + let path = file.file_path.as_deref().unwrap_or_default(); + match discover(path)? { + FileDiscovery::Items(details) => { + completed_file_count += 1; + if !details.is_empty() { + projected.push(file.clone()); + projected.extend(details); + } + } + FileDiscovery::Failed => projected.push(file.clone()), + } + } + if !is_current() { + return Err(Superseded); + } + if completed_file_count > 0 + && projected + .iter() + .all(|item| item.kind == LanguageTestItemKind::Workspace) + { + return Ok(Vec::new()); + } + Ok(projected) +} + +/// Identifier of one run preparation, discovery or debug launch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct OperationToken(u64); + +/// One kind of cancellable operation where only the latest may finish. +/// +/// Beginning a new operation revokes the previous one; finishing or cancelling +/// clears the slot so a late completion of a revoked operation is ignored. +#[derive(Debug, Default)] +pub struct OperationSlot { + next: u64, + current: Option, +} + +impl OperationSlot { + /// Revokes the current operation and returns the token of a new one. + pub fn begin(&mut self) -> OperationToken { + self.next += 1; + let token = OperationToken(self.next); + self.current = Some(token); + token + } + + /// Whether `token` is still the operation allowed to apply its result. + pub fn is_current(&self, token: OperationToken) -> bool { + self.current == Some(token) + } + + /// Clears the slot when `token` is current. Returns whether it was. + pub fn finish(&mut self, token: OperationToken) -> bool { + if self.current != Some(token) { + return false; + } + self.current = None; + true + } + + /// Revokes whatever operation is current. + pub fn cancel(&mut self) { + self.current = None; + } +} + +/// Test workflow state shared by discovery, run preparation and debugging. +/// +/// `S` is the result-channel handle of the debug launch. It is stopped +/// whenever the launch that owns it is cancelled, replaced or ends. +#[derive(Debug)] +pub struct JavaTestWorkflowState { + /// Run preparation (build before a test run). + pub run_preparation: OperationSlot, + /// Per-file test discovery for the Tests view. + pub discovery: OperationSlot, + debug_launch: OperationSlot, + result_server: Option, +} + +/// Something that releases a resource when stopped. +pub trait StopHandle { + /// Releases the resource. Stopping twice must be harmless. + fn stop(&mut self); +} + +impl Default for JavaTestWorkflowState { + fn default() -> Self { + Self { + run_preparation: OperationSlot::default(), + discovery: OperationSlot::default(), + debug_launch: OperationSlot::default(), + result_server: None, + } + } +} + +impl JavaTestWorkflowState { + /// Cancels any running debug launch and begins a new one. + pub fn begin_debug_launch(&mut self) -> OperationToken { + self.cancel_debug_launch(); + self.debug_launch.begin() + } + + /// Whether `token` is the debug launch that may still start a session. + pub fn is_current_debug_launch(&self, token: OperationToken) -> bool { + self.debug_launch.is_current(token) + } + + /// Cancels the debug launch and releases its result channel. + pub fn cancel_debug_launch(&mut self) { + self.debug_launch.cancel(); + self.stop_result_server(); + } + + /// Ends `token`'s launch; optionally releases the result channel. + pub fn finish_debug_launch(&mut self, token: OperationToken, stop_result_server: bool) { + if self.debug_launch.finish(token) && stop_result_server { + self.stop_result_server(); + } + } + + /// Adopts the result channel of a prepared launch, releasing any older one. + pub fn adopt_result_server(&mut self, server: S) { + self.stop_result_server(); + self.result_server = Some(server); + } + + /// Whether a result channel is held. + pub fn has_result_server(&self) -> bool { + self.result_server.is_some() + } + + /// Releases the held result channel, for example when debugging stops. + pub fn stop_result_server(&mut self) { + if let Some(mut server) = self.result_server.take() { + server.stop(); + } + } +} + +/// What the host must do after a prepared debug launch returns. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DebugLaunchStep { + /// The launch was superseded; the prepared result channel was stopped. + Discarded, + /// Start the debug session with the adopted result channel. + StartSession, +} + +/// Applies a prepared launch to the workflow state. +/// +/// A prepared launch whose operation is no longer current releases its own +/// result channel instead of replacing the current one. +pub fn adopt_prepared_launch( + state: &mut JavaTestWorkflowState, + token: OperationToken, + mut prepared_server: S, +) -> DebugLaunchStep { + if !state.is_current_debug_launch(token) { + prepared_server.stop(); + return DebugLaunchStep::Discarded; + } + state.adopt_result_server(prepared_server); + DebugLaunchStep::StartSession +} + +/// Default notification when the generic debugger refuses the session. +pub const DEBUG_START_FAILED: &str = "Could not debug the Java test"; + +/// Notification shown when saving the test's dirty source file fails. +pub fn save_failed_message(file_name: &str) -> String { + format!("Could not save {file_name}") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Map}; + use std::cell::Cell; + use std::rc::Rc; + + #[derive(Debug, Clone, Default)] + struct CountingServer(Rc>); + + impl StopHandle for CountingServer { + fn stop(&mut self) { + self.0.set(self.0.get() + 1); + } + } + + fn row(kind: LanguageTestItemKind, id: &str, path: Option<&str>) -> LanguageTestItem { + LanguageTestItem { + id: id.into(), + provider_id: "java".into(), + label: id.into(), + kind, + file_path: path.map(Into::into), + test_identifier: None, + depth: 0, + report_identity: None, + host_fields: Map::new(), + } + } + + #[test] + fn only_java_files_and_located_test_cases_can_be_debugged() { + assert_eq!( + resolve_debug_request("python", &TestScope::Workspace).unwrap_err(), + "Java test debugging is currently available for Java projects only" + ); + assert_eq!( + resolve_debug_request("java", &TestScope::Workspace).unwrap_err(), + "Select a Java test file or test case to debug" + ); + assert_eq!( + resolve_debug_request( + "java", + &TestScope::TestCase { + identifier: "A#b".into(), + path: None + } + ) + .unwrap_err(), + "The selected Java test has no source file" + ); + let file = resolve_debug_request( + "java", + &TestScope::File { + path: "/w/src/./test/../test/A.java".into(), + }, + ) + .unwrap(); + assert_eq!(file.file_path, PathBuf::from("/w/src/test/A.java")); + assert_eq!(file.test_identifier, None); + let case = resolve_debug_request( + "java", + &TestScope::TestCase { + identifier: "A#b".into(), + path: Some("/w/A.java".into()), + }, + ) + .unwrap(); + assert_eq!(case.test_identifier.as_deref(), Some("A#b")); + } + + #[test] + fn scopes_decode_from_tagged_json() { + let scope: TestScope = + serde_json::from_value(json!({"kind": "testCase", "identifier": "A#b"})).unwrap(); + assert_eq!( + scope, + TestScope::TestCase { + identifier: "A#b".into(), + path: None + } + ); + } + + #[test] + fn stopping_or_replacing_run_preparation_revokes_identical_selections() { + let mut state = JavaTestWorkflowState::::default(); + let first = state.run_preparation.begin(); + let next = state.run_preparation.begin(); + assert!(!state.run_preparation.finish(first)); + assert!(!state.run_preparation.is_current(first)); + assert!(state.run_preparation.is_current(next)); + state.run_preparation.cancel(); + assert!(!state.run_preparation.is_current(next)); + } + + #[test] + fn a_cancelled_debug_launch_cannot_adopt_its_result_server() { + let mut state = JavaTestWorkflowState::default(); + let token = state.begin_debug_launch(); + state.cancel_debug_launch(); + let stops = Rc::new(Cell::new(0)); + let step = adopt_prepared_launch(&mut state, token, CountingServer(stops.clone())); + assert_eq!(step, DebugLaunchStep::Discarded); + assert_eq!(stops.get(), 1); + assert!(!state.has_result_server()); + } + + #[test] + fn a_new_debug_launch_releases_the_previous_result_server() { + let mut state = JavaTestWorkflowState::default(); + let first = state.begin_debug_launch(); + let stops = Rc::new(Cell::new(0)); + assert_eq!( + adopt_prepared_launch(&mut state, first, CountingServer(stops.clone())), + DebugLaunchStep::StartSession + ); + state.finish_debug_launch(first, false); + assert!(state.has_result_server()); + let _second = state.begin_debug_launch(); + assert_eq!(stops.get(), 1); + assert!(!state.has_result_server()); + } + + #[test] + fn finishing_with_a_failure_releases_the_result_server_once() { + let mut state = JavaTestWorkflowState::default(); + let token = state.begin_debug_launch(); + let stops = Rc::new(Cell::new(0)); + adopt_prepared_launch(&mut state, token, CountingServer(stops.clone())); + state.finish_debug_launch(token, true); + state.finish_debug_launch(token, true); + assert_eq!(stops.get(), 1); + } + + #[test] + fn discovery_keeps_files_with_tests_or_errors_and_drops_empty_files() { + let base = vec![ + row(LanguageTestItemKind::Workspace, "workspace", None), + row(LanguageTestItemKind::File, "A", Some("/w/A.java")), + row(LanguageTestItemKind::File, "B", Some("/w/B.java")), + row(LanguageTestItemKind::File, "C", Some("/w/C.java")), + ]; + let projected = project_discovered_items( + &base, + |path| { + Ok(match path { + "/w/A.java" => FileDiscovery::Items(vec![row( + LanguageTestItemKind::TestCase, + "A#a", + Some(path), + )]), + "/w/B.java" => FileDiscovery::Items(Vec::new()), + _ => FileDiscovery::Failed, + }) + }, + || true, + ) + .unwrap(); + let ids = projected + .iter() + .map(|item| item.id.as_str()) + .collect::>(); + assert_eq!(ids, vec!["workspace", "A", "A#a", "C"]); + } + + #[test] + fn discovery_without_any_tests_is_empty() { + let base = vec![ + row(LanguageTestItemKind::Workspace, "workspace", None), + row(LanguageTestItemKind::File, "A", Some("/w/A.java")), + ]; + let projected = + project_discovered_items(&base, |_| Ok(FileDiscovery::Items(Vec::new())), || true) + .unwrap(); + assert!(projected.is_empty()); + } + + #[test] + fn a_superseded_discovery_stops() { + let base = vec![row(LanguageTestItemKind::File, "A", Some("/w/A.java"))]; + let current = Cell::new(true); + let result = project_discovered_items( + &base, + |_| { + current.set(false); + Ok(FileDiscovery::Failed) + }, + || current.get(), + ); + assert_eq!(result, Err(Superseded)); + } + + #[test] + fn rows_without_files_pass_through() { + let base = vec![row(LanguageTestItemKind::Workspace, "workspace", None)]; + let projected = + project_discovered_items(&base, |_| unreachable!("no file to discover"), || true) + .unwrap(); + assert_eq!(projected, base); + } + + #[test] + fn notifications_keep_the_host_wording() { + assert_eq!(save_failed_message("A.java"), "Could not save A.java"); + assert_eq!(DEBUG_START_FAILED, "Could not debug the Java test"); + } +} diff --git a/crates/java-domain/src/tests/java_commands.rs b/crates/java-domain/src/tests/java_commands.rs new file mode 100644 index 0000000..8739c7a --- /dev/null +++ b/crates/java-domain/src/tests/java_commands.rs @@ -0,0 +1,503 @@ +//! Command-level tests of the Java commands, ported from Rust Core's +//! `src/tests/languages.rs`. Maven commands moved to the Maven plugin and +//! `runConfig.generate` stayed in Core, so their tests are not here. + +use super::support::{execute_json, temporary_root}; +use serde_json::Value; +use std::fs; + +#[test] +fn java_launch_command_json_reuses_the_shared_argfile_planner() { + let classpath = (0..800) + .map(|index| format!("/workspace/.m2/library-{index}/library-{index}.jar")) + .collect::>() + .join(":"); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "java-launch-command", + "command": "execution.planLaunchCommand", + "payload": { + "executable": "/Library/Java/JavaVirtualMachines/jdk-21/bin/java", + "arguments": ["-cp", classpath, "com.example.Main"], + "argfilePath": "/tmp/lithe-run/launch.argfile", + "javaFeatureVersion": 21 + } + }) + .to_string(), + )) + .expect("Java launch-command response should be JSON"); + + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["kind"], "argfile"); + assert_eq!( + response["data"]["arguments"], + serde_json::json!(["@/tmp/lithe-run/launch.argfile", "com.example.Main"]) + ); + assert!( + response["data"]["argfileContents"] + .as_str() + .is_some_and(|contents| contents.starts_with("-cp\n\"/workspace/")), + "{response}" + ); +} + +#[test] +fn jdt_workspace_key_matches_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/lsp/jdt-workspace-key-v1.json" + )) + .expect("JDT workspace-key fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("JDT workspace-key fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "lsp.jdtWorkspaceKey", + "payload": { + "workspaceRoot": case["workspaceRoot"], + "workspaceFingerprint": case["workspaceFingerprint"] + } + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("JDT workspace-key response should be JSON"); + + assert_eq!(response["ok"], true, "case {}", case["name"]); + assert_eq!( + response["data"]["workspaceKey"], case["workspaceKey"], + "case {}", + case["name"] + ); + } +} + +#[test] +fn java_workspace_policy_matches_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/lsp/java-workspace-policy-v1.json" + )) + .expect("Java workspace-policy fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("Java workspace-policy fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "java.workspacePolicy", + "payload": { + "workspacePaths": case["workspacePaths"], + "changedPaths": case["changedPaths"] + } + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("Java workspace-policy response should be JSON"); + + assert_eq!(response["ok"], true, "case {}", case["name"]); + assert_eq!(response["data"], case["expected"], "case {}", case["name"]); + } +} + +#[test] +fn jdt_workspace_fingerprint_matches_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/lsp/jdt-workspace-fingerprint-v1.json" + )) + .expect("JDT workspace-fingerprint fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("JDT workspace-fingerprint fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "java.jdtWorkspaceFingerprint", + "payload": { + "buildFiles": case["buildFiles"], + "directMavenModules": case["directMavenModules"], + "gradleWrapperDistribution": case["gradleWrapperDistribution"], + "jdtlsVersion": case["jdtlsVersion"] + } + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("JDT workspace-fingerprint response should be JSON"); + + assert_eq!(response["ok"], true, "case {}", case["name"]); + assert_eq!( + response["data"]["workspaceFingerprint"], case["workspaceFingerprint"], + "case {}", + case["name"] + ); + } +} + +#[test] +fn jdt_cache_retention_matches_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/lsp/jdt-cache-retention-v1.json" + )) + .expect("JDT cache-retention fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("JDT cache-retention fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "java.jdtCacheRetention", + "payload": { + "nowUnixSeconds": case["nowUnixSeconds"], + "activeWorkspaceKey": case["activeWorkspaceKey"], + "entries": case["entries"] + } + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("JDT cache-retention response should be JSON"); + + assert_eq!(response["ok"], true, "case {}", case["name"]); + assert_eq!(response["data"], case["expected"], "case {}", case["name"]); + } +} + +#[test] +fn java_run_markers_match_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/java/run-markers-v1.json" + )) + .expect("Java Run-marker fixture should be valid JSON"); + for case in fixture["cases"] + .as_array() + .expect("Java Run-marker fixture should contain cases") + { + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": case["name"], + "command": "java.runMarkers", + "payload": case["request"] + }) + .to_string(), + )) + .expect("Java Run-marker response should be JSON"); + assert_eq!(response["ok"], true, "case {}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "case {}", case["name"]); + } +} + +#[test] +fn java_core_commands_return_shared_runtime_and_structure_data() { + let root = temporary_root("java"); + fs::create_dir_all(root.join("src/main/java/com/example")) + .expect("Java source should be creatable"); + fs::write( + root.join("src/main/java/com/example/App.java"), + "package com.example;\n@SpringBootApplication\nclass App {\n static void main(String[] args) {}\n}\n", + ) + .expect("Java source should be writable"); + + let structure = serde_json::json!({ + "id": "java-structure", + "command": "java.structure", + "payload": { + "source": "import a.A;\nimport b.B;\ninterface Service { String call(String value); }\n" + } + }); + let structure_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&structure).expect("Java structure request should encode"), + )) + .expect("Java structure response should be JSON"); + assert_eq!(structure_response["ok"], true); + assert_eq!( + structure_response["data"]["foldRegions"][0]["kind"], + "imports" + ); + assert!(structure_response["data"].get("testMethods").is_none()); + assert!(structure_response["data"] + .get("implementationMarkers") + .is_none()); + let syntax_highlights = structure_response["data"]["syntaxHighlights"] + .as_array() + .expect("Java structure should return syntax highlights"); + assert!(syntax_highlights + .iter() + .any(|highlight| highlight["role"] == "keyword")); + for role in ["functionDeclaration", "parameter", "punctuation", "type"] { + assert!( + syntax_highlights + .iter() + .any(|highlight| highlight["role"] == role), + "Java structure should include the {role} role" + ); + } + assert!(syntax_highlights.iter().all(|highlight| { + highlight["utf16Start"].as_u64().is_some() + && highlight["utf16Length"] + .as_u64() + .is_some_and(|length| length > 0) + })); + let swift_structure = serde_json::json!({ + "id": "swift-structure", + "command": "java.structure", + "payload": { + "source": "struct Demo {\n func run() {\n if ready {\n work()\n }\n }\n}\n" + } + }); + let swift_structure_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&swift_structure).expect("Swift structure request should encode"), + )) + .expect("Swift structure response should be JSON"); + let swift_folds = swift_structure_response["data"]["foldRegions"] + .as_array() + .expect("Swift structure should return fold regions"); + assert!(swift_folds + .iter() + .any(|fold| { fold["startLine"] == 0 && fold["endLine"] == 6 && fold["kind"] == "type" })); + assert!(swift_folds.iter().any(|fold| { + fold["startLine"] == 1 && fold["endLine"] == 5 && fold["kind"] == "method" + })); + let code_vision = serde_json::json!({ + "id": "java-vision", + "command": "java.codeVision", + "payload": { + "root": root, + "targetPath": "src/main/java/com/example/App.java", + "paths": ["src/main/java/com/example/App.java"] + } + }); + let vision_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&code_vision).expect("code vision request should encode"), + )) + .expect("code vision response should be JSON"); + assert_eq!(vision_response["ok"], true); + assert!(vision_response["data"]["hints"] + .as_array() + .unwrap() + .iter() + .any(|hint| hint["symbol"] == "App")); + let class_name = serde_json::json!({ + "id": "java-class", + "command": "java.className", + "payload": {"source": "package com.example;\nclass App {}", "simpleName": "App"} + }); + let class_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&class_name).expect("class name request should encode"), + )) + .expect("class name response should be JSON"); + assert_eq!(class_response["data"]["className"], "com.example.App"); + let definition = serde_json::json!({ + "id": "java-definition", + "command": "java.sourceDefinition", + "payload": { + "source": "class App {\n void run() {}\n}", + "declarationName": "App", + "memberName": "run" + } + }); + let definition_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&definition).expect("definition request should encode"), + )) + .expect("definition response should be JSON"); + assert_eq!(definition_response["data"]["line"], 1); + let member_lookup = |member: &str, declarations_only: bool| -> Value { + let request = serde_json::json!({ + "id": "java-member", + "command": "java.sourceDefinition", + "payload": { + "source": "class App {\n void run() { save(); total = 1; }\n String 名字, total;\n void save() {}\n}", + "declarationName": "App", + "memberName": member, + "declarationsOnly": declarations_only + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("member request should encode"), + )) + .expect("member response should be JSON"); + response["data"].clone() + }; + // The call and the assignment on line 1 come first but declare nothing. + assert_eq!(member_lookup("save", true)["line"], 3); + assert_eq!(member_lookup("total", true)["line"], 2); + assert_eq!(member_lookup("total", true)["utf16Column"], 15); + assert!(member_lookup("missing", true).is_null()); + assert_eq!(member_lookup("missing", false)["line"], 0); + let server_port = serde_json::json!({ + "id": "java-port", + "command": "java.serverPort", + "payload": {"content": "server:\n port: 8080\n", "fileExtension": "yml"} + }); + let port_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&server_port).expect("server port request should encode"), + )) + .expect("server port response should be JSON"); + assert_eq!(port_response["data"]["port"], 8080); + fs::remove_dir_all(root).expect("Java fixture should be removable"); +} + +/// Resolves the member at the first occurrence of `marker` in `source`, the +/// marker being the text up to and including the start of the name. +fn referenced_member_line(source: &str, marker: &str, member: &str) -> Value { + let offset = source.find(marker).expect("marker should exist") + marker.len() - member.len(); + let before = &source[..offset]; + let line = before.matches('\n').count(); + let column = before[before.rfind('\n').map_or(0, |index| index + 1)..] + .encode_utf16() + .count(); + let request = serde_json::json!({ + "id": "java-reference", + "command": "java.sourceDefinition", + "payload": { + "source": source, + "declarationName": "Main", + "memberName": member, + "declarationsOnly": true, + "reference": { "line": line, "utf16Column": column } + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("reference request should encode"), + )) + .expect("reference response should be JSON"); + assert_eq!(response["ok"], true, "{response}"); + response["data"]["line"].clone() +} + +#[test] +fn java_member_definition_stays_inside_the_named_type() { + let lookup = |declaration: &str| -> Value { + let request = serde_json::json!({ + "id": "java-member", + "command": "java.sourceDefinition", + "payload": { + "source": "class Main {\n class Nested {\n void save() {}\n }\n void save() {}\n}\nclass Other {\n void only() {}\n}", + "declarationName": declaration, + "memberName": "save", + "declarationsOnly": true + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("member request should encode"), + )) + .expect("member response should be JSON"); + response["data"]["line"].clone() + }; + assert_eq!(lookup("Main"), 4); + assert_eq!(lookup("Nested"), 2); + assert!(lookup("Other").is_null()); + assert!(lookup("Missing").is_null()); +} + +#[test] +fn java_member_reference_binds_to_the_type_it_is_used_in() { + let source = "class Main {\n class Nested {\n void save() {}\n void use() { save(); }\n }\n void save() {}\n void run() { this.save(); }\n}"; + assert_eq!(referenced_member_line(source, "use() { save", "save"), 2); + assert_eq!(referenced_member_line(source, "this.save", "save"), 5); + + let outer = "class Main {\n void save() {}\n class Nested {\n void use() { save(); this.save(); }\n }\n}"; + assert_eq!(referenced_member_line(outer, "use() { save", "save"), 1); + assert!(referenced_member_line(outer, "this.save", "save").is_null()); +} + +#[test] +fn java_member_reference_keeps_methods_and_fields_apart() { + let source = "class Main {\n int save;\n void save() {}\n void run() { save(); int x = this.save; }\n}"; + assert_eq!(referenced_member_line(source, "run() { save", "save"), 2); + assert_eq!(referenced_member_line(source, "this.save", "save"), 1); +} + +#[test] +fn java_member_reference_answers_nothing_syntax_cannot_prove() { + let unknown_receiver = + "class Main {\n void save() {}\n void run(Main other) { other.save(); }\n}"; + assert!(referenced_member_line(unknown_receiver, "other.save", "save").is_null()); + let method_reference = "class Main {\n void save() {}\n Runnable run = this::save;\n}"; + assert!(referenced_member_line(method_reference, "this::save", "save").is_null()); + + // `Base` may declare `save`, and it would win over `Main.save`. + let inherited = "class Main {\n void save() {}\n class Nested extends Base {\n void use() { save(); }\n }\n}"; + assert!(referenced_member_line(inherited, "use() { save", "save").is_null()); + let anonymous = "class Main {\n void save() {}\n Object task = new Object() {\n void use() { save(); }\n };\n}"; + assert!(referenced_member_line(anonymous, "use() { save", "save").is_null()); + + for (shadowed, marker) in [ + ("class Main {\n int value;\n int run() { int value = 1; return value; }\n}", "return value"), + ("class Main {\n int value;\n int run(int value) { return value; }\n}", "return value"), + ("class Main {\n int value;\n void run() { for (int value : items) { use(value); } }\n}", "use(value"), + ("class Main {\n int value;\n void run() { try {} catch (Exception value) { use(value); } }\n}", "use(value"), + ("class Main {\n int value;\n Fn run = value -> use(value);\n}", "use(value"), + ("class Main {\n int value;\n Fn run = (value, other) -> use(value);\n}", "use(value"), + ("class Main {\n int value;\n void run(Object o) { if (o instanceof String value) { use(value); } }\n}", "use(value"), + ] { + assert!( + referenced_member_line(shadowed, marker, "value").is_null(), + "{shadowed}" + ); + } + // `this.` skips the local, and a local in a nested type's method does not count. + let qualified = "class Main {\n int value;\n int run(int value) { return this.value; }\n}"; + assert_eq!(referenced_member_line(qualified, "this.value", "value"), 1); + let plain = "class Main {\n int value;\n int run() { return value; }\n}"; + assert_eq!(referenced_member_line(plain, "return value", "value"), 1); +} + +#[test] +fn java_member_reference_covers_enum_and_record_scopes() { + let enumeration = "enum Main {\n ONE;\n int total;\n int run() { return total; }\n}"; + assert_eq!( + referenced_member_line(enumeration, "return total", "total"), + 2 + ); + // A record component is a field, so the outer field must not answer. + let record = + "class Main {\n int x;\n record Point(int x) {\n int run() { return x; }\n }\n}"; + assert_eq!(referenced_member_line(record, "return x", "x"), 2); +} + +#[test] +fn java_member_reference_ignores_a_position_outside_the_source() { + let source = "class Main {\n void save() {}\n void run() { save(); }\n}"; + let request = serde_json::json!({ + "id": "java-reference", + "command": "java.sourceDefinition", + "payload": { + "source": source, + "declarationName": "Main", + "memberName": "save", + "declarationsOnly": true, + "reference": { "line": 9, "utf16Column": 0 } + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("reference request should encode"), + )) + .expect("reference response should be JSON"); + assert!(response["data"].is_null()); +} + +#[test] +fn java_test_debug_launch_matches_the_shared_contract_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/debug/java-test-launch-v1.json" + )) + .expect("Java test debug fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("Java test debug fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "debug.javaTestLaunch", + "payload": case["payload"] + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("Java test debug response should be JSON"); + + assert_eq!(response["ok"], true, "fixture case {}", case["name"]); + assert_eq!( + response["data"], case["expected"], + "fixture case {}", + case["name"] + ); + } +} diff --git a/crates/java-domain/src/tests/java_selection.rs b/crates/java-domain/src/tests/java_selection.rs new file mode 100644 index 0000000..6bef4e4 --- /dev/null +++ b/crates/java-domain/src/tests/java_selection.rs @@ -0,0 +1,93 @@ +//! Command-level tests of `runConfig.selectJava`, ported from Rust Core's +//! `src/tests/run_configuration.rs`. +//! +//! Core also read the requirements of a workspace root from its generated +//! run-configuration layer. The plugin cannot read that layer, so the cases +//! run through the requirements snapshot the host passes, and a workspace +//! root is checked to fail clearly instead of silently ignoring requirements. + +use super::support::{execute_json, temporary_root}; +use serde_json::{json, Value}; +use std::fs; + +fn select(payload: Value) -> Value { + serde_json::from_str(&execute_json( + &json!({"id": "selection", "command": "runConfig.selectJava", "payload": payload}) + .to_string(), + )) + .expect("selection response should be JSON") +} + +#[test] +fn automatic_java_selection_uses_shared_fixture_and_current_requirements() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/run-configuration/automatic-java-selection.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let document = json!({"version":1,"toolchains":{"project-jdk":{ + "type":"java", "minimumVersion":case["minimum"] + }}}); + let expected = &fixture["expected"][case["name"].as_str().unwrap()]; + // Reverse enumeration order too: tie-breaking is not filesystem order. + for reverse in [false, true] { + let mut candidates = case["candidates"].as_array().unwrap().clone(); + if reverse { + candidates.reverse(); + } + let response = select(json!({ + "requirements": document, + "candidates": candidates, + "fallbackId": case["fallbackId"] + })); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["id"], expected["id"], "{}", case["name"]); + assert_eq!( + !response["data"]["warning"].is_null(), + expected["warning"].as_bool().unwrap(), + "{}", + case["name"] + ); + } + } +} + +#[test] +fn selection_without_requirements_keeps_the_fallback() { + let response = select(json!({ + "candidates": [ + {"id":"env","version":"1.8.0_402","priority":0}, + {"id":"jdk17","version":"17.0.10","priority":1} + ], + "fallbackId": "env" + })); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["id"], "env"); + assert!(response["data"]["warning"].is_null()); +} + +#[test] +fn a_workspace_root_needs_the_host_requirements_channel() { + let root = temporary_root("java-selection-root"); + fs::create_dir_all(&root).unwrap(); + let response = select(json!({ + "root": root, + "candidates": [{"id":"env","version":"17.0.10","priority":0}], + "fallbackId": "env" + })); + fs::remove_dir_all(&root).unwrap(); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(response["error"]["code"], "not_supported"); +} + +#[test] +fn java_selection_rejects_ambiguous_or_invalid_host_snapshots() { + for payload in [ + json!({"root":"/unused","requirements":{"version":1,"toolchains":{}},"candidates":[]}), + json!({"requirements":{"version":999,"toolchains":{}},"candidates":[]}), + json!({"requirements":{"toolchains":{}},"candidates":[]}), + ] { + let response = select(payload); + assert_eq!(response["ok"], false, "{response}"); + } +} diff --git a/crates/java-domain/src/tests/mod.rs b/crates/java-domain/src/tests/mod.rs new file mode 100644 index 0000000..e0c606e --- /dev/null +++ b/crates/java-domain/src/tests/mod.rs @@ -0,0 +1,10 @@ +//! Command-level tests ported from Rust Core's `src/tests/` modules. +//! +//! They live inside the crate, as in Core, so they can reach test-only hooks +//! such as [`crate::languages::take_mybatis_disk_reads`]. + +mod java_commands; +mod java_selection; +mod mybatis; +mod spring; +mod support; diff --git a/crates/java-domain/src/tests/mybatis.rs b/crates/java-domain/src/tests/mybatis.rs new file mode 100644 index 0000000..5a010a9 --- /dev/null +++ b/crates/java-domain/src/tests/mybatis.rs @@ -0,0 +1,498 @@ +use super::support::{execute_json, temporary_root}; +use serde_json::Value; +use std::fs; + +#[test] +fn mybatis_index_links_mapper_methods_to_xml_statements() { + let root = temporary_root("mybatis-index"); + let java = root.join("src/main/java/com/example/mapper"); + let resources = root.join("src/main/resources/mapper"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::create_dir_all(&resources).expect("XML fixture directory should be creatable"); + fs::write( + java.join("UserMapper.java"), + r#"package com.example.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface UserMapper { + User selectById(@Param("id") Long id); + + int insert(User user); + + @Select("SELECT * FROM users WHERE name = #{name}") + User selectByName(String name); + + default User findOrEmpty(Long id) { + User user = selectById(id); + return user == null ? new User() : user; + } + + void updateById( + @Param("id") Long id, + @Param("name") String name + ); +} +"#, + ) + .expect("mapper interface fixture should be writable"); + fs::write( + resources.join("UserMapper.xml"), + r#" + + + + + + INSERT INTO users(name) VALUES(#{name}) + + + UPDATE users SET name = #{name} WHERE id = #{id} + + +"#, + ) + .expect("mapper XML fixture should be writable"); + + let response = execute_mybatis( + &root, + &[ + "src/main/java/com/example/mapper/UserMapper.java", + "src/main/resources/mapper/UserMapper.xml", + ], + serde_json::json!({}), + ); + + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"] + .as_array() + .expect("statements should be an array"); + let ids = statements + .iter() + .map(|value| value["statementId"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!(ids, vec!["insert", "selectById", "updateById"]); + assert!( + statements + .iter() + .all(|value| value["namespace"] == "com.example.mapper.UserMapper"), + "{statements:?}" + ); + let select = statements + .iter() + .find(|value| value["statementId"] == "selectById") + .expect("selectById should be indexed"); + assert_eq!(select["kind"], "select"); + assert_eq!( + select["javaPath"], + "src/main/java/com/example/mapper/UserMapper.java" + ); + assert_eq!(select["javaLine"], 9); + assert_eq!(select["javaColumn"], 10); + assert_eq!(select["javaEndColumn"], 20); + assert_eq!(select["javaEndLine"], 9); + assert_eq!( + select["xmlPath"], + "src/main/resources/mapper/UserMapper.xml" + ); + assert_eq!(select["xmlLine"], 5); + assert_eq!(select["xmlColumn"], 17); + assert_eq!(select["xmlEndColumn"], 27); + let insert = statements + .iter() + .find(|value| value["statementId"] == "insert") + .expect("insert should be indexed"); + assert_eq!(insert["xmlLine"], 9); + let update = statements + .iter() + .find(|value| value["statementId"] == "updateById") + .expect("updateById should be indexed"); + assert_eq!(update["javaLine"], 21); + assert_eq!(update["javaEndColumn"], 20); + assert_eq!(update["javaEndLine"], 24); + + fs::remove_dir_all(root).expect("MyBatis fixture should be removable"); +} + +/// Unsaved XML edits must win over disk so a mapper id rename can navigate +/// before the buffer is written. +#[test] +fn mybatis_index_uses_text_overrides_before_disk() { + let root = temporary_root("mybatis-overrides"); + let java = root.join("src/main/java"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("OrderMapper.java"), + "package demo;\npublic interface OrderMapper {\n Order find();\n}\n", + ) + .expect("mapper interface fixture should be writable"); + fs::write( + root.join("OrderMapper.xml"), + r#""#, + ) + .expect("stale XML fixture should be writable"); + + let response = execute_mybatis( + &root, + &["src/main/java/OrderMapper.java", "OrderMapper.xml"], + serde_json::json!({ + "textOverrides": { + "OrderMapper.xml": "" + } + }), + ); + + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"] + .as_array() + .expect("statements should be an array"); + assert_eq!(statements.len(), 1, "{statements:?}"); + assert_eq!(statements[0]["statementId"], "find"); + + fs::remove_dir_all(root).expect("MyBatis fixture should be removable"); +} + +#[test] +fn mybatis_index_pairs_nested_generic_and_split_signatures() { + let root = temporary_root("mybatis-nested-generic"); + fs::create_dir_all(&root).expect("MyBatis fixture directory should be creatable"); + fs::write( + root.join("UserMapper.java"), + r#"package demo; +public interface UserMapper { + List> find(); + + List> + splitFind(); +} +"#, + ) + .expect("mapper interface fixture should be writable"); + fs::write( + root.join("UserMapper.xml"), + r#" + + + +"#, + ) + .expect("mapper XML fixture should be writable"); + + let response = execute_mybatis( + &root, + &["UserMapper.java", "UserMapper.xml"], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"] + .as_array() + .expect("statements should be an array"); + let find = statements + .iter() + .find(|value| value["statementId"] == "find") + .expect("nested generic find should be indexed"); + assert_eq!(find["javaLine"], 3); + assert_eq!(find["javaColumn"], 31); + assert_eq!(find["javaEndColumn"], 35); + let split = statements + .iter() + .find(|value| value["statementId"] == "splitFind") + .expect("split signature should be indexed"); + assert_eq!(split["javaLine"], 6); + assert_eq!(split["javaColumn"], 5); + assert_eq!(split["javaEndColumn"], 14); + + fs::remove_dir_all(root).expect("MyBatis fixture should be removable"); +} + +/// Commented-out signatures must not steal the real method location, including +/// when the comment contains braces that a line scanner would treat as scope. +#[test] +fn mybatis_index_ignores_methods_inside_comments() { + let root = temporary_root("mybatis-commented-method"); + fs::create_dir_all(&root).expect("MyBatis fixture directory should be creatable"); + fs::write( + root.join("UserMapper.java"), + [ + "package demo;\npublic interface UserMapper {\n ", + "/", + "*\n User find();\n *", + "/\n User find();\n\n // {\n User listed();\n}\n", + ] + .concat(), + ) + .expect("mapper interface fixture should be writable"); + fs::write( + root.join("UserMapper.xml"), + r#" + + + +"#, + ) + .expect("mapper XML fixture should be writable"); + + let response = execute_mybatis( + &root, + &["UserMapper.java", "UserMapper.xml"], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"] + .as_array() + .expect("statements should be an array"); + let find = statements + .iter() + .find(|value| value["statementId"] == "find") + .expect("real find should be indexed"); + assert_eq!(find["javaLine"], 6); + let listed = statements + .iter() + .find(|value| value["statementId"] == "listed") + .expect("method after a brace comment should be indexed"); + assert_eq!(listed["javaLine"], 9); + + fs::remove_dir_all(root).expect("MyBatis fixture should be removable"); +} + +#[test] +fn mybatis_index_skips_unrelated_and_oversized_files_before_reading() { + let _ = crate::languages::take_mybatis_disk_reads(); + let root = temporary_root("mybatis-skip-reads"); + fs::create_dir_all(&root).expect("MyBatis fixture directory should be creatable"); + fs::write( + root.join("UserMapper.java"), + "package demo;\npublic interface UserMapper {\n User find();\n}\n", + ) + .expect("mapper interface fixture should be writable"); + fs::write( + root.join("UserMapper.xml"), + r#""#, + ) + .expect("mapper XML fixture should be writable"); + fs::write(root.join("dump.sql"), "SELECT 1;\n".repeat(8 * 1024)) + .expect("unrelated SQL dump should be writable"); + fs::write(root.join("pom.xml"), "").expect("pom should be writable"); + let oversized = vec![b'a'; 2 * 1024 * 1024 + 1]; + fs::write(root.join("Huge.java"), oversized).expect("oversized Java file should be writable"); + + let response = execute_mybatis( + &root, + &[ + "dump.sql", + "pom.xml", + "Huge.java", + "UserMapper.java", + "UserMapper.xml", + ], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"] + .as_array() + .expect("statements should be an array"); + assert_eq!(statements.len(), 1, "{statements:?}"); + assert_eq!(statements[0]["statementId"], "find"); + + let mut reads = crate::languages::take_mybatis_disk_reads(); + reads.sort(); + assert_eq!( + reads, + vec!["UserMapper.java".to_string(), "UserMapper.xml".to_string()], + "{reads:?}" + ); + + fs::remove_dir_all(root).expect("MyBatis fixture should be removable"); +} + +/// Only Java files a mapper namespace names are read and parsed: services and +/// controllers stay untouched however many the workspace has. +#[test] +fn mybatis_index_reads_only_java_files_named_by_a_mapper_namespace() { + let _ = crate::languages::take_mybatis_disk_reads(); + let root = temporary_root("mybatis-namespace-reads"); + fs::create_dir_all(root.join("demo")).expect("MyBatis fixture directory should be creatable"); + fs::write( + root.join("demo/UserMapper.java"), + "package demo;\npublic interface UserMapper {\n User find();\n}\n", + ) + .expect("mapper interface fixture should be writable"); + fs::write( + root.join("demo/Outer.java"), + "package demo;\npublic class Outer {\n public interface Inner {\n User nested();\n }\n}\n", + ) + .expect("nested mapper fixture should be writable"); + fs::write( + root.join("demo/UserService.java"), + "package demo;\npublic interface UserService {\n User find();\n}\n", + ) + .expect("unrelated service fixture should be writable"); + fs::write( + root.join("UserMapper.xml"), + r#""#, + ) + .expect("mapper XML fixture should be writable"); + fs::write( + root.join("Inner.xml"), + r#""#, + ) + .expect("nested mapper XML fixture should be writable"); + + let response = execute_mybatis( + &root, + &[ + "demo/UserService.java", + "demo/UserMapper.java", + "demo/Outer.java", + "UserMapper.xml", + "Inner.xml", + ], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"] + .as_array() + .expect("statements should be an array"); + let java_paths = statements + .iter() + .map(|statement| statement["javaPath"].as_str().unwrap()) + .collect::>(); + assert_eq!(java_paths, vec!["demo/Outer.java", "demo/UserMapper.java"]); + + let mut reads = crate::languages::take_mybatis_disk_reads(); + reads.sort(); + assert_eq!( + reads, + vec![ + "Inner.xml".to_string(), + "UserMapper.xml".to_string(), + "demo/Outer.java".to_string(), + "demo/UserMapper.java".to_string(), + ], + "{reads:?}" + ); + + fs::remove_dir_all(root).expect("MyBatis fixture should be removable"); +} + +#[test] +fn mybatis_index_rejects_a_relative_root() { + let response = execute_json( + r#"{"id":"mybatis","command":"mybatis.index","payload":{"root":"relative","paths":[]}}"#, + ); + let parsed: Value = serde_json::from_str(&response).expect("response should be JSON"); + assert_eq!(parsed["ok"], false, "{parsed}"); + assert_eq!(parsed["error"]["code"], "invalid_request"); +} + +#[test] +fn mybatis_index_handles_deep_expressions_in_unrelated_java() { + // A small Java file can produce thousands of left-associative AST nodes, + // even when the mapper itself is shallow and the source is below 2 MiB. + let deep_java = format!( + "package demo; class Calculation {{ int value() {{ return 1{}; }} }}", + " + 1".repeat(8192) + ); + let response = execute_mybatis( + &std::env::temp_dir(), + &["Calculation.java", "UserMapper.java", "UserMapper.xml"], + serde_json::json!({ + "textOverrides": { + "Calculation.java": deep_java, + "UserMapper.java": "package demo;\npublic interface UserMapper {\n User find();\n}\n", + "UserMapper.xml": "" + } + }), + ); + + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"].as_array().unwrap(); + assert_eq!(statements.len(), 1, "{statements:?}"); + assert_eq!(statements[0]["namespace"], "demo.UserMapper"); + assert_eq!(statements[0]["statementId"], "find"); + assert_eq!(statements[0]["javaPath"], "UserMapper.java"); + assert_eq!(statements[0]["javaLine"], 3); + assert_eq!(statements[0]["javaColumn"], 10); + assert_eq!(statements[0]["javaEndColumn"], 14); +} + +#[test] +fn mybatis_index_finds_mapper_below_deeply_nested_blocks() { + // The mapped declaration is below the deep subtree, so truncating traversal + // or skipping the Java file would lose a statement and fail this regression. + let deep_java = format!( + "package demo;\nclass Container {{ void declarations() {{ {}\nabstract class DeepMapper {{\n abstract java.util.List find();\n}}\n{} }} }}", + "{".repeat(4096), + "}".repeat(4096) + ); + let response = execute_mybatis( + &std::env::temp_dir(), + &["Container.java", "DeepMapper.xml"], + serde_json::json!({ + "textOverrides": { + "Container.java": deep_java, + "DeepMapper.xml": "" + } + }), + ); + + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"].as_array().unwrap(); + assert_eq!(statements.len(), 1, "{statements:?}"); + assert_eq!(statements[0]["namespace"], "demo.Container.DeepMapper"); + assert_eq!(statements[0]["statementId"], "find"); + assert_eq!(statements[0]["javaPath"], "Container.java"); + assert_eq!(statements[0]["javaLine"], 4); + assert_eq!(statements[0]["javaColumn"], 37); + assert_eq!(statements[0]["javaEndColumn"], 41); + assert_eq!(statements[0]["javaEndLine"], 4); +} + +#[test] +fn mybatis_index_preserves_source_order_for_nested_types_with_same_name() { + // Local classes in separate method scopes share the index's qualified name. + // Pairing must keep choosing the first declaration in source order. + let response = execute_mybatis( + &std::env::temp_dir(), + &["Container.java", "Mapper.xml"], + serde_json::json!({ + "textOverrides": { + "Container.java": "package demo;\nclass Container {\n void first() {\n abstract class Mapper {\n abstract java.util.List find();\n }\n }\n void second() {\n abstract class Mapper {\n abstract java.util.List find();\n }\n }\n}\n", + "Mapper.xml": "" + } + }), + ); + + assert_eq!(response["ok"], true, "{response}"); + let statements = response["data"]["statements"].as_array().unwrap(); + assert_eq!(statements.len(), 1, "{statements:?}"); + assert_eq!(statements[0]["namespace"], "demo.Container.Mapper"); + assert_eq!(statements[0]["statementId"], "find"); + assert_eq!(statements[0]["javaPath"], "Container.java"); + assert_eq!(statements[0]["javaLine"], 5); + assert_eq!(statements[0]["javaColumn"], 40); + assert_eq!(statements[0]["javaEndColumn"], 44); + assert_eq!(statements[0]["javaEndLine"], 5); +} + +fn execute_mybatis(root: &std::path::Path, paths: &[&str], extra: Value) -> Value { + let mut payload = serde_json::json!({"root": root, "paths": paths}); + payload + .as_object_mut() + .unwrap() + .extend(extra.as_object().cloned().unwrap_or_default()); + let request = serde_json::json!({ + "id": "mybatis", + "command": "mybatis.index", + "payload": payload + }); + serde_json::from_str(&execute_json(&request.to_string())) + .expect("MyBatis response should be JSON") +} diff --git a/crates/java-domain/src/tests/spring.rs b/crates/java-domain/src/tests/spring.rs new file mode 100644 index 0000000..84b4687 --- /dev/null +++ b/crates/java-domain/src/tests/spring.rs @@ -0,0 +1,602 @@ +use super::support::{execute_json, temporary_root}; +use serde_json::Value; +use std::fs::{self, File}; +use std::io::Write; + +#[test] +fn spring_index_links_configuration_profiles_beans_and_endpoints() { + let root = temporary_root("spring-index"); + let java = root.join("src/main/java/demo"); + let resources = root.join("src/main/resources"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::create_dir_all(resources.join("META-INF")) + .expect("resource fixture directory should be creatable"); + fs::write( + java.join("DemoProperties.java"), + r#"package demo; +@ConfigurationProperties(prefix = "demo") +public class DemoProperties { + private boolean enabled; + private int retryCount; + private Security security; + public static class Security { + private java.time.Duration timeout; + } +} +"#, + ) + .expect("configuration properties fixture should be writable"); + fs::write( + java.join("RecordProperties.java"), + r#"package demo; +@ConfigurationProperties(prefix = "recorded") +public record RecordProperties(boolean enabled, int retryCount) {} +"#, + ) + .expect("record configuration properties fixture should be writable"); + fs::write( + java.join("GreetingService.java"), + "package demo;\n@Service\npublic class GreetingService {}\n", + ) + .expect("service fixture should be writable"); + fs::write( + java.join("GreetingController.java"), + r#"package demo; +@RestController +@RequestMapping("/api") +public class GreetingController { + @Autowired + private GreetingService service; + @GetMapping("/greet") + public String greet() { return "hi"; } +} +"#, + ) + .expect("controller fixture should be writable"); + fs::write( + resources.join("application.yml"), + "demo:\n enabled: true\n retry-count: 3\n", + ) + .expect("base configuration fixture should be writable"); + fs::write( + resources.join("application-dev.yml"), + "demo:\n retry-count: nope\n", + ) + .expect("profile configuration fixture should be writable"); + fs::write( + resources.join("META-INF/spring-configuration-metadata.json"), + r#"{"properties":[{"name":"demo.title","type":"java.lang.String","description":"Display title."}]}"#, + ) + .expect("metadata fixture should be writable"); + + let paths = [ + "src/main/java/demo/DemoProperties.java", + "src/main/java/demo/RecordProperties.java", + "src/main/java/demo/GreetingService.java", + "src/main/java/demo/GreetingController.java", + "src/main/resources/application.yml", + "src/main/resources/application-dev.yml", + "src/main/resources/META-INF/spring-configuration-metadata.json", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + + assert_eq!(response["ok"], true, "{response}"); + let properties = response["data"]["properties"].as_array().unwrap(); + assert!(properties + .iter() + .any(|value| value["name"] == "demo.retry-count")); + assert!(properties.iter().any(|value| value["name"] == "demo.title")); + assert!(properties + .iter() + .any(|value| value["name"] == "demo.security.timeout")); + assert!(properties + .iter() + .any(|value| value["name"] == "recorded.retry-count")); + let profile_value = response["data"]["values"] + .as_array() + .unwrap() + .iter() + .find(|value| value["path"].as_str().unwrap().contains("application-dev")) + .unwrap(); + assert_eq!(profile_value["profile"], "dev"); + assert_eq!(profile_value["overridesBaseValue"], true); + assert!(profile_value["targetPath"] + .as_str() + .unwrap() + .ends_with("DemoProperties.java")); + assert!(response["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["severity"] == "error")); + assert!(response["data"]["beans"] + .as_array() + .unwrap() + .iter() + .any(|value| value["typeName"] == "GreetingService")); + assert!( + response["data"]["injections"][0]["beanIds"] + .as_array() + .unwrap() + .len() + == 1, + "{response}" + ); + assert_eq!(response["data"]["endpoints"][0]["route"], "/api/greet"); + assert_eq!(response["data"]["endpoints"][0]["httpMethods"][0], "GET"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +#[test] +fn spring_index_resolves_qualifiers_primary_interfaces_and_constructors() { + let root = temporary_root("spring-injection"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("PaymentService.java"), + "package demo;\npublic interface PaymentService {}\n", + ) + .expect("interface fixture should be writable"); + fs::write( + java.join("StripePaymentService.java"), + r#"package demo; +@Service("stripe") +public class StripePaymentService implements PaymentService {} +"#, + ) + .expect("qualified service fixture should be writable"); + fs::write( + java.join("PaypalPaymentService.java"), + r#"package demo; +@Service +@Primary +public class PaypalPaymentService implements PaymentService {} +"#, + ) + .expect("primary service fixture should be writable"); + fs::write( + java.join("CheckoutController.java"), + r#"package demo; +@RestController +public class CheckoutController { + private final PaymentService paymentService; + public CheckoutController(@Qualifier("stripe") PaymentService paymentService) { + this.paymentService = paymentService; + } +} +"#, + ) + .expect("qualified constructor fixture should be writable"); + fs::write( + java.join("ReportController.java"), + r#"package demo; +@Controller +public class ReportController { + public ReportController(PaymentService paymentService) {} +} +"#, + ) + .expect("primary constructor fixture should be writable"); + + let paths = [ + "src/main/java/demo/PaymentService.java", + "src/main/java/demo/StripePaymentService.java", + "src/main/java/demo/PaypalPaymentService.java", + "src/main/java/demo/CheckoutController.java", + "src/main/java/demo/ReportController.java", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + assert_eq!(response["ok"], true, "{response}"); + let injections = response["data"]["injections"].as_array().unwrap(); + let qualified = injections + .iter() + .find(|value| { + value["path"] + .as_str() + .unwrap() + .contains("CheckoutController") + }) + .unwrap_or_else(|| panic!("missing qualified injection: {response}")); + assert_eq!(qualified["qualifier"], "stripe"); + assert_eq!(qualified["beanIds"].as_array().unwrap().len(), 1); + assert!(qualified["beanIds"][0].as_str().unwrap().contains("stripe")); + let primary = injections + .iter() + .find(|value| value["path"].as_str().unwrap().contains("ReportController")) + .unwrap(); + assert_eq!(primary["beanIds"].as_array().unwrap().len(), 1); + assert!(primary["beanIds"][0] + .as_str() + .unwrap() + .contains("paypalPaymentService")); + assert!(response["data"]["diagnostics"] + .as_array() + .unwrap() + .is_empty()); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +#[test] +fn spring_index_links_value_references_profiles_and_mapping_variants() { + let root = temporary_root("spring-web-config"); + let java = root.join("src/main/java/demo"); + let resources = root.join("src/main/resources/META-INF"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::create_dir_all(&resources).expect("resource fixture directory should be creatable"); + fs::write( + java.join("ApiController.java"), + r#"package demo; +@RestController +@RequestMapping(path = {"/api", "/v2"}) +public class ApiController { + @Value("${demo.retryCount:3}") + private int retryCount; + @GetMapping(path = {"/a", "/b"}) + public String get() { return "ok"; } + @RequestMapping(path = "/multi", method = {RequestMethod.GET, RequestMethod.POST}) + public String multi() { return "ok"; } +} +"#, + ) + .expect("controller fixture should be writable"); + fs::write( + root.join("src/main/resources/application.yml"), + "demo:\n retryCount: 2\n---\ndemo:\n retry-count:\n - 3\nspring:\n config:\n activate:\n on-profile: dev\n", + ) + .expect("multi-document YAML fixture should be writable"); + fs::write( + resources.join("spring-configuration-metadata.json"), + r#"{"properties":[{"name":"demo.retry-count","type":"java.lang.Integer"}]}"#, + ) + .expect("metadata fixture should be writable"); + + let paths = [ + "src/main/java/demo/ApiController.java", + "src/main/resources/application.yml", + "src/main/resources/META-INF/spring-configuration-metadata.json", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["propertyReferences"][0]["key"], + "demo.retry-count" + ); + let values = response["data"]["values"].as_array().unwrap(); + assert!(values + .iter() + .any(|value| { value["key"] == "demo.retry-count" && value["profile"] == "dev" })); + assert!(!response["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["message"].as_str().unwrap().contains("Unknown"))); + let endpoints = response["data"]["endpoints"].as_array().unwrap(); + assert_eq!( + endpoints + .iter() + .filter(|value| value["route"].as_str().unwrap().ends_with("/a")) + .count(), + 2 + ); + assert!(endpoints.iter().any(|value| { + value["route"] == "/api/multi" && value["httpMethods"] == serde_json::json!(["GET", "POST"]) + })); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +/// Annotation detection matches `@Name` only when the name ends at whitespace, +/// an argument list, or the end of the context. Caching the compiled patterns +/// must not turn a prefix such as `@Bean` into a match for `@BeanFactory`. +#[test] +fn spring_index_does_not_treat_longer_annotations_as_recognized_ones() { + let root = temporary_root("spring-annotation-boundary"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("RealConfig.java"), + r#"package demo; +@Configuration +public class RealConfig { + @Bean + public Clock clock() { return null; } + @BeanFactory + public Clock decoyClock() { return null; } +} +"#, + ) + .expect("configuration fixture should be writable"); + fs::write( + java.join("DecoyService.java"), + "package demo;\n@ServiceLocator\npublic class DecoyService {}\n", + ) + .expect("decoy fixture should be writable"); + fs::write( + java.join("DecoyController.java"), + "package demo;\n@RestControllerAdvice\npublic class DecoyController {\n @GetMapping(\"/decoy\")\n public String decoy() { return \"\"; }\n}\n", + ) + .expect("decoy controller fixture should be writable"); + + let paths = [ + "src/main/java/demo/RealConfig.java", + "src/main/java/demo/DecoyService.java", + "src/main/java/demo/DecoyController.java", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + assert_eq!(response["ok"], true, "{response}"); + + let beans = response["data"]["beans"].as_array().unwrap(); + let names = beans + .iter() + .map(|value| value["name"].as_str().unwrap()) + .collect::>(); + assert!(names.contains(&"clock"), "{response}"); + assert!(names.contains(&"realConfig"), "{response}"); + assert!(!names.contains(&"decoyClock"), "{response}"); + assert!(!names.contains(&"decoyService"), "{response}"); + + // @RestControllerAdvice is not @RestController, so no route is collected. + assert!( + response["data"]["endpoints"].as_array().unwrap().is_empty(), + "{response}" + ); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +/// Bean and component names must come from the exact annotation's own +/// argument list. A prefix decoy or a later neighbor cannot supply the name. +#[test] +fn spring_index_isolates_bean_and_component_names_from_neighbor_annotations() { + let root = temporary_root("spring-bean-name-isolation"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("ClockConfig.java"), + r#"package demo; +@Configuration +public class ClockConfig { + @BeanFactory("decoy") @Bean("real") + public Clock clock() { return null; } + @Bean("foo(") @Bean("bar") + public Clock parenClock() { return null; } + @BeanFactory("decoyOnly") + public Clock decoyClock() { return null; } + @Bean + public Clock unnamedClock() { return null; } +} +"#, + ) + .expect("bean name fixture should be writable"); + fs::write( + java.join("Demo.java"), + "package demo;\n@Service(\"s\") @Component(\"c\")\npublic class Demo {}\n", + ) + .expect("component name fixture should be writable"); + fs::write( + java.join("Ordered.java"), + "package demo;\n@Component(\"c\") @Service(\"s\")\npublic class Ordered {}\n", + ) + .expect("reversed component fixture should be writable"); + + let paths = [ + "src/main/java/demo/ClockConfig.java", + "src/main/java/demo/Demo.java", + "src/main/java/demo/Ordered.java", + ]; + let response = execute_spring(&root, &paths, serde_json::json!({})); + assert_eq!(response["ok"], true, "{response}"); + + let beans = response["data"]["beans"].as_array().unwrap(); + let names = beans + .iter() + .map(|value| value["name"].as_str().unwrap()) + .collect::>(); + assert!(names.contains(&"real"), "{response}"); + assert!(names.contains(&"foo("), "{response}"); + assert!(names.contains(&"unnamedClock"), "{response}"); + assert!(names.contains(&"clockConfig"), "{response}"); + assert!(names.contains(&"s"), "{response}"); + assert!(names.contains(&"c"), "{response}"); + assert!(!names.contains(&"decoy"), "{response}"); + assert!(!names.contains(&"decoyOnly"), "{response}"); + assert!(!names.contains(&"decoyClock"), "{response}"); + assert!(!names.contains(&"bar"), "{response}"); + assert!(!names.contains(&") @Component("), "{response}"); + + let demo = beans + .iter() + .find(|value| value["typeName"] == "Demo") + .unwrap_or_else(|| panic!("missing Demo bean: {response}")); + assert_eq!(demo["name"], "s"); + let ordered = beans + .iter() + .find(|value| value["typeName"] == "Ordered") + .unwrap_or_else(|| panic!("missing Ordered bean: {response}")); + assert_eq!(ordered["name"], "c"); + let clock = beans + .iter() + .find(|value| value["name"] == "real" && value["kind"] == "beanMethod") + .unwrap_or_else(|| panic!("missing named Clock bean: {response}")); + assert_eq!(clock["typeName"], "Clock"); + let paren = beans + .iter() + .find(|value| value["name"] == "foo(" && value["kind"] == "beanMethod") + .unwrap_or_else(|| panic!("missing parenthesis Clock bean: {response}")); + assert_eq!(paren["typeName"], "Clock"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +/// An empty `@Component("")` value is not a bean name. Indexing must fall back +/// to the default type name instead of recording an empty id. +#[test] +fn spring_index_falls_back_when_a_component_name_is_an_empty_string() { + let root = temporary_root("spring-empty-component-name"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("EmptyName.java"), + "package demo;\n@Component(\"\")\npublic class EmptyName {}\n", + ) + .expect("empty component fixture should be writable"); + fs::write( + java.join("Neighbor.java"), + "package demo;\n@Component(\"\") @Service(\"s\")\npublic class Neighbor {}\n", + ) + .expect("empty-then-neighbor fixture should be writable"); + + let response = execute_spring( + &root, + &[ + "src/main/java/demo/EmptyName.java", + "src/main/java/demo/Neighbor.java", + ], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + + let beans = response["data"]["beans"].as_array().unwrap(); + let empty_name = beans + .iter() + .find(|value| value["typeName"] == "EmptyName") + .unwrap_or_else(|| panic!("missing EmptyName bean: {response}")); + assert_eq!(empty_name["name"], "emptyName"); + assert!( + empty_name["id"].as_str().unwrap().ends_with(":emptyName"), + "{response}" + ); + let neighbor = beans + .iter() + .find(|value| value["typeName"] == "Neighbor") + .unwrap_or_else(|| panic!("missing Neighbor bean: {response}")); + assert_eq!(neighbor["name"], "neighbor"); + assert_ne!(empty_name["name"], ""); + assert_ne!(neighbor["name"], ""); + assert_ne!(neighbor["name"], "s"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +/// Custom annotations that only share a Mapping prefix must not become +/// endpoints or class-level base routes; exact Spring Mapping names still do. +#[test] +fn spring_index_ignores_mapping_annotations_that_only_share_a_prefix() { + let root = temporary_root("spring-mapping-prefix"); + let java = root.join("src/main/java/demo"); + fs::create_dir_all(&java).expect("Java fixture directory should be creatable"); + fs::write( + java.join("DemoController.java"), + r#"package demo; +@RestController +@RequestMappingFoo("/base") +public class DemoController { + @GetMappingCustom("/x") + public String custom() { return ""; } + @PostMappingCustom("/post") + public String postCustom() { return ""; } + @PutMappingCustom("/put") + public String putCustom() { return ""; } + @DeleteMappingCustom("/delete") + public String deleteCustom() { return ""; } + @PatchMappingCustom("/patch") + public String patchCustom() { return ""; } + @GetMapping("/real") + public String real() { return ""; } +} +"#, + ) + .expect("controller fixture should be writable"); + + let response = execute_spring( + &root, + &["src/main/java/demo/DemoController.java"], + serde_json::json!({}), + ); + assert_eq!(response["ok"], true, "{response}"); + + let endpoints = response["data"]["endpoints"].as_array().unwrap(); + assert_eq!(endpoints.len(), 1, "{response}"); + assert_eq!(endpoints[0]["route"], "/real"); + assert_eq!(endpoints[0]["httpMethods"][0], "GET"); + assert_eq!(endpoints[0]["method"], "real"); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +#[test] +fn spring_dependency_metadata_cache_refresh_is_explicit() { + let root = temporary_root("spring-metadata-cache"); + let repository = root.join("repository"); + fs::create_dir_all(&repository).expect("metadata repository should be creatable"); + let archive = repository.join("fixture.jar"); + write_metadata_archive(&archive, "cache.first"); + + let refreshed = execute_spring( + &root, + &[], + serde_json::json!({ + "metadataRepository": repository, + "refreshDependencyMetadata": true + }), + ); + assert!(has_property(&refreshed, "cache.first")); + write_metadata_archive(&archive, "cache.second"); + let cached = execute_spring( + &root, + &[], + serde_json::json!({"metadataRepository": repository}), + ); + assert!(has_property(&cached, "cache.first")); + assert!(!has_property(&cached, "cache.second")); + let refreshed = execute_spring( + &root, + &[], + serde_json::json!({ + "metadataRepository": repository, + "refreshDependencyMetadata": true + }), + ); + assert!(has_property(&refreshed, "cache.second")); + + fs::remove_dir_all(root).expect("Spring fixture should be removable"); +} + +fn execute_spring(root: &std::path::Path, paths: &[&str], extra: Value) -> Value { + let mut payload = serde_json::json!({"root": root, "paths": paths}); + payload + .as_object_mut() + .unwrap() + .extend(extra.as_object().cloned().unwrap_or_default()); + let request = serde_json::json!({ + "id": "spring", + "command": "spring.index", + "payload": payload + }); + serde_json::from_str(&execute_json(&request.to_string())) + .expect("Spring response should be JSON") +} + +fn write_metadata_archive(path: &std::path::Path, property: &str) { + let file = File::create(path).expect("metadata archive should be creatable"); + let mut archive = zip::ZipWriter::new(file); + archive + .start_file( + "META-INF/spring-configuration-metadata.json", + zip::write::SimpleFileOptions::default(), + ) + .expect("metadata entry should be creatable"); + write!(archive, r#"{{"properties":[{{"name":"{property}"}}]}}"#) + .expect("metadata should be writable"); + archive.finish().expect("metadata archive should close"); +} + +fn has_property(response: &Value, name: &str) -> bool { + response["data"]["properties"] + .as_array() + .unwrap() + .iter() + .any(|value| value["name"] == name) +} diff --git a/crates/java-domain/src/tests/support.rs b/crates/java-domain/src/tests/support.rs new file mode 100644 index 0000000..79c953f --- /dev/null +++ b/crates/java-domain/src/tests/support.rs @@ -0,0 +1,73 @@ +//! Helpers shared by the command-level tests. + +use crate::commands::{execute_with_options, ExecuteOptions}; +use serde_json::{json, Value}; +use std::collections::hash_map::RandomState; +use std::hash::BuildHasher; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::OnceLock; + +/// Runs a Core-style request envelope and returns Core's response envelope. +/// +/// Rust Core's `execute_json` accepted `{id, command, payload, +/// timeoutMilliseconds}` and answered `{id, ok, data | error}`. The ported +/// tests keep asserting on that shape, which is also what the plugin backend +/// returns to the host. +pub(super) fn execute_json(request: &str) -> String { + let request: Value = match serde_json::from_str(request) { + Ok(request) => request, + Err(error) => { + return json!({ + "id": Value::Null, + "ok": false, + "error": {"code": "invalid_request", "message": "Invalid request", "details": error.to_string()} + }) + .to_string() + } + }; + let id = request["id"].as_str().map(str::to_owned); + let command = request["command"].as_str().unwrap_or_default(); + let payload = request.get("payload").cloned().unwrap_or_else(|| json!({})); + let options = ExecuteOptions { + operation_id: id.clone(), + timeout_milliseconds: request["timeoutMilliseconds"].as_u64(), + }; + let response = match execute_with_options(command, payload, options) { + Ok(data) => json!({"id": id, "ok": true, "data": data}), + Err(error) => json!({"id": id, "ok": false, "error": error}), + }; + response.to_string() +} + +/// A fresh path under the system temporary directory. +/// +/// The path combines the process ID, a per-process random nonce and a +/// sequence number, so parallel tests never share cleanup ownership and no +/// test depends on the wall clock. +pub(super) fn temporary_root(label: &str) -> PathBuf { + static NONCE: OnceLock = OnceLock::new(); + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + let nonce = NONCE.get_or_init(|| RandomState::new().hash_one(std::process::id())); + let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "lithe-java-{label}-{}-{nonce:x}-{sequence}", + std::process::id() + )) +} + +#[test] +fn temporary_roots_do_not_alias_with_the_same_label() { + let roots: std::collections::HashSet<_> = + (0..10_000).map(|_| temporary_root("same-label")).collect(); + assert_eq!(roots.len(), 10_000, "Each fixture must own a distinct path"); +} + +#[test] +fn envelopes_report_unknown_commands_as_failures() { + let response: Value = + serde_json::from_str(&execute_json(r#"{"id":"x","command":"java.unknown"}"#)).unwrap(); + assert_eq!(response["id"], "x"); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "invalid_request"); +} 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/fixtures/debug/java-test-launch-v1.json b/fixtures/debug/java-test-launch-v1.json new file mode 100644 index 0000000..9f680a1 --- /dev/null +++ b/fixtures/debug/java-test-launch-v1.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "cases": [ + { + "name": "junit replaces the result port", + "payload": { + "name": "UserServiceTest", + "framework": "junit", + "workingDirectory": "/workspace/service", + "mainClass": "org.eclipse.jdt.internal.junit.runner.RemoteTestRunner", + "projectName": "service", + "classPaths": ["/workspace/service/classes", "/workspace/service/classes", ""], + "modulePaths": [], + "vmArguments": ["--enable-preview"], + "programArguments": ["-version", "3", "-port", "-1"], + "resultPort": 43127, + "testngRunnerPath": null, + "testngTestNames": [] + }, + "expected": { + "name": "UserServiceTest", + "request": "launch", + "arguments": { + "mainClass": "org.eclipse.jdt.internal.junit.runner.RemoteTestRunner", + "cwd": "/workspace/service", + "console": "integratedTerminal", + "projectName": "service", + "classPaths": ["/workspace/service/classes"], + "args": "-version 3 -port 43127", + "vmArgs": "--enable-preview" + }, + "steppingFilters": null + } + }, + { + "name": "testng appends the packaged runner", + "payload": { + "name": "UserServiceTest", + "framework": "testng", + "workingDirectory": "/workspace/service", + "mainClass": "com.microsoft.java.test.runner.Launcher", + "projectName": "service", + "classPaths": ["/workspace/service/classes"], + "modulePaths": ["/workspace/service/modules"], + "vmArguments": [], + "programArguments": [], + "resultPort": 43128, + "testngRunnerPath": "/lithe/java-test-runner.jar", + "testngTestNames": [ + "example.UserServiceTest#logsIn", + "example.UserServiceTest#logsIn" + ] + }, + "expected": { + "name": "UserServiceTest", + "request": "launch", + "arguments": { + "mainClass": "com.microsoft.java.test.runner.Launcher", + "cwd": "/workspace/service", + "console": "integratedTerminal", + "projectName": "service", + "classPaths": ["/workspace/service/classes", "/lithe/java-test-runner.jar"], + "modulePaths": ["/workspace/service/modules"], + "args": "43128 testng example.UserServiceTest#logsIn" + }, + "steppingFilters": null + } + } + ] +} diff --git a/fixtures/java/basic.json b/fixtures/java/basic.json new file mode 100644 index 0000000..989b30b --- /dev/null +++ b/fixtures/java/basic.json @@ -0,0 +1,21 @@ +{ + "name": "Java structure", + "requests": [ + { + "command": "java.structure", + "payload": { + "source": "import a.A;\nimport b.B;\ninterface Service { String call(String value); }\n" + }, + "expected": { + "foldKind": "imports", + "syntaxRoles": [ + "functionDeclaration", + "keyword", + "parameter", + "punctuation", + "type" + ] + } + } + ] +} diff --git a/fixtures/java/run-markers-v1.json b/fixtures/java/run-markers-v1.json new file mode 100644 index 0000000..4679503 --- /dev/null +++ b/fixtures/java/run-markers-v1.json @@ -0,0 +1,216 @@ +{ + "version": 1, + "cases": [ + { + "name": "main-methods-including-nested-class", + "request": { + "mainMethods": [ + { + "mainClass": "demo.App", + "projectName": "app", + "range": { "startLine": 3, "startUtf16Column": 23, "endLine": 3, "endUtf16Column": 27 } + }, + { + "mainClass": "demo.App$Inner", + "projectName": "app", + "range": { "startLine": 6, "startUtf16Column": 27, "endLine": 6, "endUtf16Column": 31 } + } + ] + }, + "expected": { + "markers": [ + { "line": 3, "endLine": 3, "kind": "main", "label": "App.main()", "mainClass": "demo.App", "projectName": "app", "status": "none" }, + { "line": 6, "endLine": 6, "kind": "main", "label": "Inner.main()", "mainClass": "demo.App$Inner", "projectName": "app", "status": "none" } + ] + } + }, + { + "name": "nested-test-classes-with-recorded-outcomes", + "request": { + "testItems": [ + { + "id": "app@demo.OrderTest", + "label": "OrderTest", + "fullName": "demo.OrderTest", + "projectName": "app", + "testKind": 0, + "testLevel": 5, + "range": { "startLine": 5, "startUtf16Column": 6, "endLine": 14, "endUtf16Column": 1 }, + "children": [ + { + "id": "app@demo.OrderTest#creates()", + "label": "creates()", + "fullName": "demo.OrderTest#creates()", + "projectName": "app", + "testKind": 0, + "testLevel": 6, + "range": { "startLine": 7, "startUtf16Column": 9, "endLine": 7, "endUtf16Column": 21 }, + "children": [] + }, + { + "id": "app@demo.OrderTest$Refunds", + "label": "Refunds", + "fullName": "demo.OrderTest$Refunds", + "projectName": "app", + "testKind": 0, + "testLevel": 5, + "range": { "startLine": 10, "startUtf16Column": 10, "endLine": 13, "endUtf16Column": 5 }, + "children": [ + { + "id": "app@demo.OrderTest$Refunds#refunds()", + "label": "refunds()", + "fullName": "demo.OrderTest$Refunds#refunds()", + "projectName": "app", + "testKind": 0, + "testLevel": 6, + "range": { "startLine": 12, "startUtf16Column": 13, "endLine": 12, "endUtf16Column": 25 }, + "children": [] + } + ] + } + ] + } + ], + "testCases": [ + { "className": "demo.OrderTest", "method": "creates", "status": "passed", "message": null, "invocations": 1 }, + { "className": "demo.OrderTest$Refunds", "method": "refunds", "status": "failed", "message": "expected refund", "invocations": 1 } + ] + }, + "expected": { + "markers": [ + { "line": 5, "endLine": 14, "kind": "testClass", "label": "OrderTest", "testClass": "demo.OrderTest", "testItemId": "app@demo.OrderTest", "status": "failed" }, + { "line": 7, "endLine": 7, "kind": "testMethod", "label": "OrderTest.creates", "testClass": "demo.OrderTest", "testMethod": "creates", "testItemId": "app@demo.OrderTest#creates()", "status": "passed" }, + { "line": 10, "endLine": 13, "kind": "testClass", "label": "Refunds", "testClass": "demo.OrderTest$Refunds", "testItemId": "app@demo.OrderTest$Refunds", "status": "failed" }, + { "line": 12, "endLine": 12, "kind": "testMethod", "label": "Refunds.refunds", "testClass": "demo.OrderTest$Refunds", "testMethod": "refunds", "testItemId": "app@demo.OrderTest$Refunds#refunds()", "status": "failed" } + ] + } + }, + { + "name": "nested-test-classes-with-canonical-provider-outcomes", + "request": { + "testItems": [ + { + "id": "app@demo.OrderTest", + "label": "OrderTest", + "fullName": "demo.OrderTest", + "projectName": "app", + "testKind": 0, + "testLevel": 5, + "range": { + "startLine": 5, + "startUtf16Column": 6, + "endLine": 14, + "endUtf16Column": 1 + }, + "children": [ + { + "id": "app@demo.OrderTest#creates()", + "label": "creates()", + "fullName": "demo.OrderTest#creates()", + "projectName": "app", + "testKind": 0, + "testLevel": 6, + "range": { + "startLine": 7, + "startUtf16Column": 9, + "endLine": 7, + "endUtf16Column": 21 + }, + "children": [] + }, + { + "id": "app@demo.OrderTest$Refunds", + "label": "Refunds", + "fullName": "demo.OrderTest$Refunds", + "projectName": "app", + "testKind": 0, + "testLevel": 5, + "range": { + "startLine": 10, + "startUtf16Column": 10, + "endLine": 13, + "endUtf16Column": 5 + }, + "children": [ + { + "id": "app@demo.OrderTest$Refunds#refunds()", + "label": "refunds()", + "fullName": "demo.OrderTest$Refunds#refunds()", + "projectName": "app", + "testKind": 0, + "testLevel": 6, + "range": { + "startLine": 12, + "startUtf16Column": 13, + "endLine": 12, + "endUtf16Column": 25 + }, + "children": [] + } + ] + } + ] + } + ], + "testCases": [ + { + "status": "passed", + "message": null, + "invocations": 1, + "suite": "demo.OrderTest", + "case": "creates" + }, + { + "status": "failed", + "message": "expected refund", + "invocations": 1, + "suite": "demo.OrderTest$Refunds", + "case": "refunds" + } + ] + }, + "expected": { + "markers": [ + { + "line": 5, + "endLine": 14, + "kind": "testClass", + "label": "OrderTest", + "testClass": "demo.OrderTest", + "testItemId": "app@demo.OrderTest", + "status": "failed" + }, + { + "line": 7, + "endLine": 7, + "kind": "testMethod", + "label": "OrderTest.creates", + "testClass": "demo.OrderTest", + "testMethod": "creates", + "testItemId": "app@demo.OrderTest#creates()", + "status": "passed" + }, + { + "line": 10, + "endLine": 13, + "kind": "testClass", + "label": "Refunds", + "testClass": "demo.OrderTest$Refunds", + "testItemId": "app@demo.OrderTest$Refunds", + "status": "failed" + }, + { + "line": 12, + "endLine": 12, + "kind": "testMethod", + "label": "Refunds.refunds", + "testClass": "demo.OrderTest$Refunds", + "testMethod": "refunds", + "testItemId": "app@demo.OrderTest$Refunds#refunds()", + "status": "failed" + } + ] + } + } + ] +} diff --git a/fixtures/lsp/java-build-report-v1.json b/fixtures/lsp/java-build-report-v1.json new file mode 100644 index 0000000..94b69b1 --- /dev/null +++ b/fixtures/lsp/java-build-report-v1.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "continuableFailures": [ + { + "code": "javaBuildCompilationErrors", + "message": "The Java project build reported compilation errors.", + "javaBuildReport": { + "markerScope": "launchTarget", + "builderFailedEarlier": false, + "elapsedMilliseconds": 506, + "recovery": "rebuildJavaIndex" + } + }, + { + "code": "javaBuildFailed", + "message": "The Java language service could not complete the project build. Check the Java language server log for the build error.", + "javaBuildReport": { + "markerScope": "workspace", + "builderFailedEarlier": true, + "elapsedMilliseconds": 5012, + "recovery": "rebuildJavaIndex" + } + } + ], + "nonContinuableCodes": [ + "invalidServerResult", + "javaBuildCancelled", + "requestCancelled", + "requestTimeout" + ] +} diff --git a/fixtures/lsp/java-completion-v1.json b/fixtures/lsp/java-completion-v1.json new file mode 100644 index 0000000..224648f --- /dev/null +++ b/fixtures/lsp/java-completion-v1.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "pluginCompletion": { + "guessMethodArguments": "insertParameterNames", + "favoriteStaticMembers": ["java.util.Objects.*"], + "filteredTypes": ["example.internal.*"] + }, + "javaSettings": { + "completion": { + "guessMethodArguments": "off", + "favoriteStaticMembers": ["java.util.Objects.*"], + "filteredTypes": ["example.internal.*"] + } + }, + "workspaceConfigurationSections": [ + "java", + "java.completion", + "java.completion.guessMethodArguments", + "java.completion.favoriteStaticMembers", + "java.completion.filteredTypes", + "java.completion.unknown" + ] +} diff --git a/fixtures/lsp/java-launch-preparation-v1.json b/fixtures/lsp/java-launch-preparation-v1.json new file mode 100644 index 0000000..59f3e91 --- /dev/null +++ b/fixtures/lsp/java-launch-preparation-v1.json @@ -0,0 +1,80 @@ +{ + "version": 1, + "cases": [ + { + "id": "windows-module-and-case", + "rootUri": "file:///C:/Work", + "sourcePath": "c:/work/APP/Main.java", + "mainClass": "demo.Main", + "entries": [ + { + "mainClass": "demo.Main", + "projectName": "lib", + "filePath": "C:/Work/lib/Main.java" + }, + { + "mainClass": "demo.app/demo.Main", + "projectName": "app", + "filePath": "C:/Work/app/Main.java" + }, + { + "mainClass": "demo.Other", + "projectName": "app", + "filePath": "C:/Work/app/Main.java" + } + ], + "paths": [ + [ + "C:/Work/custom/modules" + ], + [ + "C:/Work/custom/tests", + "C:/cache/test-only.jar" + ] + ], + "target": { + "mainClass": "demo.app/demo.Main", + "projectName": "app", + "modulePaths": [ + "C:/Work/custom/modules" + ], + "classPaths": [ + "C:/Work/custom/tests", + "C:/cache/test-only.jar" + ] + } + }, + { + "id": "mac-source-identity", + "rootUri": "file:///workspace", + "sourcePath": "/workspace/app/Main.java", + "mainClass": "demo.Main", + "entries": [ + { + "mainClass": "demo.Main", + "projectName": "lib", + "filePath": "/workspace/lib/Main.java" + }, + { + "mainClass": "demo.Main", + "projectName": "app", + "filePath": "/workspace/app/Main.java" + } + ], + "paths": [ + [], + [ + "/workspace/app/custom-output" + ] + ], + "target": { + "mainClass": "demo.Main", + "projectName": "app", + "modulePaths": [], + "classPaths": [ + "/workspace/app/custom-output" + ] + } + } + ] +} diff --git a/fixtures/lsp/java-navigation-v1.json b/fixtures/lsp/java-navigation-v1.json new file mode 100644 index 0000000..f339e8b --- /dev/null +++ b/fixtures/lsp/java-navigation-v1.json @@ -0,0 +1,58 @@ +{ + "version": 1, + "cases": [ + { + "name": "four-marker-variants-use-deterministic-ordering", + "request": { + "sessionId": "session-1", + "uri": "file:///workspace/src/Service.java", + "documentVersion": 7 + }, + "expectedResult": { + "documentVersion": 7, + "markers": [ + { + "line": 2, + "utf16Column": 9, + "implementationCount": 1, + "direction": "up", + "relation": "interface" + }, + { + "line": 2, + "utf16Column": 9, + "implementationCount": 2, + "direction": "down", + "relation": "interface" + }, + { + "line": 8, + "utf16Column": 16, + "implementationCount": 1, + "direction": "up", + "relation": "inheritance" + }, + { + "line": 8, + "utf16Column": 16, + "implementationCount": 3, + "direction": "down", + "relation": "inheritance" + } + ] + } + }, + { + "name": "zero-target-declarations-are-omitted", + "request": { + "sessionId": "session-1", + "uri": "file:///workspace/src/Unused.java", + "documentVersion": 3 + }, + "expectedResult": { + "documentVersion": 3, + "markers": [] + } + } + ] +} diff --git a/fixtures/lsp/java-project-runtimes-v1.json b/fixtures/lsp/java-project-runtimes-v1.json new file mode 100644 index 0000000..10e1a51 --- /dev/null +++ b/fixtures/lsp/java-project-runtimes-v1.json @@ -0,0 +1,78 @@ +{ + "version": 1, + "cases": [ + { + "name": "selected-custom-major-wins", + "candidates": [ + { + "homePath": "/bundled/jdk21", + "version": "21" + }, + { + "homePath": "/jdks/jdk25", + "version": "25" + }, + { + "homePath": "/custom/jdk25", + "version": "25.0.1", + "isDefault": true + }, + { + "homePath": "/jdks/jdk17", + "version": "17", + "isDefault": true + } + ], + "expected": [ + { + "name": "JavaSE-17", + "path": "/jdks/jdk17" + }, + { + "name": "JavaSE-21", + "path": "/bundled/jdk21" + }, + { + "name": "JavaSE-25", + "path": "/custom/jdk25", + "default": true + } + ] + }, + { + "name": "legacy-without-default", + "candidates": [ + { + "homePath": "/jdks/jdk21", + "version": "21" + } + ], + "expected": [ + { + "name": "JavaSE-21", + "path": "/jdks/jdk21" + } + ] + }, + { + "name": "invalid-default-is-ignored", + "candidates": [ + { + "homePath": "", + "version": "25", + "isDefault": true + }, + { + "homePath": "/jdks/jdk21", + "version": "21" + } + ], + "expected": [ + { + "name": "JavaSE-21", + "path": "/jdks/jdk21" + } + ] + } + ] +} diff --git a/fixtures/lsp/java-workspace-policy-v1.json b/fixtures/lsp/java-workspace-policy-v1.json new file mode 100644 index 0000000..463618f --- /dev/null +++ b/fixtures/lsp/java-workspace-policy-v1.json @@ -0,0 +1,79 @@ +{ + "version": 1, + "cases": [ + { + "name": "mixed-workspace", + "workspacePaths": [ + "README.md", + "node_modules/example/Fake.java", + "src/Main.java", + "target/generated/Generated.java" + ], + "changedPaths": [ + "README.md", + "build.gradle.kts", + "src/Main.java", + "target/generated/Generated.java" + ], + "expected": { + "shouldStart": true, + "representativeJavaPath": "src/Main.java", + "changes": [ + { "path": "README.md", "kind": "other" }, + { "path": "build.gradle.kts", "kind": "buildConfiguration" }, + { "path": "src/Main.java", "kind": "source" }, + { "path": "target/generated/Generated.java", "kind": "ignored" } + ] + } + }, + { + "name": "ignored-java-only", + "workspacePaths": [ + ".worktrees/topic/src/Topic.java", + "build/generated/Generated.java", + "node_modules/example/Fake.java" + ], + "changedPaths": [ + ".mvn/wrapper/maven-wrapper.properties", + "gradle/wrapper/gradle-wrapper.properties", + "settings.gradle" + ], + "expected": { + "shouldStart": false, + "changes": [ + { "path": ".mvn/wrapper/maven-wrapper.properties", "kind": "buildConfiguration" }, + { "path": "gradle/wrapper/gradle-wrapper.properties", "kind": "buildConfiguration" }, + { "path": "settings.gradle", "kind": "buildConfiguration" } + ] + } + }, + { + "name": "maven-project-with-conventional-layout", + "workspacePaths": [ + "pom.xml", + "src/main/java/com/example/demo/DemoApplication.java" + ], + "changedPaths": [], + "expected": { + "shouldStart": true, + "representativeJavaPath": "src/main/java/com/example/demo/DemoApplication.java", + "changes": [] + } + }, + { + "name": "java-fixture-inside-another-ecosystem", + "workspacePaths": [ + "Cargo.toml", + "Package.swift", + "shared/fixtures/projects/demo/pom.xml", + "shared/fixtures/projects/demo/src/main/java/com/example/demo/DemoApplication.java" + ], + "changedPaths": [], + "expected": { + "shouldStart": false, + "representativeJavaPath": "shared/fixtures/projects/demo/src/main/java/com/example/demo/DemoApplication.java", + "changes": [] + } + } + ] +} diff --git a/fixtures/lsp/jdt-cache-retention-v1.json b/fixtures/lsp/jdt-cache-retention-v1.json new file mode 100644 index 0000000..98f4b77 --- /dev/null +++ b/fixtures/lsp/jdt-cache-retention-v1.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "cases": [ + { + "name": "expires-only-inactive-cache-older-than-thirty-days", + "nowUnixSeconds": 4000000, + "activeWorkspaceKey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "entries": [ + { + "workspaceKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "lastModifiedUnixSeconds": 1000000 + }, + { + "workspaceKey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "lastModifiedUnixSeconds": 1408000 + }, + { + "workspaceKey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "lastModifiedUnixSeconds": 1 + }, + { + "workspaceKey": "not-a-workspace-key", + "lastModifiedUnixSeconds": 1 + } + ], + "expected": { + "retentionDays": 30, + "expiredWorkspaceKeys": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] + } + }, + { + "name": "duplicate-observations-use-newest-timestamp", + "nowUnixSeconds": 4000000, + "entries": [ + { + "workspaceKey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "lastModifiedUnixSeconds": 1 + }, + { + "workspaceKey": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "lastModifiedUnixSeconds": 3999999 + } + ], + "expected": { + "retentionDays": 30, + "expiredWorkspaceKeys": [] + } + } + ] +} diff --git a/fixtures/lsp/jdt-direct-launch-v1.json b/fixtures/lsp/jdt-direct-launch-v1.json new file mode 100644 index 0000000..9095925 --- /dev/null +++ b/fixtures/lsp/jdt-direct-launch-v1.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 1, + "request": { + "providerId": "java", + "executablePath": "/opt/lithe/jdtls/bin/jdtls", + "arguments": [ + "--jvm-arg=-Duser.language=en" + ], + "environment": { + "JAVA_HOME": "/opt/lithe/jdk" + }, + "rootUri": "file:///workspace/project", + "workingDirectory": "/workspace/project", + "initializationOptions": null, + "runtimeExecutablePath": "/opt/lithe/jdk/bin/java", + "jdtlsLaunchResources": { + "launcherJarPath": "/opt/lithe/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar", + "configurationDirectory": "/opt/lithe/jdtls/config_mac", + "lombokAgentPath": "/opt/lithe/jdtls/lombok/lombok.jar", + "javaDebugBundlePath": "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + "javaExtensionBundlePaths": [ + "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + "/opt/lithe/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar" + ] + }, + "cacheDirectory": "/var/cache/lithe/language-servers", + "workspaceFingerprint": "build=|modules=|jdtls=1.55.0", + "initializeTimeoutMilliseconds": 30000, + "serviceReadyIdleTimeoutMilliseconds": 45000, + "serviceReadyAbsoluteTimeoutMilliseconds": 600000, + "requestTimeoutMilliseconds": 30000, + "shutdownTimeoutMilliseconds": 2000 + } +} diff --git a/fixtures/lsp/jdt-readiness-v1.json b/fixtures/lsp/jdt-readiness-v1.json new file mode 100644 index 0000000..2d1172b --- /dev/null +++ b/fixtures/lsp/jdt-readiness-v1.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": 1, + "idleWarningMilliseconds": 45000, + "absoluteTimeoutMilliseconds": 600000, + "cases": [ + { + "name": "quiet Maven import eventually becomes ready", + "steps": [ + { + "atMilliseconds": 0, + "action": "progress", + "percentage": 23 + }, + { + "atMilliseconds": 46000, + "action": "poll", + "warning": true, + "timeout": false + }, + { + "atMilliseconds": 60000, + "action": "poll", + "warning": false, + "timeout": false + }, + { + "atMilliseconds": 90000, + "action": "ready" + }, + { + "atMilliseconds": 601000, + "action": "poll", + "warning": false, + "timeout": false + } + ] + }, + { + "name": "quiet Maven import reaches the absolute deadline", + "steps": [ + { + "atMilliseconds": 46000, + "action": "poll", + "warning": true, + "timeout": false + }, + { + "atMilliseconds": 600000, + "action": "poll", + "warning": false, + "timeout": true, + "classification": "noProgressStall" + } + ] + }, + { + "name": "progress never extends the absolute deadline", + "steps": [ + { + "atMilliseconds": 599000, + "action": "progress", + "percentage": 99 + }, + { + "atMilliseconds": 600000, + "action": "poll", + "warning": false, + "timeout": true, + "classification": "projectImportActive" + } + ] + } + ] +} diff --git a/fixtures/lsp/jdt-workspace-fingerprint-v1.json b/fixtures/lsp/jdt-workspace-fingerprint-v1.json new file mode 100644 index 0000000..2e0103d --- /dev/null +++ b/fixtures/lsp/jdt-workspace-fingerprint-v1.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "cases": [ + { + "name": "sorts-and-deduplicates-platform-observations", + "buildFiles": [ + { "path": "pom.xml" }, + { "path": "build.gradle.kts" }, + { "path": "pom.xml" } + ], + "directMavenModules": ["core", "api", "core"], + "jdtlsVersion": "1.38.0", + "workspaceFingerprint": "build=build.gradle.kts,pom.xml|modules=api,core|jdtls=1.38.0" + }, + { + "name": "gradle-wrapper-version-selects-the-state", + "buildFiles": [ + { "path": "settings.gradle.kts" }, + { "path": "build.gradle.kts" }, + { "path": "gradle/wrapper/gradle-wrapper.properties" } + ], + "directMavenModules": [], + "gradleWrapperDistribution": "https://services.gradle.org/distributions/gradle-8.10-bin.zip", + "jdtlsVersion": "1.38.0", + "workspaceFingerprint": "build=build.gradle.kts,gradle/wrapper/gradle-wrapper.properties,settings.gradle.kts|modules=|jdtls=1.38.0|gradle=https://services.gradle.org/distributions/gradle-8.10-bin.zip" + }, + { + "name": "version-only-workspace", + "buildFiles": [], + "directMavenModules": [], + "jdtlsVersion": "7.6.5", + "workspaceFingerprint": "build=|modules=|jdtls=7.6.5" + } + ] +} diff --git a/fixtures/lsp/jdt-workspace-key-v1.json b/fixtures/lsp/jdt-workspace-key-v1.json new file mode 100644 index 0000000..16655c8 --- /dev/null +++ b/fixtures/lsp/jdt-workspace-key-v1.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "cases": [ + { + "name": "legacy-workspace-path", + "workspaceRoot": "/workspace/demo", + "workspaceFingerprint": null, + "workspaceKey": "4de1d2f8b60db00ae8307a6fe19690b23893974b259297719107bb84e767148a" + }, + { + "name": "maven-modules-and-jdtls-version", + "workspaceRoot": "/workspace/demo", + "workspaceFingerprint": "build=pom.xml@1700000000000:42|modules=api,core|jdtls=1.38.0", + "workspaceKey": "b21826804a57043eec9f155c7ee324870f7df48d7d51a311e25cb04e9671df25" + } + ] +} diff --git a/fixtures/lsp/project-preparation-v1.json b/fixtures/lsp/project-preparation-v1.json new file mode 100644 index 0000000..eb84cdf --- /dev/null +++ b/fixtures/lsp/project-preparation-v1.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "snapshots": [ + { "phase": "starting", "status": "loading", "blocksRun": true }, + { "phase": "importing", "status": "loading", "blocksRun": true }, + { "phase": "configuring", "status": "loading", "blocksRun": true }, + { "phase": "building", "status": "loading", "blocksRun": true }, + { "phase": "ready", "status": "ready", "blocksRun": false }, + { "phase": "configuring", "status": "failed", "blocksRun": false }, + { "phase": "stopped", "status": "idle", "blocksRun": true } + ] +} diff --git a/fixtures/mybatis/basic.json b/fixtures/mybatis/basic.json new file mode 100644 index 0000000..76a40ce --- /dev/null +++ b/fixtures/mybatis/basic.json @@ -0,0 +1,25 @@ +{ + "name": "MyBatis mapper XML navigation index", + "request": { + "command": "mybatis.index", + "payload": { + "root": "/fixture/workspace", + "paths": [ + "src/main/java/example/UserMapper.java", + "src/main/resources/mapper/UserMapper.xml" + ], + "textOverrides": { + "src/main/resources/mapper/UserMapper.xml": "" + } + } + }, + "expected": { + "statement": { + "namespace": "example.UserMapper", + "statementId": "selectById", + "kind": "select", + "javaPath": "src/main/java/example/UserMapper.java", + "xmlPath": "src/main/resources/mapper/UserMapper.xml" + } + } +} diff --git a/fixtures/run-configuration/automatic-java-selection.json b/fixtures/run-configuration/automatic-java-selection.json new file mode 100644 index 0000000..93c355e --- /dev/null +++ b/fixtures/run-configuration/automatic-java-selection.json @@ -0,0 +1,284 @@ +{ + "version": 1, + "cases": [ + { + "name": "skip-incompatible-java-home", + "minimum": "17", + "candidates": [ + { + "id": "env", + "version": "1.8.0_402", + "priority": 0 + }, + { + "id": "path", + "version": "17.0.12", + "priority": 1 + }, + { + "id": "latest", + "version": "21.0.4", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "compatible-java-home-wins", + "minimum": "17", + "candidates": [ + { + "id": "env", + "version": "17.0.12", + "priority": 0 + }, + { + "id": "latest", + "version": "21.0.4", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "numeric-version-order", + "minimum": "8", + "candidates": [ + { + "id": "eleven", + "version": "11.0.22", + "priority": 2 + }, + { + "id": "twenty-one", + "version": "21.0.4", + "priority": 2 + }, + { + "id": "nine", + "version": "9.0.4", + "priority": 2 + } + ], + "fallbackId": "eleven" + }, + { + "name": "numeric-patch-order", + "minimum": "17", + "candidates": [ + { + "id": "nine", + "version": "17.0.9", + "priority": 2 + }, + { + "id": "ten", + "version": "17.0.10", + "priority": 2 + } + ], + "fallbackId": "nine" + }, + { + "name": "legacy-java-eight", + "minimum": "8", + "candidates": [ + { + "id": "env", + "version": "1.8.0_402", + "priority": 0 + }, + { + "id": "latest", + "version": "21.0.4", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "stable-tie", + "minimum": "17", + "candidates": [ + { + "id": "z", + "version": "17.0.12", + "priority": 2 + }, + { + "id": "a", + "version": "17.0.12", + "priority": 2 + } + ], + "fallbackId": "z" + }, + { + "name": "exact-feature-beats-newer-java-home", + "minimum": "8", + "candidates": [ + { + "id": "env", + "version": "21.0.4", + "priority": 0 + }, + { + "id": "zulu8", + "version": "1.8.0_504", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "oldest-newer-when-exact-missing", + "minimum": "11", + "candidates": [ + { + "id": "env", + "version": "21.0.4", + "priority": 0 + }, + { + "id": "seventeen", + "version": "17.0.12", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "priority-breaks-ties-within-feature", + "minimum": "17", + "candidates": [ + { + "id": "detected", + "version": "17.0.14", + "priority": 2 + }, + { + "id": "path", + "version": "17.0.12", + "priority": 1 + }, + { + "id": "latest", + "version": "21.0.4", + "priority": 0 + } + ], + "fallbackId": "latest" + }, + { + "name": "unsatisfied-retains-fallback", + "minimum": "21", + "candidates": [ + { + "id": "env", + "version": "8.0.402", + "priority": 0 + }, + { + "id": "other", + "version": "17.0.12", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "missing-requirement-retains-fallback", + "minimum": null, + "candidates": [ + { + "id": "env", + "version": "8.0.402", + "priority": 0 + }, + { + "id": "latest", + "version": "21.0.4", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "unknown-version-is-not-compatible", + "minimum": "17", + "candidates": [ + { + "id": "env", + "version": "", + "priority": 0 + }, + { + "id": "other", + "version": "17.0.12", + "priority": 2 + } + ], + "fallbackId": "env" + }, + { + "name": "no-runtimes", + "minimum": "17", + "candidates": [], + "fallbackId": null + } + ], + "expected": { + "skip-incompatible-java-home": { + "id": "path", + "warning": false + }, + "compatible-java-home-wins": { + "id": "env", + "warning": false + }, + "numeric-version-order": { + "id": "nine", + "warning": false + }, + "numeric-patch-order": { + "id": "ten", + "warning": false + }, + "legacy-java-eight": { + "id": "env", + "warning": false + }, + "stable-tie": { + "id": "a", + "warning": false + }, + "unsatisfied-retains-fallback": { + "id": "env", + "warning": true + }, + "missing-requirement-retains-fallback": { + "id": "env", + "warning": false + }, + "unknown-version-is-not-compatible": { + "id": "other", + "warning": false + }, + "no-runtimes": { + "id": null, + "warning": true + }, + "exact-feature-beats-newer-java-home": { + "id": "zulu8", + "warning": false + }, + "oldest-newer-when-exact-missing": { + "id": "seventeen", + "warning": false + }, + "priority-breaks-ties-within-feature": { + "id": "path", + "warning": false + } + } +} diff --git a/fixtures/spring/basic.json b/fixtures/spring/basic.json new file mode 100644 index 0000000..1d5c134 --- /dev/null +++ b/fixtures/spring/basic.json @@ -0,0 +1,45 @@ +{ + "name": "Spring workspace semantic index", + "request": { + "command": "spring.index", + "payload": { + "root": "/fixture/workspace", + "paths": [ + "src/main/java/example/DemoProperties.java", + "src/main/java/example/GreetingController.java", + "src/main/resources/application-dev.yml" + ], + "metadataRepositories": [ + "/fixture/gradle-repository", + "/fixture/maven-repository" + ], + "refreshDependencyMetadata": true, + "textOverrides": { + "src/main/resources/application-dev.yml": "demo:\n enabled: true\n" + } + } + }, + "expected": { + "property": { + "name": "demo.enabled", + "sourcePath": "src/main/java/example/DemoProperties.java", + "sourceLine": 5 + }, + "value": { + "path": "src/main/resources/application-dev.yml", + "profile": "dev", + "overridesBaseValue": false + }, + "propertyReference": { + "key": "demo.enabled" + }, + "injection": { + "typeName": "GreetingService", + "qualifier": "primaryGreeting" + }, + "endpoint": { + "httpMethods": ["GET"], + "route": "/api/greeting" + } + } +} 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/scripts/verify-sdk-lock.sh b/scripts/verify-sdk-lock.sh new file mode 100755 index 0000000..b9f25d4 --- /dev/null +++ b/scripts/verify-sdk-lock.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Checks that the backend links exactly the plugin SDK recorded in +# sdk.lock.json. +# +# Without arguments the check is offline: Cargo.toml must name the locked tag +# and Cargo.lock must resolve the locked commit (backend/tests/repository_pins.rs). +# With --download it also fetches the published crate archive into +# .artifacts/sdk/ and refuses it unless its SHA-256 matches the lock, which +# proves the tag still points at the release the lock was written for. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOCK="$ROOT_DIR/sdk.lock.json" +DOWNLOAD=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --download) DOWNLOAD=true; shift ;; + *) echo "Usage: $0 [--download]" >&2; exit 2 ;; + esac +done + +cargo test --locked --manifest-path "$ROOT_DIR/Cargo.toml" -p lithe-java-backend \ + --test repository_pins cargo_resolves_the_sdk_commit_recorded_in_the_lock + +[[ "$DOWNLOAD" == true ]] || exit 0 + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +url="$(jq -r .url "$LOCK")" +expected="$(jq -r .sha256 "$LOCK")" +version="$(jq -r .version "$LOCK")" +destination="$ROOT_DIR/.artifacts/sdk/lithe-plugin-sdk-rust-$version-$expected.crate" +mkdir -p "$(dirname "$destination")" +if [[ ! -f "$destination" || "$(sha256 "$destination")" != "$expected" ]]; then + temporary="$destination.download.$$" + trap 'rm -f "$temporary"' EXIT + curl --fail --location --silent --show-error --retry 3 \ + --connect-timeout 20 --max-time 300 --output "$temporary" "$url" + actual="$(sha256 "$temporary")" + if [[ "$actual" != "$expected" ]]; then + echo "SDK archive checksum mismatch: expected $expected, got $actual ($url)" >&2 + exit 1 + fi + mv "$temporary" "$destination" +fi +echo "Verified SDK archive $url" diff --git a/sdk.lock.json b/sdk.lock.json new file mode 100644 index 0000000..e099533 --- /dev/null +++ b/sdk.lock.json @@ -0,0 +1,9 @@ +{ + "comment": "Lithe Rust plugin SDK used by backend/. backend/Cargo.toml depends on the git tag; the backend tests require Cargo.lock to resolve exactly this commit, and scripts/verify-sdk-lock.sh --download also checks the published crate archive against sha256.", + "version": "0.1.0", + "repository": "https://github.com/Lithe-IDEA/lithe-plugin-docs", + "tag": "sdk-rust-v0.1.0", + "commit": "e689971c000c15a044df0502052fe7b2c1c6f84f", + "url": "https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/download/sdk-rust-v0.1.0/lithe-plugin-sdk-rust-0.1.0.crate", + "sha256": "8a8001ca6557863f1f231bcdbfee1db4e2ce4ab5eefd1a8ba2bf888159ebc8e9" +} 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 +}