diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 6b509f5..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -target = "wasm32-wasip1" diff --git a/.github/ISSUE_TEMPLATE/1-grammar-bug.yml b/.github/ISSUE_TEMPLATE/1-grammar-bug.yml index df524c2..a8fed9b 100644 --- a/.github/ISSUE_TEMPLATE/1-grammar-bug.yml +++ b/.github/ISSUE_TEMPLATE/1-grammar-bug.yml @@ -1,6 +1,7 @@ name: Grammar Bug description: A bug related to the Tree-Sitter grammar (e.g. syntax highlighting, auto-indents, outline, bracket-closing). -labels: ["bug", "grammar"] +labels: ["grammar"] +type: "Bug" body: - type: checkboxes id: relevance-confirmation @@ -25,3 +26,12 @@ body: placeholder: I expected to see 1, 2, and 3. validations: required: true + - type: textarea + id: environment + attributes: + label: Environment + value: | + Zed: + Platform: + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/2-language-server-bug.yml b/.github/ISSUE_TEMPLATE/2-language-server-bug.yml index 9a9e03e..b9c53ed 100644 --- a/.github/ISSUE_TEMPLATE/2-language-server-bug.yml +++ b/.github/ISSUE_TEMPLATE/2-language-server-bug.yml @@ -1,6 +1,7 @@ name: Language Server Bug description: A bug related to the language server (e.g. autocomplete, diagnostics, hover-docs, go to symbol, initialization options). -labels: ["bug", "language-server"] +labels: ["language-server"] +type: "Bug" body: - type: checkboxes id: relevance-confirmation @@ -25,3 +26,13 @@ body: placeholder: I expected to see 1, 2, and 3. validations: required: true + - type: textarea + id: environment + attributes: + label: Environment + value: | + Zed: + Platform: + Java: + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/3-other-bug.yml b/.github/ISSUE_TEMPLATE/3-other-bug.yml index 48ca83e..a10d5bc 100644 --- a/.github/ISSUE_TEMPLATE/3-other-bug.yml +++ b/.github/ISSUE_TEMPLATE/3-other-bug.yml @@ -1,6 +1,7 @@ name: Other Bug description: A bug related to something else! -labels: ["bug"] +labels: [] +type: "Bug" body: - type: textarea id: what-happened @@ -17,3 +18,12 @@ body: placeholder: I expected to see 1, 2, and 3. validations: required: true + - type: textarea + id: environment + attributes: + label: Environment + value: | + Zed: + Platform: + validations: + required: true diff --git a/.github/workflows/bump_version.yml b/.github/workflows/bump_version.yml new file mode 100644 index 0000000..abe7b6c --- /dev/null +++ b/.github/workflows/bump_version.yml @@ -0,0 +1,61 @@ +# Generated from xtask::workflows::extensions::bump_version within the Zed repository. +# Rebuild with `cargo xtask workflows`. +name: extensions::bump_version +on: + pull_request: + types: + - labeled + push: + branches: + - main + paths-ignore: + - .github/** + workflow_dispatch: {} +jobs: + determine_bump_type: + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + runs-on: namespace-profile-2x4-ubuntu-2404 + permissions: {} + steps: + - id: get-bump-type + name: extensions::bump_version::get_bump_type + run: | + if [ "$HAS_MAJOR_LABEL" = "true" ]; then + bump_type="major" + elif [ "$HAS_MINOR_LABEL" = "true" ]; then + bump_type="minor" + else + bump_type="patch" + fi + echo "bump_type=$bump_type" >> $GITHUB_OUTPUT + env: + HAS_MAJOR_LABEL: |- + ${{ (github.event.action == 'labeled' && github.event.label.name == 'major') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'major')) }} + HAS_MINOR_LABEL: |- + ${{ (github.event.action == 'labeled' && github.event.label.name == 'minor') || + (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'minor')) }} + outputs: + bump_type: ${{ steps.get-bump-type.outputs.bump_type }} + call_bump_version: + needs: + - determine_bump_type + if: github.event.action != 'labeled' || needs.determine_bump_type.outputs.bump_type != 'patch' + permissions: + actions: write + contents: write + issues: write + pull-requests: write + uses: zed-industries/zed/.github/workflows/extension_bump.yml@30d3467d4bbbb47296b6cd81c8f9ffc31ae94b4b + secrets: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + app-secret: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + with: + bump-type: ${{ needs.determine_bump_type.outputs.bump_type }} + force-bump: ${{ github.event_name != 'push' }} +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}labels + cancel-in-progress: true +defaults: + run: + shell: bash -euxo pipefail {0} diff --git a/.gitignore b/.gitignore index 4536eb6..fdf4353 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ grammars/ target/ -Cargo.lock extension.wasm .DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a53688f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,940 @@ +# 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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "auditable-serde" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7bf8143dfc3c0258df908843e169b5cc5fcf76c7718bd66135ef4a9cd558c5" +dependencies = [ + "semver", + "serde", + "serde_json", + "topological-sort", +] + +[[package]] +name = "bitflags" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" + +[[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 = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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 = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[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.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25a2bc672d1148e28034f176e01fffebb08b35768468cc954630da77a1449005" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "722166aa0d7438abbaa4d5cc2c649dac844e8c56d82fb3d33e9c34b5cd268fc6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3160422bbd54dd5ecfdca71e5fd59b7b8fe2b1697ab2baf64f6d05dcc66d298" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "topological-sort" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-encoder" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1ef0faabbbba6674e97a56bee857ccddf942785a336c8b47b42373c922a91d" +dependencies = [ + "anyhow", + "auditable-serde", + "flate2", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "url", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" +dependencies = [ + "bitflags", + "futures", + "once_cell", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad19eec017904e04c60719592a803ee5da76cb51c81e3f6fbf9457f59db49799" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zed_extension_api" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" +dependencies = [ + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "zed_java" +version = "6.8.12" +dependencies = [ + "hex", + "regex", + "serde", + "serde_json", + "sha1", + "zed_extension_api", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 219ede3..04a1ba7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,19 @@ + [package] -name = "java" -version = "6.0.1" +name = "zed_java" +version = "6.8.12" edition = "2024" +publish = false +license = "Apache-2.0" [lib] +path = "src/java.rs" crate-type = ["cdylib"] [dependencies] -zed_extension_api = "0.3.0" +hex = "0.4.3" +regex = "1.12.2" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +sha1 = "0.10.6" +zed_extension_api = "0.7.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + 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 + (an example is provided in the Appendix below). + + "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. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 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 + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index ebf3830..ff01859 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,239 @@ -# Zed Java +# Java Extension for Zed -This extension adds support for the Java language. +This extension adds support for the Java language to [Zed](https://zed.dev). It is using the [Eclipse JDT Language Server](https://projects.eclipse.org/projects/eclipse.jdt.ls) (JDTLS for short) to provide completions, code-actions and diagnostics. -## Initialization Options +## Quick Start -If [Lombok] support is enabled via [JDTLS] initialization option -(`initialization_options.settings.java.jdt.ls.lombokSupport.enabled`), this -extension will download and add [Lombok] as a javaagent to the JVM arguments for -[JDTLS]. +Install the extension via Zeds extension manager. It should work out of the box for most people. However, there are some things to know: -There are also many more options you can pass directly to the language server, -for example: +- It is generally recommended to open projects with the Zed-project root at the Java project root folder (where you would commonly have your `pom.xml` or `build.gradle` file). + +- By default the extension will download and run the latest official version of JDTLS for you, but this requires Java version 21 to be available on your system via either the `$JAVA_HOME` environment variable or as a `java(.exe)` executable on your `$PATH`. If your project requires a lower Java version in the environment, you can specify a different JDK to use for running JDTLS via the `java_home` configuration option. + +- You can provide a **custom launch script for JDTLS**, by adding an executable named `jdtls` (or `jdtls.bat` on Windows) to your `$PATH` environment variable. If this is present, the extension will skip downloading and launching a managed instance and use the one from the environment. + +- To support [Lombok](https://projectlombok.org/), the lombok-jar must be downloaded and registered as a Java-Agent when launching JDTLS. By default the extension automatically takes care of that, but in case you don't want that you can set the `lombok_support` configuration-option to `false`. + +- The option to let the extension automatically download a version of OpenJDK can be enabled by setting `jdk_auto_download` to `true`. When enabled, the extension will only download a JDK if no valid java_home is provided or if the specified one does not meet the minimum version requirement. User-provided JDKs **always** take precedence. + +Here is a common `settings.json` including the above mentioned configurations: ```jsonc -{ - "lsp": { - "jdtls": { - "initialization_options": { - "bundles": [], - "workspaceFolders": ["file:///home/snjeza/Project"], - "settings": { - "java": { - "home": "/usr/local/jdk-9.0.1", - "errors": { - "incompleteClasspath": { - "severity": "warning" - } - }, - "configuration": { - "updateBuildConfiguration": "interactive", - "maven": { - "userSettings": null - } - }, - "trace": { - "server": "verbose" - }, - "import": { - "gradle": { +"lsp": { + "jdtls": { + "settings": { + "java_home": "/path/to/your/JDK21+", + "lombok_support": true, + "jdk_auto_download": false, + + // Controls when to check for updates for JDTLS, Lombok, and Debugger + // - "always" (default): Always check for and download the latest version + // - "once": Check for updates only if no local installation exists + // - "never": Never check for updates, only use existing local installations (errors if missing) + // + // Note: Invalid values will default to "always" + // If custom paths (below) are provided, check_updates is IGNORED for that component + "check_updates": "always", + + // Use custom installations instead of managed downloads + // When these are set, the extension will not download or manage these components + "jdtls_launcher": "/path/to/your/jdt-language-server/bin/jdtls", + "lombok_jar": "/path/to/your/lombok.jar", + "java_debug_jar": "/path/to/your/com.microsoft.java.debug.plugin.jar" + } + } +} +``` + +## Project Symbol Search + +The extension supports project-wide symbol search with syntax-highlighted results. This feature is powered by JDTLS and can be accessed via Zed's symbol search. + +JDTLS uses **CamelCase fuzzy matching** for symbol queries. For example, searching for `EmpMe` would match `EmptyMedia`. The pattern works like `Emp*Me*`, matching the capital letters of CamelCase names. + +## Debugger + +Debug support is enabled via our [Fork of Java Debug](https://github.com/zed-industries/java-debug), which the extension will automatically download and start for you. Please refer to the [Zed Documentation](https://zed.dev/docs/debugger#getting-started) for general information about how debugging works in Zed. + +To get started with Java, click the `edit debug.json` button in the Debug menu, and replace the contents of the file with the following: +```jsonc +[ + { + "adapter": "Java", + "request": "launch", + "label": "Launch Debugger", + // if your project has multiple entry points, specify the one to use: + // "mainClass": "com.myorganization.myproject.MyMainClass", + // + // this effectively sets a breakpoint at your program entry: + "stopOnEntry": true, + // the working directory for the debug process + "cwd": "$ZED_WORKTREE_ROOT" + } +] +``` + +You should then be able to start a new Debug Session with the "Launch Debugger" scenario from the debug menu. + +### Single-File Debugging + +If you're working a lot with single file debugging, you can use the following `debug.json` config instead: +```jsonc +[ + { + "label": "Debug $ZED_STEM", + "adapter": "Java", + "request": "launch", + "mainClass": "$ZED_STEM", + "build": { + "command": "javac -d . $ZED_FILE", + "shell": { + "with_arguments": { + "program": "/bin/sh", + "args": ["-c"] + } + } + } + } +] +``` +This will compile and launch the debugger using the currently selected file as the entry point. +Ideally, we would implement a run/debug option directly in the runnables (similar to how the Rust extension does it), which would allow you to easily start a debugging session without explicitly updating the entry point. +Note that integrating the debugger with runnables is currently limited to core languages in Zed, so this is the best workaround for now. + +## Launch Scripts (aka Tasks) in Windows + +This extension provides tasks for running your application and tests from within Zed via little play buttons next to tests/entry points. However, due to current limitiations of Zed's extension interface, we can not provide scripts that will work across Maven and Gradle on both Windows and Unix-compatible systems, so out of the box the launch scripts only work on Mac and Linux. + +There is a fairly straightforward fix that you can apply to make it work on Windows by supplying your own task scripts. Please see [this Issue](https://github.com/zed-extensions/java/issues/94) for information on how to do that and read the [Tasks section in Zeds documentation](https://zed.dev/docs/tasks) for more information. + +## Advanced Configuration/JDTLS initialization Options +JDTLS provides many configuration options that can be passed via the `initialize` LSP-request. The extension will pass the JSON-object from `lsp.jdtls.initialization_options` in your settings on to JDTLS. Please refer to the [JDTLS Configuration Wiki Page](https://github.com/eclipse-jdtls/eclipse.jdt.ls/wiki/Running-the-JAVA-LS-server-from-the-command-line#initialize-request) for the available options and values. Below is an opinionated example configuration for JDTLS with most options enabled: + +```jsonc +"lsp": { + "jdtls": { + "initialization_options": { + "bundles": [], + "workspaceFolders": [ + "file:///home/snjeza/Project" + ], + "settings": { + "java": { + "configuration": { + "updateBuildConfiguration": "automatic", + "runtimes": [] + }, + "saveActions": { + "organizeImports": true + }, + "compile": { + "nullAnalysis": { + "mode": "automatic" + } + }, + "references": { + "includeAccessors": true, + "includeDecompiledSources": true + }, + "jdt": { + "ls": { + "protobufSupport": { "enabled": true }, - "maven": { + "groovySupport": { "enabled": true - }, - "exclusions": [ - "**/node_modules/**", - "**/.metadata/**", - "**/archetype-resources/**", - "**/META-INF/maven/**", - "/**/test/**" - ] - }, - "jdt": { - "ls": { - "lombokSupport": { - "enabled": false // Set this to true to enable lombok support - } } + } + }, + "eclipse": { + "downloadSources": true + }, + "maven": { + "downloadSources": true, + "updateSnapshots": true + }, + "autobuild": { + "enabled": true + }, + "maxConcurrentBuilds": 1, + "inlayHints": { + "parameterNames": { + "enabled": "all" + } + }, + "signatureHelp": { + "enabled": true, + "description": { + "enabled": true + } + }, + "format": { + "enabled": true, + "settings": { + // The formatter config to use + "url": "~/.config/jdtls/palantir_java_jdtls.xml" }, - "referencesCodeLens": { - "enabled": false - }, - "signatureHelp": { - "enabled": false - }, - "implementationsCodeLens": { - "enabled": false - }, - "format": { + "onType": { "enabled": true + } + }, + "contentProvider": { + "preferred": null + }, + "import": { + "gradle": { + "enabled": true, + "wrapper": { + "enabled": true + } }, - "saveActions": { - "organizeImports": false + "maven": { + "enabled": true }, - "contentProvider": { - "preferred": null + "exclusions": [ + "**/node_modules/**", + "**/.metadata/**", + "**/archetype-resources/**", + "**/META-INF/maven/**", + "/**/test/**" + ] + }, + "completion": { + "enabled": true, + "favoriteStaticMembers": [ + "org.junit.Assert.*", + "org.junit.Assume.*", + "org.junit.jupiter.api.Assertions.*", + "org.junit.jupiter.api.Assumptions.*", + "org.junit.jupiter.api.DynamicContainer.*", + "org.junit.jupiter.api.DynamicTest.*", + "org.mockito.Mockito.*", + "org.mockito.ArgumentMatchers.*" + ], + "importOrder": [ + "java", + "javax", + "com", + "org" + ], + "postfix": { + "enabled": true }, - "autobuild": { - "enabled": false + "chain": { + "enabled": true }, - "completion": { - "favoriteStaticMembers": [ - "org.junit.Assert.*", - "org.junit.Assume.*", - "org.junit.jupiter.api.Assertions.*", - "org.junit.jupiter.api.Assumptions.*", - "org.junit.jupiter.api.DynamicContainer.*", - "org.junit.jupiter.api.DynamicTest.*" - ], - "importOrder": ["java", "javax", "com", "org"] + "guessMethodArguments": "insertParameterNames", + "overwrite": true + }, + "errors": { + "incompleteClasspath": { + "severity": "warning" } + }, + "implementationCodeLens": "all", + "referencesCodeLens": { + "enabled": true } } } @@ -98,11 +242,27 @@ for example: } ``` -*Example taken from JDTLS's [initialization options wiki page].* +If you're working without a Gradle or Maven project, and the following error `The declared package "Example" does not match the expected package ""` pops up, consider adding these settings under -You can see all the options JDTLS accepts [here][initialization options wiki -page]. +``` +MyProject/ + ├── .zed/ + │ └── settings.json + ``` + +```jsonc +"lsp": { + "jdtls": { + "initialization_options": { + "project": { + "sourcePaths": [ + ".", + "src" + ] + }, + } + } +} +``` -[JDTLS]: https://github.com/eclipse-jdtls/eclipse.jdt.ls -[initialization options wiki page]: https://github.com/eclipse-jdtls/eclipse.jdt.ls/wiki/Running-the-JAVA-LS-server-from-the-command-line#initialize-request -[Lombok]: https://projectlombok.org +If changes are not picked up, clean JDTLS' cache (from a java file run the task `Clear JDTLS cache`) and restart the language server diff --git a/debug_adapter_schemas/Java.json b/debug_adapter_schemas/Java.json new file mode 100644 index 0000000..e36b5bf --- /dev/null +++ b/debug_adapter_schemas/Java.json @@ -0,0 +1,173 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "oneOf": [ + { + "title": "Launch", + "properties": { + "request": { + "type": "string", + "enum": ["launch"], + "description": "The request type for the Java debug adapter, always \"launch\"." + }, + "projectName": { + "type": "string", + "description": "The fully qualified name of the project" + }, + "mainClass": { + "type": "string", + "description": "The fully qualified name of the class containing the main method. If not specified, the debugger automatically resolves the possible main class from the current project." + }, + "args": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "The command line arguments passed to the program. Can be a single string or an array of strings." + }, + "vmArgs": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "The extra options and system properties for the JVM (e.g., -Xms -Xmx -D=). Can be a single string or an array of strings." + }, + "encoding": { + "type": "string", + "description": "The file.encoding setting for the JVM. Possible values can be found in the Supported Encodings documentation." + }, + "classPaths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The classpaths for launching the JVM. If not specified, the debugger will automatically resolve them from the current project. If multiple values are specified, the debugger will merge them together. Available values for special handling include: '$Auto' - Automatically resolve the classpaths of the current project. '$Runtime' - The classpaths within the 'runtime' scope of the current project. '$Test' - The classpaths within the 'test' scope of the current project." + }, + "modulePaths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The modulepaths for launching the JVM. If not specified, the debugger will automatically resolve them from the current project." + }, + "cwd": { + "type": "string", + "description": "The working directory of the program. Defaults to '${workspaceFolder}'." + }, + "env": { + "type": "object", + "description": "The extra environment variables for the program.", + "additionalProperties": { + "type": "string" + } + }, + "stopOnEntry": { + "type": "boolean", + "description": "Automatically pause the program after launching." + }, + "noDebug": { + "type": "boolean", + "description": "If set to 'true', disables debugging. The program will be launched without attaching the debugger. Useful for launching a program without debugging, for instance for profiling." + }, + "console": { + "type": "string", + "enum": ["internalConsole", "integratedTerminal", "externalTerminal"], + "description": "The specified console to launch the program." + }, + "shortenCommandLine": { + "type": "string", + "enum": ["none", "jarmanifest", "argfile"], + "description": "Provides multiple approaches to shorten the command line when it exceeds the maximum command line string limitation allowed by the OS." + }, + "launcherScript": { + "type": "string", + "description": "The path to an external launcher script to use instead of the debugger's built-in launcher. This is an advanced option for customizing how the JVM is launched." + }, + "javaExec": { + "type": "string", + "description": "The path to the Java executable to use. By default, the project JDK's Java executable is used." + } + }, + "required": ["request"] + }, + { + "title": "Attach", + "properties": { + "request": { + "type": "string", + "enum": ["attach"], + "description": "The request type for the Java debug adapter, always \"attach\"." + }, + "hostName": { + "type": "string", + "description": "The host name or IP address of the remote debuggee." + }, + "port": { + "type": "integer", + "description": "The debug port of the remote debuggee." + }, + "timeout": { + "type": "integer", + "description": "Timeout value before reconnecting, in milliseconds (default to 30000ms)." + }, + "projectName": { + "type": "string", + "description": "The fully qualified name of the project" + }, + "sourcePaths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The source paths for the debugger. If not specified, the debugger will automatically resolve the source paths from the current project." + }, + "stepFilters": { + "type": "object", + "properties": { + "allowClasses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Restricts the events generated by the request to those whose location is in a class whose name matches this restricted regular expression. Regular expressions are limited to exact matches and patterns that begin with '*' or end with '*'; for example, \"*.Foo\" or \"java.*\"." + }, + "skipClasses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Restricts the events generated by the request to those whose location is in a class whose name does not match this restricted regular expression, e.g. \"java.*\" or \"*.Foo\"." + }, + "skipSynthetics": { + "type": "boolean", + "description": "If true, skips synthetic methods." + }, + "skipStaticInitializers": { + "type": "boolean", + "description": "If true, skips static initializers." + }, + "skipConstructors": { + "type": "boolean", + "description": "If true, skips constructors." + } + } + } + }, + "required": ["request", "hostName", "port"] + } + ] +} diff --git a/extension.toml b/extension.toml index 74a7ff7..91f82a9 100644 --- a/extension.toml +++ b/extension.toml @@ -1,11 +1,9 @@ id = "java" name = "Java" -version = "6.0.1" +version = "6.8.12" schema_version = 1 authors = [ - "Valentine Briese ", - "Samuser107 L.Longheval ", - "Yury Abykhodau ", + "Java Extension Contributors", ] description = "Java support." repository = "https://github.com/zed-extensions/java" @@ -14,6 +12,17 @@ repository = "https://github.com/zed-extensions/java" repository = "https://github.com/tree-sitter/tree-sitter-java" commit = "94703d5a6bed02b98e438d7cad1136c01a60ba2c" +[grammars.properties] +repository = "https://github.com/tree-sitter-grammars/tree-sitter-properties" +commit = "579b62f5ad8d96c2bb331f07d1408c92767531d9" + [language_servers.jdtls] name = "Eclipse JDT Language Server" language = "Java" + +[debug_adapters.Java] + +[[capabilities]] +kind = "process:exec" +command = "*" +args = ["-version"] diff --git a/languages/java/brackets.scm b/languages/java/brackets.scm index 191fd9c..72ac07e 100644 --- a/languages/java/brackets.scm +++ b/languages/java/brackets.scm @@ -1,3 +1,6 @@ -("(" @open ")" @close) -("[" @open "]" @close) ("{" @open "}" @close) +("[" @open "]" @close) +("(" @open ")" @close) +("<" @open ">" @close) +("\"" @open "\"" @close) +("\"\"\"" @open "\"\"\"" @close) diff --git a/languages/java/config.toml b/languages/java/config.toml index 121f5a1..589524e 100644 --- a/languages/java/config.toml +++ b/languages/java/config.toml @@ -7,6 +7,14 @@ brackets = [ { start = "{", end = "}", close = true, newline = true }, { start = "[", end = "]", close = true, newline = true }, { start = "(", end = ")", close = true, newline = false }, + { start = "<", end = ">", close = false, newline = false }, + { start = "\"", end = "\"", close = true, newline = false, not_in = ["string"] }, + { start = "'", end = "'", close = true, newline = false, not_in = ["string"] }, + { start = "/*", end = " */", close = true, newline = true, not_in = ["string", "comment"] }, ] +collapsed_placeholder = " /* ... */ " +block_comment = { start = "/*", prefix="", end = "*/", tab_size = 2 } +documentation_comment = { start = "/**", end = "*/", prefix = "* ", tab_size = 1 } prettier_parser_name = "java" prettier_plugins = ["prettier-plugin-java"] +debuggers = ["Java"] diff --git a/languages/java/highlights.scm b/languages/java/highlights.scm index 29770d1..a156246 100644 --- a/languages/java/highlights.scm +++ b/languages/java/highlights.scm @@ -73,7 +73,7 @@ name: (identifier) @type) (annotation_type_declaration - name: (identifier) @type) + name: (identifier) @attribute) (class_declaration name: (identifier) @type) @@ -139,11 +139,11 @@ ; Annotations (annotation - "@" @attribute + "@" @punctuation.special name: (identifier) @attribute) (marker_annotation - "@" @attribute + "@" @punctuation.special name: (identifier) @attribute) ; Literals @@ -167,7 +167,7 @@ (false) ] @boolean -(null_literal) @type +(null_literal) @constant.builtin ; Keywords [ @@ -184,6 +184,7 @@ "permits" "to" "with" + "new" ] @keyword [ @@ -212,8 +213,6 @@ "yield" ] @keyword -"new" @operator - ; Conditionals [ "if" @@ -289,7 +288,7 @@ [ "\\{" "}" - ] @string.special.symbol) + ] @punctuation.special) @embedded ; Exceptions [ diff --git a/languages/java/outline.scm b/languages/java/outline.scm index ffd036e..59a07e2 100644 --- a/languages/java/outline.scm +++ b/languages/java/outline.scm @@ -10,9 +10,10 @@ "final" "strictfp" "static" - ]* @context) + ]* @context)? "class" @context - name: (_) @name) @item + name: (_) @name + body: (_) @item) (record_declaration (modifiers @@ -26,9 +27,10 @@ "final" "strictfp" "static" - ]* @context) + ]* @context)? "record" @context - name: (_) @name) @item + name: (_) @name + body: (_) @item) (interface_declaration (modifiers @@ -41,9 +43,66 @@ "non-sealed" "strictfp" "static" - ]* @context) + ]* @context)? "interface" @context - name: (_) @name) @item + name: (_) @name + body: (_) @item) + +(enum_declaration + (modifiers + [ + "private" + "public" + "protected" + "static" + "final" + "strictfp" + ]* @context)? + "enum" @context + name: (_) @name + body: (_) @item) + +(annotation_type_declaration + (modifiers + [ + "private" + "public" + "protected" + "abstract" + "static" + "strictfp" + ]* @context)? + "@interface" @context + name: (_) @name + body: (_) @item) + +(enum_constant + name: (identifier) @name) @item + +(method_declaration + (modifiers + [ + "private" + "public" + "protected" + "abstract" + "static" + "final" + "native" + "strictfp" + "synchronized" + ]* @context)? + type: (_) @context + name: (_) @name + parameters: (formal_parameters + "(" @context + (formal_parameter + type: (_) @context)? + ("," @context + (formal_parameter + type: (_) @context)?)* + ")" @context) + body: (_) @item) (method_declaration (modifiers @@ -57,11 +116,62 @@ "native" "strictfp" "synchronized" - ]* @context) + ]* @context)? + type: (_) @context name: (_) @name parameters: (formal_parameters "(" @context - ")" @context)) @item + (formal_parameter + type: (_) @context)? + ("," @context + (formal_parameter + type: (_) @context)?)* + ")" @context) + ";" @item) + +(constructor_declaration + (modifiers + [ + "private" + "public" + "protected" + "static" + "final" + ]* @context)? + name: (_) @name + parameters: (formal_parameters + "(" @context + (formal_parameter + type: (_) @context)? + ("," @context + (formal_parameter + type: (_) @context)?)* + ")" @context) + body: (_) @item) + +(compact_constructor_declaration + (modifiers + [ + "private" + "public" + "protected" + ]* @context)? + name: (_) @name + body: (_) @item) + +(annotation_type_element_declaration + (modifiers + [ + "private" + "public" + "protected" + "abstract" + "static" + ]* @context)? + type: (_) @context + name: (_) @name + "(" @context + ")" @context) @item (field_declaration (modifiers @@ -73,6 +183,28 @@ "final" "transient" "volatile" - ]* @context) + ]* @context)? + type: (_) @context declarator: (variable_declarator name: (_) @name)) @item + +(constant_declaration + (modifiers + [ + "public" + "static" + "final" + ]* @context)? + type: (_) @context + declarator: (variable_declarator + name: (_) @name)) @item + +(static_initializer + "static" @context + (block) @item) + +(record_declaration + parameters: (formal_parameters + (formal_parameter + type: (_) @context + name: (_) @name) @item)) diff --git a/languages/java/overrides.scm b/languages/java/overrides.scm new file mode 100644 index 0000000..af73171 --- /dev/null +++ b/languages/java/overrides.scm @@ -0,0 +1,9 @@ +[ + (block_comment) + (line_comment) +] @comment.inclusive + +[ + (character_literal) + (string_literal) +] @string diff --git a/languages/java/runnables.scm b/languages/java/runnables.scm new file mode 100644 index 0000000..7ad84dc --- /dev/null +++ b/languages/java/runnables.scm @@ -0,0 +1,163 @@ +; Run the main function +( + (package_declaration + (scoped_identifier) @java_package_name + )? + (class_declaration + (modifiers) @class-modifier + (#match? @class-modifier "public") + name: (identifier) @java_class_name + body: (class_body + (method_declaration + (modifiers) @modifier + name: (identifier) @run + (#eq? @run "main") + (#match? @modifier "public") + (#match? @modifier "static") + ) + ) + ) @_ + (#set! tag java-main) +) + +; Run the main class +( + (package_declaration + (scoped_identifier) @java_package_name + )? + (class_declaration + (modifiers) @class-modifier + (#match? @class-modifier "public") + name: (identifier) @java_class_name @run + body: (class_body + (method_declaration + (modifiers) @modifier + name: (identifier) @method_name + (#eq? @method_name "main") + (#match? @modifier "public") + (#match? @modifier "static") + ) + ) + ) @_ + (#set! tag java-main) +) + +; Run test function (marker annotation, e.g. @Test) +( + (package_declaration + (scoped_identifier) @java_package_name + ) + (class_declaration + name: (identifier) @java_class_name + body: (class_body + (method_declaration + (modifiers + [(marker_annotation + name: (identifier) @annotation_name + ) + (annotation + name: (identifier) @annotation_name + )] + ) + name: (identifier) @run @java_method_name + (#match? @annotation_name "Test$") + ) + ) + ) @_ + (#set! tag java-test-method) +) + +; Run nested test function +( + (package_declaration + (scoped_identifier) @java_package_name + ) + (class_declaration + name: (identifier) @java_outer_class_name + body: (class_body + (class_declaration + (modifiers + (marker_annotation + name: (identifier) @nested_annotation + ) + ) + name: (identifier) @java_class_name + body: (class_body + (method_declaration + (modifiers + [(marker_annotation + name: (identifier) @annotation_name + ) + (annotation + name: (identifier) @annotation_name + )] + ) + name: (identifier) @run @java_method_name + (#match? @annotation_name "Test$") + ) + ) + (#eq? @nested_annotation "Nested") + ) @_ + ) + ) + (#set! tag java-test-method-nested) +) + +; Run test class +( + (package_declaration + (scoped_identifier) @java_package_name + ) + (class_declaration + name: (identifier) @java_class_name @run + body: (class_body + (method_declaration + (modifiers + [(marker_annotation + name: (identifier) @annotation_name + ) + (annotation + name: (identifier) @annotation_name + )] + ) + (#match? @annotation_name "Test$") + ) + ) + ) @_ + (#set! tag java-test-class) +) + +; Run nested test class +( + (package_declaration + (scoped_identifier) @java_package_name + ) + (class_declaration + name: (identifier) @java_outer_class_name + body: (class_body + (class_declaration + (modifiers + (marker_annotation + name: (identifier) @nested_annotation + ) + ) + name: (identifier) @run @java_class_name + body: (class_body + (method_declaration + (modifiers + [(marker_annotation + name: (identifier) @annotation_name + ) + (annotation + name: (identifier) @annotation_name + )] + ) + (#match? @annotation_name "Test$") + ) + ) + (#eq? @nested_annotation "Nested") + ) @_ + ) + ) + (#set! tag java-test-class-nested) +) diff --git a/languages/java/tasks.json b/languages/java/tasks.json new file mode 100644 index 0000000..ac2bfe8 --- /dev/null +++ b/languages/java/tasks.json @@ -0,0 +1,65 @@ +[ + { + "label": "Run $ZED_CUSTOM_java_class_name", + "command": "pkg=\"${ZED_CUSTOM_java_package_name:}\"; cls=\"$ZED_CUSTOM_java_class_name\"; if [ -n \"$pkg\" ]; then c=\"$pkg.$cls\"; else c=\"$cls\"; fi; if [ -f pom.xml ]; then [ -f ./mvnw ] && CMD=\"./mvnw\" || CMD=\"mvn\"; $CMD clean compile exec:java -Dexec.mainClass=\"$c\"; elif [ -f build.gradle ] || [ -f build.gradle.kts ]; then [ -f ./gradlew ] && CMD=\"./gradlew\" || CMD=\"gradle\"; $CMD run -PmainClass=\"$c\"; else find . -name '*.java' -not -path './bin/*' -not -path './target/*' -not -path './build/*' -print0 | xargs -0 javac -d bin && java -cp bin \"$c\"; fi;", + "use_new_terminal": false, + "reveal": "always", + "tags": ["java-main"], + "shell": { + "with_arguments": { + "program": "/bin/sh", + "args": ["-c"] + } + } + }, + { + "label": "$ZED_CUSTOM_java_class_name.${ZED_CUSTOM_java_outer_class_name:}.$ZED_CUSTOM_java_method_name", + "command": "package=\"$ZED_CUSTOM_java_package_name\"; outer=\"${ZED_CUSTOM_java_outer_class_name:}\"; inner=\"$ZED_CUSTOM_java_class_name\"; method=\"$ZED_CUSTOM_java_method_name\"; sep=\"$\"; if [ -n \"$outer\" ]; then c=\"$outer$sep$inner\"; else c=\"$inner\"; fi; if [ -f pom.xml ]; then [ -f ./mvnw ] && CMD=\"./mvnw\" || CMD=\"mvn\"; $CMD clean test -Dtest=\"$package.$c#$method\"; elif [ -f build.gradle ] || [ -f build.gradle.kts ]; then [ -f ./gradlew ] && CMD=\"./gradlew\" || CMD=\"gradle\"; $CMD test --tests \"$package.$c.$method\"; else >&2 echo 'No build tool found'; exit 1; fi;", + "use_new_terminal": false, + "reveal": "always", + "tags": ["java-test-method", "java-test-method-nested"], + "shell": { + "with_arguments": { + "program": "/bin/sh", + "args": ["-c"] + } + } + }, + { + "label": "Test class $ZED_CUSTOM_java_class_name", + "command": "package=\"$ZED_CUSTOM_java_package_name\"; outer=\"${ZED_CUSTOM_java_outer_class_name:}\"; inner=\"$ZED_CUSTOM_java_class_name\"; sep=\"$\"; if [ -n \"$outer\" ]; then c=\"$outer$sep$inner\"; else c=\"$inner\"; fi; if [ -f pom.xml ]; then [ -f ./mvnw ] && CMD=\"./mvnw\" || CMD=\"mvn\"; $CMD clean test -Dtest=\"$package.$c\"; elif [ -f build.gradle ] || [ -f build.gradle.kts ]; then [ -f ./gradlew ] && CMD=\"./gradlew\" || CMD=\"gradle\"; $CMD test --tests \"$package.$c\"; else >&2 echo 'No build tool found'; exit 1; fi;", + "use_new_terminal": false, + "reveal": "always", + "tags": ["java-test-class", "java-test-class-nested"], + "shell": { + "with_arguments": { + "program": "/bin/sh", + "args": ["-c"] + } + } + }, + { + "label": "Run tests", + "command": "if [ -f pom.xml ]; then [ -f ./mvnw ] && CMD=\"./mvnw\" || CMD=\"mvn\"; $CMD clean test; elif [ -f build.gradle ] || [ -f build.gradle.kts ]; then [ -f ./gradlew ] && CMD=\"./gradlew\" || CMD=\"gradle\"; $CMD test; else >&2 echo 'No build tool found'; exit 1; fi;", + "use_new_terminal": false, + "reveal": "always", + "shell": { + "with_arguments": { + "program": "/bin/sh", + "args": ["-c"] + } + } + }, + { + "label": "Clear JDTLS cache", + "command": "cache_dir=\"\"; if [ -n \"$XDG_CACHE_HOME\" ]; then cache_dir=\"$XDG_CACHE_HOME\"; elif [ \"$(uname)\" = \"Darwin\" ]; then cache_dir=\"$HOME/Library/Caches\"; else cache_dir=\"$HOME/.cache\"; fi; found=$(find \"$cache_dir\" -maxdepth 1 -type d -name 'jdtls-*' 2>/dev/null); if [ -n \"$found\" ]; then echo \"$found\" | xargs rm -rf && echo 'JDTLS cache cleared. Restart the language server'; else echo 'No JDTLS cache found'; fi", + "use_new_terminal": false, + "reveal": "always", + "shell": { + "with_arguments": { + "program": "/bin/sh", + "args": ["-c"] + } + } + } +] diff --git a/languages/java/textobjects.scm b/languages/java/textobjects.scm new file mode 100644 index 0000000..c5cd229 --- /dev/null +++ b/languages/java/textobjects.scm @@ -0,0 +1,65 @@ +; methods +(method_declaration) @function.around +(method_declaration + body: (block + "{" (_)* @function.inside "}" + )) + +; constructors +(constructor_declaration) @function.around +(constructor_declaration + body: (constructor_body + "{" (_)* @function.inside "}" + )) + +; lambdas +(lambda_expression) @function.around +(lambda_expression + body: (block + "{" (_)* @function.inside "}" + )) +(lambda_expression + body: (_) @function.inside) + + +; classes +(class_declaration) @class.around +(class_declaration + body: (class_body + "{" (_)* @class.inside "}" + )) + +; interfaces +(interface_declaration) @class.around +(interface_declaration + body: (interface_body + "{" (_)* @class.inside "}" + )) + +; enums +(enum_declaration) @class.around +(enum_declaration + body: (enum_body + "{" + _* @class.inside + "}" + )) + +; records +(record_declaration) @class.around +(record_declaration + body: (class_body + "{" (_)* @class.inside "}" + )) + +; annotations +(annotation_type_declaration) @class.around +(annotation_type_declaration + (annotation_type_body + "{" (_)* @class.inside "}" + )) + + +; comments +((line_comment)+) @comment.around +(block_comment) @comment.around diff --git a/languages/properties/config.toml b/languages/properties/config.toml new file mode 100644 index 0000000..9537eef --- /dev/null +++ b/languages/properties/config.toml @@ -0,0 +1,5 @@ +name = "Properties" +grammar = "properties" +path_suffixes = ["properties"] +line_comments = ["# "] +brackets = [{ start = "[", end = "]", close = true, newline = true }] diff --git a/languages/properties/highlights.scm b/languages/properties/highlights.scm new file mode 100644 index 0000000..5b71487 --- /dev/null +++ b/languages/properties/highlights.scm @@ -0,0 +1,28 @@ +(comment) @comment + +(key) @property + +(value) @string + +(value (escape) @string.escape) + +((index) @number + (#match? @number "^[0-9]+$")) + +((substitution (key) @constant) + (#match? @constant "^[A-Z0-9_]+")) + +(substitution + (key) @function + "::" @punctuation.special + (secret) @embedded) + +(property [ "=" ":" ] @operator) + +[ "${" "}" ] @punctuation.special + +(substitution ":" @punctuation.special) + +[ "[" "]" ] @punctuation.bracket + +[ "." "\\" ] @punctuation.delimiter diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..9aa99e7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,129 @@ +use zed_extension_api::{Worktree, serde_json::Value}; + +use crate::util::expand_home_path; + +#[derive(Debug, Clone, PartialEq, Default)] +pub enum CheckUpdates { + #[default] + Always, + Once, + Never, +} + +pub fn get_java_home(configuration: &Option, worktree: &Worktree) -> Option { + // try to read the value from settings + if let Some(configuration) = configuration + && let Some(java_home) = configuration + .pointer("/java_home") + .or_else(|| configuration.pointer("/java/home")) // legacy support + .and_then(|x| x.as_str()) + { + match expand_home_path(worktree, java_home.to_string()) { + Ok(home_path) => return Some(home_path), + Err(err) => { + println!("{err}"); + } + }; + } + + // try to read the value from env + match worktree + .shell_env() + .into_iter() + .find(|(k, _)| k == "JAVA_HOME") + { + Some((_, value)) if !value.is_empty() => Some(value), + _ => None, + } +} + +pub fn is_java_autodownload(configuration: &Option) -> bool { + configuration + .as_ref() + .and_then(|configuration| { + configuration + .pointer("/jdk_auto_download") + .and_then(|enabled| enabled.as_bool()) + }) + .unwrap_or(false) +} + +pub fn is_lombok_enabled(configuration: &Option) -> bool { + configuration + .as_ref() + .and_then(|configuration| { + configuration + .pointer("/lombok_support") + .or_else(|| configuration.pointer("/java/jdt/ls/lombokSupport/enabled")) // legacy support + .and_then(|enabled| enabled.as_bool()) + }) + .unwrap_or(true) +} + +pub fn get_check_updates(configuration: &Option) -> CheckUpdates { + if let Some(configuration) = configuration + && let Some(mode_str) = configuration + .pointer("/check_updates") + .and_then(|x| x.as_str()) + .map(|s| s.to_lowercase()) + { + return match mode_str.as_str() { + "once" => CheckUpdates::Once, + "never" => CheckUpdates::Never, + "always" => CheckUpdates::Always, + _ => CheckUpdates::default(), + }; + } + CheckUpdates::default() +} + +pub fn get_jdtls_launcher(configuration: &Option, worktree: &Worktree) -> Option { + if let Some(configuration) = configuration + && let Some(launcher_path) = configuration + .pointer("/jdtls_launcher") + .and_then(|x| x.as_str()) + { + match expand_home_path(worktree, launcher_path.to_string()) { + Ok(path) => return Some(path), + Err(err) => { + println!("{err}"); + } + } + } + + None +} + +pub fn get_lombok_jar(configuration: &Option, worktree: &Worktree) -> Option { + if let Some(configuration) = configuration + && let Some(jar_path) = configuration + .pointer("/lombok_jar") + .and_then(|x| x.as_str()) + { + match expand_home_path(worktree, jar_path.to_string()) { + Ok(path) => return Some(path), + Err(err) => { + println!("{err}"); + } + } + } + + None +} + +pub fn get_java_debug_jar(configuration: &Option, worktree: &Worktree) -> Option { + if let Some(configuration) = configuration + && let Some(jar_path) = configuration + .pointer("/java_debug_jar") + .and_then(|x| x.as_str()) + { + match expand_home_path(worktree, jar_path.to_string()) { + Ok(path) => return Some(path), + Err(err) => { + println!("{err}"); + } + } + } + + None +} diff --git a/src/debugger.rs b/src/debugger.rs new file mode 100644 index 0000000..4507a02 --- /dev/null +++ b/src/debugger.rs @@ -0,0 +1,458 @@ +use std::{collections::HashMap, fs, path::PathBuf}; + +use serde::{Deserialize, Serialize}; +use zed_extension_api::{ + self as zed, DownloadedFileType, LanguageServerId, LanguageServerInstallationStatus, + TcpArgumentsTemplate, Worktree, download_file, + http_client::{HttpMethod, HttpRequest, fetch}, + serde_json::{self, Value, json}, + set_language_server_installation_status, +}; + +use crate::{ + config::get_java_debug_jar, + lsp::LspWrapper, + util::{ + ArgsStringOrList, create_path_if_not_exists, get_curr_dir, mark_checked_once, + path_to_string, should_use_local_or_download, + }, +}; + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct JavaDebugLaunchConfig { + request: String, + #[serde(skip_serializing_if = "Option::is_none")] + project_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + main_class: Option, + #[serde(skip_serializing_if = "Option::is_none")] + args: Option, + #[serde(skip_serializing_if = "Option::is_none")] + vm_args: Option, + #[serde(skip_serializing_if = "Option::is_none")] + encoding: Option, + #[serde(skip_serializing_if = "Option::is_none")] + class_paths: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + module_paths: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + env: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + stop_on_entry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + no_debug: Option, + #[serde(skip_serializing_if = "Option::is_none")] + console: Option, + #[serde(skip_serializing_if = "Option::is_none")] + shorten_command_line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + launcher_script: Option, + #[serde(skip_serializing_if = "Option::is_none")] + java_exec: Option, +} + +const TEST_SCOPE: &str = "$Test"; +const AUTO_SCOPE: &str = "$Auto"; +const RUNTIME_SCOPE: &str = "$Runtime"; + +const SCOPES: [&str; 3] = [TEST_SCOPE, AUTO_SCOPE, RUNTIME_SCOPE]; + +const DEBUGGER_INSTALL_PATH: &str = "debugger"; + +const JAVA_DEBUG_PLUGIN_FORK_URL: &str = "https://github.com/zed-industries/java-debug/releases/download/0.53.2/com.microsoft.java.debug.plugin-0.53.2.jar"; + +const MAVEN_METADATA_URL: &str = "https://repo1.maven.org/maven2/com/microsoft/java/com.microsoft.java.debug.plugin/maven-metadata.xml"; + +pub fn find_latest_local_debugger() -> Option { + let prefix = PathBuf::from(DEBUGGER_INSTALL_PATH); + // walk the dir where we install debugger + fs::read_dir(&prefix) + .map(|entries| { + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + // get the most recently created jar file + .filter(|path| { + path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("jar") + }) + .filter_map(|path| { + let created_time = fs::metadata(&path).and_then(|meta| meta.created()).ok()?; + Some((path, created_time)) + }) + .max_by_key(|&(_, time)| time) + .map(|(path, _)| path) + }) + .ok() + .flatten() +} + +pub struct Debugger { + lsp: LspWrapper, + plugin_path: Option, +} + +impl Debugger { + pub fn new(lsp: LspWrapper) -> Debugger { + Debugger { + plugin_path: None, + lsp, + } + } + + pub fn loaded(&self) -> bool { + self.plugin_path.is_some() + } + + pub fn get_or_download( + &mut self, + language_server_id: &LanguageServerId, + configuration: &Option, + worktree: &Worktree, + ) -> zed::Result { + // when the fix to https://github.com/microsoft/java-debug/issues/605 becomes part of an official release + // switch back to this: + // return self.get_or_download_latest_official(language_server_id); + + // Use user-configured path if provided + if let Some(jar_path) = get_java_debug_jar(configuration, worktree) { + let path = PathBuf::from(&jar_path); + self.plugin_path = Some(path.clone()); + return Ok(path); + } + + // Use local installation if update mode requires it + if let Some(path) = should_use_local_or_download( + configuration, + find_latest_local_debugger(), + DEBUGGER_INSTALL_PATH, + ) + .map_err(|err| format!("Failed to resolve debugger installation: {err}"))? + { + self.plugin_path = Some(path.clone()); + return Ok(path); + } + + self.get_or_download_fork(language_server_id) + } + + fn get_or_download_fork( + &mut self, + _language_server_id: &LanguageServerId, + ) -> zed::Result { + let prefix = "debugger"; + let artifact = "com.microsoft.java.debug.plugin"; + let latest_version = "0.53.2"; + let jar_name = format!("{artifact}-{latest_version}.jar"); + let jar_path = PathBuf::from(prefix).join(&jar_name); + + if let Some(path) = &self.plugin_path + && fs::metadata(path).is_ok_and(|stat| stat.is_file()) + && path.ends_with(jar_name) + { + return Ok(path.clone()); + } + + create_path_if_not_exists(prefix) + .map_err(|err| format!("Failed to create debugger directory '{prefix}': {err}"))?; + + download_file( + JAVA_DEBUG_PLUGIN_FORK_URL, + &path_to_string(jar_path.clone()) + .map_err(|err| format!("Invalid debugger jar path {jar_path:?}: {err}"))?, + DownloadedFileType::Uncompressed, + ) + .map_err(|err| { + format!("Failed to download java-debug fork from {JAVA_DEBUG_PLUGIN_FORK_URL}: {err}") + })?; + + // Mark the downloaded version for "Once" mode tracking + let _ = mark_checked_once(DEBUGGER_INSTALL_PATH, latest_version); + + self.plugin_path = Some(jar_path.clone()); + Ok(jar_path) + } + + #[allow(unused)] + fn get_or_download_latest_official( + &mut self, + language_server_id: &LanguageServerId, + ) -> zed::Result { + let prefix = "debugger"; + + if let Some(path) = &self.plugin_path + && fs::metadata(path).is_ok_and(|stat| stat.is_file()) + { + return Ok(path.clone()); + } + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::CheckingForUpdate, + ); + + let res = fetch( + &HttpRequest::builder() + .method(HttpMethod::Get) + .url(MAVEN_METADATA_URL) + .build()?, + ); + + // Maven loves to be down, trying to resolve it gracefully + if let Err(err) = &res { + if !fs::metadata(prefix).is_ok_and(|stat| stat.is_dir()) { + return Err(err.to_owned()); + } + + println!("Could not fetch debugger: {err}\nFalling back to local version."); + + let exists = fs::read_dir(prefix) + .ok() + .and_then(|dir| dir.last().map(|v| v.ok())) + .flatten(); + + if let Some(file) = exists { + if !file.metadata().is_ok_and(|stat| stat.is_file()) { + return Err(err.to_owned()); + } + + if !file + .file_name() + .to_str() + .is_some_and(|name| name.ends_with(".jar")) + { + return Err(err.to_owned()); + } + + let jar_path = PathBuf::from(prefix).join(file.file_name()); + self.plugin_path = Some(jar_path.clone()); + + return Ok(jar_path); + } + } + + let xml = String::from_utf8(res?.body).map_err(|err| { + format!("Failed to get string from Maven metadata response body: {err}") + })?; + + let start_tag = ""; + let end_tag = ""; + + let latest_version = xml + .split_once(start_tag) + .and_then(|(_, rest)| rest.split_once(end_tag)) + .map(|(content, _)| content.trim()) + .ok_or(format!( + "Failed to parse maven-metadata.xml response: {xml}" + ))?; + + let artifact = "com.microsoft.java.debug.plugin"; + + let jar_name = format!("{artifact}-{latest_version}.jar"); + let jar_path = PathBuf::from(prefix).join(&jar_name); + + if !fs::metadata(&jar_path).is_ok_and(|stat| stat.is_file()) { + if let Err(err) = fs::remove_dir_all(prefix) { + println!("failed to remove directory entry: {err}"); + } + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + create_path_if_not_exists(prefix)?; + + let url = format!( + "https://repo1.maven.org/maven2/com/microsoft/java/{artifact}/{latest_version}/{jar_name}" + ); + + download_file( + url.as_str(), + &path_to_string(&jar_path)?, + DownloadedFileType::Uncompressed, + ) + .map_err(|err| format!("Failed to download {url}: {err}"))?; + + // Mark the downloaded version for "Once" mode tracking + let _ = mark_checked_once(DEBUGGER_INSTALL_PATH, latest_version); + } + + self.plugin_path = Some(jar_path.clone()); + Ok(jar_path) + } + + pub fn start_session(&self) -> zed::Result { + let port = self + .lsp + .get() + .map_err(|err| format!("Failed to acquire LSP client lock: {err}"))? + .request::( + "workspace/executeCommand", + json!({ "command": "vscode.java.startDebugSession" }), + ) + .map_err(|err| format!("Failed to start debug session via LSP: {err}"))?; + + Ok(TcpArgumentsTemplate { + host: None, + port: Some(port), + timeout: None, + }) + } + + pub fn inject_config(&self, worktree: &Worktree, config_string: String) -> zed::Result { + let config: Value = serde_json::from_str(&config_string) + .map_err(|err| format!("Failed to parse debug config: {err}"))?; + + if config + .get("request") + .and_then(Value::as_str) + .is_some_and(|req| req != "launch") + { + return Ok(config_string); + } + + let mut config = serde_json::from_value::(config) + .map_err(|err| format!("Failed to parse Java debug config: {err}"))?; + + let workspace_folder = worktree.root_path(); + + let (main_class, project_name) = { + let arguments = [config.main_class.clone(), config.project_name.clone()] + .iter() + .flatten() + .cloned() + .collect::>(); + + let entries = self + .lsp + .get() + .map_err(|err| format!("Failed to acquire LSP client lock: {err}"))? + .resolve_main_class(arguments) + .map_err(|err| format!("Failed to resolve main class: {err}"))? + .into_iter() + .filter(|entry| { + config + .main_class + .as_ref() + .map(|class| &entry.main_class == class) + .unwrap_or(true) + }) + .filter(|entry| { + config + .project_name + .as_ref() + .map(|class| &entry.project_name == class) + .unwrap_or(true) + }) + .collect::>(); + + if entries.len() > 1 { + return Err("Project have multiple entry points, you must explicitly specify \"mainClass\" or \"projectName\"".to_owned()); + } + + match entries.first() { + None => (config.main_class, config.project_name), + Some(entry) => ( + Some(entry.main_class.to_owned()), + Some(entry.project_name.to_owned()), + ), + } + }; + + let mut classpaths = config.class_paths.unwrap_or(vec![AUTO_SCOPE.to_string()]); + + if classpaths + .iter() + .any(|class| SCOPES.contains(&class.as_str())) + { + // https://github.com/microsoft/vscode-java-debug/blob/main/src/configurationProvider.ts#L518 + let scope = { + if classpaths.iter().any(|class| class == TEST_SCOPE) { + Some("test".to_string()) + } else if classpaths.iter().any(|class| class == AUTO_SCOPE) { + None + } else if classpaths.iter().any(|class| class == RUNTIME_SCOPE) { + Some("runtime".to_string()) + } else { + None + } + }; + + let arguments = vec![main_class.clone(), project_name.clone(), scope.clone()]; + + let result = self + .lsp + .get() + .map_err(|err| format!("Failed to acquire LSP client lock: {err}"))? + .resolve_class_path(arguments) + .map_err(|err| format!("Failed to resolve classpath: {err}"))?; + + for resolved in result { + classpaths.extend(resolved); + } + } + + classpaths.retain(|class| !SCOPES.contains(&class.as_str())); + classpaths.dedup(); + + config.class_paths = Some(classpaths); + + config.main_class = main_class; + config.project_name = project_name; + + config.cwd = config.cwd.or(Some(workspace_folder.to_string())); + + let config = serde_json::to_string(&config) + .map_err(|err| format!("Failed to stringify debug config: {err}"))? + .replace("${workspaceFolder}", &workspace_folder); + + Ok(config) + } + + pub fn inject_plugin_into_options( + &self, + initialization_options: Option, + ) -> zed::Result { + let current_dir = get_curr_dir() + .map_err(|err| format!("Failed to get current directory for debugger plugin: {err}"))?; + + let canonical_path = Value::String( + current_dir + .join( + self.plugin_path + .as_ref() + .ok_or("Debugger plugin path not set")?, + ) + .to_string_lossy() + .to_string(), + ); + + match initialization_options { + None => Ok(json!({ + "bundles": [canonical_path] + })), + Some(options) => { + let mut options = options.clone(); + + let mut bundles = options + .get_mut("bundles") + .unwrap_or(&mut Value::Array(vec![])) + .take(); + + let bundles_vec = bundles + .as_array_mut() + .ok_or("Invalid initialization_options format")?; + + if !bundles_vec.contains(&canonical_path) { + bundles_vec.push(canonical_path); + } + + options["bundles"] = bundles; + + Ok(options) + } + } + } +} diff --git a/src/java.rs b/src/java.rs new file mode 100644 index 0000000..05f7438 --- /dev/null +++ b/src/java.rs @@ -0,0 +1,605 @@ +mod config; +mod debugger; +mod jdk; +mod jdtls; +mod lsp; +mod util; + +use std::{ + env, + fs::{self, metadata}, + path::PathBuf, + str::FromStr, +}; + +use zed_extension_api::{ + self as zed, CodeLabel, CodeLabelSpan, DebugAdapterBinary, DebugTaskDefinition, Extension, + LanguageServerId, LanguageServerInstallationStatus, StartDebuggingRequestArguments, + StartDebuggingRequestArgumentsRequest, Worktree, + lsp::{Completion, CompletionKind, Symbol, SymbolKind}, + register_extension, + serde_json::{Value, json}, + set_language_server_installation_status, + settings::LspSettings, +}; + +use crate::{ + config::{get_java_home, get_jdtls_launcher, get_lombok_jar, is_lombok_enabled}, + debugger::Debugger, + jdtls::{ + build_jdtls_launch_args, find_latest_local_jdtls, find_latest_local_lombok, + get_jdtls_launcher_from_path, try_to_fetch_and_install_latest_jdtls, + try_to_fetch_and_install_latest_lombok, + }, + lsp::LspWrapper, + util::path_to_string, +}; + +const PROXY_FILE: &str = include_str!("proxy.mjs"); +const DEBUG_ADAPTER_NAME: &str = "Java"; +const LSP_INIT_ERROR: &str = "Lsp client is not initialized yet"; + +struct Java { + cached_binary_path: Option, + cached_lombok_path: Option, + integrations: Option<(LspWrapper, Debugger)>, +} + +impl Java { + fn lsp(&mut self) -> zed::Result<&LspWrapper> { + self.integrations + .as_ref() + .ok_or(LSP_INIT_ERROR.to_string()) + .map(|v| &v.0) + } + + fn debugger(&mut self) -> zed::Result<&mut Debugger> { + self.integrations + .as_mut() + .ok_or(LSP_INIT_ERROR.to_string()) + .map(|v| &mut v.1) + } + + fn init(&mut self, worktree: &Worktree) { + // Initialize lsp client and debugger + + if self.integrations.is_none() { + let lsp = LspWrapper::new(worktree.root_path()); + let debugger = Debugger::new(lsp.clone()); + + self.integrations = Some((lsp, debugger)); + } + } + + fn language_server_binary_path( + &mut self, + language_server_id: &LanguageServerId, + configuration: &Option, + ) -> zed::Result { + // Use cached path if exists + + if let Some(path) = &self.cached_binary_path + && metadata(path).is_ok_and(|stat| stat.is_file()) + { + return Ok(path.clone()); + } + + // Check for latest version + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::CheckingForUpdate, + ); + + match try_to_fetch_and_install_latest_jdtls(language_server_id, configuration) { + Ok(path) => { + self.cached_binary_path = Some(path.clone()); + Ok(path) + } + Err(e) => { + if let Some(local_version) = find_latest_local_jdtls() { + self.cached_binary_path = Some(local_version.clone()); + Ok(local_version) + } else { + Err(e) + } + } + } + } + + fn lombok_jar_path( + &mut self, + language_server_id: &LanguageServerId, + configuration: &Option, + worktree: &Worktree, + ) -> zed::Result { + // Use user-configured path if provided + if let Some(jar_path) = get_lombok_jar(configuration, worktree) { + let path = PathBuf::from(&jar_path); + self.cached_lombok_path = Some(path.clone()); + return Ok(path); + } + + // Use cached path if exists + if let Some(path) = &self.cached_lombok_path + && fs::metadata(path).is_ok_and(|stat| stat.is_file()) + { + return Ok(path.clone()); + } + + match try_to_fetch_and_install_latest_lombok(language_server_id, configuration) { + Ok(path) => { + self.cached_lombok_path = Some(path.clone()); + Ok(path) + } + Err(e) => { + if let Some(local_version) = find_latest_local_lombok() { + self.cached_lombok_path = Some(local_version.clone()); + Ok(local_version) + } else { + Err(e) + } + } + } + } +} + +impl Extension for Java { + fn new() -> Self + where + Self: Sized, + { + Self { + cached_binary_path: None, + cached_lombok_path: None, + integrations: None, + } + } + + fn get_dap_binary( + &mut self, + adapter_name: String, + config: DebugTaskDefinition, + _user_provided_debug_adapter_path: Option, + worktree: &Worktree, + ) -> zed_extension_api::Result { + if !self.debugger().is_ok_and(|v| v.loaded()) { + return Err("Debugger plugin is not loaded".to_string()); + } + + if adapter_name != DEBUG_ADAPTER_NAME { + return Err(format!( + "Cannot create binary for adapter \"{adapter_name}\"" + )); + } + + if self.integrations.is_some() { + self.lsp()? + .switch_workspace(worktree.root_path()) + .map_err(|err| { + format!("Failed to switch LSP workspace for debug adapter: {err}") + })?; + } + + Ok(DebugAdapterBinary { + command: None, + arguments: vec![], + cwd: Some(worktree.root_path()), + envs: vec![], + request_args: StartDebuggingRequestArguments { + request: self + .dap_request_kind( + adapter_name, + Value::from_str(config.config.as_str()) + .map_err(|err| format!("Invalid JSON configuration: {err}"))?, + ) + .map_err(|err| format!("Failed to determine debug request kind: {err}"))?, + configuration: self + .debugger()? + .inject_config(worktree, config.config) + .map_err(|err| format!("Failed to inject debug configuration: {err}"))?, + }, + connection: Some(zed::resolve_tcp_template( + self.debugger()? + .start_session() + .map_err(|err| format!("Failed to start debug session: {err}"))?, + )?), + }) + } + + fn dap_request_kind( + &mut self, + adapter_name: String, + config: Value, + ) -> Result { + if adapter_name != DEBUG_ADAPTER_NAME { + return Err(format!( + "Cannot create binary for adapter \"{adapter_name}\"" + )); + } + + match config.get("request") { + Some(launch) if launch == "launch" => Ok(StartDebuggingRequestArgumentsRequest::Launch), + Some(attach) if attach == "attach" => Ok(StartDebuggingRequestArgumentsRequest::Attach), + Some(value) => Err(format!( + "Unexpected value for `request` key in Java debug adapter configuration: {value:?}" + )), + None => { + Err("Missing required `request` field in Java debug adapter configuration".into()) + } + } + } + + fn dap_config_to_scenario( + &mut self, + config: zed::DebugConfig, + ) -> zed::Result { + if !self.debugger().is_ok_and(|v| v.loaded()) { + return Err("Debugger plugin is not loaded".to_string()); + } + + match config.request { + zed::DebugRequest::Attach(attach) => { + let debug_config = if let Some(process_id) = attach.process_id { + json!({ + "request": "attach", + "processId": process_id, + "stopOnEntry": config.stop_on_entry + }) + } else { + json!({ + "request": "attach", + "hostName": "localhost", + "port": 5005, + }) + }; + + Ok(zed::DebugScenario { + adapter: config.adapter, + build: None, + tcp_connection: Some( + self.debugger()? + .start_session() + .map_err(|err| format!("Failed to start debug session: {err}"))?, + ), + label: "Attach to Java process".to_string(), + config: debug_config.to_string(), + }) + } + + zed::DebugRequest::Launch(_launch) => { + Err("Java Extension doesn't support launching".to_string()) + } + } + } + + fn language_server_command( + &mut self, + language_server_id: &LanguageServerId, + worktree: &Worktree, + ) -> zed::Result { + let current_dir = + env::current_dir().map_err(|err| format!("Failed to get current directory: {err}"))?; + + let configuration = + self.language_server_workspace_configuration(language_server_id, worktree)?; + + let mut env = Vec::new(); + + if let Some(java_home) = get_java_home(&configuration, worktree) { + env.push(("JAVA_HOME".to_string(), java_home)); + } + + // our proxy takes workdir, bin, argv + let mut args = vec![ + "--input-type=module".to_string(), + "-e".to_string(), + PROXY_FILE.to_string(), + path_to_string(current_dir.clone()) + .map_err(|err| format!("Failed to convert current directory to string: {err}"))?, + ]; + + // Add lombok as javaagent if settings.java.jdt.ls.lombokSupport.enabled is true + let lombok_jvm_arg = if is_lombok_enabled(&configuration) { + let lombok_jar_path = self + .lombok_jar_path(language_server_id, &configuration, worktree) + .map_err(|err| format!("Failed to get Lombok jar path: {err}"))?; + let canonical_lombok_jar_path = path_to_string(current_dir.join(lombok_jar_path)) + .map_err(|err| format!("Failed to convert Lombok jar path to string: {err}"))?; + + Some(format!("-javaagent:{canonical_lombok_jar_path}")) + } else { + None + }; + + self.init(worktree); + + // Check for user-configured JDTLS launcher first + if let Some(launcher) = get_jdtls_launcher(&configuration, worktree) { + args.push(launcher); + if let Some(lombok_jvm_arg) = lombok_jvm_arg { + args.push(format!("--jvm-arg={lombok_jvm_arg}")); + } + } else if let Some(launcher) = get_jdtls_launcher_from_path(worktree) { + // if the user has `jdtls(.bat)` on their PATH, we use that + args.push(launcher); + if let Some(lombok_jvm_arg) = lombok_jvm_arg { + args.push(format!("--jvm-arg={lombok_jvm_arg}")); + } + } else { + // otherwise we launch ourselves + args.extend( + build_jdtls_launch_args( + &self + .language_server_binary_path(language_server_id, &configuration) + .map_err(|err| format!("Failed to get JDTLS binary path: {err}"))?, + &configuration, + worktree, + lombok_jvm_arg.into_iter().collect(), + language_server_id, + ) + .map_err(|err| format!("Failed to build JDTLS launch arguments: {err}"))?, + ); + } + + // download debugger if not exists + if let Err(err) = + self.debugger()? + .get_or_download(language_server_id, &configuration, worktree) + { + println!("Failed to download debugger: {err}"); + }; + + self.lsp()? + .switch_workspace(worktree.root_path()) + .map_err(|err| format!("Failed to switch LSP workspace: {err}"))?; + + Ok(zed::Command { + command: zed::node_binary_path() + .map_err(|err| format!("Failed to get Node.js binary path: {err}"))?, + args, + env, + }) + } + + fn language_server_initialization_options( + &mut self, + language_server_id: &LanguageServerId, + worktree: &Worktree, + ) -> zed::Result> { + if self.integrations.is_some() { + self.lsp()? + .switch_workspace(worktree.root_path()) + .map_err(|err| { + format!("Failed to switch LSP workspace for initialization: {err}") + })?; + } + + let options = LspSettings::for_worktree(language_server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.initialization_options) + .map_err(|err| format!("Failed to get LSP settings for worktree: {err}"))?; + + if self.debugger().is_ok_and(|v| v.loaded()) { + return Ok(Some( + self.debugger()? + .inject_plugin_into_options(options) + .map_err(|err| { + format!("Failed to inject debugger plugin into options: {err}") + })?, + )); + } + + Ok(options) + } + + fn language_server_workspace_configuration( + &mut self, + language_server_id: &LanguageServerId, + worktree: &Worktree, + ) -> zed::Result> { + if let Ok(Some(settings)) = LspSettings::for_worktree(language_server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.settings) + { + Ok(Some(settings)) + } else { + self.language_server_initialization_options(language_server_id, worktree) + .map(|init_options| { + init_options.and_then(|init_options| init_options.get("settings").cloned()) + }) + } + } + + fn label_for_completion( + &self, + _language_server_id: &LanguageServerId, + completion: Completion, + ) -> Option { + // uncomment when debugging completions + // println!("Java completion: {completion:#?}"); + + completion.kind.and_then(|kind| match kind { + CompletionKind::Field | CompletionKind::Constant => { + let modifiers = match kind { + CompletionKind::Field => "", + CompletionKind::Constant => "static final ", + _ => return None, + }; + let property_type = completion.detail.as_ref().and_then(|detail| { + detail + .split_once(" : ") + .map(|(_, property_type)| format!("{property_type} ")) + })?; + let semicolon = ";"; + let code = format!("{modifiers}{property_type}{}{semicolon}", completion.label); + + Some(CodeLabel { + spans: vec![ + CodeLabelSpan::code_range( + modifiers.len() + property_type.len()..code.len() - semicolon.len(), + ), + CodeLabelSpan::literal(" : ", None), + CodeLabelSpan::code_range( + modifiers.len()..modifiers.len() + property_type.len(), + ), + ], + code, + filter_range: (0..completion.label.len()).into(), + }) + } + CompletionKind::Method => { + let detail = completion.detail?; + let (left, return_type) = detail + .split_once(" : ") + .map(|(left, return_type)| (left, format!("{return_type} "))) + .unwrap_or((&detail, "void".to_string())); + let parameters = left + .find('(') + .map(|parameters_start| &left[parameters_start..]); + let name_and_parameters = + format!("{}{}", completion.label, parameters.unwrap_or("()")); + let braces = " {}"; + let code = format!("{return_type}{name_and_parameters}{braces}"); + let mut spans = vec![CodeLabelSpan::code_range( + return_type.len()..code.len() - braces.len(), + )]; + + if parameters.is_some() { + spans.push(CodeLabelSpan::literal(" : ", None)); + spans.push(CodeLabelSpan::code_range(0..return_type.len())); + } else { + spans.push(CodeLabelSpan::literal(" - ", None)); + spans.push(CodeLabelSpan::literal(detail, None)); + } + + Some(CodeLabel { + spans, + code, + filter_range: (0..completion.label.len()).into(), + }) + } + CompletionKind::Class | CompletionKind::Interface | CompletionKind::Enum => { + let keyword = match kind { + CompletionKind::Class => "class ", + CompletionKind::Interface => "interface ", + CompletionKind::Enum => "enum ", + _ => return None, + }; + let braces = " {}"; + let code = format!("{keyword}{}{braces}", completion.label); + let namespace = completion.detail.and_then(|detail| { + if detail.len() > completion.label.len() { + let prefix_len = detail.len() - completion.label.len() - 1; + Some(detail[..prefix_len].to_string()) + } else { + None + } + }); + let mut spans = vec![CodeLabelSpan::code_range( + keyword.len()..code.len() - braces.len(), + )]; + + if let Some(namespace) = namespace { + spans.push(CodeLabelSpan::literal(format!(" ({namespace})"), None)); + } + + Some(CodeLabel { + spans, + code, + filter_range: (0..completion.label.len()).into(), + }) + } + CompletionKind::Snippet => Some(CodeLabel { + code: String::new(), + spans: vec![CodeLabelSpan::literal( + format!("{} - {}", completion.label, completion.detail?), + None, + )], + filter_range: (0..completion.label.len()).into(), + }), + CompletionKind::Keyword | CompletionKind::Variable => Some(CodeLabel { + spans: vec![CodeLabelSpan::code_range(0..completion.label.len())], + filter_range: (0..completion.label.len()).into(), + code: completion.label, + }), + CompletionKind::Constructor => { + let detail = completion.detail?; + let parameters = &detail[detail.find('(')?..]; + let braces = " {}"; + let code = format!("{}{parameters}{braces}", completion.label); + + Some(CodeLabel { + spans: vec![CodeLabelSpan::code_range(0..code.len() - braces.len())], + code, + filter_range: (0..completion.label.len()).into(), + }) + } + _ => None, + }) + } + + fn label_for_symbol( + &self, + _language_server_id: &LanguageServerId, + symbol: Symbol, + ) -> Option { + let name = &symbol.name; + + match symbol.kind { + SymbolKind::Class | SymbolKind::Interface | SymbolKind::Enum => { + let keyword = match symbol.kind { + SymbolKind::Class => "class ", + SymbolKind::Interface => "interface ", + SymbolKind::Enum => "enum ", + _ => unreachable!(), + }; + let code = format!("{keyword}{name} {{}}"); + + Some(CodeLabel { + spans: vec![CodeLabelSpan::code_range(0..keyword.len() + name.len())], + filter_range: (keyword.len()..keyword.len() + name.len()).into(), + code, + }) + } + SymbolKind::Method | SymbolKind::Function => { + // jdtls: "methodName(Type, Type) : ReturnType" or "methodName(Type)" + // display: "ReturnType methodName(Type, Type)" (Java declaration order) + let method_name = name.split('(').next().unwrap_or(name); + let after_name = &name[method_name.len()..]; + + let (params, return_type) = if let Some((p, r)) = after_name.split_once(" : ") { + (p, Some(r)) + } else { + (after_name, None) + }; + + let ret = return_type.unwrap_or("void"); + let class_open = "class _ { "; + let code = format!("{class_open}{ret} {method_name}() {{}} }}"); + + let ret_start = class_open.len(); + let name_start = ret_start + ret.len() + 1; + + // Display: "void methodName(String, int)" + let mut spans = vec![ + CodeLabelSpan::code_range(ret_start..ret_start + ret.len()), + CodeLabelSpan::literal(" ".to_string(), None), + CodeLabelSpan::code_range(name_start..name_start + method_name.len()), + ]; + if !params.is_empty() { + spans.push(CodeLabelSpan::literal(params.to_string(), None)); + } + + // filter on "methodName(params)" portion of displayed text + let type_prefix_len = ret.len() + 1; // "void " + let filter_end = type_prefix_len + method_name.len() + params.len(); + Some(CodeLabel { + spans, + filter_range: (type_prefix_len..filter_end).into(), + code, + }) + } + _ => None, + } + } +} + +register_extension!(Java); diff --git a/src/jdk.rs b/src/jdk.rs new file mode 100644 index 0000000..a844c9a --- /dev/null +++ b/src/jdk.rs @@ -0,0 +1,171 @@ +use std::path::{Path, PathBuf}; + +use zed_extension_api::{ + self as zed, Architecture, DownloadedFileType, LanguageServerId, + LanguageServerInstallationStatus, Os, current_platform, download_file, serde_json::Value, + set_language_server_installation_status, +}; + +use crate::util::{ + get_curr_dir, mark_checked_once, path_to_string, remove_all_files_except, + should_use_local_or_download, +}; + +// Errors +const JDK_DIR_ERROR: &str = "Failed to read into JDK install directory"; +const NO_JDK_DIR_ERROR: &str = "No match for jdk or corretto in the extracted directory"; + +const CORRETTO_REPO: &str = "corretto/corretto-25"; +const CORRETTO_UNIX_URL_TEMPLATE: &str = "https://corretto.aws/downloads/resources/{version}/amazon-corretto-{version}-{platform}-{arch}.tar.gz"; +const CORRETTO_WINDOWS_URL_TEMPLATE: &str = "https://corretto.aws/downloads/resources/{version}/amazon-corretto-{version}-{platform}-{arch}-jdk.zip"; +const JDK_INSTALL_PATH: &str = "jdk"; + +fn build_corretto_url(version: &str, platform: &str, arch: &str) -> String { + let template = match zed::current_platform().0 { + Os::Windows => CORRETTO_WINDOWS_URL_TEMPLATE, + _ => CORRETTO_UNIX_URL_TEMPLATE, + }; + + template + .replace("{version}", version) + .replace("{platform}", platform) + .replace("{arch}", arch) +} + +// For now keep in this file as they are not used anywhere else +// otherwise move to util +fn get_architecture() -> zed::Result { + match zed::current_platform() { + (_, Architecture::Aarch64) => Ok("aarch64".to_string()), + (_, Architecture::X86) => Ok("x86".to_string()), + (_, Architecture::X8664) => Ok("x64".to_string()), + } +} + +fn get_platform() -> zed::Result { + match zed::current_platform() { + (Os::Mac, _) => Ok("macosx".to_string()), + (Os::Linux, _) => Ok("linux".to_string()), + (Os::Windows, _) => Ok("windows".to_string()), + } +} + +fn find_latest_local_jdk() -> Option { + let jdk_path = get_curr_dir().ok()?.join(JDK_INSTALL_PATH); + std::fs::read_dir(&jdk_path) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .filter_map(|path| { + let created_time = std::fs::metadata(&path) + .and_then(|meta| meta.created()) + .ok()?; + Some((path, created_time)) + }) + .max_by_key(|&(_, time)| time) + .map(|(path, _)| path) +} + +pub fn try_to_fetch_and_install_latest_jdk( + language_server_id: &LanguageServerId, + configuration: &Option, +) -> zed::Result { + let jdk_path = get_curr_dir() + .map_err(|err| format!("Failed to get current directory for JDK installation: {err}"))? + .join(JDK_INSTALL_PATH); + + // Check if we should use local installation based on update mode + if let Some(path) = + should_use_local_or_download(configuration, find_latest_local_jdk(), JDK_INSTALL_PATH) + .map_err(|err| format!("Failed to resolve JDK installation: {err}"))? + { + return get_jdk_bin_path(&path); + } + + // Check for updates, if same version is already downloaded skip download + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::CheckingForUpdate, + ); + + let version = zed::latest_github_release( + CORRETTO_REPO, + zed_extension_api::GithubReleaseOptions { + require_assets: false, + pre_release: false, + }, + ) + .map_err(|err| format!("Failed to fetch latest Corretto release from {CORRETTO_REPO}: {err}"))? + .version; + + let install_path = jdk_path.join(&version); + + if !install_path.exists() { + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + + let platform = get_platform() + .map_err(|err| format!("Failed to detect platform for JDK download: {err}"))?; + let arch = get_architecture() + .map_err(|err| format!("Failed to detect architecture for JDK download: {err}"))?; + + let download_url = build_corretto_url(&version, &platform, &arch); + download_file( + download_url.as_str(), + path_to_string(install_path.clone()) + .map_err(|err| format!("Invalid JDK install path {install_path:?}: {err}"))? + .as_str(), + match zed::current_platform().0 { + Os::Windows => DownloadedFileType::Zip, + _ => DownloadedFileType::GzipTar, + }, + ) + .map_err(|err| format!("Failed to download Corretto JDK from {download_url}: {err}"))?; + + // Remove older versions + let _ = remove_all_files_except(&jdk_path, version.as_str()); + + // Mark the downloaded version for "Once" mode tracking + let _ = mark_checked_once(JDK_INSTALL_PATH, &version); + } + + get_jdk_bin_path(&install_path) +} + +fn get_jdk_bin_path(install_path: &Path) -> zed::Result { + // Depending on the platform the name of the extracted dir might differ + // Rather than hard coding, extract it dynamically + let extracted_dir = get_extracted_dir(install_path) + .map_err(|err| format!("Failed to find JDK directory in {install_path:?}: {err}"))?; + + Ok(install_path + .join(extracted_dir) + .join(match current_platform().0 { + Os::Mac => "Contents/Home/bin", + _ => "bin", + })) +} + +fn get_extracted_dir(path: &Path) -> zed::Result { + let Ok(mut entries) = path.read_dir() else { + return Err(JDK_DIR_ERROR.to_string()); + }; + + match entries.find_map(|entry| { + let entry = entry.ok()?; + let file_name = entry.file_name(); + let name_str = file_name.to_string_lossy().to_string(); + + if name_str.contains("jdk") || name_str.contains("corretto") { + Some(name_str) + } else { + None + } + }) { + Some(dir_path) => Ok(dir_path), + None => Err(NO_JDK_DIR_ERROR.to_string()), + } +} diff --git a/src/jdtls.rs b/src/jdtls.rs new file mode 100644 index 0000000..18972a1 --- /dev/null +++ b/src/jdtls.rs @@ -0,0 +1,407 @@ +use std::{ + env::current_dir, + fs::{metadata, read_dir}, + path::{Path, PathBuf}, +}; + +use sha1::{Digest, Sha1}; +use zed_extension_api::{ + self as zed, DownloadedFileType, LanguageServerId, LanguageServerInstallationStatus, Os, + Worktree, current_platform, download_file, + http_client::{HttpMethod, HttpRequest, fetch}, + make_file_executable, + serde_json::Value, + set_language_server_installation_status, +}; + +use crate::{ + config::is_java_autodownload, + jdk::try_to_fetch_and_install_latest_jdk, + util::{ + create_path_if_not_exists, get_curr_dir, get_java_exec_name, get_java_executable, + get_java_major_version, get_latest_versions_from_tag, mark_checked_once, path_to_string, + remove_all_files_except, should_use_local_or_download, + }, +}; + +const JDTLS_INSTALL_PATH: &str = "jdtls"; +const JDTLS_REPO: &str = "eclipse-jdtls/eclipse.jdt.ls"; +const LOMBOK_INSTALL_PATH: &str = "lombok"; +const LOMBOK_REPO: &str = "projectlombok/lombok"; + +// Errors + +const JAVA_VERSION_ERROR: &str = "JDTLS requires at least Java version 21 to run. You can either specify a different JDK to use by configuring lsp.jdtls.settings.java_home to point to a different JDK, or set lsp.jdtls.settings.jdk_auto_download to true to let the extension automatically download one for you."; +const JDTLS_VERION_ERROR: &str = "No version to fallback to"; + +pub fn build_jdtls_launch_args( + jdtls_path: &PathBuf, + configuration: &Option, + worktree: &Worktree, + jvm_args: Vec, + language_server_id: &LanguageServerId, +) -> zed::Result> { + if let Some(jdtls_launcher) = get_jdtls_launcher_from_path(worktree) { + return Ok(vec![jdtls_launcher]); + } + + let mut java_executable = get_java_executable(configuration, worktree, language_server_id) + .map_err(|err| format!("Failed to locate Java executable for JDTLS: {err}"))?; + let java_major_version = get_java_major_version(&java_executable) + .map_err(|err| format!("Failed to determine Java version: {err}"))?; + if java_major_version < 21 { + if is_java_autodownload(configuration) { + java_executable = + try_to_fetch_and_install_latest_jdk(language_server_id, configuration) + .map_err(|err| format!("Failed to auto-download JDK for JDTLS: {err}"))? + .join(get_java_exec_name()); + } else { + return Err(JAVA_VERSION_ERROR.to_string()); + } + } + + let extension_workdir = get_curr_dir() + .map_err(|err| format!("Failed to get extension working directory: {err}"))?; + + let jdtls_base_path = extension_workdir.join(jdtls_path); + + let shared_config_path = get_shared_config_path(&jdtls_base_path); + let jar_path = find_equinox_launcher(&jdtls_base_path).map_err(|err| { + format!("Failed to find JDTLS equinox launcher in {jdtls_base_path:?}: {err}") + })?; + let jdtls_data_path = get_jdtls_data_path(worktree) + .map_err(|err| format!("Failed to determine JDTLS data path: {err}"))?; + + let mut args = vec![ + path_to_string(java_executable)?, + "-Declipse.application=org.eclipse.jdt.ls.core.id1".to_string(), + "-Dosgi.bundles.defaultStartLevel=4".to_string(), + "-Declipse.product=org.eclipse.jdt.ls.core.product".to_string(), + "-Dosgi.checkConfiguration=true".to_string(), + format!( + "-Dosgi.sharedConfiguration.area={}", + path_to_string(shared_config_path)? + ), + "-Dosgi.sharedConfiguration.area.readOnly=true".to_string(), + "-Dosgi.configuration.cascaded=true".to_string(), + "-Djava.import.generatesMetadataFilesAtProjectRoot=false".to_string(), + "-Xms1G".to_string(), + "--add-modules=ALL-SYSTEM".to_string(), + "--add-opens".to_string(), + "java.base/java.util=ALL-UNNAMED".to_string(), + "--add-opens".to_string(), + "java.base/java.lang=ALL-UNNAMED".to_string(), + ]; + args.extend(jvm_args); + args.extend(vec![ + "-jar".to_string(), + path_to_string(jar_path)?, + "-data".to_string(), + path_to_string(jdtls_data_path)?, + ]); + if java_major_version >= 24 { + args.push("-Djdk.xml.maxGeneralEntitySizeLimit=0".to_string()); + args.push("-Djdk.xml.totalEntitySizeLimit=0".to_string()); + } + Ok(args) +} + +pub fn find_latest_local_jdtls() -> Option { + let prefix = PathBuf::from(JDTLS_INSTALL_PATH); + // walk the dir where we install jdtls + read_dir(&prefix) + .map(|entries| { + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + // get the most recently created subdirectory + .filter_map(|path| { + let created_time = metadata(&path).and_then(|meta| meta.created()).ok()?; + Some((path, created_time)) + }) + .max_by_key(|&(_, time)| time) + // and return it + .map(|(path, _)| path) + }) + .ok() + .flatten() +} + +pub fn find_latest_local_lombok() -> Option { + let prefix = PathBuf::from(LOMBOK_INSTALL_PATH); + // walk the dir where we install lombok + read_dir(&prefix) + .map(|entries| { + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + // get the most recently created jar file + .filter(|path| { + path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("jar") + }) + .filter_map(|path| { + let created_time = metadata(&path).and_then(|meta| meta.created()).ok()?; + Some((path, created_time)) + }) + .max_by_key(|&(_, time)| time) + .map(|(path, _)| path) + }) + .ok() + .flatten() +} + +pub fn get_jdtls_launcher_from_path(worktree: &Worktree) -> Option { + let jdtls_executable_filename = match current_platform().0 { + Os::Windows => "jdtls.bat", + _ => "jdtls", + }; + + worktree.which(jdtls_executable_filename) +} + +pub fn try_to_fetch_and_install_latest_jdtls( + language_server_id: &LanguageServerId, + configuration: &Option, +) -> zed::Result { + // Use local installation if update mode requires it + if let Some(path) = + should_use_local_or_download(configuration, find_latest_local_jdtls(), JDTLS_INSTALL_PATH) + .map_err(|err| format!("Failed to resolve JDTLS installation: {err}"))? + { + return Ok(path); + } + + // Download latest version + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::CheckingForUpdate, + ); + + let (last, second_last) = get_latest_versions_from_tag(JDTLS_REPO) + .map_err(|err| format!("Failed to fetch JDTLS versions from {JDTLS_REPO}: {err}"))?; + + let (latest_version, latest_version_build) = download_jdtls_milestone(last.as_ref()) + .map_or_else( + |_| { + second_last + .as_ref() + .ok_or(JDTLS_VERION_ERROR.to_string()) + .and_then(|fallback| download_jdtls_milestone(fallback)) + .map(|milestone| (second_last.unwrap(), milestone.trim_end().to_string())) + }, + |milestone| Ok((last, milestone.trim_end().to_string())), + )?; + + let prefix = PathBuf::from(JDTLS_INSTALL_PATH); + + let build_directory = latest_version_build.replace(".tar.gz", ""); + let build_path = prefix.join(&build_directory); + let binary_path = build_path.join("bin").join(get_binary_name()); + + // If latest version isn't installed, + if !metadata(&binary_path).is_ok_and(|stat| stat.is_file()) { + // then download it... + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + let download_url = format!( + "https://www.eclipse.org/downloads/download.php?file=/jdtls/milestones/{latest_version}/{latest_version_build}" + ); + download_file( + &download_url, + path_to_string(build_path.clone()) + .map_err(|err| format!("Invalid JDTLS build path {build_path:?}: {err}"))? + .as_str(), + DownloadedFileType::GzipTar, + ) + .map_err(|err| format!("Failed to download JDTLS from {download_url}: {err}"))?; + make_file_executable( + path_to_string(&binary_path) + .map_err(|err| format!("Invalid JDTLS binary path {binary_path:?}: {err}"))? + .as_str(), + ) + .map_err(|err| format!("Failed to make JDTLS executable at {binary_path:?}: {err}"))?; + + // ...and delete other versions + let _ = remove_all_files_except(prefix, build_directory.as_str()); + + // Mark the downloaded version for "Once" mode tracking + let _ = mark_checked_once(JDTLS_INSTALL_PATH, &latest_version); + } + + // return jdtls base path + Ok(build_path) +} + +pub fn try_to_fetch_and_install_latest_lombok( + language_server_id: &LanguageServerId, + configuration: &Option, +) -> zed::Result { + // Use local installation if update mode requires it + if let Some(path) = should_use_local_or_download( + configuration, + find_latest_local_lombok(), + LOMBOK_INSTALL_PATH, + ) + .map_err(|err| format!("Failed to resolve Lombok installation: {err}"))? + { + return Ok(path); + } + + // Download latest version + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::CheckingForUpdate, + ); + + let (latest_version, _) = get_latest_versions_from_tag(LOMBOK_REPO) + .map_err(|err| format!("Failed to fetch Lombok versions from {LOMBOK_REPO}: {err}"))?; + let prefix = LOMBOK_INSTALL_PATH; + let jar_name = format!("lombok-{latest_version}.jar"); + let jar_path = Path::new(prefix).join(&jar_name); + + // If latest version isn't installed, + if !metadata(&jar_path).is_ok_and(|stat| stat.is_file()) { + // then download it... + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + create_path_if_not_exists(prefix) + .map_err(|err| format!("Failed to create Lombok directory '{prefix}': {err}"))?; + let download_url = format!("https://projectlombok.org/downloads/{jar_name}"); + download_file( + &download_url, + path_to_string(jar_path.clone()) + .map_err(|err| format!("Invalid Lombok jar path {jar_path:?}: {err}"))? + .as_str(), + DownloadedFileType::Uncompressed, + ) + .map_err(|err| format!("Failed to download Lombok from {download_url}: {err}"))?; + + // ...and delete other versions + + let _ = remove_all_files_except(prefix, jar_name.as_str()); + + // Mark the downloaded version for "Once" mode tracking + let _ = mark_checked_once(LOMBOK_INSTALL_PATH, &latest_version); + } + + // else use it + Ok(jar_path) +} + +fn download_jdtls_milestone(version: &str) -> zed::Result { + String::from_utf8( + fetch( + &HttpRequest::builder() + .method(HttpMethod::Get) + .url(format!( + "https://download.eclipse.org/jdtls/milestones/{version}/latest.txt" + )) + .build()?, + ) + .map_err(|err| format!("Failed to get latest version's build: {err}"))? + .body, + ) + .map_err(|err| format!("Failed to get latest version's build (malformed response): {err}")) +} + +fn find_equinox_launcher(jdtls_base_directory: &Path) -> Result { + let plugins_dir = jdtls_base_directory.join("plugins"); + + // if we have `org.eclipse.equinox.launcher.jar` use that + let specific_launcher = plugins_dir.join("org.eclipse.equinox.launcher.jar"); + if specific_launcher.is_file() { + return Ok(specific_launcher); + } + + // else get the first file that matches the glob 'org.eclipse.equinox.launcher_*.jar' + let entries = + read_dir(&plugins_dir).map_err(|err| format!("Failed to read plugins directory: {err}"))?; + + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| { + path.is_file() + && path.file_name().and_then(|s| s.to_str()).is_some_and(|s| { + s.starts_with("org.eclipse.equinox.launcher_") && s.ends_with(".jar") + }) + }) + .ok_or_else(|| "Cannot find equinox launcher".to_string()) +} + +fn get_jdtls_data_path(worktree: &Worktree) -> zed::Result { + // Note: the JDTLS data path is where JDTLS stores its own caches. + // In the unlikely event we can't find the canonical OS-Level cache-path, + // we fall back to the the extension's workdir, which may never get cleaned up. + // In future we may want to deliberately manage caches to be able to force-clean them. + + let env = worktree.shell_env(); + let base_cachedir = match current_platform().0 { + Os::Mac => env + .iter() + .find(|(k, _)| k == "XDG_CACHE_HOME") + .map(|(_, v)| PathBuf::from(v)) + .or_else(|| { + env.iter() + .find(|(k, _)| k == "HOME") + .map(|(_, v)| PathBuf::from(v).join("Library").join("Caches")) + }), + Os::Linux => env + .iter() + .find(|(k, _)| k == "XDG_CACHE_HOME") + .map(|(_, v)| PathBuf::from(v)) + .or_else(|| { + env.iter() + .find(|(k, _)| k == "HOME") + .map(|(_, v)| PathBuf::from(v).join(".cache")) + }), + Os::Windows => env + .iter() + .find(|(k, _)| k == "APPDATA") + .map(|(_, v)| PathBuf::from(v)), + } + .unwrap_or_else(|| { + current_dir() + .expect("should be able to get extension workdir") + .join("caches") + }); + + // caches are unique per worktree-root-path + let cache_key = worktree.root_path(); + + let hex_digest = get_sha1_hex(&cache_key); + let unique_dir_name = format!("jdtls-{hex_digest}"); + Ok(base_cachedir.join(unique_dir_name)) +} + +fn get_binary_name() -> &'static str { + match current_platform().0 { + Os::Windows => "jdtls.bat", + _ => "jdtls", + } +} + +fn get_sha1_hex(input: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(input.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) +} + +fn get_shared_config_path(jdtls_base_directory: &Path) -> PathBuf { + // Note: JDTLS also provides config_linux_arm and config_mac_arm (and others), + // but does not use them in their own launch script. It may be worth investigating if we should use them when appropriate. + let config_to_use = match current_platform().0 { + Os::Linux => "config_linux", + Os::Mac => "config_mac", + Os::Windows => "config_win", + }; + jdtls_base_directory.join(config_to_use) +} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 26965b3..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,467 +0,0 @@ -use std::{ - collections::BTreeSet, - fs::{self, create_dir}, -}; - -use zed_extension_api::{ - self as zed, CodeLabel, CodeLabelSpan, DownloadedFileType, Extension, LanguageServerId, - LanguageServerInstallationStatus, Os, Worktree, current_platform, download_file, - http_client::{HttpMethod, HttpRequest, fetch}, - lsp::{Completion, CompletionKind}, - make_file_executable, register_extension, - serde_json::{self, Value}, - set_language_server_installation_status, - settings::LspSettings, -}; - -struct Java { - cached_binary_path: Option, - cached_lombok_path: Option, -} - -impl Java { - fn language_server_binary_path( - &mut self, - language_server_id: &LanguageServerId, - worktree: &Worktree, - ) -> zed::Result { - // Use cached path if exists - - if let Some(path) = &self.cached_binary_path { - if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { - return Ok(path.clone()); - } - } - - // Use $PATH if binary is in it - - let (platform, _) = current_platform(); - let binary_name = match platform { - Os::Windows => "jdtls.bat", - _ => "jdtls", - }; - - if let Some(path_binary) = worktree.which(binary_name) { - return Ok(path_binary); - } - - // Check for latest version - - set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::CheckingForUpdate, - ); - - // Yeah, this part's all pretty terrible... - // Note to self: make it good eventually - let downloads_html = String::from_utf8( - fetch( - &HttpRequest::builder() - .method(HttpMethod::Get) - .url("https://download.eclipse.org/jdtls/milestones/") - .build()?, - ) - .map_err(|err| format!("failed to get available versions: {err}"))? - .body, - ) - .map_err(|err| format!("could not get string from downloads page response body: {err}"))?; - let mut versions = BTreeSet::new(); - let mut number_buffer = String::new(); - let mut version_buffer: (Option, Option, Option) = (None, None, None); - - for char in downloads_html.chars() { - if char.is_numeric() { - number_buffer.push(char); - } else if char == '.' { - if version_buffer.0.is_none() && !number_buffer.is_empty() { - version_buffer.0 = Some( - number_buffer - .parse() - .map_err(|err| format!("could not parse number buffer: {err}"))?, - ); - } else if version_buffer.1.is_none() && !number_buffer.is_empty() { - version_buffer.1 = Some( - number_buffer - .parse() - .map_err(|err| format!("could not parse number buffer: {err}"))?, - ); - } else { - version_buffer = (None, None, None); - } - - number_buffer.clear(); - } else { - if version_buffer.0.is_some() - && version_buffer.1.is_some() - && version_buffer.2.is_none() - { - versions.insert(( - version_buffer.0.ok_or("no major version number")?, - version_buffer.1.ok_or("no minor version number")?, - number_buffer - .parse::() - .map_err(|err| format!("could not parse number buffer: {err}"))?, - )); - } - - number_buffer.clear(); - version_buffer = (None, None, None); - } - } - - let (major, minor, patch) = versions.last().ok_or("no available versions")?; - let latest_version = format!("{major}.{minor}.{patch}"); - let latest_version_build = String::from_utf8( - fetch( - &HttpRequest::builder() - .method(HttpMethod::Get) - .url(format!( - "https://download.eclipse.org/jdtls/milestones/{latest_version}/latest.txt" - )) - .build()?, - ) - .map_err(|err| format!("failed to get latest version's build: {err}"))? - .body, - ) - .map_err(|err| { - format!("attempt to get latest version's build resulted in a malformed response: {err}") - })?; - let latest_version_build = latest_version_build.trim_end(); - let prefix = "jdtls"; - // Exclude ".tar.gz" - let build_directory = &latest_version_build[..latest_version_build.len() - 7]; - let build_path = format!("{prefix}/{build_directory}"); - let binary_path = format!("{build_path}/bin/{binary_name}"); - - // If latest version isn't installed, - if !fs::metadata(&binary_path).is_ok_and(|stat| stat.is_file()) { - // then download it... - - set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::Downloading, - ); - download_file( - &format!( - "https://www.eclipse.org/downloads/download.php?file=/jdtls/milestones/{latest_version}/{latest_version_build}", - ), - &build_path, - DownloadedFileType::GzipTar, - )?; - make_file_executable(&binary_path)?; - - // ...and delete other versions - - // This step is expected to fail sometimes, and since we don't know - // how to fix it yet, we just carry on so the user doesn't have to - // restart the language server. - match fs::read_dir(prefix) { - Ok(entries) => { - for entry in entries { - match entry { - Ok(entry) => { - if entry.file_name().to_str() != Some(build_directory) { - if let Err(err) = fs::remove_dir_all(entry.path()) { - println!("failed to remove directory entry: {err}"); - } - } - } - Err(err) => println!("failed to load directory entry: {err}"), - } - } - } - Err(err) => println!("failed to list prefix directory: {err}"), - } - } - - // else use it - - self.cached_binary_path = Some(binary_path.clone()); - - Ok(binary_path) - } - - fn lombok_jar_path(&mut self, language_server_id: &LanguageServerId) -> zed::Result { - // Use cached path if exists - - if let Some(path) = &self.cached_lombok_path { - if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { - return Ok(path.clone()); - } - } - - // Check for latest version - - set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::CheckingForUpdate, - ); - - let tags_response_body = serde_json::from_slice::( - &fetch( - &HttpRequest::builder() - .method(HttpMethod::Get) - .url("https://api.github.com/repos/projectlombok/lombok/tags") - .build()?, - ) - .map_err(|err| format!("failed to fetch GitHub tags: {err}"))? - .body, - ) - .map_err(|err| format!("failed to deserialize GitHub tags response: {err}"))?; - let latest_version = &tags_response_body - .as_array() - .and_then(|tag| { - tag.first().and_then(|latest_tag| { - latest_tag - .get("name") - .and_then(|tag_name| tag_name.as_str()) - }) - }) - // Exclude 'v' at beginning - .ok_or("malformed GitHub tags response")?[1..]; - let prefix = "lombok"; - let jar_name = format!("lombok-{latest_version}.jar"); - let jar_path = format!("{prefix}/{jar_name}"); - - // If latest version isn't installed, - if !fs::metadata(&jar_path).is_ok_and(|stat| stat.is_file()) { - // then download it... - - set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::Downloading, - ); - create_dir(prefix).map_err(|err| err.to_string())?; - download_file( - &format!("https://projectlombok.org/downloads/{jar_name}"), - &jar_path, - DownloadedFileType::Uncompressed, - )?; - - // ...and delete other versions - - // This step is expected to fail sometimes, and since we don't know - // how to fix it yet, we just carry on so the user doesn't have to - // restart the language server. - match fs::read_dir(prefix) { - Ok(entries) => { - for entry in entries { - match entry { - Ok(entry) => { - if entry.file_name().to_str() != Some(&jar_name) { - if let Err(err) = fs::remove_dir_all(entry.path()) { - println!("failed to remove directory entry: {err}"); - } - } - } - Err(err) => println!("failed to load directory entry: {err}"), - } - } - } - Err(err) => println!("failed to list prefix directory: {err}"), - } - } - - // else use it - - self.cached_lombok_path = Some(jar_path.clone()); - - Ok(jar_path) - } -} - -impl Extension for Java { - fn new() -> Self - where - Self: Sized, - { - Self { - cached_binary_path: None, - cached_lombok_path: None, - } - } - - fn language_server_command( - &mut self, - language_server_id: &LanguageServerId, - worktree: &Worktree, - ) -> zed::Result { - let java_home = LspSettings::for_worktree(language_server_id.as_ref(), worktree)? - .initialization_options - .and_then(|initialization_options| { - initialization_options - .pointer("/settings/java/home") - .and_then(|java_home_value| { - java_home_value - .as_str() - .map(|java_home_str| java_home_str.to_string()) - }) - }); - let mut env = Vec::new(); - - if let Some(java_home) = java_home { - env.push(("JAVA_HOME".to_string(), java_home)); - } - - let mut args = Vec::new(); - - // Add lombok as javaagent if initialization_options.settings.java.jdt.ls.lombokSupport.enabled is true - let lombok_enabled = LspSettings::for_worktree(language_server_id.as_ref(), worktree)? - .initialization_options - .and_then(|initialization_options| { - initialization_options - .pointer("/settings/java/jdt/ls/lombokSupport/enabled") - .and_then(|enabled| enabled.as_bool()) - }) - .unwrap_or(false); - if lombok_enabled { - let lombok_jar_path = self.lombok_jar_path(language_server_id)?; - let lombok_jar_full_path = std::env::current_dir() - .map_err(|e| format!("could not get current dir: {e}"))? - .join(&lombok_jar_path) - .to_string_lossy() - .to_string(); - args.push(format!("--jvm-arg=-javaagent:{lombok_jar_full_path}")); - } - - Ok(zed::Command { - command: self.language_server_binary_path(language_server_id, worktree)?, - args, - env, - }) - } - - fn language_server_initialization_options( - &mut self, - language_server_id: &LanguageServerId, - worktree: &Worktree, - ) -> zed::Result> { - LspSettings::for_worktree(language_server_id.as_ref(), worktree) - .map(|lsp_settings| lsp_settings.initialization_options) - } - - fn label_for_completion( - &self, - _language_server_id: &LanguageServerId, - completion: Completion, - ) -> Option { - // uncomment when debugging completions - // println!("Java completion: {completion:#?}"); - - completion.kind.and_then(|kind| match kind { - CompletionKind::Field | CompletionKind::Constant => { - let modifiers = match kind { - CompletionKind::Field => "", - CompletionKind::Constant => "static final ", - _ => return None, - }; - let property_type = completion.detail.as_ref().and_then(|detail| { - detail - .split_once(" : ") - .map(|(_, property_type)| format!("{property_type} ")) - })?; - let semicolon = ";"; - let code = format!("{modifiers}{property_type}{}{semicolon}", completion.label); - - Some(CodeLabel { - spans: vec![ - CodeLabelSpan::code_range( - modifiers.len() + property_type.len()..code.len() - semicolon.len(), - ), - CodeLabelSpan::literal(" : ", None), - CodeLabelSpan::code_range( - modifiers.len()..modifiers.len() + property_type.len(), - ), - ], - code, - filter_range: (0..completion.label.len()).into(), - }) - } - CompletionKind::Method => { - let detail = completion.detail?; - let (left, return_type) = detail - .split_once(" : ") - .map(|(left, return_type)| (left, format!("{return_type} "))) - .unwrap_or((&detail, "void".to_string())); - let parameters = left - .find('(') - .map(|parameters_start| &left[parameters_start..]); - let name_and_parameters = - format!("{}{}", completion.label, parameters.unwrap_or("()")); - let braces = " {}"; - let code = format!("{return_type}{name_and_parameters}{braces}"); - let mut spans = vec![CodeLabelSpan::code_range( - return_type.len()..code.len() - braces.len(), - )]; - - if parameters.is_some() { - spans.push(CodeLabelSpan::literal(" : ", None)); - spans.push(CodeLabelSpan::code_range(0..return_type.len())); - } else { - spans.push(CodeLabelSpan::literal(" - ", None)); - spans.push(CodeLabelSpan::literal(detail, None)); - } - - Some(CodeLabel { - spans, - code, - filter_range: (0..completion.label.len()).into(), - }) - } - CompletionKind::Class | CompletionKind::Interface | CompletionKind::Enum => { - let keyword = match kind { - CompletionKind::Class => "class ", - CompletionKind::Interface => "interface ", - CompletionKind::Enum => "enum ", - _ => return None, - }; - let braces = " {}"; - let code = format!("{keyword}{}{braces}", completion.label); - let namespace = completion - .detail - .map(|detail| detail[..detail.len() - completion.label.len() - 1].to_string()); - let mut spans = vec![CodeLabelSpan::code_range( - keyword.len()..code.len() - braces.len(), - )]; - - if let Some(namespace) = namespace { - spans.push(CodeLabelSpan::literal(format!(" ({namespace})"), None)); - } - - Some(CodeLabel { - spans, - code, - filter_range: (0..completion.label.len()).into(), - }) - } - CompletionKind::Snippet => Some(CodeLabel { - code: String::new(), - spans: vec![CodeLabelSpan::literal( - format!("{} - {}", completion.label, completion.detail?), - None, - )], - filter_range: (0..completion.label.len()).into(), - }), - CompletionKind::Keyword | CompletionKind::Variable => Some(CodeLabel { - spans: vec![CodeLabelSpan::code_range(0..completion.label.len())], - filter_range: (0..completion.label.len()).into(), - code: completion.label, - }), - CompletionKind::Constructor => { - let detail = completion.detail?; - let parameters = &detail[detail.find('(')?..]; - let braces = " {}"; - let code = format!("{}{parameters}{braces}", completion.label); - - Some(CodeLabel { - spans: vec![CodeLabelSpan::code_range(0..code.len() - braces.len())], - code, - filter_range: (0..completion.label.len()).into(), - }) - } - _ => None, - }) - } -} - -register_extension!(Java); diff --git a/src/lsp.rs b/src/lsp.rs new file mode 100644 index 0000000..5c1b804 --- /dev/null +++ b/src/lsp.rs @@ -0,0 +1,146 @@ +use std::{ + fs::{self}, + path::Path, + sync::{Arc, RwLock}, +}; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::json; +use zed_extension_api::{ + self as zed, + http_client::{HttpMethod, HttpRequest, fetch}, + serde_json::{self, Map, Value}, +}; + +/// +/// `proxy.mjs` starts an HTTP server and writes its port to +/// `${workdir}/proxy/${hex(project_root)}`. +/// +/// This allows us to send LSP requests directly from the Java extension. +/// It’s a temporary workaround until `zed_extension_api` +/// provides the ability to send LSP requests directly. +/// +pub struct LspClient { + workspace: String, +} + +#[derive(Clone)] +pub struct LspWrapper(Arc>); + +impl LspWrapper { + pub fn new(workspace: String) -> Self { + LspWrapper(Arc::new(RwLock::new(LspClient { workspace }))) + } + + pub fn get(&self) -> zed::Result> { + self.0 + .read() + .map_err(|err| format!("LspClient RwLock poisoned during read: {err}")) + } + + pub fn switch_workspace(&self, workspace: String) -> zed::Result<()> { + let mut lock = self + .0 + .write() + .map_err(|err| format!("LspClient RwLock poisoned during read: {err}"))?; + + lock.workspace = workspace; + + Ok(()) + } +} + +impl LspClient { + pub fn resolve_class_path(&self, args: Vec>) -> zed::Result>> { + self.request::>>( + "workspace/executeCommand", + json!({ + "command": "vscode.java.resolveClasspath", + "arguments": args + }), + ) + } + + pub fn resolve_main_class(&self, args: Vec) -> zed::Result> { + self.request::>( + "workspace/executeCommand", + json!({ + "command": "vscode.java.resolveMainClass", + "arguments": args + }), + ) + } + + pub fn request(&self, method: &str, params: Value) -> Result + where + T: DeserializeOwned, + { + // We cannot cache it because the user may restart the LSP + let port = { + let filename = string_to_hex(&self.workspace); + + let port_path = Path::new("proxy").join(filename); + + if !fs::metadata(&port_path).is_ok_and(|file| file.is_file()) { + return Err("Failed to find lsp port file".to_owned()); + } + + fs::read_to_string(port_path) + .map_err(|err| format!("Failed to read LSP proxy port from file: {err}"))? + .parse::() + .map_err(|err| format!("Failed to parse LSP proxy port (file corrupted): {err}"))? + }; + + let mut body = Map::new(); + body.insert("method".to_owned(), Value::String(method.to_owned())); + body.insert("params".to_owned(), params); + + let res = fetch( + &HttpRequest::builder() + .method(HttpMethod::Post) + .url(format!("http://localhost:{port}")) + .body(Value::Object(body).to_string()) + .build()?, + ) + .map_err(|err| format!("Failed to send request to LSP proxy: {err}"))?; + + let data: LspResponse = serde_json::from_slice(&res.body) + .map_err(|err| format!("Failed to parse response from LSP proxy: {err}"))?; + + match data { + LspResponse::Success { result } => Ok(result), + LspResponse::Error { error } => { + Err(format!("{} {} {}", error.code, error.message, error.data)) + } + } + } +} +fn string_to_hex(s: &str) -> String { + let mut hex_string = String::new(); + for byte in s.as_bytes() { + hex_string.push_str(&format!("{byte:02x}")); + } + hex_string +} + +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +pub enum LspResponse { + Success { result: T }, + Error { error: LspError }, +} + +#[derive(Serialize, Deserialize)] +pub struct LspError { + code: i64, + message: String, + data: Value, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MainClassEntry { + pub main_class: String, + pub project_name: String, + pub file_path: String, +} diff --git a/src/proxy.mjs b/src/proxy.mjs new file mode 100644 index 0000000..41bb04e --- /dev/null +++ b/src/proxy.mjs @@ -0,0 +1,341 @@ +import { Buffer } from "node:buffer"; +import { spawn, exec } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { + existsSync, + mkdirSync, + readdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { Transform } from "node:stream"; +import { text } from "node:stream/consumers"; + +const HTTP_PORT = 0; // 0 - random free one +const HEADER_SEPARATOR = Buffer.from("\r\n", "ascii"); +const CONTENT_SEPARATOR = Buffer.from("\r\n\r\n", "ascii"); +const NAME_VALUE_SEPARATOR = Buffer.from(": ", "ascii"); +const LENGTH_HEADER = "Content-Length"; +const TIMEOUT = 5_000; + +const workdir = process.argv[1]; +const bin = process.argv[2]; +const args = process.argv.slice(3); + +const PROXY_ID = Buffer.from(process.cwd().replace(/\/+$/, "")).toString("hex"); +const PROXY_HTTP_PORT_FILE = join(workdir, "proxy", PROXY_ID); +const isWindows = process.platform === "win32"; +const command = (isWindows && bin.endsWith(".bat")) ? `"${bin}"` : bin; + +const lsp = spawn(command, args, { + shell: (isWindows && bin.endsWith(".bat")), + detached: false, +}); + +function cleanup() { + if (!lsp || lsp.killed || lsp.exitCode !== null) { + return; + } + + if (isWindows) { + // Windows: Use taskkill to kill the process tree (cmd.exe + the child) + // /T = Tree kill (child processes), /F = Force + exec(`taskkill /pid ${lsp.pid} /T /F`); + } else { + lsp.kill("SIGTERM"); + setTimeout(() => { + if (!lsp.killed && lsp.exitCode === null) { + lsp.kill("SIGKILL"); + } + }, 1000); + } +} + +// Handle graceful IDE shutdown via stdin close +process.stdin.on("end", () => { + cleanup(); + process.exit(0); +}); +// Ensure node is monitoring the pipe +process.stdin.resume(); + +// Fallback: monitor parent process for ungraceful shutdown +setInterval(() => { + try { + // Check if parent is still alive + process.kill(process.ppid, 0); + } catch (e) { + // On Windows, checking a process you don't own might throw EPERM. + // We only want to kill if the error is ESRCH (No Such Process). + if (e.code === "ESRCH") { + cleanup(); + process.exit(0); + } + // If e.code is EPERM, the parent is alive but we don't have permission to signal it. + // Do nothing. + } +}, 5000); + +const proxy = createLspProxy({ server: lsp, proxy: process }); + +proxy.on("client", (data, passthrough) => { + passthrough(); +}); +proxy.on("server", (data, passthrough) => { + passthrough(); +}); + +function countParams(detail) { + if (!detail || detail === "()") return 0; + const inner = detail.slice(1, -1).trim(); + if (inner === "") return 0; + let count = 1, depth = 0; + for (const ch of inner) { + if (ch === "<") depth++; + else if (ch === ">") depth--; + else if (ch === "," && depth === 0) count++; + } + return count; +} + +function sortCompletionsByParamCount(result) { + const items = Array.isArray(result) ? result : result?.items; + if (!Array.isArray(items)) return; + for (const item of items) { + if (item.kind === 2 || item.kind === 3) { // Method or Function + const paramCount = countParams(item.labelDetails?.detail); + item.sortText = String(paramCount).padStart(2, "0") + (item.sortText || ""); + } + } +} + +const server = createServer(async (req, res) => { + if (req.method !== "POST") { + res.status = 405; + res.end("Method not allowed"); + return; + } + + const data = await text(req) + .then(safeJsonParse) + .catch(() => null); + + if (!data) { + res.status = 400; + res.end("Bad Request"); + return; + } + + const result = await proxy.request(data.method, data.params); + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.write(JSON.stringify(result)); + res.end(); +}).listen(HTTP_PORT, () => { + mkdirSync(dirname(PROXY_HTTP_PORT_FILE), { recursive: true }); + writeFileSync(PROXY_HTTP_PORT_FILE, server.address().port.toString()); +}); + +export function createLspProxy({ + server: { stdin: serverStdin, stdout: serverStdout, stderr: serverStderr }, + proxy: { stdin: proxyStdin, stdout: proxyStdout, stderr: proxyStderr }, +}) { + const events = new EventEmitter(); + const queue = new Map(); + const nextid = iterid(); + + proxyStdin.pipe(lspMessageSeparator()).on("data", (data) => { + events.emit("client", parse(data), () => serverStdin.write(data)); + }); + + serverStdout.pipe(lspMessageSeparator()).on("data", (data) => { + const message = parse(data); + + const pending = queue.get(message?.id); + if (pending) { + pending(message); + queue.delete(message.id); + return; + } + + // Modify completion responses to sort by param count + if (message?.result?.items || Array.isArray(message?.result)) { + sortCompletionsByParamCount(message.result); + proxyStdout.write(stringify(message)); + return; + } + + events.emit("server", message, () => proxyStdout.write(data)); + }); + + serverStderr.pipe(proxyStderr); + + return Object.assign(events, { + /** + * + * @param {string} method + * @param {any} params + * @returns void + */ + notification(method, params) { + proxyStdout.write(stringify({ jsonrpc: "2.0", method, params })); + }, + + /** + * + * @param {string} method + * @param {any} params + * @returns Promise + */ + request(method, params) { + return new Promise((resolve, reject) => { + const id = nextid(); + queue.set(id, resolve); + + setTimeout(() => { + if (queue.has(id)) { + reject({ + jsonrpc: "2.0", + id, + error: { + code: -32803, + message: "Request to language server timed out after 5000ms.", + }, + }); + this.cancel(id); + } + }, TIMEOUT); + + serverStdin.write(stringify({ jsonrpc: "2.0", id, method, params })); + }); + }, + + cancel(id) { + queue.delete(id); + + serverStdin.write( + stringify({ + jsonrpc: "2.0", + method: "$/cancelRequest", + params: { id }, + }), + ); + }, + }); +} + +function iterid() { + let acc = 1; + return () => PROXY_ID + "-" + acc++; +} + +/** + * The base protocol consists of a header and a content part (comparable to HTTP). + * The header and content part are separated by a ‘\r\n’. + * + * The header part consists of header fields. + * Each header field is comprised of a name and a value, + * separated by ‘: ‘ (a colon and a space). + * The structure of header fields conforms to the HTTP semantic. + * Each header field is terminated by ‘\r\n’. + * Considering the last header field and the overall header + * itself are each terminated with ‘\r\n’, + * and that at least one header is mandatory, + * this means that two ‘\r\n’ sequences always immediately precede + * the content part of a message. + * + * @returns {Transform} + * @see [language-server-protocol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#headerPart) + */ +function lspMessageSeparator() { + let buffer = Buffer.alloc(0); + let contentLength = null; + let headersLength = null; + + return new Transform({ + transform(chunk, encoding, callback) { + buffer = Buffer.concat([buffer, chunk]); + + // A single chunk may contain multiple messages + while (true) { + // Wait until we get the whole headers block + if (buffer.indexOf(CONTENT_SEPARATOR) === -1) { + break; + } + + if (!headersLength) { + const headersEnd = buffer.indexOf(CONTENT_SEPARATOR); + const headers = Object.fromEntries( + buffer + .subarray(0, headersEnd) + .toString() + .split(HEADER_SEPARATOR) + .map((header) => header.split(NAME_VALUE_SEPARATOR)) + .map(([name, value]) => [name.toLowerCase(), value]), + ); + + // A "Content-Length" header must always be present + contentLength = parseInt(headers[LENGTH_HEADER.toLowerCase()], 10); + headersLength = headersEnd + CONTENT_SEPARATOR.length; + } + + const msgLength = headersLength + contentLength; + + // Wait until we get the whole content part + if (buffer.length < msgLength) { + break; + } + + this.push(buffer.subarray(0, msgLength)); + + buffer = buffer.subarray(msgLength); + contentLength = null; + headersLength = null; + } + + callback(); + }, + }); +} + +/** + * + * @param {any} content + * @returns {string} + */ +function stringify(content) { + const json = JSON.stringify(content); + return ( + LENGTH_HEADER + + NAME_VALUE_SEPARATOR + + json.length + + CONTENT_SEPARATOR + + json + ); +} + +/** + * + * @param {string} message + * @returns {any | null} + */ +function parse(message) { + const content = message.slice(message.indexOf(CONTENT_SEPARATOR)); + return safeJsonParse(content); +} + +/** + * + * @param {string} json + * @returns {any | null} + */ +function safeJsonParse(json) { + try { + return JSON.parse(json); + } catch (err) { + return null; + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..2f4984b --- /dev/null +++ b/src/util.rs @@ -0,0 +1,502 @@ +use regex::Regex; +use serde::{Deserialize, Serialize, Serializer}; +use std::{ + env::current_dir, + fs, + path::{Path, PathBuf}, +}; +use zed_extension_api::{ + self as zed, Command, LanguageServerId, Os, Worktree, current_platform, + http_client::{HttpMethod, HttpRequest, fetch}, + serde_json::Value, +}; + +use crate::{ + config::{CheckUpdates, get_check_updates, get_java_home, is_java_autodownload}, + jdk::try_to_fetch_and_install_latest_jdk, +}; + +// Errors +const EXPAND_ERROR: &str = "Failed to expand ~"; +const CURR_DIR_ERROR: &str = "Could not get current dir"; +const DIR_ENTRY_LOAD_ERROR: &str = "Failed to load directory entry"; +const DIR_ENTRY_RM_ERROR: &str = "Failed to remove directory entry"; +const ENTRY_TYPE_ERROR: &str = "Could not determine entry type"; +const FILE_ENTRY_RM_ERROR: &str = "Failed to remove file entry"; +const PATH_TO_STR_ERROR: &str = "Failed to convert path to string"; +const JAVA_EXEC_ERROR: &str = "Failed to convert Java executable path to string"; +const JAVA_VERSION_ERROR: &str = "Failed to determine Java major version"; +const JAVA_EXEC_NOT_FOUND_ERROR: &str = "Could not find Java executable in JAVA_HOME or on PATH"; +const TAG_RETRIEVAL_ERROR: &str = "Failed to fetch GitHub tags"; +const TAG_RESPONSE_ERROR: &str = "Failed to deserialize GitHub tags response"; +const TAG_UNEXPECTED_FORMAT_ERROR: &str = "Malformed GitHub tags response"; +const PATH_IS_NOT_DIR: &str = "File exists but is not a path"; +const NO_LOCAL_INSTALL_NEVER_ERROR: &str = + "Update checks disabled (never) and no local installation found"; +const NO_LOCAL_INSTALL_ONCE_ERROR: &str = + "Update check already performed once and no local installation found"; + +const ONCE_CHECK_MARKER: &str = ".update_checked"; + +/// Create a Path if it does not exist +/// +/// **Errors** if a file that is not a path exists at the location or read/write access failed for the location +/// +///# Arguments +/// * [`path`] the path to create +/// +///# Returns +/// +/// Ok(()) if the path exists or was created successfully +pub fn create_path_if_not_exists>(path: P) -> zed::Result<()> { + let path_ref = path.as_ref(); + match fs::metadata(path_ref) { + Ok(metadata) => { + if metadata.is_dir() { + Ok(()) + } else { + Err(format!("{PATH_IS_NOT_DIR}: {path_ref:?}")) + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir_all(path_ref).map_err(|e| e.to_string()) + } + Err(e) => Err(e.to_string()), + } +} + +/// Check if update check has been performed once for a component +/// +/// # Arguments +/// +/// * [`component_name`] - The component directory name (e.g., "jdtls", "lombok") +/// +/// # Returns +/// +/// Returns true if the marker file exists, indicating a check was already performed +pub fn has_checked_once(component_name: &str) -> bool { + PathBuf::from(component_name) + .join(ONCE_CHECK_MARKER) + .exists() +} + +/// Mark that an update check has been performed for a component +/// +/// # Arguments +/// +/// * [`component_name`] - The component directory name (e.g., "jdtls", "lombok") +/// * [`version`] - The version that was downloaded +/// +/// # Returns +/// +/// Returns Ok(()) if the marker was created successfully +/// +/// # Errors +/// +/// Returns an error if the directory or marker file could not be created +pub fn mark_checked_once(component_name: &str, version: &str) -> zed::Result<()> { + let marker_path = PathBuf::from(component_name).join(ONCE_CHECK_MARKER); + create_path_if_not_exists(PathBuf::from(component_name)) + .map_err(|err| format!("Failed to create directory for {component_name}: {err}"))?; + fs::write(&marker_path, version) + .map_err(|err| format!("Failed to write marker file {marker_path:?}: {err}")) +} + +/// Expand ~ on Unix-like systems +/// +/// # Arguments +/// +/// * [`worktree`] Zed extension worktree with access to ENV +/// * [`path`] path to expand +/// +/// # Returns +/// +/// On Unix-like systems ~ is replaced with the value stored in HOME +/// +/// On Windows systems [`path`] is returned untouched +pub fn expand_home_path(worktree: &Worktree, path: String) -> zed::Result { + match zed::current_platform() { + (Os::Windows, _) => Ok(path), + (_, _) => worktree + .shell_env() + .iter() + .find(|&(key, _)| key == "HOME") + .map_or_else( + || Err(EXPAND_ERROR.to_string()), + |(_, value)| Ok(path.replace("~", value)), + ), + } +} + +/// Get the extension current directory +/// +/// # Returns +/// +/// The [`PathBuf`] of the extension directory +/// +/// # Errors +/// +/// This functoin will return an error if it was not possible to retrieve the current directory +pub fn get_curr_dir() -> zed::Result { + current_dir().map_err(|_| CURR_DIR_ERROR.to_string()) +} + +/// Retrieve the path to a java exec either: +/// - defined by the user in `settings.json` under option `java_home` +/// - from PATH +/// - from JAVA_HOME +/// - from the bundled OpenJDK if option `jdk_auto_download` is true +/// +/// # Arguments +/// +/// * [`configuration`] a JSON object representing the user configuration +/// * [`worktree`] Zed extension worktree +/// +/// # Returns +/// +/// Returns the path to the java exec file +/// +/// # Errors +/// +/// This function will return an error if neither PATH or JAVA_HOME led +/// to a java exec file +pub fn get_java_executable( + configuration: &Option, + worktree: &Worktree, + language_server_id: &LanguageServerId, +) -> zed::Result { + let java_executable_filename = get_java_exec_name(); + + // Get executable from $JAVA_HOME + if let Some(java_home) = get_java_home(configuration, worktree) { + let java_executable = PathBuf::from(java_home) + .join("bin") + .join(java_executable_filename); + return Ok(java_executable); + } + // If we can't, try to get it from $PATH + if let Some(java_home) = worktree.which(java_executable_filename.as_str()) { + return Ok(PathBuf::from(java_home)); + } + + // If the user has set the option, retrieve the latest version of Corretto (OpenJDK) + if is_java_autodownload(configuration) { + return Ok( + try_to_fetch_and_install_latest_jdk(language_server_id, configuration)? + .join(java_executable_filename), + ); + } + + Err(JAVA_EXEC_NOT_FOUND_ERROR.to_string()) +} + +/// Retrieve the executable name for Java on this platform +/// +/// # Returns +/// +/// Returns the executable java name +pub fn get_java_exec_name() -> String { + match current_platform().0 { + Os::Windows => "java.exe".to_string(), + _ => "java".to_string(), + } +} + +/// Retrieve the java major version accessible by the extension +/// +/// # Arguments +/// +/// * [`java_executable`] the path to a java exec file +/// +/// # Returns +/// +/// Returns the java major version +/// +/// # Errors +/// +/// This function will return an error if: +/// +/// * [`java_executable`] can't be converted into a String +/// * No major version can be determined +pub fn get_java_major_version(java_executable: &PathBuf) -> zed::Result { + let program = path_to_string(java_executable) + .map_err(|err| format!("{JAVA_EXEC_ERROR} '{java_executable:?}': {err}"))?; + let output_bytes = Command::new(&program) + .arg("-version") + .output() + .map_err(|err| format!("Failed to execute '{program} -version': {err}"))? + .stderr; + let output = String::from_utf8(output_bytes) + .map_err(|err| format!("Invalid UTF-8 in java version output: {err}"))?; + + let major_version_regex = Regex::new(r#"version\s"(?P\d+)(\.\d+\.\d+(_\d+)?)?"#) + .map_err(|err| format!("Invalid regex for Java version parsing: {err}"))?; + let major_version = major_version_regex + .captures_iter(&output) + .find_map(|c| c.name("major").and_then(|m| m.as_str().parse::().ok())); + + if let Some(major_version) = major_version { + Ok(major_version) + } else { + Err(JAVA_VERSION_ERROR.to_string()) + } +} + +/// Retrieve the latest and second latest versions from the repo tags +/// +/// # Arguments +/// +/// * [`repo`] The GitHub repository from which to retrieve the tags +/// +/// # Returns +/// +/// A tuple containing the latest version, and optionally, the second latest version if available +/// +/// # Errors +/// +/// This function will return an error if: +/// * Could not fetch tags from Github +/// * Failed to deserialize response +/// * Unexpected Github response format +pub fn get_latest_versions_from_tag(repo: &str) -> zed::Result<(String, Option)> { + let tags_response_body = serde_json::from_slice::( + &fetch( + &HttpRequest::builder() + .method(HttpMethod::Get) + .url(format!("https://api.github.com/repos/{repo}/tags")) + .build()?, + ) + .map_err(|err| format!("{TAG_RETRIEVAL_ERROR}: {err}"))? + .body, + ) + .map_err(|err| format!("{TAG_RESPONSE_ERROR}: {err}"))?; + + let latest_version = get_tag_at(&tags_response_body, 0); + let second_version = get_tag_at(&tags_response_body, 1); + + if latest_version.is_none() { + return Err(TAG_UNEXPECTED_FORMAT_ERROR.to_string()); + } + + Ok(( + latest_version.unwrap().to_string(), + second_version.map(|second| second.to_string()), + )) +} + +fn get_tag_at(github_tags: &Value, index: usize) -> Option<&str> { + github_tags.as_array().and_then(|tag| { + tag.get(index).and_then(|latest_tag| { + latest_tag + .get("name") + .and_then(|tag_name| tag_name.as_str()) + .map(|val| &val[1..]) + }) + }) +} + +/// Converts a [`Path`] into a [`String`]. +/// +/// # Arguments +/// +/// * `path` - The path of type `AsRef` to be converted. +/// +/// # Returns +/// +/// Returns a `String` representing the path. +/// +/// # Errors +/// +/// This function will return an error when the string conversion fails. +pub fn path_to_string>(path: P) -> zed::Result { + path.as_ref() + .to_path_buf() + .into_os_string() + .into_string() + .map_err(|_| PATH_TO_STR_ERROR.to_string()) +} + +/// Remove all files or directories that aren't equal to [`filename`]. +/// +/// This function scans the directory given by [`prefix`] and removes any +/// file or directory whose name does not exactly match [`filename`]. +/// +/// # Arguments +/// +/// * [`prefix`] - The path to the directory to clean. See [`AsRef`] for supported types. +/// * [`filename`] - The name of the file to keep. +/// +/// # Returns +/// +/// Returns `Ok(())` on success, even if some removals fail (errors are printed to stdout). +pub fn remove_all_files_except>(prefix: P, filename: &str) -> zed::Result<()> { + let entries: Vec<_> = match fs::read_dir(prefix) { + Ok(entries) => entries.filter_map(|e| e.ok()).collect(), + Err(err) => { + println!("{DIR_ENTRY_LOAD_ERROR}: {err}"); + return Err(format!("{DIR_ENTRY_LOAD_ERROR}: {err}")); + } + }; + + for entry in entries { + if entry.file_name().to_str() != Some(filename) { + match entry.file_type() { + Ok(t) => { + if t.is_dir() + && let Err(err) = fs::remove_dir_all(entry.path()) + { + println!("{DIR_ENTRY_RM_ERROR}: {err}"); + } else if t.is_file() + && let Err(err) = fs::remove_file(entry.path()) + { + println!("{FILE_ENTRY_RM_ERROR}: {err}"); + } + } + Err(type_err) => println!("{ENTRY_TYPE_ERROR}: {type_err}"), + } + } + } + + Ok(()) +} + +/// Determine whether to use local component or download based on update mode +/// +/// This function handles the common logic for all components (JDTLS, Lombok, Debugger): +/// 1. Apply update check mode (Never/Once/Always) +/// 2. Find local installation if applicable +/// +/// # Arguments +/// * `configuration` - User configuration JSON +/// * `local` - Optional path to local installation +/// * `component_name` - Component name for error messages (e.g., "jdtls", "lombok", "debugger") +/// +/// # Returns +/// * `Ok(Some(PathBuf))` - Local installation should be used +/// * `Ok(None)` - Should download +/// * `Err(String)` - Error message if resolution failed +/// +/// # Errors +/// - Update mode is Never but no local installation found +/// - Update mode is Once and already checked but no local installation found +pub fn should_use_local_or_download( + configuration: &Option, + local: Option, + component_name: &str, +) -> zed::Result> { + match get_check_updates(configuration) { + CheckUpdates::Never => match local { + Some(path) => Ok(Some(path)), + None => Err(format!( + "{NO_LOCAL_INSTALL_NEVER_ERROR} for {component_name}" + )), + }, + CheckUpdates::Once => { + // If we have a local installation, use it + if let Some(path) = local { + return Ok(Some(path)); + } + + // If we've already checked once, don't check again + if has_checked_once(component_name) { + return Err(format!( + "{NO_LOCAL_INSTALL_ONCE_ERROR} for {component_name}" + )); + } + + // First time checking - allow download + Ok(None) + } + CheckUpdates::Always => Ok(None), + } +} + +/// A type that can be deserialized from either a single string or a list of strings. +/// +/// When serialized, it always produces a single string. If it was a list, +/// the elements are joined with a space, quoting elements that contain spaces. +#[derive(Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum ArgsStringOrList { + String(String), + List(Vec), +} + +impl Serialize for ArgsStringOrList { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + ArgsStringOrList::String(s) => serializer.serialize_str(s), + ArgsStringOrList::List(l) => { + let quoted: Vec = l + .iter() + .map(|s| { + if s.contains(' ') { + format!("\"{}\"", s) + } else { + s.clone() + } + }) + .collect(); + serializer.serialize_str("ed.join(" ")) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json; + + #[derive(Deserialize, Serialize)] + struct ArgsWrapper { + args: ArgsStringOrList, + } + + #[test] + fn test_args_list_with_spaces_quotes_elements() { + let json = std::fs::read_to_string("testdata/args_with_spaces.json").unwrap(); + let wrapper: ArgsWrapper = serde_json::from_str(&json).unwrap(); + let serialized = serde_json::to_value(&wrapper).unwrap(); + assert_eq!( + serialized["args"], + r#""C:\path with spaces\some file.txt" arg2"# + ); + } + + #[test] + fn test_args_single_string_preserved_as_is() { + let json = std::fs::read_to_string("testdata/args_single_string.json").unwrap(); + let wrapper: ArgsWrapper = serde_json::from_str(&json).unwrap(); + let serialized = serde_json::to_value(&wrapper).unwrap(); + assert_eq!(serialized["args"], r#"C:\path with spaces\some file.txt"#); + } + + #[test] + fn test_args_list_no_spaces_not_quoted() { + let json = std::fs::read_to_string("testdata/args_list_no_spaces.json").unwrap(); + let wrapper: ArgsWrapper = serde_json::from_str(&json).unwrap(); + let serialized = serde_json::to_value(&wrapper).unwrap(); + assert_eq!(serialized["args"], "arg1 arg2"); + } + + #[test] + fn test_args_single_element_with_spaces_quoted() { + let json = + std::fs::read_to_string("testdata/args_single_element_with_spaces.json").unwrap(); + let wrapper: ArgsWrapper = serde_json::from_str(&json).unwrap(); + let serialized = serde_json::to_value(&wrapper).unwrap(); + assert_eq!(serialized["args"], r#""path with spaces""#); + } + + #[test] + fn test_args_empty_list() { + let json = std::fs::read_to_string("testdata/args_empty_list.json").unwrap(); + let wrapper: ArgsWrapper = serde_json::from_str(&json).unwrap(); + let serialized = serde_json::to_value(&wrapper).unwrap(); + assert_eq!(serialized["args"], ""); + } +} diff --git a/testdata/args_empty_list.json b/testdata/args_empty_list.json new file mode 100644 index 0000000..21a41b3 --- /dev/null +++ b/testdata/args_empty_list.json @@ -0,0 +1,3 @@ +{ + "args": [] +} diff --git a/testdata/args_list_no_spaces.json b/testdata/args_list_no_spaces.json new file mode 100644 index 0000000..917d7b8 --- /dev/null +++ b/testdata/args_list_no_spaces.json @@ -0,0 +1,3 @@ +{ + "args": ["arg1", "arg2"] +} diff --git a/testdata/args_single_element_with_spaces.json b/testdata/args_single_element_with_spaces.json new file mode 100644 index 0000000..54bd527 --- /dev/null +++ b/testdata/args_single_element_with_spaces.json @@ -0,0 +1,3 @@ +{ + "args": ["path with spaces"] +} diff --git a/testdata/args_single_string.json b/testdata/args_single_string.json new file mode 100644 index 0000000..6e344aa --- /dev/null +++ b/testdata/args_single_string.json @@ -0,0 +1,3 @@ +{ + "args": "C:\\path with spaces\\some file.txt" +} diff --git a/testdata/args_with_spaces.json b/testdata/args_with_spaces.json new file mode 100644 index 0000000..c53f037 --- /dev/null +++ b/testdata/args_with_spaces.json @@ -0,0 +1,3 @@ +{ + "args": ["C:\\path with spaces\\some file.txt", "arg2"] +}