mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 17:20:38 -10:00
Compare commits
192
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03ee48ad5 | ||
|
|
8cd121ed62 | ||
|
|
8971febef7 | ||
|
|
e152cc4ad9 | ||
|
|
ad0b9e478e | ||
|
|
8131eaae5b | ||
|
|
099d614516 | ||
|
|
b08a9bedf9 | ||
|
|
0b6bf83b9e | ||
|
|
6c51e3307e | ||
|
|
8961f6dbf4 | ||
|
|
329f0b1330 | ||
|
|
71251d1746 | ||
|
|
05527ade4a | ||
|
|
49fc9e9257 | ||
|
|
cc53be62d0 | ||
|
|
6c3e5ae469 | ||
|
|
64dad9c520 | ||
|
|
ccc6bd9751 | ||
|
|
6b70e1ea08 | ||
|
|
cbae278355 | ||
|
|
b4e2d4493b | ||
|
|
bc048e2413 | ||
|
|
86dc82534e | ||
|
|
bdb8ca5549 | ||
|
|
2841a5a406 | ||
|
|
231c47b5cd | ||
|
|
7f88d3d723 | ||
|
|
38b56e724e | ||
|
|
98cef2aa48 | ||
|
|
718e07c831 | ||
|
|
4392ffacf6 | ||
|
|
6b4be5a15b | ||
|
|
84cfac9c17 | ||
|
|
51abfe9481 | ||
|
|
aee86444ec | ||
|
|
2d4e91546b | ||
|
|
22a56bfc36 | ||
|
|
e3f41a2284 | ||
|
|
9f16141e6e | ||
|
|
0df19ce81e | ||
|
|
358808833c | ||
|
|
d132f1adf5 | ||
|
|
0b70ede663 | ||
|
|
2738bd0ccc | ||
|
|
632b21ac46 | ||
|
|
c1a6079ee1 | ||
|
|
9797295282 | ||
|
|
d881737fb3 | ||
|
|
16ba1ba3f5 | ||
|
|
50f541fa64 | ||
|
|
27401ad438 | ||
|
|
3ae6bddcc0 | ||
|
|
b6cb416036 | ||
|
|
4126822be8 | ||
|
|
29af5994e3 | ||
|
|
226ac26954 | ||
|
|
658c38a193 | ||
|
|
20be9ff646 | ||
|
|
394e9919c7 | ||
|
|
e839e5dfb7 | ||
|
|
ff17c3753f | ||
|
|
5f970d97d5 | ||
|
|
16bf9b53a2 | ||
|
|
68dd88993b | ||
|
|
b50219b6fa | ||
|
|
7090a8db56 | ||
|
|
37c2a6048e | ||
|
|
d71434a0cb | ||
|
|
53f837b704 | ||
|
|
7c80b68787 | ||
|
|
44d871caee | ||
|
|
89f96edc07 | ||
|
|
fd79a5c55e | ||
|
|
bb8c9fa0b4 | ||
|
|
039ec09122 | ||
|
|
d02aae2280 | ||
|
|
e44a3af310 | ||
|
|
ff2eb95b01 | ||
|
|
b2b881e075 | ||
|
|
90d32f7883 | ||
|
|
f72e3a9a8a | ||
|
|
1ae8572021 | ||
|
|
023c3f36c2 | ||
|
|
88a1e4161e | ||
|
|
0b1f723b9a | ||
|
|
e2f22de824 | ||
|
|
24d3173a55 | ||
|
|
799391b301 | ||
|
|
8ddf979cfe | ||
|
|
f3f9fdb1ad | ||
|
|
687471c19e | ||
|
|
a46d873f6c | ||
|
|
5d3432a000 | ||
|
|
16ebb6fde8 | ||
|
|
0e673c206a | ||
|
|
2680ba8463 | ||
|
|
660587a6fe | ||
|
|
2955cdcf9c | ||
|
|
5a48beab07 | ||
|
|
dfa610beb0 | ||
|
|
e86134ce83 | ||
|
|
4bc08a1084 | ||
|
|
cb2f249db5 | ||
|
|
2dd57d095f | ||
|
|
5ab5c8c677 | ||
|
|
14aa612038 | ||
|
|
ab5f71b442 | ||
|
|
c5124b809d | ||
|
|
a206ac0ca5 | ||
|
|
b12fabd472 | ||
|
|
e77ee39047 | ||
|
|
1a008fd17e | ||
|
|
d2603facdf | ||
|
|
89c7ae7d4c | ||
|
|
89b178f9aa | ||
|
|
c5c5930a75 | ||
|
|
40be4e4c17 | ||
|
|
502cc348ba | ||
|
|
eb72b78abb | ||
|
|
542584abb8 | ||
|
|
0ccc1e687d | ||
|
|
92b527ab0e | ||
|
|
0e81c6aeb1 | ||
|
|
f8e15a7980 | ||
|
|
d17f6755f3 | ||
|
|
f1c10f4d84 | ||
|
|
54d35d8a67 | ||
|
|
b6b09eb968 | ||
|
|
36fae164a8 | ||
|
|
8c21a1e088 | ||
|
|
ecba8aff68 | ||
|
|
c90d3a7a8e | ||
|
|
115d789091 | ||
|
|
fb92a82101 | ||
|
|
7829bee55c | ||
|
|
13b590f2c7 | ||
|
|
8d5504b376 | ||
|
|
60b19b2ffb | ||
|
|
73b3a99699 | ||
|
|
95843ea12d | ||
|
|
3dac5b8bdc | ||
|
|
fd8b4ff14c | ||
|
|
f70eb13f75 | ||
|
|
337bf4b1ca | ||
|
|
047cac209b | ||
|
|
c5647a5934 | ||
|
|
1f2d86f793 | ||
|
|
998b478de3 | ||
|
|
8dd29bad41 | ||
|
|
75565927d8 | ||
|
|
9a76d1766d | ||
|
|
02ff61e6a7 | ||
|
|
fd82beb309 | ||
|
|
ccd1f3aca1 | ||
|
|
1dbebefa94 | ||
|
|
064d4506cd | ||
|
|
95047cefb3 | ||
|
|
e0982cc51d | ||
|
|
e1be533bd2 | ||
|
|
299fb14f80 | ||
|
|
a41fe86d47 | ||
|
|
7579ecbbe1 | ||
|
|
3dae4ef729 | ||
|
|
067feac89c | ||
|
|
a7a00da263 | ||
|
|
9f9a2d8cf0 | ||
|
|
43bcd66b2b | ||
|
|
004da36bed | ||
|
|
62a39dadb7 | ||
|
|
ea8e04e448 | ||
|
|
b9d18250f0 | ||
|
|
29b2b1a272 | ||
|
|
1a06f6e9cd | ||
|
|
6f9279e753 | ||
|
|
75607c0bbf | ||
|
|
c133d2dba9 | ||
|
|
0648856359 | ||
|
|
32f5ef20a0 | ||
|
|
25cd480c30 | ||
|
|
8427e48975 | ||
|
|
4139cb64c2 | ||
|
|
a73e3c21ad | ||
|
|
6d8e41c2c1 | ||
|
|
cad33ef119 | ||
|
|
5c3feea2a8 | ||
|
|
80d438098c | ||
|
|
68782a7220 | ||
|
|
cb6bcd2fa2 | ||
|
|
a243c255fe | ||
|
|
92b5d2dafd | ||
|
|
6bf1794ed6 |
No files matched your search
@@ -22,6 +22,8 @@ env:
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
# The schedule is for this repository's release; forks can still run it by hand.
|
||||
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge the community index
|
||||
|
||||
Generated
+217
-60
@@ -60,6 +60,20 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
@@ -616,6 +630,12 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borrow-or-share"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
@@ -690,6 +710,12 @@ dependencies = [
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
@@ -1496,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2030,6 +2056,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.12"
|
||||
@@ -2187,6 +2222,17 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "faster-hex"
|
||||
version = "0.10.1"
|
||||
@@ -2287,6 +2333,17 @@ dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluent-uri"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5"
|
||||
dependencies = [
|
||||
"borrow-or-share",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
@@ -2346,6 +2403,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fraction"
|
||||
version = "0.15.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-set-times"
|
||||
version = "0.20.3"
|
||||
@@ -4113,7 +4180,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4137,7 +4204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4151,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4162,7 +4229,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
@@ -4178,7 +4245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
@@ -4192,8 +4259,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
@@ -4201,11 +4269,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4218,7 +4287,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4232,12 +4301,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"libc",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
@@ -4253,10 +4324,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
@@ -4274,13 +4347,14 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml_edit 0.25.17+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4293,7 +4367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4313,7 +4387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4325,7 +4399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4338,7 +4412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4380,7 +4454,9 @@ dependencies = [
|
||||
"ide-vfs",
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"quick-xml 0.42.0",
|
||||
"regex-lite",
|
||||
"regex-syntax",
|
||||
@@ -4403,7 +4479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4421,7 +4497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4443,7 +4519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4460,7 +4536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4516,7 +4592,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4542,7 +4618,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-mcp"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dunce",
|
||||
@@ -4561,7 +4637,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4584,9 +4660,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
"ide-core",
|
||||
"ide-extension",
|
||||
"parking_lot",
|
||||
@@ -4598,6 +4675,7 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"wat",
|
||||
@@ -4605,7 +4683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4618,8 +4696,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4636,7 +4715,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4660,7 +4739,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4668,6 +4747,7 @@ dependencies = [
|
||||
"ide-run",
|
||||
"ide-vfs",
|
||||
"ignore",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4679,7 +4759,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4699,7 +4779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4712,7 +4792,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4733,7 +4813,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4748,7 +4828,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4761,7 +4841,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4774,7 +4854,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4795,7 +4875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4816,7 +4896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4840,7 +4920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4856,7 +4936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5287,6 +5367,32 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"base64 0.22.1",
|
||||
"bytecount",
|
||||
"email_address",
|
||||
"fancy-regex",
|
||||
"fraction",
|
||||
"idna",
|
||||
"itoa",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"referencing",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"uuid-simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-protocol"
|
||||
version = "2.0.3"
|
||||
@@ -5477,9 +5583,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.37.0"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
||||
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@@ -5520,7 +5626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"fluent-uri",
|
||||
"fluent-uri 0.1.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -5862,6 +5968,12 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-cmp"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
@@ -6253,6 +6365,15 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-system-configuration"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.2.2"
|
||||
@@ -6402,7 +6523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6539,6 +6660,12 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.14.0"
|
||||
@@ -7306,6 +7433,20 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"fluent-uri 0.3.2",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regalloc2"
|
||||
version = "0.15.2"
|
||||
@@ -8499,8 +8640,7 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
@@ -8512,8 +8652,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -8535,7 +8674,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2 0.11.1",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
@@ -8548,8 +8687,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -8561,8 +8699,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dotenvy",
|
||||
@@ -8573,7 +8710,7 @@ dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2 0.11.1",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
@@ -8586,8 +8723,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-mysql"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"byteorder",
|
||||
@@ -8612,8 +8748,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-postgres"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
@@ -8647,8 +8782,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"flume",
|
||||
@@ -9413,7 +9547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -10496,6 +10630,17 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid-simd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
|
||||
dependencies = [
|
||||
"outref",
|
||||
"uuid",
|
||||
"vsimd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -10520,6 +10665,12 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vsimd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
version = "0.1.0"
|
||||
@@ -11162,6 +11313,12 @@ name = "whoami"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
"objc2-system-configuration",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "win_uds"
|
||||
|
||||
+6
-3
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -103,7 +103,8 @@ portable-pty = "0.9"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls-platform-verifier = "0.7"
|
||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
|
||||
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
oracle = { version = "0.6", default-features = false }
|
||||
bb8 = "0.9"
|
||||
@@ -131,7 +132,9 @@ debug = false
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
# Thin LTO: fat LTO's whole-program link outgrew the 16 GB release runners
|
||||
# (Linux arm64 was killed mid-link from v0.1.39 on).
|
||||
lto = "thin"
|
||||
opt-level = 3
|
||||
# Unwind, so a panic fails one task or command instead of quitting the IDE.
|
||||
panic = "unwind"
|
||||
|
||||
@@ -69,11 +69,13 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
### Contributors
|
||||
|
||||
<a href="https://github.com/l0ng-ai/openidea/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=100" alt="OpenIDEA contributors">
|
||||
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=500" alt="OpenIDEA contributors">
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
|
||||
|
||||
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
|
||||
@@ -2,7 +2,8 @@
|
||||
//! the agent reports them over ACP.
|
||||
|
||||
use crate::model::{
|
||||
AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit, AgentUsage,
|
||||
AgentCommand, AgentCommandSource, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit,
|
||||
AgentUsage,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -145,15 +146,22 @@ pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static
|
||||
}
|
||||
|
||||
/// `available_commands_update`.
|
||||
pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
|
||||
pub(crate) fn commands_of(update: &Value, provider: &str) -> Vec<AgentCommand> {
|
||||
update
|
||||
.get("availableCommands")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| {
|
||||
let name = c.get("name")?.as_str()?.trim_start_matches('/');
|
||||
let source = if provider == "codex-acp" && name.starts_with('$') {
|
||||
AgentCommandSource::Skill
|
||||
} else {
|
||||
c.get("source").and_then(|s| serde_json::from_value(s.clone()).ok()).unwrap_or_default()
|
||||
};
|
||||
Some(AgentCommand {
|
||||
name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(),
|
||||
name: name.to_owned(),
|
||||
source,
|
||||
description: str_of(c, "description").unwrap_or_default(),
|
||||
hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
})
|
||||
@@ -325,12 +333,39 @@ mod tests {
|
||||
assert!(bypass_approvals(&url_mode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sources_in_updates_and_stored_tasks() {
|
||||
let update = json!({"availableCommands": [
|
||||
{"name": "review", "source": "skill"},
|
||||
{"name": "$lint"},
|
||||
{"name": "compact"}
|
||||
]});
|
||||
let sources = |provider| commands_of(&update, provider).into_iter().map(|c| c.source).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
sources("codex-acp"),
|
||||
[AgentCommandSource::Skill, AgentCommandSource::Skill, AgentCommandSource::Command]
|
||||
);
|
||||
assert_eq!(
|
||||
sources("custom"),
|
||||
[AgentCommandSource::Skill, AgentCommandSource::Command, AgentCommandSource::Command]
|
||||
);
|
||||
let old: AgentCommand =
|
||||
serde_json::from_value(json!({"name": "review", "description": "Review", "hint": null})).unwrap();
|
||||
assert_eq!(old.source, AgentCommandSource::Command);
|
||||
let skill = AgentCommand { source: AgentCommandSource::Skill, ..old };
|
||||
assert_eq!(serde_json::from_value::<AgentCommand>(serde_json::to_value(&skill).unwrap()).unwrap(), skill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_and_usage() {
|
||||
let cmds = commands_of(&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}));
|
||||
let cmds = commands_of(
|
||||
&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}),
|
||||
"claude",
|
||||
);
|
||||
assert_eq!(cmds[0].source, AgentCommandSource::Command);
|
||||
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
|
||||
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
|
||||
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
|
||||
|
||||
+105
-28
@@ -100,6 +100,8 @@ struct Shared {
|
||||
providers: RwLock<Vec<provider::ProviderSpec>>,
|
||||
/// Last probe of each provider.
|
||||
probes: Mutex<HashMap<String, AgentProbe>>,
|
||||
/// One pre-session lookup of commands and settings per provider, including failed attempts.
|
||||
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
|
||||
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
|
||||
idle_release_ms: AtomicU64,
|
||||
}
|
||||
@@ -151,6 +153,46 @@ impl Shared {
|
||||
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
|
||||
let mut ready = {
|
||||
let mut fetches = self.command_fetches.lock();
|
||||
fetches
|
||||
.entry(spec.id.clone())
|
||||
.or_insert_with(|| {
|
||||
let (done, ready) = tokio::sync::watch::channel(false);
|
||||
let shared = self.clone();
|
||||
// Keep the lookup alive if a composer unmounts while it is waiting.
|
||||
self.runtime.spawn(async move {
|
||||
if let Ok((commands, options)) =
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
|
||||
{
|
||||
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
|
||||
let options = config::options_of(&json!({ "configOptions": options }));
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
// Notifies subscribers itself when the discovery
|
||||
// changed the cached config; unchanged results
|
||||
// stay silent.
|
||||
shared.remember_discovered(&spec.id, &options, &commands);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let _ = done.send(true);
|
||||
});
|
||||
ready
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
while !*ready.borrow_and_update() {
|
||||
if ready.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_config(&self, provider: &str) -> bool {
|
||||
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
|
||||
}
|
||||
|
||||
fn root_key(&self) -> String {
|
||||
self.paths.project_root.to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -163,41 +205,63 @@ impl Shared {
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
) {
|
||||
let snapshot = {
|
||||
let mut cache = self.known.lock();
|
||||
let known = cache.entry(provider.to_owned()).or_default();
|
||||
let mut changed = false;
|
||||
if let Some(c) = config {
|
||||
if let Some(model) = model {
|
||||
let levels: Vec<_> =
|
||||
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
|
||||
if known.thought_levels.get(model) != Some(&levels) {
|
||||
known.thought_levels.insert(model.to_owned(), levels);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !c.is_empty() && known.config != c {
|
||||
known.config = c.to_vec();
|
||||
self.remember_inner(provider, config, commands, model, false);
|
||||
}
|
||||
|
||||
fn remember_discovered(&self, provider: &str, config: &[AgentConfigOption], commands: &[AgentCommand]) {
|
||||
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), true);
|
||||
}
|
||||
|
||||
fn remember_inner(
|
||||
&self,
|
||||
provider: &str,
|
||||
config: Option<&[AgentConfigOption]>,
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
discovered: bool,
|
||||
) {
|
||||
// Keep the update and its disk write ordered; a session report wins over discovery.
|
||||
let mut cache = self.known.lock();
|
||||
let known = cache.entry(provider.to_owned()).or_default();
|
||||
let mut changed = false;
|
||||
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
|
||||
if let Some(model) = model {
|
||||
let levels: Vec<_> =
|
||||
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
|
||||
if known.thought_levels.get(model) != Some(&levels) {
|
||||
known.thought_levels.insert(model.to_owned(), levels);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let root = self.root_key();
|
||||
if let Some(c) =
|
||||
commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c))
|
||||
{
|
||||
known.commands.insert(root, c.to_vec());
|
||||
if !c.is_empty() && known.config != c {
|
||||
known.config = c.to_vec();
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
serde_json::to_vec_pretty(&*cache)
|
||||
};
|
||||
if let Ok(bytes) = snapshot
|
||||
}
|
||||
let root = self.root_key();
|
||||
if let Some(c) = commands.filter(|c| {
|
||||
!c.is_empty()
|
||||
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
|
||||
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
|
||||
}) {
|
||||
known.commands.insert(root, c.to_vec());
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
|
||||
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
|
||||
{
|
||||
tracing::warn!("saving agent settings: {e}");
|
||||
}
|
||||
// The cached provider config actually changed (a session report, discovery,
|
||||
// or a turn update): tell subscribers to re-list, so a changed model list
|
||||
// reaches the composer without waiting for the next task (#1490). Unchanged
|
||||
// reports stay silent, so routine session traffic emits nothing. The cache
|
||||
// lock is released first; the sink never runs under it.
|
||||
drop(cache);
|
||||
self.emit(AgentEvent::Providers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +381,7 @@ impl AgentManager {
|
||||
known: Mutex::new(known),
|
||||
providers,
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
command_fetches: Mutex::new(HashMap::new()),
|
||||
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
|
||||
});
|
||||
let mut tasks = HashMap::new();
|
||||
@@ -420,9 +485,21 @@ impl AgentManager {
|
||||
// Commands the agent implements itself and never lists (its CLI builtins):
|
||||
// merged without duplicating what `availableCommands` or the disk offer.
|
||||
let mut offered = offered;
|
||||
if task.is_none()
|
||||
&& (offered.is_empty() || !self.shared.has_config(&provider))
|
||||
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
|
||||
{
|
||||
self.shared.fetch_settings(spec).await;
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
if !offered.iter().any(|c| c.name == name) {
|
||||
offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None });
|
||||
offered.push(AgentCommand {
|
||||
name: name.to_owned(),
|
||||
description: description.to_owned(),
|
||||
hint: None,
|
||||
source: AgentCommandSource::Command,
|
||||
});
|
||||
}
|
||||
}
|
||||
if provider != "claude" && provider != "claude-acp" {
|
||||
@@ -1712,7 +1789,7 @@ impl Task {
|
||||
true
|
||||
}
|
||||
"available_commands_update" => {
|
||||
s.task.commands = config::commands_of(update);
|
||||
s.task.commands = config::commands_of(update, &provider);
|
||||
true
|
||||
}
|
||||
"usage_update" => {
|
||||
|
||||
@@ -156,6 +156,14 @@ pub struct AgentConfigOption {
|
||||
pub via: AgentConfigVia,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentCommandSource {
|
||||
#[default]
|
||||
Command,
|
||||
Skill,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCommand {
|
||||
@@ -164,6 +172,8 @@ pub struct AgentCommand {
|
||||
pub description: String,
|
||||
/// What to type after the command, when it takes input.
|
||||
pub hint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source: AgentCommandSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -69,6 +69,63 @@ pub(crate) fn connect(
|
||||
Ok(super::serve(shim, client))
|
||||
}
|
||||
|
||||
/// Discover the slash commands and the model, mode and effort options without a prompt,
|
||||
/// ACP session, or pending session file.
|
||||
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Value>, Value), String> {
|
||||
tokio::time::timeout(Duration::from_secs(5), async move {
|
||||
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
|
||||
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
|
||||
let mut args = spec.args;
|
||||
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
|
||||
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
|
||||
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
|
||||
let _stop = io.kill.clone().drop_guard();
|
||||
let mut lines = BufReader::new(io.stdout).lines();
|
||||
let init = control(
|
||||
&mut io.stdin,
|
||||
&mut lines,
|
||||
"commands",
|
||||
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
|
||||
)
|
||||
.await?
|
||||
.ok_or("Claude Code did not initialize")?;
|
||||
// The effort and model the user configured; without them the options show the defaults.
|
||||
let applied = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let settings = Settings::from_cli(&init, &applied);
|
||||
Ok((settings.commands.clone(), settings.options()))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
|
||||
}
|
||||
|
||||
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
|
||||
async fn control(
|
||||
stdin: &mut (impl AsyncWriteExt + Unpin),
|
||||
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
|
||||
id: &str,
|
||||
request: Value,
|
||||
) -> Result<Option<Value>, String> {
|
||||
let line = json!({ "type": "control_request", "request_id": id, "request": request });
|
||||
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
stdin.flush().await.map_err(|e| e.to_string())?;
|
||||
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
|
||||
let response = &msg["response"];
|
||||
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
|
||||
}
|
||||
}
|
||||
Err("Claude Code exited".into())
|
||||
}
|
||||
|
||||
struct Config {
|
||||
pub program: String,
|
||||
/// Extra arguments (a custom agent's), before the session's own.
|
||||
|
||||
@@ -53,6 +53,7 @@ fn attach(
|
||||
client: OnceLock::new(),
|
||||
state: Mutex::new(State::default()),
|
||||
models: tokio::sync::Mutex::new(None),
|
||||
skills: tokio::sync::Mutex::new(()),
|
||||
});
|
||||
let codex = connect_codex(Arc::new(CodexEvents(Arc::downgrade(&shim))))?;
|
||||
let _ = shim.codex.set(codex.clone());
|
||||
@@ -82,6 +83,8 @@ struct CodexShim {
|
||||
state: Mutex<State>,
|
||||
/// `model/list`, fetched once.
|
||||
models: tokio::sync::Mutex<Option<Arc<Vec<ModelInfo>>>>,
|
||||
/// Keep skill snapshots in request order, including invalidations during session setup.
|
||||
skills: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -300,6 +303,20 @@ impl CodexShim {
|
||||
models
|
||||
}
|
||||
|
||||
async fn publish_skills(&self, session: &str) {
|
||||
let _listing = self.skills.lock().await;
|
||||
let cwd = self.state.lock().threads.get(session).and_then(|t| t.cwd.clone());
|
||||
let Some(cwd) = cwd else { return };
|
||||
let result =
|
||||
tokio::time::timeout(Duration::from_secs(5), self.call("skills/list", json!({ "cwds": [cwd] }))).await;
|
||||
if let Ok(Ok(result)) = result
|
||||
&& let Some(update) = translate::skill_commands(&result)
|
||||
&& self.state.lock().threads.contains_key(session)
|
||||
{
|
||||
self.emit(session, update);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a thread codex opened for a session of its own; returns its `configOptions`.
|
||||
fn open_thread(&self, result: &Value, models: &[ModelInfo], settings: Option<Settings>) -> (String, Value) {
|
||||
let thread = result.get("thread").unwrap_or(&Value::Null);
|
||||
@@ -326,6 +343,7 @@ impl CodexShim {
|
||||
if id.is_empty() {
|
||||
return Err(error(codes::INTERNAL_ERROR, "codex started no thread"));
|
||||
}
|
||||
self.publish_skills(&id).await;
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -344,6 +362,7 @@ impl CodexShim {
|
||||
for update in translate::history(thread, cwd.as_deref()) {
|
||||
self.emit(id, update);
|
||||
}
|
||||
self.publish_skills(id).await;
|
||||
Ok(json!({ "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -362,6 +381,7 @@ impl CodexShim {
|
||||
}
|
||||
let result = self.call("thread/fork", request).await?;
|
||||
let (id, options) = self.open_thread(&result, &models, settings);
|
||||
self.publish_skills(&id).await;
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -551,9 +571,25 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_codex_notification(&self, method: &str, params: &Value) {
|
||||
fn on_codex_notification(self: &Arc<Self>, method: &str, params: &Value) {
|
||||
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default();
|
||||
match method {
|
||||
"skills/changed" => {
|
||||
let shim = self.clone();
|
||||
let sessions: Vec<String> = self
|
||||
.state
|
||||
.lock()
|
||||
.threads
|
||||
.iter()
|
||||
.filter(|(_, t)| t.settings.is_some() && t.parent.is_none())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
self.runtime.spawn(async move {
|
||||
for session in sessions {
|
||||
shim.publish_skills(&session).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
"turn/started" => {
|
||||
if let Some(turn) = params.pointer("/turn/id").and_then(Value::as_str) {
|
||||
self.turn_known(thread, turn, true);
|
||||
|
||||
@@ -528,12 +528,21 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
let mut sent = Vec::new();
|
||||
for (out, frame) in &t.frames {
|
||||
if *out {
|
||||
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
|
||||
let actual: Value = serde_json::from_str(&line).unwrap();
|
||||
let actual: Value = loop {
|
||||
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
|
||||
let actual: Value = serde_json::from_str(&line).unwrap();
|
||||
// Older recordings predate skill discovery; play an unsupported server.
|
||||
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
|
||||
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
|
||||
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
|
||||
continue;
|
||||
}
|
||||
break actual;
|
||||
};
|
||||
match frame.get("method") {
|
||||
Some(method) => {
|
||||
assert_eq!(actual.get("method"), Some(method), "expected {frame}, got {actual}");
|
||||
@@ -1056,3 +1065,117 @@ fn session_mcp_servers_become_thread_configuration() {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn skills_are_listed_for_sessions_and_refreshed_on_change() {
|
||||
for (method, native) in
|
||||
[("session/new", "thread/start"), ("session/load", "thread/resume"), ("session/fork", "thread/fork")]
|
||||
{
|
||||
for initial in [
|
||||
json!({"result": {"data": [{"cwd": "/work", "errors": [], "skills": [
|
||||
{"name": "review", "enabled": true, "description": "Review", "path": "/work/SKILL.md", "scope": "repo"}
|
||||
]}]}}),
|
||||
json!({"error": {"code": -32601, "message": "unsupported"}}),
|
||||
json!({"result": {}}),
|
||||
] {
|
||||
let listed = initial.pointer("/result/data").is_some();
|
||||
let mut initial = initial;
|
||||
initial["id"] = json!(4);
|
||||
let transcript = Transcript {
|
||||
meta: Value::Null,
|
||||
frames: vec![
|
||||
(true, json!({"id": 1, "method": "initialize"})),
|
||||
(false, json!({"id": 1, "result": {}})),
|
||||
(true, json!({"method": "initialized"})),
|
||||
(true, json!({"id": 2, "method": "model/list"})),
|
||||
(false, json!({"id": 2, "result": {"data": []}})),
|
||||
(true, json!({"id": 3, "method": native})),
|
||||
(
|
||||
false,
|
||||
json!({"id": 3, "result": {"thread": {"id": "s", "cwd": "/work", "turns": []}, "cwd": "/work"}}),
|
||||
),
|
||||
(true, json!({"id": 4, "method": "skills/list"})),
|
||||
(false, initial),
|
||||
(true, json!({"id": 5, "method": "turn/start"})),
|
||||
(false, json!({"id": 5, "error": {"code": -32602, "message": "no model in this test"}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 6, "method": "skills/list"})),
|
||||
(false, json!({"id": 6, "result": {"data": [{"cwd": "/work", "skills": [], "errors": []}]}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 7, "method": "skills/list"})),
|
||||
(false, json!({"id": 7, "error": {"code": -32603, "message": "listing failed"}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 8, "method": "skills/list"})),
|
||||
(false, json!({"id": 8, "result": {"data": []}})),
|
||||
],
|
||||
};
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 16);
|
||||
let client = Arc::new(Client::default());
|
||||
let acp = attach(client.clone(), |events| {
|
||||
let (read, write) = tokio::io::split(ours);
|
||||
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
|
||||
})
|
||||
.unwrap();
|
||||
let (read, write) = tokio::io::split(theirs);
|
||||
let player = tokio::spawn(async move { play(&transcript, read, write).await });
|
||||
request(&acp, "initialize", json!({})).await.unwrap();
|
||||
request(&acp, method, json!({"sessionId": "s", "cwd": "/work"})).await.unwrap();
|
||||
let initial_updates = client.of("s");
|
||||
assert_eq!(initial_updates.len(), usize::from(listed), "{method}");
|
||||
if listed {
|
||||
assert_eq!(initial_updates[0]["availableCommands"][0]["name"], "$review");
|
||||
assert_eq!(initial_updates[0]["availableCommands"][0]["source"], "skill");
|
||||
}
|
||||
assert!(
|
||||
request(&acp, "session/prompt", json!({"sessionId": "s", "prompt": text("/$review check this")}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let (frames, _read, _write) = player.await.unwrap();
|
||||
for frame in sent(&frames, "skills/list") {
|
||||
assert_eq!(frame["params"], json!({"cwds": ["/work"]}));
|
||||
}
|
||||
assert_eq!(
|
||||
sent(&frames, "turn/start")[0]["params"]["input"],
|
||||
json!([{"type": "text", "text": "/$review check this"}])
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while client.of("s").len() < usize::from(listed) + 2 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let updates = client.of("s");
|
||||
assert_eq!(updates.len(), usize::from(listed) + 2);
|
||||
assert_eq!(updates.last().unwrap()["availableCommands"], json!([]));
|
||||
acp.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_become_commands_with_their_invocation_names() {
|
||||
let update = skill_commands(&json!({"data": [{"skills": [
|
||||
{"name": "review", "enabled": true, "description": "Long", "shortDescription": "Short"},
|
||||
{"name": "review", "enabled": true, "description": "Duplicate"},
|
||||
{"name": "hidden", "enabled": false, "description": "Disabled"},
|
||||
{"name": "modern", "enabled": true, "description": "Long", "interface": {"shortDescription": "Modern"}},
|
||||
{"name": "full", "enabled": true, "description": "Full description"},
|
||||
{"name": "", "enabled": true}, {"description": "No name"}
|
||||
]}]}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
update,
|
||||
json!({"sessionUpdate": "available_commands_update", "availableCommands": [
|
||||
{"name": "$review", "description": "Short", "source": "skill", "input": null},
|
||||
{"name": "$modern", "description": "Modern", "source": "skill", "input": null},
|
||||
{"name": "$full", "description": "Full description", "source": "skill", "input": null}
|
||||
]})
|
||||
);
|
||||
let commands = crate::config::commands_of(&update, "codex");
|
||||
assert!(commands.iter().all(|c| c.source == crate::AgentCommandSource::Skill));
|
||||
assert_eq!(commands[0].name, "$review");
|
||||
assert!(skill_commands(&json!({"data": []})).unwrap()["availableCommands"].as_array().unwrap().is_empty());
|
||||
assert!(skill_commands(&json!({})).is_none());
|
||||
}
|
||||
@@ -256,6 +256,31 @@ fn effort_name(effort: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The enabled skills from `skills/list`, named as Codex ACP advertises them.
|
||||
pub(super) fn skill_commands(result: &Value) -> Option<Value> {
|
||||
let entries = result.get("data")?.as_array()?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let commands: Vec<Value> = entries
|
||||
.iter()
|
||||
.filter_map(|entry| entry.get("skills").and_then(Value::as_array))
|
||||
.flatten()
|
||||
.filter_map(|skill| {
|
||||
let name = skill.get("name")?.as_str()?.trim();
|
||||
if name.is_empty() || skill.get("enabled") == Some(&Value::Bool(false)) || !seen.insert(name) {
|
||||
return None;
|
||||
}
|
||||
let description = skill
|
||||
.pointer("/interface/shortDescription")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| skill.get("shortDescription").and_then(Value::as_str))
|
||||
.or_else(|| skill.get("description").and_then(Value::as_str))
|
||||
.unwrap_or(name);
|
||||
Some(json!({ "name": format!("${name}"), "description": description, "source": "skill", "input": null }))
|
||||
})
|
||||
.collect();
|
||||
Some(json!({ "sessionUpdate": "available_commands_update", "availableCommands": commands }))
|
||||
}
|
||||
|
||||
/// ACP prompt content blocks as codex `UserInput`s. Embedded files become text.
|
||||
pub(super) fn user_input(prompt: &Value) -> Vec<Value> {
|
||||
let blocks = prompt.as_array().map(Vec::as_slice).unwrap_or_default();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! skills (`.claude/skills/<dir>/SKILL.md`) and custom commands (`.claude/commands/**/*.md`)
|
||||
//! of the working directory and every directory above it, then the user's `~/.claude`.
|
||||
|
||||
use crate::AgentCommand;
|
||||
use crate::{AgentCommand, AgentCommandSource};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -36,9 +36,14 @@ pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec<AgentCommand> {
|
||||
|
||||
/// `first` (the agent's own list) plus the commands of `more` it lacks.
|
||||
pub(crate) fn merge(first: Vec<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
|
||||
let names: HashSet<String> = first.iter().map(|c| c.name.clone()).collect();
|
||||
let mut out = first;
|
||||
out.extend(more.into_iter().filter(|c| !names.contains(&c.name)));
|
||||
for c in more {
|
||||
if let Some(offered) = out.iter_mut().find(|offered| offered.name == c.name) {
|
||||
offered.source = c.source;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -61,7 +66,7 @@ fn skills(dir: &Path) -> Vec<AgentCommand> {
|
||||
return None;
|
||||
}
|
||||
let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?;
|
||||
Some(meta.command(name, &text))
|
||||
Some(meta.command(name, &text, AgentCommandSource::Skill))
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
@@ -79,7 +84,7 @@ fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<AgentCommand>)
|
||||
} else if let Some(stem) = name.strip_suffix(".md")
|
||||
&& let Ok(text) = std::fs::read_to_string(&path)
|
||||
{
|
||||
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text));
|
||||
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text, AgentCommandSource::Command));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +152,7 @@ impl Frontmatter {
|
||||
}
|
||||
|
||||
/// `description`, else the body's first line.
|
||||
fn command(&self, name: String, text: &str) -> AgentCommand {
|
||||
fn command(&self, name: String, text: &str, source: AgentCommandSource) -> AgentCommand {
|
||||
let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| {
|
||||
let body = text.get(self.body..).unwrap_or_default();
|
||||
body.lines()
|
||||
@@ -158,6 +163,7 @@ impl Frontmatter {
|
||||
});
|
||||
AgentCommand {
|
||||
name,
|
||||
source,
|
||||
description: truncate(&description),
|
||||
hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
}
|
||||
@@ -194,13 +200,20 @@ mod tests {
|
||||
}
|
||||
|
||||
fn cmd(name: &str, description: &str) -> AgentCommand {
|
||||
AgentCommand { name: name.into(), description: description.into(), hint: None }
|
||||
AgentCommand {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
hint: None,
|
||||
source: AgentCommandSource::Command,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_and_user_skills_and_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("home");
|
||||
// Home is an ancestor of the project, so the walk stops there instead of climbing
|
||||
// past it into the real profile (a temp dir under the user's home holds its skills).
|
||||
let home = tmp.path().to_path_buf();
|
||||
let repo = tmp.path().join("work").join("idea");
|
||||
let skills = repo.join(".claude/skills");
|
||||
write(
|
||||
@@ -227,6 +240,12 @@ mod tests {
|
||||
["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"]
|
||||
);
|
||||
let by = |n: &str| found.iter().find(|c| c.name == n).unwrap();
|
||||
for name in ["issue-fix", "triage", "verify", "lint"] {
|
||||
assert_eq!(by(name).source, AgentCommandSource::Skill);
|
||||
}
|
||||
for name in ["git:commit", "review", "parent", "brooks-review"] {
|
||||
assert_eq!(by(name).source, AgentCommandSource::Command);
|
||||
}
|
||||
assert_eq!(by("triage").description, "One pass over open issues and PRs");
|
||||
assert_eq!(by("issue-fix").description, "Fix an issue");
|
||||
assert_eq!(by("verify").description, "Build and drive the app");
|
||||
@@ -262,10 +281,14 @@ mod tests {
|
||||
fn the_agents_entries_win() {
|
||||
let merged = merge(
|
||||
vec![cmd("triage", "from the agent"), cmd("compact", "Compact")],
|
||||
vec![cmd("triage", "from disk"), cmd("verify", "Verify")],
|
||||
vec![
|
||||
AgentCommand { source: AgentCommandSource::Skill, ..cmd("triage", "from disk") },
|
||||
cmd("verify", "Verify"),
|
||||
],
|
||||
);
|
||||
assert_eq!(names(&merged), ["triage", "compact", "verify"]);
|
||||
assert_eq!(merged[0].description, "from the agent");
|
||||
assert_eq!(merged[0].source, AgentCommandSource::Skill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -339,6 +339,9 @@ fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
}
|
||||
|
||||
fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
// These tests speak to FakeAgent, never an installed CLI, including after a provider reload.
|
||||
let agents = dir.join("agents");
|
||||
provider::set_builtin_command(&agents, "claude", Some(&dir.join("missing-claude").to_string_lossy())).unwrap();
|
||||
let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| {
|
||||
let (a, b) = duplex(1 << 20);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
@@ -353,14 +356,200 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
|
||||
store_dir: dir.join("store"),
|
||||
worktrees_dir: dir.join("worktrees"),
|
||||
models_file: models_file.to_path_buf(),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
custom_agents_dir: Some(agents),
|
||||
// The project is the boundary of the skill walk: nothing above it (such as the
|
||||
// user's profile, when temp lives under it) leaks into the test.
|
||||
home_dir: Some(dir.to_path_buf()),
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn saved_conversation_reads_do_not_start_or_load_the_agent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Review the saved conversation"),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.shutdown();
|
||||
let restored_agent = Arc::new(FakeAgent::default());
|
||||
let restored = manager(tmp.path(), restored_agent.clone());
|
||||
assert!(!restored.entries(&task.id, None).unwrap().is_empty());
|
||||
assert!(restored_agent.calls.lock().is_empty());
|
||||
assert!(restored.entries("missing-task", None).is_err());
|
||||
assert!(restored.entries(&task.id, Some("missing-subagent".into())).is_err());
|
||||
restored.task(&task.id).unwrap().state.lock().conversation = Conversation::default();
|
||||
assert!(restored.entries(&task.id, None).unwrap().is_empty());
|
||||
restored.shutdown();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn claude_commands_before_session_are_fetched_once_and_saved() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let script = root.join("fake-claude");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"#!/usr/bin/env python3
|
||||
import json, os, pathlib, sys, time
|
||||
root = pathlib.Path.cwd()
|
||||
(root / 'pid').write_text(str(os.getpid()))
|
||||
with (root / 'launches').open('a') as f:
|
||||
f.write(json.dumps(sys.argv[1:]) + '\n')
|
||||
request = json.loads(sys.stdin.readline())
|
||||
with (root / 'requests').open('a') as f:
|
||||
f.write(json.dumps(request) + '\n')
|
||||
outcome = os.environ['OUTCOME']
|
||||
if outcome == 'exit':
|
||||
sys.exit(1)
|
||||
if outcome == 'timeout':
|
||||
time.sleep(60)
|
||||
response = {'subtype': outcome, 'request_id': request['request_id']}
|
||||
if outcome in ('success', 'session_report', 'empty', 'settings_timeout'):
|
||||
response['subtype'] = 'success'
|
||||
response['response'] = {'commands': [
|
||||
{'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'},
|
||||
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}],
|
||||
'models': [
|
||||
{'value': 'default', 'displayName': 'Default (recommended)', 'description': 'Use the default model',
|
||||
'supportsEffort': True, 'supportedEffortLevels': ['low', 'high']},
|
||||
{'value': 'custom-opus', 'displayName': 'custom-opus', 'description': 'Custom Opus model'}]}
|
||||
if outcome == 'empty':
|
||||
response['response'] = {}
|
||||
else:
|
||||
response['error'] = 'Authentication required'
|
||||
if outcome == 'session_report':
|
||||
(root / 'initialized').touch()
|
||||
while not (root / 'continue').exists():
|
||||
time.sleep(0.01)
|
||||
print(json.dumps({'type': 'control_response', 'response': response}), flush=True)
|
||||
for line in sys.stdin:
|
||||
with (root / 'requests').open('a') as f:
|
||||
f.write(line)
|
||||
request = json.loads(line)
|
||||
if request['request']['subtype'] == 'get_settings':
|
||||
if outcome == 'settings_timeout':
|
||||
time.sleep(60)
|
||||
reply = {'subtype': 'success', 'request_id': request['request_id'],
|
||||
'response': {'applied': {'effort': 'high'}, 'effective': {}}}
|
||||
print(json.dumps({'type': 'control_response', 'response': reply}), flush=True)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::create_dir_all(root.join(".claude/skills/triage")).unwrap();
|
||||
std::fs::write(root.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n")
|
||||
.unwrap();
|
||||
let mgr = manager(root, Arc::new(FakeAgent::default()));
|
||||
{
|
||||
let mut providers = mgr.shared.providers.write();
|
||||
let spec = providers.iter_mut().find(|p| p.id == "claude").unwrap();
|
||||
if outcome != "missing" {
|
||||
spec.command = script.to_string_lossy().into_owned();
|
||||
}
|
||||
spec.env = vec![("OUTCOME".into(), outcome.into())];
|
||||
}
|
||||
let concurrent = async {
|
||||
if outcome == "session_report" {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !root.join("initialized").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let reported = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
|
||||
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": "low",
|
||||
"options": [{ "value": "default" }, { "value": "low" }, { "value": "high" }] }
|
||||
] }));
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
|
||||
std::fs::write(root.join("continue"), "").unwrap();
|
||||
}
|
||||
mgr.commands("claude", None).await
|
||||
};
|
||||
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), concurrent);
|
||||
let first = first.unwrap();
|
||||
assert_eq!(first, concurrent.unwrap());
|
||||
assert_eq!(first, mgr.commands("claude", None).await.unwrap());
|
||||
let names: Vec<_> = first.iter().map(|c| c.name.as_str()).collect();
|
||||
if matches!(outcome, "success" | "session_report" | "settings_timeout") {
|
||||
assert_eq!(names, ["compact", "clear", "model", "context", "triage"]);
|
||||
assert_eq!(first[0].hint.as_deref(), Some("[instructions]"));
|
||||
let restored = manager(root, Arc::new(FakeAgent::default()));
|
||||
assert_eq!(first, restored.commands("claude", None).await.unwrap());
|
||||
// The same handshake lists the models, so the composer has them before any task.
|
||||
for mgr in [&mgr, &restored] {
|
||||
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
|
||||
assert_eq!(model.current.as_deref(), Some("default"));
|
||||
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
|
||||
assert_eq!(values, ["default", "custom-opus"]);
|
||||
let effort =
|
||||
claude.thought_levels["default"].iter().find(|o| o.category.as_deref() == Some("thought_level"));
|
||||
assert_eq!(
|
||||
effort.unwrap().current.as_deref(),
|
||||
Some(match outcome {
|
||||
"settings_timeout" => "default",
|
||||
"session_report" => "low",
|
||||
_ => "high",
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert_eq!(names, ["triage"], "{outcome}");
|
||||
assert_eq!(root.join("models.json").exists(), outcome == "empty");
|
||||
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
assert!(!claude.config.iter().any(|o| o.category.as_deref() == Some("model")), "{outcome}");
|
||||
}
|
||||
assert!(mgr.tasks.read().is_empty());
|
||||
assert!(!root.join("store").exists());
|
||||
if outcome != "missing" {
|
||||
let launches = std::fs::read_to_string(root.join("launches")).unwrap();
|
||||
assert_eq!(launches.lines().count(), 1, "{outcome}");
|
||||
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
|
||||
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
|
||||
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
|
||||
let subtypes: Vec<String> = requests
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str::<Value>(l).unwrap()["request"]["subtype"].as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
// The handshake and the settings read: no prompt or other control request.
|
||||
let expected: &[&str] = if matches!(outcome, "success" | "session_report" | "empty" | "settings_timeout") {
|
||||
&["initialize", "get_settings"]
|
||||
} else {
|
||||
&["initialize"]
|
||||
};
|
||||
assert_eq!(subtypes, expected, "{outcome}");
|
||||
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("discovery process must exit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() {
|
||||
for auth_on_new in [true, false] {
|
||||
@@ -575,6 +764,7 @@ async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metada
|
||||
name: "compact".into(),
|
||||
description: "Agent supplied description".into(),
|
||||
hint: Some("instructions".into()),
|
||||
source: AgentCommandSource::Command,
|
||||
};
|
||||
mgr.task(&task.id).unwrap().state.lock().task.commands = vec![advertised.clone()];
|
||||
// The task's provider wins over the caller's provider; metadata is not replaced.
|
||||
@@ -1854,6 +2044,7 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
||||
};
|
||||
git(&["init", "-q", "-b", "member-main"]);
|
||||
git(&["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(member.join("file.txt"), "original\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-qm", "initial"]);
|
||||
@@ -1984,6 +2175,49 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
|
||||
assert!(probe.error.is_none());
|
||||
}
|
||||
|
||||
/// A session report that changes the cached provider config notifies subscribers,
|
||||
/// so the composer re-lists models without waiting for the next task (#1490).
|
||||
/// Reports that change nothing stay silent.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn remembered_config_changes_notify_subscribers_once() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = events.clone();
|
||||
mgr.subscribe(Arc::new(move |e| sink.lock().push(e)));
|
||||
events.lock().clear();
|
||||
|
||||
let reported = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
|
||||
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
|
||||
] }));
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
|
||||
assert!(
|
||||
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
|
||||
"a first report changing the cache notifies subscribers"
|
||||
);
|
||||
events.lock().clear();
|
||||
|
||||
// The same report again: the cache is unchanged, so subscribers hear nothing.
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
|
||||
assert!(events.lock().is_empty(), "an unchanged report must stay silent");
|
||||
|
||||
// A changed model list notifies again, and providers() serves the new list.
|
||||
let updated = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
|
||||
"options": [{ "value": "default" }, { "value": "custom-opus" }, { "value": "custom-sonnet" }] },
|
||||
] }));
|
||||
mgr.shared.remember("claude", Some(&updated), None, Some("default"));
|
||||
assert!(
|
||||
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
|
||||
"a changed model list notifies subscribers"
|
||||
);
|
||||
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
|
||||
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
|
||||
assert_eq!(values, ["default", "custom-opus", "custom-sonnet"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
|
||||
let new = || NewAgentTask {
|
||||
@@ -2167,6 +2401,7 @@ async fn delete_running_agent(no_load: bool) {
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
git(&["init", "-q", "-b", "main"]);
|
||||
git(&["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(root.join("file.txt"), "original\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-qm", "initial"]);
|
||||
@@ -2269,3 +2504,31 @@ fn mcp_servers_follow_server_state_and_agent_capabilities() {
|
||||
"args":["--mcp-stdio","--project","/project"],"env":[]})]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
|
||||
for discovery_first in [false, true] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let options = |model: &str, effort: &str| {
|
||||
config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
|
||||
"options": [{ "value": "saved" }, { "value": "discovered" }] },
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
|
||||
"options": [{ "value": "low" }, { "value": "high" }] }
|
||||
] }))
|
||||
};
|
||||
let saved = options("saved", "low");
|
||||
let discovered = options("discovered", "high");
|
||||
if discovery_first {
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[]);
|
||||
}
|
||||
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[]);
|
||||
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
for mgr in [&mgr, &restored] {
|
||||
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
|
||||
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ pub fn launch_fingerprint(
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
|
||||
let mut environment: BTreeMap<String, String> = std::env::vars()
|
||||
.map(|(key, value)| (key.to_ascii_uppercase(), value))
|
||||
.filter(|(key, _)| {
|
||||
|
||||
@@ -772,13 +772,7 @@ pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Pat
|
||||
has_non_java_sources(module)
|
||||
}
|
||||
|
||||
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
|
||||
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
|
||||
// JDT may have left newer error stubs in Maven's output directories.
|
||||
BuildKind::Rebuild
|
||||
} else {
|
||||
BuildKind::Build
|
||||
};
|
||||
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind: BuildKind) -> IdeResult<BuildPlan> {
|
||||
let mut plan = plan(root, model, kind, Some(module), None)?;
|
||||
if plan.system == BuildSystem::Maven && !test {
|
||||
for step in &mut plan.steps {
|
||||
@@ -790,6 +784,12 @@ fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> Id
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LaunchBuild {
|
||||
pub class_dirs: Vec<PathBuf>,
|
||||
pub failure: Option<String>,
|
||||
}
|
||||
|
||||
/// Compiles the module at `module` with its build system before a JVM launch and
|
||||
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
|
||||
pub fn build_for_launch(
|
||||
@@ -797,11 +797,12 @@ pub fn build_for_launch(
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
kind: BuildKind,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Vec<PathBuf>> {
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
) -> IdeResult<LaunchBuild> {
|
||||
let plan = launch_plan(root, model, module, test, kind)?;
|
||||
let result = run(root, &plan, cancel, &|_| {})?;
|
||||
if !result.success {
|
||||
let failure = if !result.success {
|
||||
let errors: Vec<String> = result
|
||||
.messages
|
||||
.iter()
|
||||
@@ -812,17 +813,21 @@ pub fn build_for_launch(
|
||||
_ => m.text.clone(),
|
||||
})
|
||||
.collect();
|
||||
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
|
||||
}
|
||||
if plan.system == BuildSystem::Maven
|
||||
Some(format!("Build failed:\n{}", errors.join("\n")))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let class_dirs = if plan.system == BuildSystem::Maven
|
||||
&& let Some(m) = &model.maven
|
||||
{
|
||||
return Ok(maven_launch_projects(root, m, module, test)
|
||||
maven_launch_projects(root, m, module, test)
|
||||
.iter()
|
||||
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
|
||||
.collect());
|
||||
}
|
||||
Ok(launch_class_dirs(plan.system, module, test))
|
||||
.collect()
|
||||
} else {
|
||||
launch_class_dirs(plan.system, module, test)
|
||||
};
|
||||
Ok(LaunchBuild { class_dirs, failure })
|
||||
}
|
||||
|
||||
fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<PathBuf> {
|
||||
@@ -1309,13 +1314,17 @@ mod tests {
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
let model = reactor_model();
|
||||
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
|
||||
// A normal launch is incremental: no `clean` (IDEA's Make, not Rebuild).
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Build).unwrap();
|
||||
let step = &plan.steps[0];
|
||||
assert_eq!(step.cwd, root);
|
||||
assert_eq!(step.program, "mvnw");
|
||||
assert_eq!(step.env["JAVA_HOME"], "/jdk");
|
||||
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", goal]);
|
||||
// Rebuild cleans first, as IDEA's Rebuild does.
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Rebuild).unwrap();
|
||||
assert_eq!(
|
||||
step.args,
|
||||
plan.steps[0].args,
|
||||
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
|
||||
);
|
||||
}
|
||||
@@ -1337,9 +1346,10 @@ mod tests {
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut model = reactor_model();
|
||||
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
|
||||
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
|
||||
let dirs = build_for_launch(root, &model, &root.join("app"), true, BuildKind::Build, &CancellationToken::new())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
dirs,
|
||||
dirs.class_dirs,
|
||||
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
|
||||
.map(|p| root.join(p))
|
||||
);
|
||||
@@ -1348,12 +1358,16 @@ mod tests {
|
||||
"mvnw",
|
||||
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
|
||||
);
|
||||
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
|
||||
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
|
||||
let error =
|
||||
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.failure
|
||||
.unwrap();
|
||||
assert!(error.contains("Kotlin compilation failed"), "{error}");
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
|
||||
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &cancel).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String {
|
||||
/// Quoted string literals in `s`.
|
||||
fn literals(s: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '"' || c == '\'' {
|
||||
out.push(chars.by_ref().take_while(|&n| n != c).collect());
|
||||
}
|
||||
let mut rest = s;
|
||||
while let Some(start) = rest.find(['"', '\'']) {
|
||||
let Some((value, used)) = literal(&rest[start..]) else { break };
|
||||
out.push(value);
|
||||
rest = &rest[start + used..];
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -565,11 +565,12 @@ fn is_dependency_configuration(name: &str) -> bool {
|
||||
/// `(group, artifact, version)` of the external dependencies a build script declares:
|
||||
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
|
||||
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
|
||||
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
|
||||
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
|
||||
/// Versions written as `${gsonVersion}` are filled in from `properties` (`ext` values, see
|
||||
/// [`script_properties`]); ranges, unknown names and catalog aliases (`libs.x`) are skipped.
|
||||
pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) -> Vec<(String, String, String)> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
|
||||
for line in text.lines().flat_map(statements) {
|
||||
let line = line.trim_start();
|
||||
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
|
||||
if name.is_empty() || !is_dependency_configuration(&name) {
|
||||
@@ -591,6 +592,7 @@ pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
|
||||
}),
|
||||
};
|
||||
if let Some((g, a, v)) = coordinate {
|
||||
let v = interpolate(&v, properties);
|
||||
let plain = |s: &str| {
|
||||
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
|
||||
};
|
||||
@@ -602,11 +604,354 @@ pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `line` split at `{` and `;` outside quoted strings, so a `${version}` stays in one piece.
|
||||
fn statements(line: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut index = 0;
|
||||
while index < line.len() {
|
||||
let c = line[index..].chars().next().unwrap();
|
||||
if c == '"' || c == '\'' {
|
||||
index += literal(&line[index..]).map_or(line.len() - index, |(_, used)| used);
|
||||
continue;
|
||||
}
|
||||
if matches!(c, '{' | '}' | ';' | '\n') {
|
||||
out.push(&line[start..index]);
|
||||
start = index + c.len_utf8();
|
||||
}
|
||||
index += c.len_utf8();
|
||||
}
|
||||
out.push(&line[start..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// `apply from: 'x.gradle'` (Groovy) and `apply(from = "x.gradle.kts")` (Kotlin DSL)
|
||||
/// targets, as written in the script.
|
||||
pub fn applied_scripts(text: &str) -> Vec<String> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = Vec::new();
|
||||
for (index, word) in code_words(&text) {
|
||||
if word != "apply" || !starts_word(&text, index) {
|
||||
continue;
|
||||
}
|
||||
let mut rest = text[index + "apply".len()..].trim_start();
|
||||
if let Some(inner) = rest.strip_prefix('(') {
|
||||
rest = inner.trim_start();
|
||||
}
|
||||
let Some(rest) = rest.strip_prefix("from") else { continue };
|
||||
let Some(rest) = rest.trim_start().strip_prefix(':').or_else(|| rest.trim_start().strip_prefix('=')) else {
|
||||
continue;
|
||||
};
|
||||
let mut rest = rest.trim_start();
|
||||
if let Some(inner) = rest.strip_prefix("file") {
|
||||
let inner = inner.trim_start();
|
||||
if let Some(inner) = inner.strip_prefix('(') {
|
||||
rest = inner.trim_start();
|
||||
}
|
||||
}
|
||||
if let Some((path, _)) = literal(rest) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Literal extra properties declared by Groovy's `ext` or Kotlin's `extra`.
|
||||
pub fn script_properties(text: &str) -> BTreeMap<String, String> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = BTreeMap::new();
|
||||
for (index, word) in code_words(&text) {
|
||||
if !starts_word(&text, index) {
|
||||
continue;
|
||||
}
|
||||
let rest = text[index + word.len()..].trim_start();
|
||||
if word == "val" || word == "var" {
|
||||
let (name, rest) = identifier(rest);
|
||||
let Some(rest) = rest.trim_start().strip_prefix("by").map(str::trim_start) else { continue };
|
||||
let Some(rest) = rest.strip_prefix("extra").map(str::trim_start) else { continue };
|
||||
if let Some(rest) = rest.strip_prefix('(')
|
||||
&& let Some((value, used)) = literal(rest.trim_start())
|
||||
&& rest.trim_start()[used..].trim_start().starts_with(')')
|
||||
&& !name.is_empty()
|
||||
{
|
||||
out.insert(name.to_owned(), value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if word != "ext" && word != "extra" {
|
||||
continue;
|
||||
}
|
||||
if let Some(block) = rest.strip_prefix('{') {
|
||||
if let Some(end) = matching_brace(block) {
|
||||
out.extend(assignments(&block[..end]));
|
||||
}
|
||||
} else if let Some(rest) = rest.strip_prefix('.') {
|
||||
let (name, rest) = identifier(rest);
|
||||
if let Some(value) = assigned_literal(rest)
|
||||
&& !name.is_empty()
|
||||
{
|
||||
out.insert(name.to_owned(), value);
|
||||
}
|
||||
} else if let Some(rest) = rest.strip_prefix('[')
|
||||
&& let Some((name, used)) = literal(rest.trim_start())
|
||||
&& let Some(rest) = rest.trim_start()[used..].trim_start().strip_prefix(']')
|
||||
&& let Some(value) = assigned_literal(rest)
|
||||
{
|
||||
out.insert(name, value);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn file_properties(build_dir: &Path) -> BTreeMap<String, String> {
|
||||
let text = std::fs::read_to_string(build_dir.join("gradle.properties")).unwrap_or_default();
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim();
|
||||
if line.starts_with(['#', '!']) {
|
||||
return None;
|
||||
}
|
||||
let separator = line.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?;
|
||||
let (key, rest) = line.split_at(separator);
|
||||
let value = rest.trim_start().trim_start_matches(['=', ':']).trim_start();
|
||||
(!key.is_empty()).then(|| (key.to_owned(), value.to_owned()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The build script of a project and, recursively, the scripts it applies with `apply from`,
|
||||
/// read from disk: absolute path and text. Targets that do not exist are skipped.
|
||||
pub fn scripts_of(root: &Path, project_dir: &Path, build_dir: &Path, build_file: &Path) -> Vec<(PathBuf, String)> {
|
||||
const MAX_SCRIPTS: usize = 256;
|
||||
const MAX_DEPTH: usize = 32;
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let first = normalize(&root.join(build_file));
|
||||
let Ok(identity) = dunce::canonicalize(&first) else { return out };
|
||||
seen.insert(identity);
|
||||
let mut stack = vec![(first, 0)];
|
||||
while let Some((path, depth)) = stack.pop() {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
||||
if depth < MAX_DEPTH {
|
||||
let mut targets = Vec::new();
|
||||
for spec in applied_scripts(&text) {
|
||||
if seen.len() >= MAX_SCRIPTS {
|
||||
break;
|
||||
}
|
||||
if let Some(target) = resolve_script(&spec, &path, project_dir, build_dir)
|
||||
&& let Ok(identity) = dunce::canonicalize(&target)
|
||||
&& seen.insert(identity)
|
||||
{
|
||||
targets.push((target, depth + 1));
|
||||
}
|
||||
}
|
||||
stack.extend(targets.into_iter().rev());
|
||||
}
|
||||
out.push((path, text));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Relative targets are project-scoped, with a lenient fallback beside the applying script.
|
||||
fn resolve_script(spec: &str, script: &Path, project_dir: &Path, build_dir: &Path) -> Option<PathBuf> {
|
||||
let spec = spec.trim();
|
||||
if spec.contains("://") {
|
||||
return None;
|
||||
}
|
||||
let properties = BTreeMap::from([
|
||||
("rootDir".into(), build_dir.to_string_lossy().into_owned()),
|
||||
("rootProject.projectDir".into(), build_dir.to_string_lossy().into_owned()),
|
||||
("projectDir".into(), project_dir.to_string_lossy().into_owned()),
|
||||
]);
|
||||
let spec = interpolate(spec, &properties);
|
||||
if spec.contains('$') {
|
||||
return None;
|
||||
}
|
||||
let path = Path::new(&spec);
|
||||
if path.is_absolute() {
|
||||
return path.is_file().then(|| normalize(path));
|
||||
}
|
||||
let project_path = normalize(&project_dir.join(path));
|
||||
if project_path.is_file() {
|
||||
return Some(project_path);
|
||||
}
|
||||
let path = normalize(&script.parent()?.join(path));
|
||||
path.is_file().then_some(path)
|
||||
}
|
||||
|
||||
pub(crate) fn project_build_dir(root: &Path, model: &GradleModel, project: &GradleProject) -> PathBuf {
|
||||
let dir = project.build.as_ref().and_then(|build| model.projects.iter().find(|p| &p.path == build));
|
||||
root.join(dir.map_or(&model.dir, |p| &p.dir))
|
||||
}
|
||||
|
||||
/// `2.8.9` for `${gsonVersion}` / `$gsonVersion` / `${ext.gsonVersion}`; unknown names and
|
||||
/// expressions are left as written, so ranges and catalogs stay skipped.
|
||||
fn interpolate(value: &str, properties: &BTreeMap<String, String>) -> String {
|
||||
if !value.contains('$') {
|
||||
return value.to_owned();
|
||||
}
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut chars = value.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '$' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
let braced = chars.peek() == Some(&'{');
|
||||
if braced {
|
||||
chars.next();
|
||||
}
|
||||
let mut name = String::new();
|
||||
if braced {
|
||||
for n in chars.by_ref() {
|
||||
if n == '}' {
|
||||
break;
|
||||
}
|
||||
name.push(n);
|
||||
}
|
||||
} else {
|
||||
while let Some(&n) = chars.peek() {
|
||||
if n.is_alphanumeric() || n == '_' || n == '.' {
|
||||
name.push(n);
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let key = name.strip_prefix("ext.").or_else(|| name.strip_prefix("extra.")).unwrap_or(&name);
|
||||
let bracket = name.strip_prefix("ext[").or_else(|| name.strip_prefix("extra[")).and_then(|rest| {
|
||||
let (key, used) = literal(rest.trim_start())?;
|
||||
(rest.trim_start()[used..].trim() == "]").then_some(key)
|
||||
});
|
||||
match properties.get(bracket.as_deref().unwrap_or(key)) {
|
||||
Some(v) if !name.is_empty() => out.push_str(v),
|
||||
_ => {
|
||||
out.push('$');
|
||||
if braced {
|
||||
out.push('{');
|
||||
}
|
||||
out.push_str(&name);
|
||||
if braced {
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `index` starts a whole word in `text` (not `reapply`, `my.apply`).
|
||||
fn starts_word(text: &str, index: usize) -> bool {
|
||||
text[..index].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.'))
|
||||
}
|
||||
|
||||
/// Identifiers outside quoted strings, with their byte positions.
|
||||
fn code_words(text: &str) -> Vec<(usize, &str)> {
|
||||
let mut out = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < text.len() {
|
||||
let c = text[index..].chars().next().unwrap();
|
||||
if c == '"' || c == '\'' {
|
||||
index += literal(&text[index..]).map_or(text.len() - index, |(_, used)| used);
|
||||
} else if c.is_alphabetic() || c == '_' {
|
||||
let (word, _) = identifier(&text[index..]);
|
||||
out.push((index, word));
|
||||
index += word.len();
|
||||
} else {
|
||||
index += c.len_utf8();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn identifier(text: &str) -> (&str, &str) {
|
||||
let end = text.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(text.len());
|
||||
text.split_at(end)
|
||||
}
|
||||
|
||||
fn assigned_literal(text: &str) -> Option<String> {
|
||||
let rest = text.trim_start().strip_prefix('=')?.trim_start();
|
||||
let (value, used) = literal(rest)?;
|
||||
// Do not turn a computed value (`'1.' + suffix`) into a concrete version.
|
||||
let tail = rest[used..].trim_start_matches([' ', '\t', '\r']);
|
||||
(tail.is_empty() || tail.starts_with(['\n', ';', '}'])).then_some(value)
|
||||
}
|
||||
|
||||
/// The quoted string at the start of `s`, and how many bytes it spans.
|
||||
fn literal(s: &str) -> Option<(String, usize)> {
|
||||
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
|
||||
let rest = &s[quote.len_utf8()..];
|
||||
let mut index = 0;
|
||||
while index < rest.len() {
|
||||
let c = rest[index..].chars().next()?;
|
||||
if c == quote {
|
||||
return Some((rest[..index].to_owned(), quote.len_utf8() + index + c.len_utf8()));
|
||||
}
|
||||
if c == '\\' {
|
||||
index += c.len_utf8();
|
||||
index += rest[index..].chars().next()?.len_utf8();
|
||||
} else if quote == '"' && rest[index..].starts_with("${") {
|
||||
index += 2;
|
||||
index += matching_brace(&rest[index..])? + 1;
|
||||
} else {
|
||||
index += c.len_utf8();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Byte index of the `}` closing a `{` that was already consumed, ignoring quoted text.
|
||||
fn matching_brace(text: &str) -> Option<usize> {
|
||||
let mut depth = 1usize;
|
||||
let mut index = 0;
|
||||
while index < text.len() {
|
||||
let c = text[index..].chars().next()?;
|
||||
if c == '"' || c == '\'' {
|
||||
index += literal(&text[index..])?.1;
|
||||
continue;
|
||||
}
|
||||
if c == '{' {
|
||||
depth += 1;
|
||||
} else if c == '}' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
index += c.len_utf8();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `name = 'value'` pairs in `text`.
|
||||
fn assignments(text: &str) -> Vec<(String, String)> {
|
||||
statements(text)
|
||||
.into_iter()
|
||||
.filter_map(|statement| {
|
||||
let (name, rest) = identifier(statement.trim_start());
|
||||
let value = assigned_literal(rest)?;
|
||||
(!name.is_empty()).then(|| (name.to_owned(), value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every settings and build script of the model (for change detection).
|
||||
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
let dir = root.join(&model.dir);
|
||||
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
|
||||
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
|
||||
// Scripts a build script applies are build files too: editing one can change the model.
|
||||
for project in &model.projects {
|
||||
let Some(file) = project.build_file.as_deref() else { continue };
|
||||
let build_dir = project_build_dir(root, model, project);
|
||||
let properties = build_dir.join("gradle.properties");
|
||||
if properties.is_file() {
|
||||
out.push(properties);
|
||||
}
|
||||
let applied = scripts_of(root, &root.join(&project.dir), &build_dir, Path::new(file));
|
||||
out.extend(applied.into_iter().skip(1).map(|(path, _)| path));
|
||||
}
|
||||
// The settings of each nested build (its root project's folder).
|
||||
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
|
||||
let nested = root.join(&p.dir);
|
||||
@@ -624,6 +969,8 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
catalogs.sort();
|
||||
out.extend(catalogs);
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -808,7 +1155,7 @@ dependencies {
|
||||
classpath 'not:a:library'
|
||||
}
|
||||
"#;
|
||||
let deps = script_dependencies(script);
|
||||
let deps = script_dependencies(script, &BTreeMap::new());
|
||||
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
@@ -822,6 +1169,291 @@ dependencies {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_read_groovy_and_kotlin_forms() {
|
||||
let script = r#"
|
||||
apply plugin: 'java'
|
||||
apply from: 'gradle/dependencies.gradle'
|
||||
apply from: file('gradle/extra.gradle')
|
||||
apply(from = "gradle/kotlin.gradle.kts")
|
||||
apply(from = file("gradle/kotlin-file.gradle.kts"))
|
||||
// apply from: 'commented.gradle'
|
||||
reapply from: 'not-a-call.gradle'
|
||||
"#;
|
||||
assert_eq!(
|
||||
applied_scripts(script),
|
||||
[
|
||||
"gradle/dependencies.gradle",
|
||||
"gradle/extra.gradle",
|
||||
"gradle/kotlin.gradle.kts",
|
||||
"gradle/kotlin-file.gradle.kts"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_values_fill_in_dependency_versions() {
|
||||
let script = r#"
|
||||
ext {
|
||||
gsonVersion = '2.8.9'
|
||||
junit = "4.13.2"
|
||||
}
|
||||
ext.logback = '1.4.14'
|
||||
dependencies {
|
||||
implementation "com.google.code.gson:gson:${gsonVersion}"
|
||||
testImplementation "junit:junit:$junit"
|
||||
implementation "ch.qos.logback:logback-classic:${ext.logback}"
|
||||
implementation 'org.plain:literal:1.0'
|
||||
implementation "org.x:unknown:${missing}"
|
||||
}
|
||||
"#;
|
||||
let names: Vec<String> = script_dependencies(script, &script_properties(script))
|
||||
.into_iter()
|
||||
.map(|(g, a, v)| format!("{g}:{a}:{v}"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"com.google.code.gson:gson:2.8.9",
|
||||
"junit:junit:4.13.2",
|
||||
"ch.qos.logback:logback-classic:1.4.14",
|
||||
"org.plain:literal:1.0",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_properties_support_brackets_and_kotlin_delegates() {
|
||||
for declaration in [
|
||||
"ext.x = '1.2'",
|
||||
"ext { x = '1.2' }",
|
||||
"ext[\"x\"] = '1.2'",
|
||||
"extra[\"x\"] = \"1.2\"",
|
||||
"val x by extra(\"1.2\")",
|
||||
] {
|
||||
let script = format!("{declaration}\ndependencies {{ implementation \"g:a:${{x}}\" }}");
|
||||
assert_eq!(
|
||||
script_dependencies(&script, &script_properties(&script)),
|
||||
[("g".into(), "a".into(), "1.2".into())],
|
||||
"{declaration}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsers_ignore_apply_and_ext_inside_strings() {
|
||||
let script = r#"
|
||||
println "apply from: 'fake.gradle'"
|
||||
println "ext.x = 'bogus'"
|
||||
ext.real = '1'
|
||||
"#;
|
||||
assert!(applied_scripts(script).is_empty());
|
||||
assert_eq!(script_properties(script), BTreeMap::from([("real".into(), "1".into())]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_statements_preserve_interpolation_and_escaped_quotes() {
|
||||
let script = r#"dependencies { implementation "g:a:${v}"; api(group = "g", name = "b", version = "${v}-RC1") }; dependencies { runtimeOnly 'g:c:2' }"#;
|
||||
let properties = BTreeMap::from([("v".into(), "1.2".into())]);
|
||||
assert_eq!(
|
||||
script_dependencies(script, &properties),
|
||||
[
|
||||
("g".into(), "a".into(), "1.2".into()),
|
||||
("g".into(), "b".into(), "1.2-RC1".into()),
|
||||
("g".into(), "c".into(), "2".into())
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
statements(r#"println "escaped\"; {still quoted}"; implementation 'g:a:1'"#),
|
||||
[r#"println "escaped\"; {still quoted}""#, " implementation 'g:a:1'"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_versions_skip_unresolved_names_and_expressions() {
|
||||
let script = r#"dependencies {
|
||||
implementation "g:missing:${unknown}"
|
||||
implementation "g:expression:${1 + 2}"
|
||||
implementation "g:cyclic:${cycle}"
|
||||
implementation 'g:range:[1,2)'
|
||||
implementation 'g:dynamic:1.+'
|
||||
}"#;
|
||||
let properties = BTreeMap::from([("cycle".into(), "${cycle}".into())]);
|
||||
assert!(script_dependencies(script, &properties).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_property_references_support_qualified_and_bracket_access() {
|
||||
let script = r#"dependencies {
|
||||
implementation "g:a:${ext.x}"
|
||||
implementation "g:b:${extra.x}"
|
||||
implementation "g:c:${ext['x']}"
|
||||
implementation "g:d:${extra["x"]}"
|
||||
}"#;
|
||||
let properties = BTreeMap::from([("x".into(), "1".into())]);
|
||||
assert_eq!(script_dependencies(script, &properties).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_are_read_recursively_and_survive_cycles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'\n").unwrap();
|
||||
std::fs::write(root.join("gradle/deps.gradle"), "apply from: '$rootDir/gradle/more.gradle'\n").unwrap();
|
||||
std::fs::write(root.join("gradle/more.gradle"), "apply from: 'deps.gradle'\n").unwrap();
|
||||
|
||||
let scripts: Vec<String> = scripts_of(root, root, root, Path::new("build.gradle"))
|
||||
.into_iter()
|
||||
.map(|(path, _)| path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"))
|
||||
.collect();
|
||||
assert_eq!(scripts, ["build.gradle", "gradle/deps.gradle", "gradle/more.gradle"]);
|
||||
assert_eq!(build_files(root, &load(root).unwrap()).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_paths_prefer_the_project_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/deps.gradle"), "apply from: 'versions.gradle'").unwrap();
|
||||
std::fs::write(root.join("versions.gradle"), "right").unwrap();
|
||||
std::fs::write(root.join("gradle/versions.gradle"), "wrong").unwrap();
|
||||
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
|
||||
assert_eq!(scripts.len(), 3);
|
||||
assert_eq!(scripts[2].1, "right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_applied_paths_are_relative_to_the_project_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'gradle/b.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/b.gradle"), "").unwrap();
|
||||
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
|
||||
assert_eq!(scripts.len(), 3);
|
||||
assert_eq!(scripts[2].0, root.join("gradle/b.gradle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_paths_fall_back_to_the_applying_script_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'c.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/c.gradle"), "").unwrap();
|
||||
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
|
||||
assert_eq!(scripts.len(), 3);
|
||||
assert_eq!(scripts[2].0, root.join("gradle/c.gradle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_paths_resolve_gradle_directory_variables() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let project = root.join("app");
|
||||
let script = project.join("gradle/deps.gradle");
|
||||
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
|
||||
std::fs::write(root.join("shared.gradle"), "").unwrap();
|
||||
std::fs::write(project.join("local.gradle"), "").unwrap();
|
||||
for prefix in ["$rootDir", "${rootDir}", "$rootProject.projectDir", "${rootProject.projectDir}"] {
|
||||
assert_eq!(
|
||||
resolve_script(&format!("{prefix}/shared.gradle"), &script, &project, root),
|
||||
Some(root.join("shared.gradle")),
|
||||
"{prefix}"
|
||||
);
|
||||
}
|
||||
for prefix in ["$projectDir", "${projectDir}"] {
|
||||
assert_eq!(
|
||||
resolve_script(&format!("{prefix}/local.gradle"), &script, &project, root),
|
||||
Some(project.join("local.gradle")),
|
||||
"{prefix}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
resolve_script(root.join("shared.gradle").to_str().unwrap(), &script, &project, root),
|
||||
Some(root.join("shared.gradle"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_skip_urls_and_missing_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
// Even a local path resembling a URL must not be read.
|
||||
if !cfg!(windows) {
|
||||
for scheme in ["http", "https"] {
|
||||
let local = root.join(format!("{scheme}:/example.com/deps.gradle"));
|
||||
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
|
||||
std::fs::write(local, "implementation 'g:remote:1'").unwrap();
|
||||
}
|
||||
}
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'http://example.com/deps.gradle'\napply(from = \"https://example.com/deps.gradle\")\napply from: 'missing.gradle'").unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_deduplicate_self_application_and_path_aliases() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("build.gradle"),
|
||||
"apply from: 'build.gradle'\napply from: 'gradle/../build.gradle'\napply from: 'gradle/a.gradle'",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("gradle/a.gradle"), "apply from: '../build.gradle'").unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 2);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn applied_scripts_deduplicate_symlink_cycles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'alias/build.gradle'").unwrap();
|
||||
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_script_traversal_bounds_depth_and_total_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
for i in 0..300 {
|
||||
std::fs::write(root.join(format!("{i}.gradle")), format!("apply from: '{}.gradle'", i + 1)).unwrap();
|
||||
}
|
||||
let chain = scripts_of(root, root, root, Path::new("0.gradle"));
|
||||
assert_eq!(chain.len(), 33);
|
||||
let fanout: String = (0..300).map(|i| format!("apply from: '{i}.gradle'\n")).collect();
|
||||
std::fs::write(root.join("build.gradle"), fanout).unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_use_their_composite_build_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("included/app")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
std::fs::write(root.join("included/build.gradle"), "").unwrap();
|
||||
std::fs::write(root.join("included/app/build.gradle"), "apply from: \"${rootDir}/deps.gradle\"").unwrap();
|
||||
std::fs::write(root.join("included/deps.gradle"), "implementation 'g:included:1'").unwrap();
|
||||
let mut model = load(root).unwrap();
|
||||
for (path, dir) in [(":included", "included"), (":included:app", "included/app")] {
|
||||
let mut project = project_at(root, path.into(), path.into(), &root.join(dir));
|
||||
project.build = Some(":included".into());
|
||||
model.projects.push(project);
|
||||
}
|
||||
assert!(build_files(root, &model).contains(&root.join("included/deps.gradle")));
|
||||
let model = crate::BuildModel { maven: None, gradle: vec![model] };
|
||||
assert_eq!(crate::dependency_coordinates(root, &model)[0].artifact_id, "included");
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -76,7 +76,7 @@ pub fn load_linked(
|
||||
gradle_settings: &ide_core::settings::GradleSettings,
|
||||
) -> BuildModel {
|
||||
let maven = maven::has_projects(root, settings).then(|| {
|
||||
let profiles = sync::maven_profiles(cache_dir);
|
||||
let profiles = settings.profiles(&sync::maven_profiles(cache_dir));
|
||||
let repo = maven_repo.or_else(maven::default_local_repo);
|
||||
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
|
||||
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
|
||||
@@ -147,7 +147,8 @@ pub struct DependencyCoordinate {
|
||||
|
||||
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
|
||||
/// (with the transitive ones the last sync read), except the build's own modules,
|
||||
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
|
||||
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts,
|
||||
/// including the scripts they pull in with `apply from`.
|
||||
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(m) = &model.maven {
|
||||
@@ -177,11 +178,26 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
|
||||
}
|
||||
}
|
||||
for g in &model.gradle {
|
||||
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
|
||||
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
|
||||
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
|
||||
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
|
||||
}));
|
||||
for p in &g.projects {
|
||||
let Some(build_file) = p.build_file.as_deref() else { continue };
|
||||
let build_dir = gradle::project_build_dir(root, g, p);
|
||||
let scripts = gradle::scripts_of(root, &root.join(&p.dir), &build_dir, Path::new(build_file));
|
||||
// The script's own `ext` values win over the scripts it applies.
|
||||
let mut properties = std::collections::BTreeMap::new();
|
||||
for (_, text) in scripts.iter().rev() {
|
||||
properties.extend(gradle::script_properties(text));
|
||||
}
|
||||
properties.extend(gradle::file_properties(&build_dir));
|
||||
for (_, text) in &scripts {
|
||||
out.extend(gradle::script_dependencies(text, &properties).into_iter().map(
|
||||
|(group_id, artifact_id, version)| DependencyCoordinate {
|
||||
gradle: true,
|
||||
group_id,
|
||||
artifact_id,
|
||||
version,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
@@ -222,6 +238,30 @@ mod tests {
|
||||
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_profiles_and_checkbox_choices_control_modules_before_sync() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join("extra")).unwrap();
|
||||
std::fs::write(root.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
|
||||
std::fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project><artifactId>root</artifactId><packaging>pom</packaging>
|
||||
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cache = root.join("cache");
|
||||
let settings = ide_core::settings::MavenSettings { active_profiles: "extra".into(), ..Default::default() };
|
||||
let modules = |settings: &ide_core::settings::MavenSettings| {
|
||||
load_linked(root, &cache, None, settings, &Default::default()).maven.unwrap().projects[0].modules.clone()
|
||||
};
|
||||
assert!(modules(&Default::default()).is_empty());
|
||||
assert_eq!(modules(&settings), ["extra/pom.xml"]);
|
||||
assert_eq!(maven::load_linked(root, &settings).projects[0].modules, ["extra/pom.xml"]);
|
||||
save_maven_profiles(&cache, &["!extra".into()]).unwrap();
|
||||
assert!(modules(&settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_build_systems_and_build_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -288,4 +328,39 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_coordinates_read_scripts_brought_in_by_apply_from() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'\n").unwrap();
|
||||
std::fs::write(
|
||||
root.join("gradle/dependencies.gradle"),
|
||||
"ext { gson = '2.8.9' }\ndependencies { implementation \"com.google.code.gson:gson:${gson}\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
|
||||
let names: Vec<String> = dependency_coordinates(root, &model)
|
||||
.into_iter()
|
||||
.map(|c| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version))
|
||||
.collect();
|
||||
assert_eq!(names, ["com.google.code.gson:gson:2.8.9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_properties_override_script_extra_values() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("build.gradle"), "ext.v = 'wrong'\napply from: 'deps.gradle'").unwrap();
|
||||
std::fs::write(
|
||||
root.join("deps.gradle"),
|
||||
"ext.v = 'alsoWrong'\nimplementation \"g:a:${v}\"\nimplementation \"g:b:${onlyInProperties}\"",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("gradle.properties"), "# versions\nv = 1.2\nonlyInProperties: 2.3\n").unwrap();
|
||||
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
|
||||
let versions: Vec<_> = dependency_coordinates(root, &model).into_iter().map(|c| c.version).collect();
|
||||
assert_eq!(versions, ["1.2", "2.3"]);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
pub mod sources;
|
||||
|
||||
/// Phases shown under "Lifecycle" (IDEA's default list).
|
||||
pub const LIFECYCLE: &[&str] =
|
||||
&["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"];
|
||||
@@ -71,6 +73,9 @@ pub struct MavenModel {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MavenProject {
|
||||
/// Owning project folder when displayed in a workspace.
|
||||
#[specta(optional)]
|
||||
pub project_root: Option<String>,
|
||||
/// Project-relative, `/`-separated (`pom.xml`, `core/pom.xml`).
|
||||
pub pom: String,
|
||||
/// Project-relative directory; `""` = the project root.
|
||||
@@ -169,6 +174,9 @@ pub enum Omission {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MavenProfile {
|
||||
/// Owning project folder when displayed in a workspace.
|
||||
#[specta(optional)]
|
||||
pub project_root: Option<String>,
|
||||
pub id: String,
|
||||
pub active_by_default: bool,
|
||||
}
|
||||
@@ -503,7 +511,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
|
||||
continue;
|
||||
}
|
||||
let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
|
||||
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
|
||||
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id, ..Default::default() });
|
||||
}
|
||||
profiles
|
||||
}
|
||||
@@ -1063,7 +1071,7 @@ pub fn load(root: &Path) -> MavenModel {
|
||||
|
||||
/// [`load`] with the POMs `settings` link (Add Maven Projects).
|
||||
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
|
||||
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
|
||||
load_with_linked(root, default_local_repo(), &settings.profiles(&[]), &settings.linked_projects)
|
||||
}
|
||||
|
||||
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
|
||||
@@ -1212,11 +1220,12 @@ pub fn load_with_linked(
|
||||
// The project can activate the profiles of its own POM and its ancestors'.
|
||||
for (id, active) in &ctx.profiles {
|
||||
if !model.profiles.iter().any(|x| x.id == *id) {
|
||||
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active });
|
||||
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active, ..Default::default() });
|
||||
}
|
||||
}
|
||||
let artifact_id = i(&pom.artifact_id);
|
||||
model.projects.push(MavenProject {
|
||||
project_root: None,
|
||||
pom: rel_path(root, &path),
|
||||
dir: rel_path(root, &dir),
|
||||
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
|
||||
@@ -1836,7 +1845,7 @@ mod tests {
|
||||
</settings>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
|
||||
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active, ..Default::default() };
|
||||
assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
|
||||
assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! On-demand sources resolution using the project's Maven launcher and repositories.
|
||||
|
||||
use super::MavenProject;
|
||||
use crate::process;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Artifact {
|
||||
pub group: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub sources: PathBuf,
|
||||
}
|
||||
|
||||
/// Resolve only jars inside the selected local repository, including custom repositories.
|
||||
pub fn artifact(jar: &Path, repository: &Path) -> Option<Artifact> {
|
||||
let relative = jar.strip_prefix(repository).ok()?;
|
||||
let parts: Vec<&str> = relative
|
||||
.components()
|
||||
.map(|c| match c {
|
||||
Component::Normal(s) => s.to_str(),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
if parts.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let n = parts.len();
|
||||
let (name, version, file) = (parts[n - 3], parts[n - 2], parts[n - 1]);
|
||||
let valid = |s: &str| {
|
||||
!s.is_empty() && s != "." && s != ".." && s.chars().all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
|
||||
};
|
||||
if !parts[..n - 1].iter().all(|s| valid(s)) || file != format!("{name}-{version}.jar") {
|
||||
return None;
|
||||
}
|
||||
Some(Artifact {
|
||||
group: parts[..n - 3].join("."),
|
||||
name: name.into(),
|
||||
version: version.into(),
|
||||
sources: jar.with_file_name(format!("{name}-{version}-sources.jar")),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn download(
|
||||
root: &Path,
|
||||
project: &MavenProject,
|
||||
artifact: &Artifact,
|
||||
repository: &Path,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<PathBuf>> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if artifact.sources.is_file() {
|
||||
return Ok(Some(artifact.sources.clone()));
|
||||
}
|
||||
let mut args = vec!["-B".into(), "-N".into(), "-Dstyle.color=never".into()];
|
||||
args.extend(project.options.clone());
|
||||
args.extend([
|
||||
format!("-Dmaven.repo.local={}", repository.display()),
|
||||
"org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get".into(),
|
||||
format!("-Dartifact={}:{}:{}:jar:sources", artifact.group, artifact.name, artifact.version),
|
||||
"-Dtransitive=false".into(),
|
||||
]);
|
||||
let output = process::run(&project.command, &args, &root.join(&project.dir), &project.env, cancel, |_, _| {})?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if artifact.sources.is_file() {
|
||||
return Ok(Some(artifact.sources.clone()));
|
||||
}
|
||||
let coordinate = format!("{}:{}:jar:sources:{}", artifact.group, artifact.name, artifact.version);
|
||||
if output.success
|
||||
|| output.text.lines().any(|line| {
|
||||
line.contains(&coordinate) && (line.contains("Could not find artifact") || line.contains("was not found"))
|
||||
})
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Err(IdeError::unavailable(
|
||||
output.text.lines().rev().take(20).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_sources_coordinates_from_maven_layouts() {
|
||||
for repository in ["/home/me/.m2/repository", "/tmp/project repo", "C:/Users/me/.m2/repository"] {
|
||||
for (group, name, version) in [
|
||||
("org/apache/commons", "commons-lang3", "3.17.0"),
|
||||
("com/example", "example-lib", "1.0-SNAPSHOT"),
|
||||
("single", "lib", "1.0.0-rc.1"),
|
||||
] {
|
||||
let repository = Path::new(repository);
|
||||
let dir = repository.join(group).join(name).join(version);
|
||||
let found = artifact(&dir.join(format!("{name}-{version}.jar")), repository).unwrap();
|
||||
assert_eq!(found.group, group.replace('/', "."));
|
||||
assert_eq!(found.name, name);
|
||||
assert_eq!(found.version, version);
|
||||
assert_eq!(found.sources, dir.join(format!("{name}-{version}-sources.jar")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_artifacts_and_paths_outside_the_repository() {
|
||||
let repository = Path::new("/m2/repository");
|
||||
for path in [
|
||||
"/m2/repository-other/g/a/1/a-1.jar",
|
||||
"/m2/repository/g/a/1/a-1-sources.jar",
|
||||
"/m2/repository/g/a/1/other.jar",
|
||||
"/m2/repository/g/a/../a-1.jar",
|
||||
"/m2/repository/g/a/1/a-1.pom",
|
||||
"/m2/repository/g/a-1.jar",
|
||||
"/gradle/caches/modules-2/files-2.1/g/a/1/hash/a-1.jar",
|
||||
] {
|
||||
assert_eq!(artifact(Path::new(path), repository), None, "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,7 +304,7 @@ pub fn sync_maven(
|
||||
let mut model = maven::load_with_linked(
|
||||
root,
|
||||
maven::default_local_repo(),
|
||||
&options.profiles,
|
||||
&options.maven_settings.profiles(&options.profiles),
|
||||
&options.maven_settings.linked_projects,
|
||||
);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
@@ -818,6 +818,40 @@ mod tests {
|
||||
assert!(m.stale, "gradle.properties is watched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_script_edits_invalidate_the_cached_gradle_model() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let cache_dir = tmp.path().join("cache");
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'").unwrap();
|
||||
let applied = root.join("gradle/dependencies.gradle");
|
||||
std::fs::write(&applied, "implementation 'g:a:1'").unwrap();
|
||||
let model = gradle::load(&root).unwrap();
|
||||
let cache = GradleCache {
|
||||
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
|
||||
projects: vec![ReportedProject {
|
||||
path: ":".into(),
|
||||
dir: root.to_string_lossy().into_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
|
||||
let stale = || {
|
||||
let mut model = gradle::load(&root).unwrap();
|
||||
apply_gradle_cache(&root, &cache_dir, &mut model);
|
||||
model.stale
|
||||
};
|
||||
assert!(!stale());
|
||||
std::fs::write(&applied, "// comment\nimplementation 'g:a:1'\n").unwrap();
|
||||
assert!(!stale());
|
||||
std::fs::write(&applied, "implementation 'g:a:2'").unwrap();
|
||||
assert!(stale());
|
||||
std::fs::remove_file(&applied).unwrap();
|
||||
assert!(stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
|
||||
let online = maven_sync_goals(Path::new("e.xml"), false);
|
||||
|
||||
@@ -11,3 +11,6 @@ license.workspace = true
|
||||
objc2 = { version = "0.6", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
@@ -21,6 +21,12 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Replaces every clipboard flavor with plain text. `owner` is the calling window
|
||||
/// handle on Windows, ignored elsewhere. Call on the main thread on macOS and Linux.
|
||||
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
imp::set_text(text, owner)
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -165,8 +171,12 @@ mod imp {
|
||||
/// Opens the clipboard as its owner: the list is written in full, so no
|
||||
/// owning window is needed.
|
||||
fn open() -> bool {
|
||||
open_for(ptr::null_mut())
|
||||
}
|
||||
|
||||
fn open_for(owner: Handle) -> bool {
|
||||
for _ in 0..OPEN_ATTEMPTS {
|
||||
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
|
||||
if unsafe { OpenClipboard(owner) } != 0 {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(OPEN_RETRY);
|
||||
@@ -230,6 +240,29 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
let (memory, _) = global_text(text);
|
||||
if memory.is_null() {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: ownership transfers only on success; otherwise we free the allocation.
|
||||
unsafe {
|
||||
if !open_for(owner as Handle) {
|
||||
GlobalFree(memory);
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
let taken = EmptyClipboard() != 0 && !SetClipboardData(CF_UNICODETEXT, memory).is_null();
|
||||
let error = std::io::Error::last_os_error();
|
||||
CloseClipboard();
|
||||
if taken {
|
||||
Ok(())
|
||||
} else {
|
||||
GlobalFree(memory);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
let bytes = encode(paths);
|
||||
if bytes.len() <= HEADER {
|
||||
@@ -340,6 +373,19 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
board.clearContents();
|
||||
if board.setString_forType(&NSString::from_str(text), unsafe { NSPasteboardTypeString }) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other("Cannot write clipboard text"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
@@ -370,6 +416,19 @@ mod imp {
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD).set_text(text);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = text;
|
||||
Err(std::io::Error::other("Clipboard text is unavailable on this platform"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_files() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dunce.workspace = true
|
||||
percent-encoding = "2"
|
||||
encoding_rs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -22,4 +23,4 @@ tempfile.workspace = true
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
|
||||
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
|
||||
@@ -20,6 +20,8 @@ pub enum McpUiRequest {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum IdeEvent {
|
||||
/// Project data source settings changed; reload the Database tree.
|
||||
DbSourcesChanged,
|
||||
McpRequest {
|
||||
id: u32,
|
||||
request: McpUiRequest,
|
||||
@@ -40,6 +42,10 @@ pub enum IdeEvent {
|
||||
created: Vec<crate::IpcPath>,
|
||||
modified: Vec<crate::IpcPath>,
|
||||
removed: Vec<crate::IpcPath>,
|
||||
/// Subtrees whose ignore rules changed, after the matchers were rebuilt.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
ignore_rules: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
|
||||
ExcludedDirsChanged {
|
||||
@@ -146,6 +152,12 @@ pub enum IdeEvent {
|
||||
SettingsChanged {
|
||||
key: String,
|
||||
},
|
||||
UpdateStatusChanged {
|
||||
status: crate::updates::UpdateStatus,
|
||||
},
|
||||
UpdateAvailable {
|
||||
update: crate::updates::UpdateInfo,
|
||||
},
|
||||
/// The project's source / test / resource / excluded roots changed (build files
|
||||
/// changed or folders were marked); the UI re-reads them with `project_roots`.
|
||||
ProjectRootsChanged {
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
//! Bounded, ordered process input. Only the worker touches the OS writer.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
#[cfg(any(not(unix), test))]
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_WRITES: usize = 1024;
|
||||
#[cfg(any(not(unix), test))]
|
||||
const RETRY: Duration = Duration::from_millis(4);
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
queue: VecDeque<Vec<u8>>,
|
||||
bytes: usize,
|
||||
finished: bool,
|
||||
aborted: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// One worker per pipe/PTY. The byte budget includes the in-flight write.
|
||||
/// Rejected writes enqueue nothing. Dropping this handle discards pending input
|
||||
/// without joining the worker; `finish` instead drains accepted input before EOF.
|
||||
pub struct InputWriter {
|
||||
shared: Arc<(Mutex<State>, Condvar)>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl InputWriter {
|
||||
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
|
||||
pub fn new(
|
||||
name: String,
|
||||
mut writer: Box<dyn Write + Send>,
|
||||
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
|
||||
on_error: impl FnOnce(io::Error) + Send + 'static,
|
||||
) -> io::Result<Self> {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
let worker_shared = shared.clone();
|
||||
let worker = std::thread::Builder::new().name(name).spawn(move || {
|
||||
let (lock, wake) = &*worker_shared;
|
||||
let result = (|| {
|
||||
loop {
|
||||
let data = {
|
||||
let mut state = lock.lock().unwrap();
|
||||
while state.queue.is_empty() && !state.finished && !state.aborted {
|
||||
state = wake.wait(state).unwrap();
|
||||
}
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(data) = state.queue.pop_front() else { return Ok(()) };
|
||||
data
|
||||
};
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
if lock.lock().unwrap().aborted {
|
||||
return Ok(());
|
||||
}
|
||||
let flushing = offset == data.len();
|
||||
let result = if !flushing {
|
||||
writer.write(&data[offset..]).and_then(|n| {
|
||||
if n == 0 {
|
||||
Err(io::ErrorKind::WriteZero.into())
|
||||
} else {
|
||||
offset += n;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
} else {
|
||||
writer.flush()
|
||||
};
|
||||
match result {
|
||||
Ok(()) if flushing => break,
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
|
||||
#[cfg(unix)]
|
||||
match wait_writable() {
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||
result => result?,
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
drop(wake.wait_timeout(state, RETRY).unwrap());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
let len = data.len();
|
||||
drop(data);
|
||||
let mut state = lock.lock().unwrap();
|
||||
state.bytes = state.bytes.saturating_sub(len);
|
||||
}
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
let mut state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return;
|
||||
}
|
||||
state.error = Some(e.to_string());
|
||||
state.queue.clear();
|
||||
state.bytes = 0;
|
||||
drop(state);
|
||||
on_error(e);
|
||||
}
|
||||
})?;
|
||||
Ok(Self { shared, worker: Some(worker) })
|
||||
}
|
||||
|
||||
pub fn write(&self, data: &[u8]) -> io::Result<()> {
|
||||
let (lock, wake) = &*self.shared;
|
||||
let mut state = lock.lock().unwrap();
|
||||
if let Some(error) = &state.error {
|
||||
return Err(io::Error::new(io::ErrorKind::BrokenPipe, error.clone()));
|
||||
}
|
||||
if state.finished || state.aborted {
|
||||
return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin is closed"));
|
||||
}
|
||||
if data.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if data.len() > MAX_BYTES.saturating_sub(state.bytes) || state.queue.len() >= MAX_WRITES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::WouldBlock,
|
||||
"Input queue is full (4 MiB or 1024 writes); input was not sent.",
|
||||
));
|
||||
}
|
||||
state.bytes += data.len();
|
||||
state.queue.push_back(data.to_vec());
|
||||
wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send EOF after the accepted writes, without waiting for the child to read.
|
||||
pub fn finish(&self) {
|
||||
let (lock, wake) = &*self.shared;
|
||||
lock.lock().unwrap().finished = true;
|
||||
wake.notify_one();
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let (lock, wake) = &*self.shared;
|
||||
let mut state = lock.lock().unwrap();
|
||||
state.aborted = true;
|
||||
state.queue.clear();
|
||||
wake.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InputWriter {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
let Some(worker) = self.worker.take() else { return };
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
// A cancellation can race with entry into WriteFile, so retry off
|
||||
// the caller's thread until the worker observes the closed queue.
|
||||
let _ = std::thread::Builder::new().name("openidea-input-cancel".into()).spawn(move || {
|
||||
while !worker.is_finished() {
|
||||
unsafe {
|
||||
windows_sys::Win32::System::IO::CancelSynchronousIo(worker.as_raw_handle());
|
||||
}
|
||||
std::thread::sleep(RETRY);
|
||||
}
|
||||
let _ = worker.join();
|
||||
});
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
drop(worker);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
|
||||
/// status flags, so readers must also handle WouldBlock.
|
||||
#[cfg(unix)]
|
||||
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
let wait = readiness(fd, libc::POLLOUT)?;
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(wait)
|
||||
}
|
||||
|
||||
/// Wait for output from a nonblocking PTY without delaying the child's next read.
|
||||
#[cfg(unix)]
|
||||
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
readiness(fd, libc::POLLIN)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||
|
||||
// Own a duplicate so the hook remains valid if the caller closes its fd.
|
||||
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
|
||||
if duplicate == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: fcntl returned a new, owned descriptor.
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
|
||||
Ok(move || {
|
||||
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
|
||||
// Readiness wakes immediately; the timeout only bounds abort latency.
|
||||
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if pollfd.revents & libc::POLLNVAL != 0 {
|
||||
return Err(io::Error::from_raw_os_error(libc::EBADF));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
#[cfg(unix)]
|
||||
use std::time::Instant;
|
||||
|
||||
struct SlowWriter {
|
||||
ready: Arc<AtomicBool>,
|
||||
output: Arc<Mutex<Vec<u8>>>,
|
||||
dropped: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl Write for SlowWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if !self.ready.load(Ordering::Acquire) {
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
let n = bytes.len().min(7);
|
||||
self.output.lock().unwrap().extend_from_slice(&bytes[..n]);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SlowWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.dropped.send(());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_include_inflight_bytes_and_reject_whole_writes() {
|
||||
let ready = Arc::new(AtomicBool::new(false));
|
||||
let output = Arc::new(Mutex::new(Vec::new()));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = vec![b'x'; MAX_BYTES];
|
||||
writer.write(&bytes).unwrap();
|
||||
std::thread::sleep(RETRY * 2);
|
||||
assert_eq!(writer.write(b"rejected").unwrap_err().kind(), io::ErrorKind::WouldBlock);
|
||||
writer.close();
|
||||
rx.recv_timeout(Duration::from_secs(1)).expect("worker stops while peer still refuses input");
|
||||
assert!(output.lock().unwrap().is_empty());
|
||||
assert!(writer.write(b"closed").is_err());
|
||||
assert!(writer.shared.0.lock().unwrap().queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_writes_preserve_order_and_finish_drains_before_eof() {
|
||||
let ready = Arc::new(AtomicBool::new(false));
|
||||
let output = Arc::new(Mutex::new(Vec::new()));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
let paste = "abé".repeat(1024);
|
||||
writer.write(paste.as_bytes()).unwrap();
|
||||
writer.write(b"tail").unwrap();
|
||||
writer.finish();
|
||||
assert!(writer.write(b"after EOF").is_err());
|
||||
ready.store(true, Ordering::Release);
|
||||
rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn readiness_errors_are_reported_and_reject_following_writes() {
|
||||
let (dropped, _rx) = mpsc::channel();
|
||||
let (errors, error) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-error-input".into(),
|
||||
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|
||||
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
|
||||
move |e| errors.send(e.raw_os_error()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write(b"input").unwrap();
|
||||
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
|
||||
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
|
||||
let state = writer.shared.0.lock().unwrap();
|
||||
assert!(state.queue.is_empty());
|
||||
assert_eq!(state.bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_writes_are_also_bounded() {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
let writer = InputWriter { shared, worker: None };
|
||||
for _ in 0..MAX_WRITES {
|
||||
writer.write(b"x").unwrap();
|
||||
}
|
||||
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_input_resumes_without_timer_delays() {
|
||||
use std::io::Read;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
let (pipe, mut peer) = UnixStream::pair().unwrap();
|
||||
let capacity: libc::c_int = 1024;
|
||||
// Keep the OS buffer small enough that a timer per buffer takes seconds.
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
libc::setsockopt(
|
||||
pipe.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_SNDBUF,
|
||||
(&capacity as *const libc::c_int).cast(),
|
||||
std::mem::size_of_val(&capacity) as libc::socklen_t,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let writer =
|
||||
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
let bytes = vec![b'x'; MAX_BYTES];
|
||||
writer.write(&bytes).unwrap();
|
||||
writer.finish();
|
||||
// Start with a full buffer, then make the fd writable from another thread.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let started = Instant::now();
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
peer.read_to_end(&mut output).unwrap();
|
||||
tx.send(output).unwrap();
|
||||
});
|
||||
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
|
||||
assert_eq!(output, bytes);
|
||||
reader.join().unwrap();
|
||||
eprintln!("4 MiB input drained in {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
let (pipe, _peer) = UnixStream::pair().unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let mut writer =
|
||||
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(!writer.worker.as_ref().unwrap().is_finished());
|
||||
let started = Instant::now();
|
||||
writer.close();
|
||||
writer.worker.take().unwrap().join().unwrap();
|
||||
assert!(started.elapsed() < Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,14 @@ pub mod event;
|
||||
pub mod go;
|
||||
pub mod ids;
|
||||
pub mod ignored;
|
||||
pub mod input;
|
||||
pub mod inspection_settings;
|
||||
pub mod ipc_path;
|
||||
pub mod java_runtime;
|
||||
pub mod jsonc;
|
||||
pub mod output_encoding;
|
||||
pub mod path_mapping;
|
||||
pub mod platform;
|
||||
pub mod process;
|
||||
pub mod project_dir;
|
||||
pub mod project_roots;
|
||||
@@ -24,6 +27,7 @@ pub mod settings;
|
||||
pub mod task;
|
||||
pub mod text;
|
||||
pub mod tool_path;
|
||||
pub mod updates;
|
||||
pub mod version_managers;
|
||||
pub mod workflow_settings;
|
||||
pub mod wsl;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Lexical path translation at protocol boundaries, independent of LSP or DAP.
|
||||
use percent_encoding::{CONTROLS, percent_decode_str, utf8_percent_encode};
|
||||
use serde_json::Value;
|
||||
|
||||
const ESCAPES: &percent_encoding::AsciiSet = &CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'"')
|
||||
.add(b'#')
|
||||
.add(b'%')
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b'?')
|
||||
.add(b'[')
|
||||
.add(b']')
|
||||
.add(b'\\')
|
||||
.add(b'^')
|
||||
.add(b'`')
|
||||
.add(b'{')
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PathMapping {
|
||||
host: String,
|
||||
target: String,
|
||||
windows: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Direction {
|
||||
ToTarget,
|
||||
ToHost,
|
||||
}
|
||||
|
||||
fn normalized(path: &str) -> String {
|
||||
let path = path
|
||||
.strip_prefix(r"\\?\UNC\")
|
||||
.map(|p| format!("//{p}"))
|
||||
.unwrap_or_else(|| path.strip_prefix(r"\\?\").unwrap_or(path).to_owned());
|
||||
let path = path.replace('\\', "/");
|
||||
if path == "/" { path } else { path.trim_end_matches('/').to_owned() }
|
||||
}
|
||||
|
||||
pub fn file_path(uri: &str) -> Option<String> {
|
||||
if !uri.get(..7)?.eq_ignore_ascii_case("file://") {
|
||||
return None;
|
||||
}
|
||||
let rest = &uri[7..];
|
||||
let path = if rest.starts_with('/') {
|
||||
rest.to_owned()
|
||||
} else {
|
||||
let (authority, path) = rest.split_once('/')?;
|
||||
if authority.eq_ignore_ascii_case("localhost") { format!("/{path}") } else { format!("//{authority}/{path}") }
|
||||
};
|
||||
let decoded = percent_decode_str(path.split(['?', '#']).next()?).decode_utf8().ok()?;
|
||||
let path = decoded.strip_prefix('/').filter(|p| p.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
|
||||
Some(path.to_owned())
|
||||
}
|
||||
|
||||
pub fn file_uri(path: &str) -> String {
|
||||
let path = normalized(path);
|
||||
format!("file://{}{}", if path.starts_with('/') { "" } else { "/" }, utf8_percent_encode(&path, ESCAPES))
|
||||
}
|
||||
|
||||
impl PathMapping {
|
||||
pub fn new(host: &str, target: &str) -> Self {
|
||||
let host = normalized(host);
|
||||
let windows = host.as_bytes().get(1) == Some(&b':') || host.starts_with("//");
|
||||
Self { host, target: normalized(target), windows }
|
||||
}
|
||||
|
||||
pub fn map(&self, value: &str, direction: Direction) -> Option<String> {
|
||||
let uri_path = file_path(value);
|
||||
let is_uri = uri_path.is_some();
|
||||
let path = normalized(uri_path.as_deref().unwrap_or(value));
|
||||
let (from, to) = match direction {
|
||||
Direction::ToTarget => (&self.host, &self.target),
|
||||
Direction::ToHost => (&self.target, &self.host),
|
||||
};
|
||||
let prefix = path.get(..from.len())?;
|
||||
let same = if self.windows && matches!(direction, Direction::ToTarget) {
|
||||
prefix.eq_ignore_ascii_case(from)
|
||||
} else {
|
||||
prefix == from
|
||||
};
|
||||
if !same {
|
||||
return None;
|
||||
}
|
||||
let suffix = &path[from.len()..];
|
||||
if !suffix.is_empty() && !suffix.starts_with('/') && from != "/" {
|
||||
return None;
|
||||
}
|
||||
// Never let lexical parent segments escape either mapped root.
|
||||
if suffix.split('/').any(|p| p == ".." || p == ".") {
|
||||
return None;
|
||||
}
|
||||
let mapped = format!("{}/{}", to.trim_end_matches('/'), suffix.trim_start_matches('/'));
|
||||
let mapped = if suffix.is_empty() { to.clone() } else { mapped };
|
||||
Some(if is_uri {
|
||||
let suffix = value.find(['?', '#']).map(|i| &value[i..]).unwrap_or("");
|
||||
format!("{}{suffix}", file_uri(&mapped))
|
||||
} else if self.windows && matches!(direction, Direction::ToHost) {
|
||||
mapped.replace('/', "\\")
|
||||
} else {
|
||||
mapped
|
||||
})
|
||||
}
|
||||
|
||||
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
|
||||
rewrite_strings(value, &|s| self.map(s, direction));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites strings and object keys (WorkspaceEdit.changes uses URIs as keys).
|
||||
/// Resolve tokens and command arguments remain opaque; source text is never a path.
|
||||
pub fn rewrite_strings(value: &mut Value, map: &dyn Fn(&str) -> Option<String>) {
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
if let Some(mapped) = map(s) {
|
||||
*s = mapped;
|
||||
}
|
||||
}
|
||||
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_strings(v, map)),
|
||||
Value::Object(fields) => {
|
||||
let old = std::mem::take(fields);
|
||||
for (key, mut value) in old {
|
||||
if !matches!(key.as_str(), "data" | "arguments" | "text" | "newText" | "insertText") {
|
||||
rewrite_strings(&mut value, map);
|
||||
}
|
||||
fields.insert(map(&key).unwrap_or(key), value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn roots_encoding_boundaries_and_windows() {
|
||||
for host in ["C:/Users/me/项目", r"C:\Users\me\项目", r"\\?\C:\Users\me\项目"] {
|
||||
let m = PathMapping::new(host, "/app");
|
||||
assert_eq!(m.map(r"\\?\c:\Users\me\项目\a b#%.php", Direction::ToTarget).unwrap(), "/app/a b#%.php");
|
||||
assert_eq!(
|
||||
m.map("file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php", Direction::ToTarget).unwrap(),
|
||||
"file:///app/a%20b%23%25.php"
|
||||
);
|
||||
assert_eq!(
|
||||
m.map("file:///app/a%20b%23%25.php", Direction::ToHost).unwrap(),
|
||||
"file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php"
|
||||
);
|
||||
assert_eq!(m.map("/app/a.php", Direction::ToHost).unwrap(), r"C:\Users\me\项目\a.php");
|
||||
for outside in ["/app2/a", "/vendor/a", "/app/../outside", "jdt://app/a"] {
|
||||
assert!(m.map(outside, Direction::ToHost).is_none());
|
||||
}
|
||||
}
|
||||
let unc = PathMapping::new(r"\\?\UNC\server\share\project", "/app");
|
||||
assert_eq!(unc.map("file://server/share/project/a.php", Direction::ToTarget).unwrap(), "file:///app/a.php");
|
||||
assert_eq!(unc.map("/app/a.php", Direction::ToHost).unwrap(), r"\\server\share\project\a.php");
|
||||
let m = PathMapping::new("/home/me/project", "/app");
|
||||
assert_eq!(m.map("/home/me/project", Direction::ToTarget).unwrap(), "/app");
|
||||
assert_eq!(m.map("file://localhost/app/a", Direction::ToHost).unwrap(), "file:///home/me/project/a");
|
||||
assert!(m.map("/home/me/project2/a", Direction::ToTarget).is_none());
|
||||
assert_eq!(
|
||||
m.map("file:///app/a%23b?query#fragment", Direction::ToHost).unwrap(),
|
||||
"file:///home/me/project/a%23b?query#fragment"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_messages_resource_operations_and_opaque_payloads() {
|
||||
let m = PathMapping::new("/host/project", "/app");
|
||||
let mut v = json!({"rootUri":"file:///host/project", "rootPath":"/host/project", "workspaceFolders":[{"uri":"file:///host/project"}],
|
||||
"edit":{"changes":{"file:///host/project/a":[]}, "documentChanges":[{"oldUri":"file:///host/project/a", "newUri":"file:///host/project/b"}]},
|
||||
"relatedInformation":[{"location":{"uri":"file:///host/project/a"}}], "target":"file:///host/project/b",
|
||||
"configuration":[{"includePath":"/host/project/vendor"}], "text":"/host/project/a", "newText":"/host/project/b", "data":{"uri":"file:///host/project/a"}, "command":{"arguments":["/host/project/a"]}});
|
||||
let original = v.clone();
|
||||
m.rewrite(&mut v, Direction::ToTarget);
|
||||
assert_eq!(v["rootUri"], "file:///app");
|
||||
assert_eq!(v["rootPath"], "/app");
|
||||
assert!(v["edit"]["changes"].get("file:///app/a").is_some());
|
||||
assert_eq!(v["edit"]["documentChanges"][0]["newUri"], "file:///app/b");
|
||||
assert_eq!(v["relatedInformation"][0]["location"]["uri"], "file:///app/a");
|
||||
assert_eq!(v["configuration"][0]["includePath"], "/app/vendor");
|
||||
assert_eq!(v["data"], original["data"]);
|
||||
assert_eq!(v["command"], original["command"]);
|
||||
m.rewrite(&mut v, Direction::ToHost);
|
||||
assert_eq!(v, original);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
|
||||
#[cfg(windows)]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
|
||||
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
|
||||
|
||||
let mut info =
|
||||
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
|
||||
// SAFETY: `info` is initialized and declares the size of the writable structure.
|
||||
let status = unsafe { RtlGetVersion(&mut info) };
|
||||
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn windows_build_is_platform_specific() {
|
||||
let build = super::windows_build_number();
|
||||
#[cfg(windows)]
|
||||
assert!(build.is_some_and(|build| build > 0));
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(build, None);
|
||||
}
|
||||
}
|
||||
@@ -104,12 +104,23 @@ fn signal_group(child: &Child, signal: libc::c_int) {
|
||||
#[cfg(windows)]
|
||||
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[cfg(windows)]
|
||||
pub use windows_job::Job;
|
||||
#[cfg(windows)]
|
||||
pub use windows_tree::ProcessTree;
|
||||
|
||||
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
|
||||
/// started is gone: termination is asynchronous, and until a process is gone the files it
|
||||
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
|
||||
#[cfg(windows)]
|
||||
fn taskkill_tree_of(pid: u32) {
|
||||
let started = windows_tree::descendants(pid);
|
||||
// When the child started nothing, killing it alone is the whole job:
|
||||
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
|
||||
// same outcome, and the callers kill the child themselves.
|
||||
if started.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &pid.to_string()])
|
||||
@@ -130,13 +141,48 @@ mod windows_tree {
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
|
||||
TerminateProcess, WaitForSingleObject,
|
||||
};
|
||||
|
||||
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
|
||||
pub struct ProcessTree {
|
||||
processes: Vec<OwnedHandle>,
|
||||
}
|
||||
|
||||
impl ProcessTree {
|
||||
pub fn capture(pid: u32) -> Self {
|
||||
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
|
||||
let children = descendants_with(pid, PROCESS_TERMINATE);
|
||||
Self { processes: std::iter::once(root).chain(children).collect() }
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.processes.iter().any(|process| {
|
||||
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
|
||||
unsafe {
|
||||
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Requests termination children first; poll `is_running` until termination completes.
|
||||
pub fn terminate(&self) {
|
||||
for process in self.processes.iter().rev() {
|
||||
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
|
||||
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every process `root` started, directly or not, opened so it can be waited for. A
|
||||
/// parent's id is reused once it is gone, so a process counts only if it started after
|
||||
/// its parent.
|
||||
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
|
||||
descendants_with(root, 0)
|
||||
}
|
||||
|
||||
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
|
||||
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
|
||||
let processes = processes();
|
||||
let mut seen = HashSet::from([root]);
|
||||
@@ -147,7 +193,7 @@ mod windows_tree {
|
||||
if ppid != parent || !seen.insert(pid) {
|
||||
continue;
|
||||
}
|
||||
let Some(handle) = open(pid) else { continue };
|
||||
let Some(handle) = open_with(pid, access) else { continue };
|
||||
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
|
||||
parents.push((pid, at));
|
||||
found.push(handle);
|
||||
@@ -187,8 +233,12 @@ mod windows_tree {
|
||||
}
|
||||
|
||||
fn open(pid: u32) -> Option<OwnedHandle> {
|
||||
open_with(pid, 0)
|
||||
}
|
||||
|
||||
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
|
||||
// SAFETY: as above.
|
||||
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
|
||||
}
|
||||
@@ -203,6 +253,167 @@ mod windows_tree {
|
||||
}
|
||||
}
|
||||
|
||||
/// A kernel job object over one launched process tree. Everything a launch starts joins
|
||||
/// the job by inheritance, so [`Job::terminate`] ends the whole tree — also processes
|
||||
/// started after the launch's snapshot was taken, orphans of a launcher that already
|
||||
/// exited (where a `taskkill /T /F` from the tracked pid no longer finds them), and
|
||||
/// processes born in the window a process-enumeration kill would miss. Spawns assign
|
||||
/// their root right after it starts; when the assignment fails (an enclosing job that
|
||||
/// disallows nesting, an already-exited pid), callers fall back to `taskkill /T /F`.
|
||||
#[cfg(windows)]
|
||||
mod windows_job {
|
||||
use std::mem::size_of;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle};
|
||||
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_BASIC_LIMIT_INFORMATION, JOBOBJECT_BASIC_PROCESS_ID_LIST, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JobObjectBasicProcessIdList, JobObjectExtendedLimitInformation, QueryInformationJobObject,
|
||||
SetInformationJobObject, TerminateJobObject,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{IO_COUNTERS, OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
|
||||
|
||||
/// A job object holding one launch's processes. The handle is kept open for the
|
||||
/// launch's lifetime: `KILL_ON_JOB_CLOSE` ends anything still in the job when the
|
||||
/// launch record (a run entry, a debug session) is finally dropped.
|
||||
pub struct Job(OwnedHandle);
|
||||
|
||||
impl Job {
|
||||
/// Takes the just-started `child` into a new job, so everything it starts is in
|
||||
/// there too. `None` when the job could not be made or the process rejected the
|
||||
/// assignment (callers fall back to `taskkill /T /F` from the child's pid).
|
||||
pub fn for_child(child: &std::process::Child) -> Option<Job> {
|
||||
// SAFETY: the child's own handle is a live process handle.
|
||||
Self::from_handle(child.as_raw_handle())
|
||||
}
|
||||
|
||||
/// [`Job::for_child`] for a process known by its id (a PTY child, a follow-up run,
|
||||
/// a `tokio` child). `None` when the id no longer names an openable process — a
|
||||
/// caller that holds the child cannot have had it reaped, so this is a dead or
|
||||
/// already-gone launch.
|
||||
pub fn for_pid(pid: u32) -> Option<Job> {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
|
||||
// Assignment needs `SET_QUOTA | TERMINATE` on the process.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the handle was just opened and is owned here.
|
||||
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
|
||||
Self::from_handle(handle.as_raw_handle())
|
||||
}
|
||||
|
||||
fn from_handle(handle: RawHandle) -> Option<Job> {
|
||||
// SAFETY: plain calls; a non-null result is a job handle owned by the caller.
|
||||
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
|
||||
if job.is_null() || job == INVALID_HANDLE_VALUE {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
|
||||
let job = unsafe { OwnedHandle::from_raw_handle(job) };
|
||||
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
|
||||
BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
|
||||
PerProcessUserTimeLimit: 0,
|
||||
PerJobUserTimeLimit: 0,
|
||||
LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
MinimumWorkingSetSize: 0,
|
||||
MaximumWorkingSetSize: 0,
|
||||
ActiveProcessLimit: 0,
|
||||
Affinity: 0,
|
||||
PriorityClass: 0,
|
||||
SchedulingClass: 0,
|
||||
},
|
||||
IoInfo: IO_COUNTERS {
|
||||
ReadOperationCount: 0,
|
||||
WriteOperationCount: 0,
|
||||
OtherOperationCount: 0,
|
||||
ReadTransferCount: 0,
|
||||
WriteTransferCount: 0,
|
||||
OtherTransferCount: 0,
|
||||
},
|
||||
ProcessMemoryLimit: 0,
|
||||
JobMemoryLimit: 0,
|
||||
PeakProcessMemoryUsed: 0,
|
||||
PeakJobMemoryUsed: 0,
|
||||
};
|
||||
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
|
||||
let ok = unsafe {
|
||||
SetInformationJobObject(
|
||||
job.as_raw_handle(),
|
||||
JobObjectExtendedLimitInformation,
|
||||
&mut limits as *mut _ as *const _,
|
||||
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the process handle belongs to the just-spawned child and outlives
|
||||
// this call; the job handle is owned here.
|
||||
let ok = unsafe { AssignProcessToJobObject(job.as_raw_handle(), handle) };
|
||||
if ok == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Job(job))
|
||||
}
|
||||
|
||||
/// Adds another just-started root of the same launch to the job (a Go build's
|
||||
/// follow-up run, a JVM a debug session starts itself). `false` when it did not
|
||||
/// take; the process then relies on its own kill.
|
||||
pub fn assign_pid(&self, pid: u32) -> bool {
|
||||
// SAFETY: plain call; the handle is opened only to assign it, and the job
|
||||
// handle is owned and valid.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: both handles are valid for the duration of the call.
|
||||
let ok = unsafe { AssignProcessToJobObject(self.0.as_raw_handle(), handle) };
|
||||
// SAFETY: the process handle was opened above and is owned here.
|
||||
unsafe { drop(OwnedHandle::from_raw_handle(handle)) };
|
||||
ok != 0
|
||||
}
|
||||
|
||||
/// Terminates every process in the job. `false` when the call failed (the job
|
||||
/// handle is invalid): callers then fall back to `taskkill /T /F`. Termination is
|
||||
/// asynchronous; poll [`Job::is_running`] (bounded by [`TREE_EXIT_WAIT`]) until
|
||||
/// the tree is gone.
|
||||
pub fn terminate(&self) -> bool {
|
||||
// SAFETY: a job handle owned here.
|
||||
unsafe { TerminateJobObject(self.0.as_raw_handle(), 1) != 0 }
|
||||
}
|
||||
|
||||
/// Whether any process of the launch is still in the job. When the job's state
|
||||
/// cannot be read, reports gone: waits on this must not wedge, and a kill via
|
||||
/// [`Job::terminate`] does not depend on the answer.
|
||||
pub fn is_running(&self) -> bool {
|
||||
// A list of one is the common case; `ERROR_MORE_DATA` says the real list is
|
||||
// longer, which means there are processes.
|
||||
let mut list = JOBOBJECT_BASIC_PROCESS_ID_LIST {
|
||||
NumberOfAssignedProcesses: 0,
|
||||
NumberOfProcessIdsInList: 0,
|
||||
ProcessIdList: [0],
|
||||
};
|
||||
let mut returned = 0u32;
|
||||
// SAFETY: the job handle is owned here and `list` is sized as the API requires.
|
||||
let ok = unsafe {
|
||||
QueryInformationJobObject(
|
||||
self.0.as_raw_handle(),
|
||||
JobObjectBasicProcessIdList,
|
||||
&mut list as *mut _ as *mut _,
|
||||
size_of::<JOBOBJECT_BASIC_PROCESS_ID_LIST>() as u32,
|
||||
&mut returned,
|
||||
)
|
||||
};
|
||||
if ok != 0 {
|
||||
list.NumberOfProcessIdsInList > 0
|
||||
} else {
|
||||
std::io::Error::last_os_error().raw_os_error() == Some(ERROR_MORE_DATA as i32)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
|
||||
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
|
||||
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
|
||||
@@ -908,3 +1119,66 @@ mod kill_tests {
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod job_tests {
|
||||
use super::*;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Stdio;
|
||||
use std::time::Instant;
|
||||
|
||||
/// `cmd` that keeps a `ping` grandchild for a minute, like a launcher that started an
|
||||
/// application: the job must reach both.
|
||||
fn launch_tree() -> (std::process::Child, Job, ProcessTree) {
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
|
||||
let child = cmd.spawn().unwrap();
|
||||
let job = Job::for_child(&child).expect("the job takes the launch");
|
||||
let tree = ProcessTree::capture(child.id());
|
||||
assert!(tree.is_running(), "cmd started a grandchild the job inherits");
|
||||
(child, job, tree)
|
||||
}
|
||||
|
||||
/// Termination is asynchronous: the job reports its processes gone while their
|
||||
/// handles still wait, so wait on the handles (the reused-pid-proof oracle).
|
||||
fn assert_gone(tree: &ProcessTree) {
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
while tree.is_running() {
|
||||
assert!(Instant::now() < until, "the tree survived the job");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_terminates_the_tree_the_root_started() {
|
||||
let (mut child, job, tree) = launch_tree();
|
||||
assert!(job.is_running());
|
||||
assert!(job.terminate(), "TerminateJobObject succeeds");
|
||||
// The captured tree's handles survive the root's death, so the wait below is the
|
||||
// grandchild's, not a reused pid's.
|
||||
assert_gone(&tree);
|
||||
assert!(!job.is_running());
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_spawned_root_joins_the_job() {
|
||||
let (mut first, job, _) = launch_tree();
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
|
||||
let mut second = cmd.spawn().unwrap();
|
||||
let second_tree = ProcessTree::capture(second.id());
|
||||
assert!(job.assign_pid(second.id()), "the follow-up run joins the job");
|
||||
assert!(job.terminate());
|
||||
assert_gone(&second_tree);
|
||||
let _ = first.wait();
|
||||
let _ = second.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_pid_makes_no_job() {
|
||||
// An id of a process this test cannot open (or that does not exist) gives no job,
|
||||
// which is the callers' cue for the `taskkill /T /F` fallback.
|
||||
assert!(Job::for_pid(u32::MAX).is_none());
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,30 @@ pub struct RunTarget {
|
||||
pub image_user: bool,
|
||||
}
|
||||
|
||||
/// A plugin-owned argv wrapper for project runs (API 1.4).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRunTarget {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub workdir_args: Vec<String>,
|
||||
pub container_root: Option<String>,
|
||||
pub commands: Vec<PluginCommandOverride>,
|
||||
}
|
||||
|
||||
/// Per-command argv prefix and optional replacement working-directory arguments.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginCommandOverride {
|
||||
pub name: String,
|
||||
pub args: Vec<String>,
|
||||
/// None inherits the target's list; Some([]) suppresses it.
|
||||
#[serde(default)]
|
||||
pub workdir_args: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deployment_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -48,6 +48,8 @@ pub struct Settings {
|
||||
/// Per language server, by name (`rust-analyzer`): launch and protocol
|
||||
/// options on top of the language definition, or a server defined here.
|
||||
pub language_servers: BTreeMap<String, LanguageServerSettings>,
|
||||
/// Project language id → server and plugin target. Empty target uses the host.
|
||||
pub language_server_targets: BTreeMap<String, LanguageServerTarget>,
|
||||
/// Editor › Inspections profiles.
|
||||
pub inspections: InspectionSettings,
|
||||
/// Editor › Natural Languages › Spelling.
|
||||
@@ -139,6 +141,14 @@ pub struct ToolSettings {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguageServerTarget {
|
||||
pub target: String,
|
||||
/// Empty selects the first configured server for the language.
|
||||
pub server: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguageServerSettings {
|
||||
@@ -233,6 +243,7 @@ impl Default for Settings {
|
||||
external_tools: Vec::new(),
|
||||
tools: BTreeMap::new(),
|
||||
language_servers: BTreeMap::new(),
|
||||
language_server_targets: BTreeMap::new(),
|
||||
inspections: InspectionSettings::default(),
|
||||
spelling: SpellingSettings::default(),
|
||||
ssh: SshSettings::default(),
|
||||
@@ -266,6 +277,10 @@ pub struct CustomKeymap {
|
||||
pub struct GeneralSettings {
|
||||
pub reopen_last_project: bool,
|
||||
pub confirm_exit: bool,
|
||||
/// When on, the last window closes instead of showing the Welcome screen.
|
||||
pub close_without_welcome: bool,
|
||||
/// When on, closing a window drops its saved editor tabs, so they are not restored next time.
|
||||
pub close_tabs_with_window: bool,
|
||||
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
|
||||
pub open_project_in: String,
|
||||
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
|
||||
@@ -281,6 +296,7 @@ pub struct GeneralSettings {
|
||||
pub project_name: String,
|
||||
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
|
||||
pub update_source: String,
|
||||
pub auto_check_updates: bool,
|
||||
}
|
||||
|
||||
impl Default for GeneralSettings {
|
||||
@@ -288,6 +304,8 @@ impl Default for GeneralSettings {
|
||||
Self {
|
||||
reopen_last_project: true,
|
||||
confirm_exit: true,
|
||||
close_without_welcome: false,
|
||||
close_tabs_with_window: false,
|
||||
open_project_in: "ask".into(),
|
||||
default_project_directory: String::new(),
|
||||
save_on_idle: false,
|
||||
@@ -296,6 +314,7 @@ impl Default for GeneralSettings {
|
||||
power_save_mode: false,
|
||||
project_name: String::new(),
|
||||
update_source: "auto".into(),
|
||||
auto_check_updates: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,6 +369,21 @@ impl Default for NotificationGroupSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the Navigation Bar (the file path breadcrumbs) is shown. Mirrors
|
||||
/// IDEA's `View | Appearance | Navigation Bar` (Top / Status Bar / Don't show).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NavigationBarPosition {
|
||||
/// Above the editor, below the main menu / toolbar.
|
||||
Top,
|
||||
/// In the footer, next to the status bar widgets — the default, matching
|
||||
/// where the bar lived before the three-state choice existed.
|
||||
#[default]
|
||||
StatusBar,
|
||||
/// Not shown at all.
|
||||
Hidden,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AppearanceSettings {
|
||||
@@ -386,6 +420,8 @@ pub struct AppearanceSettings {
|
||||
pub menu_icons: bool,
|
||||
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
|
||||
pub main_menu_style: String,
|
||||
/// A lone Alt press focuses or opens the main menu (IDEA `ide.windows.main.menu.focus.on.alt`).
|
||||
pub main_menu_alt: bool,
|
||||
/// `subpixel` | `greyscale` | `none`
|
||||
pub ide_antialiasing: String,
|
||||
pub editor_antialiasing: String,
|
||||
@@ -393,6 +429,12 @@ pub struct AppearanceSettings {
|
||||
pub show_tool_window_names: bool,
|
||||
pub show_tool_window_badges: bool,
|
||||
pub widescreen_tool_windows: bool,
|
||||
/// View › Appearance › Toolbar; hidden leaves IDEA's compact window header.
|
||||
pub show_main_toolbar: bool,
|
||||
pub show_status_bar: bool,
|
||||
/// View › Appearance › Navigation Bar: the file path breadcrumbs, shown in
|
||||
/// the status bar by default, above the editor, or not at all.
|
||||
pub navigation_bar: NavigationBarPosition,
|
||||
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
|
||||
/// (status bar context menu); widgets not listed use their default.
|
||||
pub status_bar_widgets: BTreeMap<String, bool>,
|
||||
@@ -456,12 +498,16 @@ impl Default for AppearanceSettings {
|
||||
dnd_with_alt_only: false,
|
||||
menu_icons: true,
|
||||
main_menu_style: "hamburger".into(),
|
||||
main_menu_alt: true,
|
||||
ide_antialiasing: "greyscale".into(),
|
||||
editor_antialiasing: "greyscale".into(),
|
||||
show_tool_window_bars: true,
|
||||
show_tool_window_names: false,
|
||||
show_tool_window_badges: true,
|
||||
widescreen_tool_windows: false,
|
||||
show_main_toolbar: true,
|
||||
show_status_bar: true,
|
||||
navigation_bar: NavigationBarPosition::StatusBar,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
}
|
||||
@@ -499,6 +545,9 @@ pub struct EditorSettings {
|
||||
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
|
||||
/// parameters get a colour derived from their name (IDEA's rainbow identifiers).
|
||||
pub semantic_highlighting: bool,
|
||||
/// Docstring markup format whose tags and keywords the Python editor highlights
|
||||
/// (`plain`, `rest`, `epytext`, `google`, `numpy`).
|
||||
pub docstring_format: String,
|
||||
/// Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth.
|
||||
pub rainbow_brackets: bool,
|
||||
pub ligatures: bool,
|
||||
@@ -543,6 +592,7 @@ impl Default for EditorSettings {
|
||||
color_overrides: BTreeMap::new(),
|
||||
custom_schemes: BTreeMap::new(),
|
||||
semantic_highlighting: false,
|
||||
docstring_format: "rest".into(),
|
||||
rainbow_brackets: false,
|
||||
ligatures: false,
|
||||
fallback_font: String::new(),
|
||||
@@ -587,6 +637,15 @@ pub struct FileSettings {
|
||||
pub file_types: BTreeMap<String, FileTypeAssociation>,
|
||||
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
|
||||
pub file_type_overrides: BTreeMap<String, String>,
|
||||
/// Compose detection choices, keyed by project-relative file path.
|
||||
pub compose_file_overrides: BTreeMap<String, ComposeFileOverride>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ComposeFileOverride {
|
||||
Kept,
|
||||
Excluded,
|
||||
}
|
||||
|
||||
/// One language's file name and hashbang patterns on top of its definition.
|
||||
@@ -614,6 +673,7 @@ impl Default for FileSettings {
|
||||
local_history_max_mb: 512,
|
||||
file_types: BTreeMap::new(),
|
||||
file_type_overrides: BTreeMap::new(),
|
||||
compose_file_overrides: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -621,6 +681,8 @@ impl Default for FileSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguagesSettings {
|
||||
/// Explicit plugin formatter, by language id. Empty entries use the normal formatter policy.
|
||||
pub plugin_formatters: BTreeMap<String, String>,
|
||||
pub python: PythonSettings,
|
||||
pub node: NodeSettings,
|
||||
pub php: PhpSettings,
|
||||
@@ -633,11 +695,13 @@ pub struct LanguagesSettings {
|
||||
pub java: JavaSettings,
|
||||
pub ruby: RubySettings,
|
||||
pub markdown: MarkdownSettings,
|
||||
pub arduino: ArduinoSettings,
|
||||
}
|
||||
|
||||
impl Default for LanguagesSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugin_formatters: BTreeMap::new(),
|
||||
python: PythonSettings::default(),
|
||||
node: NodeSettings::default(),
|
||||
php: PhpSettings::default(),
|
||||
@@ -653,10 +717,22 @@ impl Default for LanguagesSettings {
|
||||
java: JavaSettings::default(),
|
||||
ruby: RubySettings::default(),
|
||||
markdown: MarkdownSettings::default(),
|
||||
arduino: ArduinoSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arduino tool discovery paths and default board FQBN, configured in `settings.toml`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ArduinoSettings {
|
||||
pub cli: String,
|
||||
pub clangd: String,
|
||||
pub language_server: String,
|
||||
pub cli_config: String,
|
||||
pub fqbn: String,
|
||||
}
|
||||
|
||||
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -759,6 +835,8 @@ impl Default for PythonSettings {
|
||||
pub struct PhpSettings {
|
||||
/// `php` CLI executable; empty = the one on `PATH`.
|
||||
pub interpreter: String,
|
||||
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
|
||||
pub run_target: String,
|
||||
/// PHP version the language server checks the code against (`7.4`); empty = from
|
||||
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
|
||||
pub language_level: String,
|
||||
@@ -786,6 +864,8 @@ pub struct RubySettings {
|
||||
pub struct NodeSettings {
|
||||
/// `node` executable; empty = the one on `PATH`.
|
||||
pub interpreter: String,
|
||||
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
|
||||
pub run_target: String,
|
||||
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
|
||||
pub package_manager: String,
|
||||
}
|
||||
@@ -870,6 +950,8 @@ pub struct JavaSettings {
|
||||
/// Extra JVM options for the Java language server (jdtls), space-separated
|
||||
/// (VS Code's `java.jdt.ls.vmargs`).
|
||||
pub jdtls_vm_args: String,
|
||||
/// Share one JDT LS across builds in an ordinary project window; other windows remain separate.
|
||||
pub jdtls_shared: bool,
|
||||
/// Build Tools › Maven.
|
||||
pub maven: MavenSettings,
|
||||
/// Build Tools › Gradle.
|
||||
@@ -926,6 +1008,8 @@ pub struct MavenSettings {
|
||||
pub home: String,
|
||||
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
|
||||
pub user_settings: String,
|
||||
/// Toolchains file (`-t`); empty = `~/.m2/toolchains.xml`.
|
||||
pub toolchains: String,
|
||||
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
|
||||
pub local_repository: String,
|
||||
/// Run Maven through the Maven Daemon (`mvnd`) for faster builds. mvnd embeds
|
||||
@@ -937,9 +1021,25 @@ pub struct MavenSettings {
|
||||
/// Thread count of Maven builds (`-T`): a number of threads (`4`) or a core
|
||||
/// multiplier (`2C`, `2.5C`); empty = Maven's default (one thread per module).
|
||||
pub threads: String,
|
||||
/// Active Maven profiles (`-P`), comma-separated; empty leaves profiles to the POM/settings.
|
||||
pub active_profiles: String,
|
||||
}
|
||||
|
||||
impl MavenSettings {
|
||||
/// Settings profiles followed by the Maven tool window's choices.
|
||||
pub fn profiles(&self, choices: &[String]) -> Vec<String> {
|
||||
let mut profiles = Vec::new();
|
||||
for profile in self.active_profiles.split(',').chain(choices.iter().map(String::as_str)).map(str::trim) {
|
||||
if !profile.is_empty() && !profiles.iter().any(|p| p == profile) {
|
||||
profiles.push(profile.to_owned());
|
||||
}
|
||||
}
|
||||
let excluded: Vec<String> =
|
||||
profiles.iter().filter_map(|p| p.strip_prefix(['!', '-']).map(str::to_owned)).collect();
|
||||
profiles.retain(|p| !excluded.contains(p));
|
||||
profiles
|
||||
}
|
||||
|
||||
/// The `-T` value when it is one Maven accepts: a thread count (`4`) or a
|
||||
/// core multiplier with an optional decimal fraction (`1C`, `2.5C`).
|
||||
/// Anything else is dropped: a bad `-T` would make every Maven run fail.
|
||||
@@ -1135,6 +1235,26 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn window_close_settings_default_off_and_round_trip() {
|
||||
for old in [r#"{}"#, r#"{"general":{"confirmExit":false}}"#] {
|
||||
let settings: Settings = serde_json::from_str(old).unwrap();
|
||||
assert!(!settings.general.close_without_welcome);
|
||||
assert!(!settings.general.close_tabs_with_window);
|
||||
}
|
||||
for close_window in [false, true] {
|
||||
for close_tabs in [false, true] {
|
||||
let mut settings = Settings::default();
|
||||
settings.general.close_without_welcome = close_window;
|
||||
settings.general.close_tabs_with_window = close_tabs;
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["general"]["closeWithoutWelcome"], close_window);
|
||||
assert_eq!(json["general"]["closeTabsWithWindow"], close_tabs);
|
||||
assert_eq!(serde_json::from_value::<Settings>(json).unwrap().general, settings.general);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_profile_argument_arrays_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
@@ -1197,6 +1317,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docstring_format_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"editor":{"semanticHighlighting":true}}"#).unwrap();
|
||||
assert_eq!(old.editor.docstring_format, "rest");
|
||||
assert_eq!(Settings::default().editor.docstring_format, "rest");
|
||||
for format in ["plain", "rest", "epytext", "google", "numpy"] {
|
||||
let mut settings = Settings::default();
|
||||
settings.editor.docstring_format = format.into();
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["editor"]["docstringFormat"], format);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.editor.docstring_format, format);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_window_badges_default_on_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
|
||||
@@ -1230,9 +1365,13 @@ mod tests {
|
||||
fn main_menu_style_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
|
||||
assert_eq!(old.appearance.main_menu_style, "hamburger");
|
||||
assert!(old.appearance.main_menu_alt);
|
||||
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
|
||||
assert_eq!(merged.appearance.main_menu_style, "merged");
|
||||
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
|
||||
let off: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuAlt":false}}"#).unwrap();
|
||||
assert!(!off.appearance.main_menu_alt);
|
||||
assert_eq!(serde_json::to_value(off).unwrap()["appearance"]["mainMenuAlt"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1399,6 +1538,24 @@ mod maven_linkage_tests {
|
||||
assert_eq!(threads("2..5C").as_deref(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_profiles_combine_settings_and_tool_window_choices() {
|
||||
let settings = MavenSettings { active_profiles: " a, b, ,a ".into(), ..Default::default() };
|
||||
assert_eq!(settings.profiles(&["!b".into(), "c".into(), "a".into()]), ["a", "!b", "c"]);
|
||||
let settings = MavenSettings { active_profiles: "!a".into(), ..Default::default() };
|
||||
assert_eq!(settings.profiles(&["a".into()]), ["!a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_profiles_and_toolchains_round_trip_and_default_to_empty() {
|
||||
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
|
||||
assert!(old.toolchains.is_empty() && old.active_profiles.is_empty());
|
||||
let settings =
|
||||
MavenSettings { toolchains: "config files/toolchains.xml".into(), active_profiles: "a,b,!c".into(), ..old };
|
||||
let encoded = serde_json::to_string(&settings).unwrap();
|
||||
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_count_round_trips_and_defaults_to_empty() {
|
||||
// Old settings files predate the thread count; `serde(default)` keeps them loadable.
|
||||
@@ -1411,6 +1568,26 @@ mod maven_linkage_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod java_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_jdtls_defaults_off_and_round_trips() {
|
||||
for old in [r#"{}"#, r#"{"jdk":"/jdk"}"#] {
|
||||
let java: JavaSettings = serde_json::from_str(old).unwrap();
|
||||
assert!(!java.jdtls_shared);
|
||||
}
|
||||
for shared in [false, true] {
|
||||
let java = JavaSettings { jdtls_shared: shared, ..JavaSettings::default() };
|
||||
let json = serde_json::to_value(&java).unwrap();
|
||||
assert_eq!(json["jdtlsShared"], serde_json::json!(shared));
|
||||
assert_eq!(serde_json::from_value::<JavaSettings>(json).unwrap(), java);
|
||||
}
|
||||
assert!(serde_json::from_str::<JavaSettings>(r#"{"jdtlsShared":"true"}"#).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashSet;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UpdateSource {
|
||||
#[default]
|
||||
Github,
|
||||
Cnb,
|
||||
}
|
||||
|
||||
impl UpdateSource {
|
||||
pub fn resolve(preference: &str, locale: &str, system_locale: &str) -> Self {
|
||||
match preference {
|
||||
"github" => return Self::Github,
|
||||
"cnb" => return Self::Cnb,
|
||||
_ => {}
|
||||
}
|
||||
let locale = locale.trim();
|
||||
let locale = if matches!(locale, "" | "auto" | "system") { system_locale } else { locale };
|
||||
if locale.split(['-', '_']).next().is_some_and(|base| base.eq_ignore_ascii_case("zh")) {
|
||||
Self::Cnb
|
||||
} else {
|
||||
Self::Github
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateInfo {
|
||||
pub version: String,
|
||||
pub current_version: String,
|
||||
pub notes: Option<String>,
|
||||
pub date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateCheck {
|
||||
pub enabled: bool,
|
||||
pub update: Option<UpdateInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateStatus {
|
||||
/// Unix milliseconds of the last successful check in this process.
|
||||
pub last_checked: Option<f64>,
|
||||
pub checking: bool,
|
||||
}
|
||||
|
||||
/// One schedule and announcement history for the desktop process, independent of its windows.
|
||||
#[derive(Default)]
|
||||
pub struct UpdatePolicy {
|
||||
attempted: Option<Instant>,
|
||||
pub status: UpdateStatus,
|
||||
announced: HashSet<String>,
|
||||
}
|
||||
|
||||
impl UpdatePolicy {
|
||||
pub fn due_in(&self, now: Instant) -> Duration {
|
||||
self.attempted.map_or(Duration::ZERO, |at| CHECK_INTERVAL.saturating_sub(now.saturating_duration_since(at)))
|
||||
}
|
||||
|
||||
pub fn begin(&mut self, now: Instant) {
|
||||
self.attempted = Some(now);
|
||||
self.status.checking = true;
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, checked_at: Option<f64>) {
|
||||
self.status.checking = false;
|
||||
if let Some(at) = checked_at {
|
||||
self.status.last_checked = Some(at);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn announced(&self, version: &str) -> bool {
|
||||
self.announced.contains(version)
|
||||
}
|
||||
|
||||
/// Manual checks always show their result, but also count as an announcement.
|
||||
pub fn announce(&mut self, version: &str) -> bool {
|
||||
self.announced.insert(version.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn automatic_and_manual_checks_resolve_the_same_preferred_source() {
|
||||
for (preference, locale, system, expected) in [
|
||||
("auto", "zh-CN", "en-US", UpdateSource::Cnb),
|
||||
("auto", " ZH_CN ", "en-US", UpdateSource::Cnb),
|
||||
("auto", "system", "zh-CN", UpdateSource::Cnb),
|
||||
("auto", "auto", "zh-TW", UpdateSource::Cnb),
|
||||
("auto", "", "zh", UpdateSource::Cnb),
|
||||
("auto", "en", "zh-CN", UpdateSource::Github),
|
||||
("github", "zh", "zh-CN", UpdateSource::Github),
|
||||
("cnb", "en", "en-US", UpdateSource::Cnb),
|
||||
] {
|
||||
assert_eq!(UpdateSource::resolve(preference, locale, system), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_at_startup_and_every_four_hours_even_after_failure() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
let now = Instant::now();
|
||||
assert_eq!(policy.due_in(now), Duration::ZERO);
|
||||
policy.begin(now);
|
||||
assert!(policy.status.checking);
|
||||
policy.finish(None);
|
||||
assert_eq!(policy.status.last_checked, None);
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL - Duration::from_secs(1)), Duration::from_secs(1));
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::ZERO);
|
||||
policy.begin(now + CHECK_INTERVAL);
|
||||
policy.finish(Some(1234.0));
|
||||
assert_eq!(policy.status.last_checked, Some(1234.0));
|
||||
assert!(!policy.status.checking);
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_checks_reset_the_schedule_without_losing_last_success_on_failure() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
let now = Instant::now();
|
||||
policy.begin(now);
|
||||
policy.finish(Some(1000.0));
|
||||
policy.begin(now + Duration::from_secs(60));
|
||||
policy.finish(None);
|
||||
assert_eq!(policy.status.last_checked, Some(1000.0));
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announcements_are_per_version_across_windows_sources_and_manual_checks() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
assert!(policy.announce("0.1.40"));
|
||||
assert!(!policy.announce("0.1.40"));
|
||||
assert!(policy.announce("0.1.41"));
|
||||
assert!(!policy.announce("0.1.40"));
|
||||
assert!(UpdatePolicy::default().announce("0.1.40"));
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,8 @@ impl Default for PythonDebuggerSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RunSettings {
|
||||
/// Target for configurations without an explicit target. Empty = local.
|
||||
pub default_target: String,
|
||||
pub confirm_rerun: bool,
|
||||
/// Ask before closing a Run tab whose process is still running.
|
||||
pub confirm_terminate: bool,
|
||||
@@ -282,7 +284,13 @@ pub struct RunSettings {
|
||||
|
||||
impl Default for RunSettings {
|
||||
fn default() -> Self {
|
||||
Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 }
|
||||
Self {
|
||||
default_target: String::new(),
|
||||
confirm_rerun: true,
|
||||
confirm_terminate: true,
|
||||
show_on_start: true,
|
||||
termination_timeout_ms: 3000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,5 +24,7 @@ tracing.workspace = true
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
ide-remote.workspace = true
|
||||
ide-run.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
|
||||
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
|
||||
+155
-77
@@ -31,6 +31,7 @@ pub enum Transport {
|
||||
/// A fully resolved adapter command line.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AdapterSpec {
|
||||
pub target: Option<crate::target::TargetMapping>,
|
||||
pub adapter: String,
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
@@ -51,6 +52,7 @@ pub struct AdapterSpec {
|
||||
/// Built-in adapters used when no language definition names `adapter`.
|
||||
fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
let cfg = |command: &str, args: &[&str], transport: &str, hint: &str| DebuggerConfig {
|
||||
target_placement: None,
|
||||
adapter: adapter.to_owned(),
|
||||
command: command.to_owned(),
|
||||
args: args.iter().map(|a| (*a).to_owned()).collect(),
|
||||
@@ -101,6 +103,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"lldb-dap" => {
|
||||
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
|
||||
}
|
||||
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
|
||||
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
|
||||
"java-debug" => cfg(
|
||||
"com.microsoft.java.debug.plugin.jar",
|
||||
@@ -133,6 +136,7 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
|
||||
|
||||
pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
|
||||
AdapterSpec {
|
||||
target: None,
|
||||
adapter: config.adapter.clone(),
|
||||
command: config.command.clone(),
|
||||
args: config.args.clone(),
|
||||
@@ -165,6 +169,11 @@ pub fn parse_address(addr: &str) -> Option<(String, u16)> {
|
||||
}
|
||||
|
||||
/// A port that was free a moment ago on the loopback interface.
|
||||
///
|
||||
/// Inherently racy: another process can bind the port between the listener being
|
||||
/// dropped here and the adapter binding it. Callers that hand the port to a child
|
||||
/// retry on a bind conflict (see [`ProcessLauncher`]), or — better — let the child
|
||||
/// pick `:0` and report the actual port back (as the Kotlin JDWP debuggee does).
|
||||
pub fn free_port() -> std::io::Result<u16> {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
|
||||
Ok(listener.local_addr()?.port())
|
||||
@@ -373,8 +382,8 @@ pub struct AdapterIo {
|
||||
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
|
||||
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
|
||||
|
||||
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
|
||||
const SHARES_STDIO: &str = "delve";
|
||||
/// These debuggees inherit adapter stdio; no DAP event carries their output.
|
||||
const SHARES_STDIO: &[&str] = &["delve", "rdbg"];
|
||||
const DELVE_BANNER: &str = "DAP server listening at:";
|
||||
|
||||
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
|
||||
@@ -393,6 +402,9 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// Probe of a `connect` address before starting the command.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
/// Attempts at a fresh port for a TCP adapter: the one `free_port` hands out can be
|
||||
/// claimed by another process before the adapter binds it, so a new one usually wins.
|
||||
const PORT_ATTEMPTS: u32 = 3;
|
||||
|
||||
impl AdapterLauncher for ProcessLauncher {
|
||||
fn launch(&self, mut spec: AdapterSpec, reuse_port: Option<u16>) -> LaunchFuture {
|
||||
@@ -438,7 +450,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
program_output: None,
|
||||
});
|
||||
}
|
||||
if spec.adapter == DEBUGPY_TOOL {
|
||||
if spec.adapter == DEBUGPY_TOOL && spec.target.is_none() {
|
||||
prepare_debugpy(&mut spec).await?;
|
||||
}
|
||||
if is_plugin_bundle(&spec.command) {
|
||||
@@ -458,87 +470,141 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
.ok_or_else(|| missing(&spec))?
|
||||
}
|
||||
};
|
||||
let port = match spec.transport {
|
||||
// An embedded adapter started without `${port}` listens on its usual address.
|
||||
Transport::Tcp => match &embedded {
|
||||
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
|
||||
_ => Some(free_port()?),
|
||||
},
|
||||
Transport::Stdio => None,
|
||||
};
|
||||
let args = substitute_root(&spec.args, &spec.cwd);
|
||||
let args = match port {
|
||||
Some(port) => substitute_port(&args, port),
|
||||
None => args,
|
||||
};
|
||||
let mut cmd = Command::new(&binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(&args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(&spec.cwd)
|
||||
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let Some(err) = child.stderr.take() {
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
|
||||
}
|
||||
match port {
|
||||
None => {
|
||||
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(stdout),
|
||||
writer: Box::new(stdin),
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
Some(port) => {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
|
||||
}
|
||||
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = child.start_kill();
|
||||
let tail = stderr.lock().join("\n");
|
||||
return Err(if tail.is_empty() {
|
||||
e
|
||||
} else {
|
||||
IdeError::unavailable(format!("{}\n{tail}", e.message))
|
||||
});
|
||||
}
|
||||
let base_args = substitute_root(&spec.args, &spec.cwd);
|
||||
match spec.transport {
|
||||
Transport::Stdio => spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await,
|
||||
Transport::Tcp => {
|
||||
// An embedded adapter started without `${port}` listens on its usual
|
||||
// address; every other TCP adapter gets a port chosen here so it can be
|
||||
// retried if another process takes it before the adapter binds.
|
||||
let fixed = match &embedded {
|
||||
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
|
||||
_ => None,
|
||||
};
|
||||
let (r, w) = stream.into_split();
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: Some(child),
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
let start_timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
|
||||
for attempt in 1..=PORT_ATTEMPTS {
|
||||
let port = match fixed {
|
||||
Some(port) => port,
|
||||
None => free_port()?,
|
||||
};
|
||||
let args = substitute_port(&base_args, port);
|
||||
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
|
||||
Ok(io) => return Ok(io),
|
||||
Err(e) => {
|
||||
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
|
||||
tracing::warn!(
|
||||
target: "ide_dap::adapter",
|
||||
"debug adapter failed to start on port {port}: {}; retrying",
|
||||
e.message
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
unreachable!("the retry loop returns on its last attempt")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts `binary` with `args`, then connects to it when `port` is set (a TCP adapter)
|
||||
/// or hands back its stdio when it is not. The child is stopped if it never accepts.
|
||||
async fn spawn_adapter(
|
||||
spec: &AdapterSpec,
|
||||
binary: &Path,
|
||||
args: &[String],
|
||||
port: Option<u16>,
|
||||
start_timeout: Duration,
|
||||
) -> IdeResult<AdapterIo> {
|
||||
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
|
||||
let mut cmd = Command::new(binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
// Every adapter leads its own process group: the program an adapter launches itself
|
||||
// (java-debug's JVM) joins the group, so a forced stop reaches the whole tree with
|
||||
// one `kill(-pgid)`. On Windows the same coverage comes from the session's job object.
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
cmd.args(args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(&spec.cwd)
|
||||
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let Some(err) = child.stderr.take() {
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
|
||||
}
|
||||
match port {
|
||||
None => {
|
||||
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(stdout),
|
||||
writer: Box::new(stdin),
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
Some(port) => {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
|
||||
}
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), start_timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if spec.adapter == "rdbg"
|
||||
&& let Some(pid) = child.id()
|
||||
{
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
let tail = stderr.lock().join("\n");
|
||||
return Err(if tail.is_empty() {
|
||||
e
|
||||
} else {
|
||||
IdeError::unavailable(format!("{}\n{tail}", e.message))
|
||||
});
|
||||
}
|
||||
};
|
||||
let (r, w) = stream.into_split();
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: Some(child),
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed adapter start that another process grabbing the free port can explain
|
||||
/// (POSIX and Windows "address already in use" texts, or the adapter exiting because
|
||||
/// it could not bind). Anything else is a real start failure and is not retried.
|
||||
fn is_port_race(err: &IdeError) -> bool {
|
||||
let message = err.message.to_ascii_lowercase();
|
||||
message.contains("address already in use")
|
||||
|| message.contains("addrinuse")
|
||||
|| message.contains("only one usage of each socket address")
|
||||
|| message.contains("exited before accepting connections")
|
||||
}
|
||||
|
||||
fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
|
||||
stream: R,
|
||||
name: String,
|
||||
@@ -766,4 +832,16 @@ mod tests {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_conflicts_are_recognised_for_retry() {
|
||||
let in_use = IdeError::unavailable(
|
||||
"debug adapter exited before accepting connections (exit status: 1)\nbind: address already in use",
|
||||
);
|
||||
assert!(is_port_race(&in_use));
|
||||
assert!(is_port_race(&IdeError::unavailable(
|
||||
"Only one usage of each socket address (protocol/network address/port) is normally permitted."
|
||||
)));
|
||||
assert!(!is_port_race(&IdeError::unavailable("failed to start codelldb: file not found")));
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ pub enum Incoming {
|
||||
type Pending = HashMap<i64, (String, oneshot::Sender<Result<Value, DapError>>)>;
|
||||
|
||||
struct Inner {
|
||||
mapping: Option<crate::target::TargetMapping>,
|
||||
out: mpsc::UnboundedSender<Vec<u8>>,
|
||||
pending: Mutex<Pending>,
|
||||
seq: AtomicI64,
|
||||
@@ -106,9 +107,22 @@ impl Connection {
|
||||
/// [`start`](Self::start) that hands lines outside the protocol (an adapter's
|
||||
/// own output on stdout, decoded) to `on_output`.
|
||||
pub fn start_with_output<R, W>(
|
||||
reader: R,
|
||||
writer: W,
|
||||
on_output: Box<dyn Fn(String) + Send + Sync>,
|
||||
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
Self::start_with_target(reader, writer, on_output, None)
|
||||
}
|
||||
|
||||
pub fn start_with_target<R, W>(
|
||||
reader: R,
|
||||
mut writer: W,
|
||||
on_output: Box<dyn Fn(String) + Send + Sync>,
|
||||
target: Option<crate::target::TargetMapping>,
|
||||
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
@@ -117,6 +131,7 @@ impl Connection {
|
||||
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (in_tx, in_rx) = mpsc::unbounded_channel();
|
||||
let inner = Arc::new(Inner {
|
||||
mapping: target,
|
||||
out: out_tx,
|
||||
pending: Mutex::new(HashMap::new()),
|
||||
seq: AtomicI64::new(1),
|
||||
@@ -187,7 +202,11 @@ impl Connection {
|
||||
if self.is_closed() {
|
||||
return Err(DapError::Closed);
|
||||
}
|
||||
let body = serde_json::to_vec(value).map_err(|e| DapError::Protocol(e.to_string()))?;
|
||||
let mut value = value.clone();
|
||||
if let Some(mapping) = self.inner.mapping.as_ref() {
|
||||
mapping.rewrite(&mut value, ide_core::path_mapping::Direction::ToTarget);
|
||||
}
|
||||
let body = serde_json::to_vec(&value).map_err(|e| DapError::Protocol(e.to_string()))?;
|
||||
tracing::trace!(target: "ide_dap::wire", "--> {}", String::from_utf8_lossy(&body));
|
||||
self.inner.out.send(body).map_err(|_| DapError::Closed)
|
||||
}
|
||||
@@ -246,13 +265,16 @@ impl Connection {
|
||||
|
||||
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
let mut msg: Value = match serde_json::from_slice(body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("dap: unparsable message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(mapping) = inner.mapping.as_ref() {
|
||||
mapping.rewrite(&mut msg, ide_core::path_mapping::Direction::ToHost);
|
||||
}
|
||||
match msg.get("type").and_then(Value::as_str) {
|
||||
Some("response") => {
|
||||
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
|
||||
|
||||
+161
-21
@@ -16,6 +16,7 @@ pub mod jdwp;
|
||||
pub mod kotlin;
|
||||
mod kotlin_jdwp;
|
||||
mod session;
|
||||
pub mod target;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
use connection::{Connection, DapError};
|
||||
@@ -282,7 +283,6 @@ impl SessionOwner {
|
||||
}
|
||||
|
||||
const INIT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Redefining many classes in a large JVM can take a while.
|
||||
@@ -305,6 +305,63 @@ pub struct DebugManager {
|
||||
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
|
||||
}
|
||||
|
||||
/// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program,
|
||||
/// arguments and environment: they go on its command line after `--` and into its process.
|
||||
/// In a Bundler project it runs from the bundle, which must have `debug`: the debuggee then
|
||||
/// loads that version, and the gems rdbg needs (irb is not a default gem from Ruby 3.5).
|
||||
fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) -> IdeResult<()> {
|
||||
if config.request != "launch" {
|
||||
return Ok(());
|
||||
}
|
||||
if spec.command == "rdbg"
|
||||
&& let Some(gemfile) = bundler_gemfile(cwd, &config.env)
|
||||
{
|
||||
if !bundles_debug(&gemfile) {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"the bundle of {} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`",
|
||||
gemfile.display()
|
||||
)));
|
||||
}
|
||||
spec.args.splice(0..0, ["exec".to_owned(), "rdbg".to_owned()]);
|
||||
spec.command = "bundle".into();
|
||||
}
|
||||
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
spec.args.push("--".into());
|
||||
// With only a command, Ruby's exec treats it as shell text. Ruby entry points
|
||||
// without arguments go through -S so spaces and metacharacters stay literal.
|
||||
if config.program.is_some() && config.args.is_empty() {
|
||||
spec.args.extend(["ruby", "-S", "--"].map(str::to_owned));
|
||||
}
|
||||
spec.args.extend(config.program.iter().cloned());
|
||||
spec.args.extend(config.args.iter().cloned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Gemfile Bundler uses in `dir`: `BUNDLE_GEMFILE`, else the closest `Gemfile` / `gems.rb` from `dir` up.
|
||||
fn bundler_gemfile(dir: &Path, env: &BTreeMap<String, String>) -> Option<PathBuf> {
|
||||
let gemfile = env
|
||||
.get("BUNDLE_GEMFILE")
|
||||
.cloned()
|
||||
.or_else(|| ide_core::process::host_var_os("BUNDLE_GEMFILE").map(|v| v.to_string_lossy().into_owned()));
|
||||
match gemfile.filter(|g| !g.is_empty()) {
|
||||
Some(gemfile) => Some(dir.join(gemfile)),
|
||||
None => dir.ancestors().flat_map(|d| [d.join("gems.rb"), d.join("Gemfile")]).find(|g| g.is_file()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the lockfile of `gemfile` lists the `debug` gem.
|
||||
fn bundles_debug(gemfile: &Path) -> bool {
|
||||
let lockfile = if gemfile.file_name().is_some_and(|n| n == "gems.rb") {
|
||||
gemfile.with_file_name("gems.locked")
|
||||
} else {
|
||||
let mut name = gemfile.as_os_str().to_owned();
|
||||
name.push(".lock");
|
||||
PathBuf::from(name)
|
||||
};
|
||||
// The bundle's gems are the lines indented by four spaces: ` debug (1.9.2)`.
|
||||
std::fs::read_to_string(lockfile).is_ok_and(|text| text.lines().any(|l| l.starts_with(" debug (")))
|
||||
}
|
||||
|
||||
/// Whether the debugged project depends on the Flutter SDK, so it must go
|
||||
/// through `flutter debug_adapter` instead of the plain Dart one.
|
||||
///
|
||||
@@ -551,11 +608,22 @@ fn remove_delve_binaries(cwd: &Path) {
|
||||
|| s == ".exe"
|
||||
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
|
||||
});
|
||||
if leftover
|
||||
&& entry.file_type().is_ok_and(|t| t.is_file())
|
||||
&& let Err(e) = std::fs::remove_file(entry.path())
|
||||
{
|
||||
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
|
||||
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
// Windows: a just-killed dlv / debuggee can still hold this session's
|
||||
// binary while the previous one's lock is already gone, so one try
|
||||
// leaves exactly the current leftover behind. Retry on a short back-off
|
||||
// to outlast the handle release; a truly busy file is retried into the
|
||||
// next run's sweep anyway.
|
||||
for attempt in 0..5 {
|
||||
match std::fs::remove_file(entry.path()) {
|
||||
Ok(()) => break,
|
||||
Err(e) if attempt == 4 => {
|
||||
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
|
||||
}
|
||||
Err(_) => std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -629,6 +697,17 @@ impl DebugManager {
|
||||
config: LaunchConfig,
|
||||
owner: SessionOwner,
|
||||
custom_command: impl FnOnce(&str) -> Option<String>,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
self.start_prepared(config, owner, custom_command, |_, _| Ok(())).await
|
||||
}
|
||||
|
||||
/// Configure a target adapter before any host tool resolution or launch.
|
||||
pub async fn start_prepared(
|
||||
&self,
|
||||
config: LaunchConfig,
|
||||
owner: SessionOwner,
|
||||
custom_command: impl FnOnce(&str) -> Option<String>,
|
||||
prepare: impl FnOnce(&mut AdapterSpec, &mut Value) -> IdeResult<()>,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
let mut arguments = launch_arguments(&config)?;
|
||||
// An IDE-side marker (never sent to the adapter): the session is a Flutter
|
||||
@@ -643,6 +722,22 @@ impl DebugManager {
|
||||
let dbg = adapter::find_config(&self.languages, &config.adapter)
|
||||
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
|
||||
let mut spec = adapter::resolve(&dbg, cwd.clone());
|
||||
prepare(&mut spec, &mut arguments)?;
|
||||
if spec.target.is_some() {
|
||||
return self
|
||||
.start_session(
|
||||
DebugSessionId::next(),
|
||||
owner,
|
||||
config.name,
|
||||
config.adapter,
|
||||
None,
|
||||
config.request,
|
||||
arguments,
|
||||
spec,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if config.adapter == kotlin::ADAPTER
|
||||
&& let Some(home) = arguments
|
||||
.as_object_mut()
|
||||
@@ -651,6 +746,9 @@ impl DebugManager {
|
||||
{
|
||||
spec.env.push(("JAVA_HOME".into(), home));
|
||||
}
|
||||
if config.adapter == "rdbg" {
|
||||
rdbg_command_line(&mut spec, &config, &cwd)?;
|
||||
}
|
||||
if let Some(command) = custom_command(&spec.command) {
|
||||
spec.command = command;
|
||||
}
|
||||
@@ -788,20 +886,40 @@ impl DebugManager {
|
||||
if request != "launch" && request != "attach" {
|
||||
return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`")));
|
||||
}
|
||||
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
|
||||
let io = self.launcher.launch(spec.clone(), reuse_port).await.map_err(|e| {
|
||||
if spec.target.is_some() {
|
||||
IdeError::new(e.kind, format!("{}\nargv: {:?} {:?}", e.message, spec.command, spec.args))
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
let stderr = io.stderr.clone();
|
||||
let (conn, incoming) = {
|
||||
let stderr = stderr.clone();
|
||||
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
|
||||
Connection::start_with_target(
|
||||
io.reader,
|
||||
io.writer,
|
||||
Box::new(move |line| adapter::push_line(&stderr, line)),
|
||||
spec.target.clone(),
|
||||
)
|
||||
};
|
||||
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
|
||||
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
|
||||
.flatten();
|
||||
let session =
|
||||
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
|
||||
// rdbg execs the debuggee in its own process and sends no DAP process event.
|
||||
if let Some(pid) = rdbg_pid {
|
||||
session.debuggee_pid.store(i64::from(pid), Ordering::Release);
|
||||
}
|
||||
if let Some(mut output) = io.program_output {
|
||||
session.output_drained.send_replace(false);
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some((category, output)) = output.recv().await {
|
||||
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
|
||||
}
|
||||
session.output_drained.send_replace(true);
|
||||
});
|
||||
}
|
||||
self.sessions.lock().insert(id, session.clone());
|
||||
@@ -814,11 +932,19 @@ impl DebugManager {
|
||||
});
|
||||
tokio::spawn(session::event_loop(self.me.clone(), session.clone(), incoming));
|
||||
|
||||
match self.configure(&session, arguments).await {
|
||||
let configured = tokio::select! {
|
||||
biased;
|
||||
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
result = self.configure(&session, arguments) => result,
|
||||
};
|
||||
match configured {
|
||||
Ok(()) => Ok(id),
|
||||
Err(e) => {
|
||||
let e = if session.conn.is_closed() {
|
||||
let mut msg = format!("debug adapter `{}` exited during startup", session.adapter);
|
||||
if session.spec.target.is_some() {
|
||||
msg.push_str(&format!("\nargv: {:?} {:?}", session.spec.command, session.spec.args));
|
||||
}
|
||||
let tail = stderr.lock().join("\n");
|
||||
if !tail.is_empty() {
|
||||
msg.push_str(":\n");
|
||||
@@ -829,6 +955,17 @@ impl DebugManager {
|
||||
msg.push_str(hint);
|
||||
}
|
||||
IdeError::unavailable(msg)
|
||||
} else if session.spec.target.is_some() {
|
||||
IdeError::new(
|
||||
e.kind,
|
||||
format!(
|
||||
"{}\nargv: {:?} {:?}\n{}",
|
||||
e.message,
|
||||
session.spec.command,
|
||||
session.spec.args,
|
||||
stderr.lock().join("\n")
|
||||
),
|
||||
)
|
||||
} else {
|
||||
e
|
||||
};
|
||||
@@ -866,8 +1003,8 @@ impl DebugManager {
|
||||
"pathFormat": "path",
|
||||
"supportsVariableType": true,
|
||||
"supportsVariablePaging": false,
|
||||
"supportsRunInTerminalRequest": true,
|
||||
"supportsStartDebuggingRequest": true,
|
||||
"supportsRunInTerminalRequest": s.spec.target.is_none(),
|
||||
"supportsStartDebuggingRequest": s.spec.target.is_none(),
|
||||
"supportsProgressReporting": true,
|
||||
"supportsInvalidatedEvent": false,
|
||||
"supportsMemoryReferences": false,
|
||||
@@ -902,7 +1039,9 @@ impl DebugManager {
|
||||
arguments["hostName"] = json!("127.0.0.1");
|
||||
arguments["port"] = json!(port);
|
||||
}
|
||||
// Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet.
|
||||
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
|
||||
// failure; Stop cancels configuration and a closed connection fails the pending reply.
|
||||
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
|
||||
let launch = s.conn.send_request(request, arguments)?;
|
||||
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
|
||||
let mut initialized = s.initialized.subscribe();
|
||||
@@ -914,7 +1053,6 @@ impl DebugManager {
|
||||
launch_result = Some(());
|
||||
false
|
||||
}
|
||||
_ = tokio::time::sleep(LAUNCH_TIMEOUT) => return Err(DapError::Timeout(s.request.clone()).into()),
|
||||
};
|
||||
if !got_initialized {
|
||||
// Launched before `initialized`; give the adapter a moment to send it.
|
||||
@@ -972,12 +1110,7 @@ impl DebugManager {
|
||||
}
|
||||
s.configured.store(true, Ordering::Release);
|
||||
if launch_result.is_none() {
|
||||
match tokio::time::timeout(LAUNCH_TIMEOUT, launch).await {
|
||||
Ok(r) => {
|
||||
r?;
|
||||
}
|
||||
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
|
||||
}
|
||||
launch.await?;
|
||||
}
|
||||
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
|
||||
relay.release();
|
||||
@@ -997,13 +1130,18 @@ impl DebugManager {
|
||||
|
||||
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
|
||||
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
|
||||
if terminate {
|
||||
crate::session::remember_debuggee(s).await;
|
||||
}
|
||||
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
|
||||
// ends that DAP session, so every line the program writes while shutting down is lost.
|
||||
// Ask the program to exit ourselves and keep the adapter attached until it has drained
|
||||
// the console (`terminated`), as IDEA does with the process it owns.
|
||||
let pid = s.debuggee_pid.load(Ordering::Acquire);
|
||||
let own_terminate = terminate && s.request == "launch" && pid > 0;
|
||||
let graceful = terminate && s.request == "launch" && s.supports("supportsTerminateRequest");
|
||||
// rdbg's terminate exits the Ruby process without terminating its children.
|
||||
let graceful =
|
||||
terminate && s.request == "launch" && s.adapter != "rdbg" && s.supports("supportsTerminateRequest");
|
||||
if !s.conn.is_closed() {
|
||||
if graceful {
|
||||
let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await;
|
||||
@@ -1022,7 +1160,7 @@ impl DebugManager {
|
||||
if own_terminate && !graceful {
|
||||
// Signal before anything else: waiting on a request the adapter may not answer
|
||||
// would delay the shutdown the console is supposed to show.
|
||||
crate::session::signal_debuggee(s, false);
|
||||
crate::session::signal_debuggee(s, false).await;
|
||||
}
|
||||
if graceful {
|
||||
// java-debug sends `terminated` only after it has drained the program's console
|
||||
@@ -1659,9 +1797,11 @@ impl DebugManager {
|
||||
}
|
||||
|
||||
async fn stop_tree(&self, session: Arc<Session>) -> IdeResult<()> {
|
||||
session.startup_cancel.cancel();
|
||||
let children: Vec<Arc<Session>> =
|
||||
self.live_sessions().into_iter().filter(|c| c.parent == Some(session.id)).collect();
|
||||
for child in children {
|
||||
child.startup_cancel.cancel();
|
||||
self.shutdown(&child, true).await;
|
||||
}
|
||||
self.shutdown(&session, true).await;
|
||||
|
||||
+259
-28
@@ -45,9 +45,18 @@ pub(crate) struct Session {
|
||||
pub debuggees: Mutex<Vec<Child>>,
|
||||
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
|
||||
pub program: Mutex<Option<ProgramTerminal>>,
|
||||
/// The job object over the adapter and everything it started: the program an adapter
|
||||
/// like java-debug launches is its child, so a forced stop (and the session's
|
||||
/// cleanup) ends the whole tree even when the adapter exited first and the reported
|
||||
/// pid no longer names it. `None` when the assignment failed; the kill then falls
|
||||
/// back to the captured process tree.
|
||||
#[cfg(windows)]
|
||||
pub job: Mutex<Option<ide_core::process::Job>>,
|
||||
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
|
||||
pub configured: AtomicBool,
|
||||
pub initialized: watch::Sender<bool>,
|
||||
pub startup_cancel: ide_core::CancellationToken,
|
||||
pub output_drained: watch::Sender<bool>,
|
||||
pub terminated: AtomicBool,
|
||||
/// Set by a second Stop: the one waiting for the program kills it instead.
|
||||
pub stop_force: AtomicBool,
|
||||
@@ -57,6 +66,8 @@ pub(crate) struct Session {
|
||||
/// adapter may report the launcher shell, whose child is the program).
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
|
||||
#[cfg(windows)]
|
||||
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
|
||||
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
|
||||
pub pending_restore: Mutex<PendingRestore>,
|
||||
/// Adapter breakpoint id → (file, index in the stored list).
|
||||
@@ -87,6 +98,10 @@ impl Session {
|
||||
port: Option<u16>,
|
||||
child: Option<Child>,
|
||||
) -> Self {
|
||||
// Before `child` moves into the session: the adapter is the root of the job, so
|
||||
// everything it starts (java-debug's JVM) is in there by inheritance.
|
||||
#[cfg(windows)]
|
||||
let job = child.as_ref().and_then(tokio::process::Child::id).and_then(ide_core::process::Job::for_pid);
|
||||
Self {
|
||||
id,
|
||||
owner,
|
||||
@@ -101,12 +116,18 @@ impl Session {
|
||||
adapter_process: Mutex::new(child),
|
||||
debuggees: Mutex::new(Vec::new()),
|
||||
program: Mutex::new(None),
|
||||
#[cfg(windows)]
|
||||
job: Mutex::new(job),
|
||||
configured: AtomicBool::new(false),
|
||||
initialized: watch::channel(false).0,
|
||||
startup_cancel: ide_core::CancellationToken::new(),
|
||||
output_drained: watch::channel(true).0,
|
||||
terminated: AtomicBool::new(false),
|
||||
stop_force: AtomicBool::new(false),
|
||||
debuggee_pid: AtomicI64::new(0),
|
||||
stop_pids: parking_lot::Mutex::new(Vec::new()),
|
||||
#[cfg(windows)]
|
||||
stop_tree: Mutex::new(None),
|
||||
pending_restore: Mutex::default(),
|
||||
breakpoint_ids: Mutex::new(HashMap::new()),
|
||||
resolved_paths: Mutex::new(HashMap::new()),
|
||||
@@ -274,7 +295,30 @@ impl Session {
|
||||
/// Kills the adapter and every debuggee we started.
|
||||
pub fn kill_processes(&self) {
|
||||
self.kotlin_relay.lock().take();
|
||||
#[cfg(unix)]
|
||||
if self.request == "launch" {
|
||||
for group in launch_groups(self) {
|
||||
ide_core::process::kill_tree_of(group as u32);
|
||||
}
|
||||
}
|
||||
// The job first: it ends the adapter and everything it started (the program
|
||||
// java-debug launched is its child), members included that the stop's captured
|
||||
// tree never saw. An empty job is unaffected, and whatever survives this session
|
||||
// dies when the handle closes (`KILL_ON_JOB_CLOSE`).
|
||||
#[cfg(windows)]
|
||||
if let Some(job) = self.job.lock().as_ref() {
|
||||
job.terminate();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Some(tree) = self.stop_tree.lock().as_ref() {
|
||||
tree.terminate();
|
||||
}
|
||||
if let Some(mut c) = self.adapter_process.lock().take() {
|
||||
if self.adapter == "rdbg"
|
||||
&& let Some(pid) = c.id()
|
||||
{
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
}
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
|
||||
@@ -282,6 +326,9 @@ impl Session {
|
||||
}
|
||||
if let Some(mut program) = self.program.lock().take() {
|
||||
let _ = program.child.kill();
|
||||
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
|
||||
let _ = program.child.wait();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,6 +336,21 @@ impl Session {
|
||||
/// How long Stop waits for a launched program to exit before reporting the session ended.
|
||||
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
|
||||
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
|
||||
return;
|
||||
}
|
||||
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
|
||||
s.stop_tree.lock().get_or_insert(tree);
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = s;
|
||||
}
|
||||
|
||||
/// The program and everything it started: an adapter may report the launcher shell, whose
|
||||
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
|
||||
#[cfg(unix)]
|
||||
@@ -318,18 +380,36 @@ fn process_tree(pid: i32) -> Vec<i32> {
|
||||
/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session:
|
||||
/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid
|
||||
/// tree, because the reported pid may die before its program does.
|
||||
pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
let pid = s.debuggee_pid.load(Ordering::Acquire);
|
||||
if pid <= 0 {
|
||||
pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
if s.request != "launch" {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let pid = s.debuggee_pid.load(Ordering::Acquire);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut known = s.stop_pids.lock();
|
||||
if known.is_empty() {
|
||||
*known = process_tree(pid as i32);
|
||||
let needs_tree = pid > 0 && s.stop_pids.lock().is_empty();
|
||||
if needs_tree {
|
||||
let tree = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
|
||||
*s.stop_pids.lock() = tree;
|
||||
}
|
||||
let known = s.stop_pids.lock();
|
||||
let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
|
||||
if force {
|
||||
// A forced stop kills the launch: every root the session spawned leads its own
|
||||
// process group (`own_group` at spawn), so one signal reaches the program and
|
||||
// everything it started, even processes born after the snapshot below was
|
||||
// taken — the adapter's program is in the adapter's group. The session's own
|
||||
// JVM roots (Kotlin) and `runInTerminal` programs lead groups of their own.
|
||||
// The roots are unreaped until the session drops them, so their ids (and the
|
||||
// groups named by them) cannot have been reused yet.
|
||||
for group in launch_groups(s) {
|
||||
// SAFETY: plain syscalls on the processes this session started.
|
||||
if unsafe { libc::kill(-group, libc::SIGKILL) } != 0 {
|
||||
unsafe { libc::kill(group, libc::SIGKILL) };
|
||||
}
|
||||
}
|
||||
}
|
||||
// Children first: the shell that started the program would otherwise reparent it.
|
||||
for p in known.iter().rev() {
|
||||
// SAFETY: plain syscall on the processes this session started.
|
||||
@@ -340,17 +420,53 @@ pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut cmd = std::process::Command::new("taskkill");
|
||||
cmd.args(["/PID", &pid.to_string(), "/T"]);
|
||||
// Console programs have no external graceful signal; the adapter gets its chance
|
||||
// first. The job ends the program and everything it started, members included
|
||||
// that the captured tree never saw.
|
||||
if force {
|
||||
cmd.arg("/F");
|
||||
if let Some(job) = s.job.lock().as_ref() {
|
||||
job.terminate();
|
||||
}
|
||||
// A separately spawned program may have rejected assignment to the adapter's job.
|
||||
if let Some(tree) = s.stop_tree.lock().as_ref() {
|
||||
tree.terminate();
|
||||
}
|
||||
}
|
||||
let _ = cmd.status();
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = (pid, force);
|
||||
}
|
||||
|
||||
/// The process groups the session spawned roots for: the adapter's (the program it
|
||||
/// launched is in it), the JVMs the session started itself, the `runInTerminal` program.
|
||||
#[cfg(unix)]
|
||||
fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
|
||||
let mut groups = Vec::new();
|
||||
let mut push = |id: Option<u32>| {
|
||||
if let Ok(pid) = libc::pid_t::try_from(id.unwrap_or(0)) {
|
||||
groups.push(pid);
|
||||
}
|
||||
};
|
||||
push(s.adapter_process.lock().as_ref().and_then(tokio::process::Child::id));
|
||||
for child in s.debuggees.lock().iter() {
|
||||
push(child.id());
|
||||
}
|
||||
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
|
||||
groups.retain(|g| *g > 0);
|
||||
groups
|
||||
}
|
||||
|
||||
/// Whether the launch is still running: the stop's captured tree when there is one,
|
||||
/// otherwise the session's job (the adapter and everything it started).
|
||||
#[cfg(windows)]
|
||||
fn launch_running(s: &Arc<Session>) -> bool {
|
||||
if let Some(tree) = s.stop_tree.lock().as_ref() {
|
||||
tree.is_running()
|
||||
} else {
|
||||
s.job.lock().as_ref().is_some_and(|job| job.is_running())
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
|
||||
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
|
||||
if !terminate || s.request != "launch" {
|
||||
@@ -364,25 +480,39 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::time::Instant;
|
||||
let targets = {
|
||||
let known = s.stop_pids.lock();
|
||||
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
|
||||
};
|
||||
let mut targets = s.stop_pids.lock().clone();
|
||||
if targets.is_empty() {
|
||||
targets = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
|
||||
}
|
||||
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
|
||||
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
|
||||
while alive() {
|
||||
if s.stop_force.swap(false, Ordering::AcqRel) {
|
||||
signal_debuggee(s, true);
|
||||
signal_debuggee(s, true).await;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// The program ignored the terminate: do not leave it running.
|
||||
signal_debuggee(s, true);
|
||||
signal_debuggee(s, true).await;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
{
|
||||
remember_debuggee(s).await;
|
||||
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
|
||||
// The captured tree is the oracle when it was captured; otherwise the job (every
|
||||
// process the launch started) is, so a failed capture can no longer end the wait
|
||||
// while the program still holds its port.
|
||||
while launch_running(s) {
|
||||
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
|
||||
signal_debuggee(s, true).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = pid;
|
||||
}
|
||||
@@ -448,6 +578,11 @@ pub(crate) async fn event_loop(
|
||||
Incoming::Closed => break,
|
||||
}
|
||||
}
|
||||
// rdbg closes DAP before Ruby's remaining at_exit handlers print the test summary.
|
||||
if session.adapter == "rdbg" && session.request == "launch" {
|
||||
let mut drained = session.output_drained.subscribe();
|
||||
let _ = tokio::time::timeout(DEBUGGEE_EXIT_WAIT, drained.wait_for(|done| *done)).await;
|
||||
}
|
||||
if let Some(mgr) = manager.upgrade() {
|
||||
mgr.session_closed(&session);
|
||||
}
|
||||
@@ -565,6 +700,10 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
}
|
||||
|
||||
fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, command: &str, args: Value) {
|
||||
if s.spec.target.is_some() {
|
||||
let _ = s.conn.respond(seq, command, Err("This debugger does not support plugin run targets".into()));
|
||||
return;
|
||||
}
|
||||
match command {
|
||||
"runInTerminal" => {
|
||||
let result = run_in_terminal(s, &args);
|
||||
@@ -588,15 +727,14 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
|
||||
|
||||
/// A `runInTerminal` program and the pseudo-terminal it runs in.
|
||||
pub(crate) struct ProgramTerminal {
|
||||
writer: Box<dyn std::io::Write + Send>,
|
||||
writer: ide_core::input::InputWriter,
|
||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
_master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
impl ProgramTerminal {
|
||||
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
|
||||
self.writer.write_all(text.as_bytes())?;
|
||||
self.writer.flush()
|
||||
self.writer.write(text.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +754,16 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
|
||||
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
let wait_writable = ide_core::input::nonblocking(
|
||||
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
|
||||
)
|
||||
.map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
let wait_readable = ide_core::input::readable(
|
||||
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
|
||||
)
|
||||
.map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let mut cmd = CommandBuilder::new(program);
|
||||
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
|
||||
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
|
||||
@@ -641,12 +789,39 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
};
|
||||
}
|
||||
}
|
||||
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
let mut child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
// The program joins the session's job, so Stop reaches what it starts (its children),
|
||||
// though only the program itself is the session's child.
|
||||
#[cfg(windows)]
|
||||
if let Some(pid) = child.process_id()
|
||||
&& let Some(job) = s.job.lock().as_ref()
|
||||
{
|
||||
job.assign_pid(pid);
|
||||
}
|
||||
drop(pair.slave);
|
||||
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let writer = ide_core::input::InputWriter::new(
|
||||
format!("openidea-debug-input-{}", s.id.0),
|
||||
writer,
|
||||
#[cfg(unix)]
|
||||
wait_writable,
|
||||
|e| {
|
||||
tracing::warn!("debug input: {e}");
|
||||
},
|
||||
)
|
||||
.inspect_err(|_| {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|e| format!("terminal input worker: {e}"))?;
|
||||
let pid = child.process_id();
|
||||
forward_terminal_output(s, reader);
|
||||
forward_terminal_output(
|
||||
s,
|
||||
reader,
|
||||
#[cfg(unix)]
|
||||
wait_readable,
|
||||
);
|
||||
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
|
||||
let _ = previous.child.kill();
|
||||
}
|
||||
@@ -655,7 +830,11 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
}
|
||||
|
||||
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
|
||||
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
|
||||
fn forward_terminal_output(
|
||||
s: &Session,
|
||||
mut reader: Box<dyn std::io::Read + Send>,
|
||||
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
|
||||
) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
|
||||
@@ -663,8 +842,27 @@ fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(0) => break,
|
||||
Ok(n) => pending.extend_from_slice(&buf[..n]),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
#[cfg(unix)]
|
||||
if let Err(e) = wait_readable()
|
||||
&& e.kind() != std::io::ErrorKind::Interrupted
|
||||
{
|
||||
tracing::warn!(%session, "debug terminal read: {e}");
|
||||
break;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::thread::sleep(std::time::Duration::from_millis(4));
|
||||
continue;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
|
||||
Err(e) => {
|
||||
tracing::warn!(%session, "debug terminal read: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
|
||||
let mut valid = match std::str::from_utf8(&pending) {
|
||||
@@ -726,7 +924,6 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
|
||||
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
|
||||
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
let port = crate::adapter::free_port()?;
|
||||
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
|
||||
let strings = |key: &str| -> Vec<String> {
|
||||
args.get(key)
|
||||
@@ -739,7 +936,9 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
|
||||
let mut cmd = tokio::process::Command::new(java);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(strings("vmArguments"))
|
||||
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
|
||||
// Let the JVM pick its own port (`:0`) and report it below: pre-picking a free
|
||||
// port here races with other processes that may take it before the JVM binds.
|
||||
.arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0")
|
||||
.arg("-cp")
|
||||
.arg(classpath);
|
||||
let modules = strings("modulePaths");
|
||||
@@ -766,20 +965,33 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
|
||||
}
|
||||
}
|
||||
}
|
||||
// A root the session spawns itself: its own process group on Unix (`kill(-pgid)` in a
|
||||
// forced stop), the session's job on Windows, so a stop reaches what it forks.
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
let mut child = cmd.spawn()?;
|
||||
#[cfg(windows)]
|
||||
if let Some(pid) = child.id()
|
||||
&& let Some(job) = s.job.lock().as_ref()
|
||||
{
|
||||
job.assign_pid(pid);
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
forward_output(s, err, "stderr");
|
||||
}
|
||||
let mut stdout =
|
||||
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
// The JVM chose the port (`:0` above) and reports it here; reading it back removes
|
||||
// the free-port race window entirely.
|
||||
let port = tokio::time::timeout(Duration::from_secs(15), async {
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if stdout.read_line(&mut line).await? == 0 {
|
||||
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
|
||||
}
|
||||
if line.contains("Listening for transport dt_socket") {
|
||||
return Ok(());
|
||||
return parse_jdwp_port(&line).ok_or_else(|| {
|
||||
ide_core::IdeError::unavailable(format!("JVM reported an unreadable debug port: {}", line.trim()))
|
||||
});
|
||||
}
|
||||
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
|
||||
}
|
||||
@@ -793,3 +1005,22 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
|
||||
args["timeout"] = json!(10000);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The port a JVM chose, from `Listening for transport dt_socket at address: <port>`
|
||||
/// (some JVMs print `host:port` or `[::1]:port`, so take what follows the last colon).
|
||||
fn parse_jdwp_port(line: &str) -> Option<u16> {
|
||||
line.rsplit_once("address:")?.1.trim().rsplit(':').next()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_jdwp_port;
|
||||
|
||||
#[test]
|
||||
fn reads_the_port_the_jvm_chose() {
|
||||
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 51234"), Some(51234));
|
||||
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 127.0.0.1:51234"), Some(51234));
|
||||
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
|
||||
assert_eq!(parse_jdwp_port("unrelated output"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! DAP path fields only: output, expressions, environment and program arguments are opaque.
|
||||
use ide_core::path_mapping::{Direction, PathMapping, file_path, file_uri};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TargetMapping {
|
||||
pub paths: PathMapping,
|
||||
pub external_prefix: String,
|
||||
}
|
||||
|
||||
impl TargetMapping {
|
||||
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
|
||||
match value {
|
||||
Value::Array(items) => items.iter_mut().for_each(|v| self.rewrite(v, direction)),
|
||||
Value::Object(fields) => {
|
||||
for (key, value) in fields {
|
||||
if key == "pathMappings" {
|
||||
continue;
|
||||
}
|
||||
if matches!(key.as_str(), "path" | "program" | "cwd" | "localRoot" | "remoteRoot") {
|
||||
if let Some(path) = value.as_str() {
|
||||
if matches!(direction, Direction::ToTarget) && path.starts_with(&self.external_prefix) {
|
||||
if let Some(decoded) =
|
||||
file_path(&format!("file:///{}", path.trim_start_matches(&self.external_prefix)))
|
||||
{
|
||||
*value = decoded.into();
|
||||
}
|
||||
} else if let Some(mapped) = self.paths.map(path, direction) {
|
||||
*value = mapped.into();
|
||||
} else if matches!(direction, Direction::ToHost) && key == "path" && path.starts_with('/') {
|
||||
*value = format!(
|
||||
"{}{}",
|
||||
self.external_prefix,
|
||||
file_uri(path).trim_start_matches("file:///")
|
||||
)
|
||||
.into();
|
||||
}
|
||||
}
|
||||
} else if !matches!(key.as_str(), "args" | "env" | "output" | "expression" | "value") {
|
||||
self.rewrite(value, direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn maps_protocol_paths_without_changing_user_data_or_adapter_mappings() {
|
||||
let m = TargetMapping {
|
||||
paths: PathMapping::new("C:/project", "/app"),
|
||||
external_prefix: "openidea-target://test/0/".into(),
|
||||
};
|
||||
let mut request = json!({"arguments":{"source":{"path":"C:/project/a.py"},"program":"C:/project/a.py","cwd":"C:/project", "args":["C:/project/text"],"env":{"path":"C:/project/text"},"pathMappings":[{"localRoot":"/app","remoteRoot":"/app"}]}});
|
||||
m.rewrite(&mut request, Direction::ToTarget);
|
||||
assert_eq!(request["arguments"]["source"]["path"], "/app/a.py");
|
||||
assert_eq!(request["arguments"]["cwd"], "/app");
|
||||
assert_eq!(request["arguments"]["args"][0], "C:/project/text");
|
||||
assert_eq!(request["arguments"]["env"]["path"], "C:/project/text");
|
||||
let mut response = json!({"body":{"stackFrames":[{"source":{"path":"/app/a.py"}},{"source":{"path":"/usr/lib/a.py"}}],"sources":[{"path":"/app/b.py"}],"output":"/app/a.py"}});
|
||||
m.rewrite(&mut response, Direction::ToHost);
|
||||
assert_eq!(response["body"]["stackFrames"][0]["source"]["path"], "C:\\project\\a.py");
|
||||
assert_eq!(response["body"]["sources"][0]["path"], "C:\\project\\b.py");
|
||||
assert_eq!(response["body"]["stackFrames"][1]["source"]["path"], "openidea-target://test/0/usr/lib/a.py");
|
||||
assert_eq!(response["body"]["output"], "/app/a.py");
|
||||
}
|
||||
}
|
||||
+408
-1
@@ -14,6 +14,93 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
type Log = Arc<Mutex<Vec<Value>>>;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[ignore = "child process for Windows stop tests"]
|
||||
fn windows_debuggee_process() {
|
||||
if std::env::var_os("OPENIDEA_TEST_DEBUGGEE").is_none() {
|
||||
return;
|
||||
}
|
||||
if std::env::var("OPENIDEA_TEST_DEBUGGEE").unwrap() == "wrapper" {
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
let mut child = ide_core::process::prepare(&mut command)
|
||||
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
|
||||
.env("OPENIDEA_TEST_DEBUGGEE", "leaf")
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let _ = child.wait();
|
||||
return;
|
||||
}
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
println!("LISTENING {}", listener.local_addr().unwrap().port());
|
||||
std::thread::sleep(Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn windows_stop_releases_the_debuggees_port_before_returning() {
|
||||
use std::io::BufRead;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
struct Child(std::process::Child, Option<ide_core::process::ProcessTree>);
|
||||
impl Drop for Child {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tree) = &self.1 {
|
||||
tree.terminate();
|
||||
}
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
for (second_stop, wrapper, supports_terminate) in
|
||||
[(false, false, false), (true, false, false), (true, true, false), (true, true, true)]
|
||||
{
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
ide_core::process::prepare(&mut command)
|
||||
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
|
||||
.env("OPENIDEA_TEST_DEBUGGEE", if wrapper { "wrapper" } else { "leaf" })
|
||||
.stdout(std::process::Stdio::piped());
|
||||
let mut child = Child(command.spawn().unwrap(), None);
|
||||
let port: u16 = std::io::BufReader::new(child.0.stdout.take().unwrap())
|
||||
.lines()
|
||||
.map(Result::unwrap)
|
||||
.find_map(|line| line.split("LISTENING ").nth(1).and_then(|p| p.parse().ok()))
|
||||
.unwrap();
|
||||
let pid = child.0.id();
|
||||
child.1 = Some(ide_core::process::ProcessTree::capture(pid));
|
||||
let h = harness(Script { debuggee_pid: Some(pid), supports_terminate, ..Default::default() });
|
||||
let id = h.start(config()).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
while session.debuggee_pid.load(Ordering::Acquire) != i64::from(pid) {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let stop = tokio::spawn({
|
||||
let manager = h.manager.clone();
|
||||
async move { manager.stop(id).await }
|
||||
});
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while session.stop_tree.lock().is_none() && !stop.is_finished() {
|
||||
assert!(std::time::Instant::now() < deadline, "Stop did not capture the program tree");
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(!stop.is_finished(), "Stop must wait for the live program");
|
||||
if wrapper {
|
||||
assert!(session.stop_tree.lock().is_some());
|
||||
child.0.kill().unwrap();
|
||||
child.0.wait().unwrap();
|
||||
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the child still owns its port");
|
||||
}
|
||||
if second_stop {
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
let limit = if second_stop { 10 } else { 25 };
|
||||
tokio::time::timeout(Duration::from_secs(limit), stop).await.unwrap().unwrap().unwrap();
|
||||
assert!(child.0.try_wait().unwrap().is_some(), "Stop returned with the program alive");
|
||||
let _listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("Stop returned with its port occupied");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Script {
|
||||
/// Send a `runInTerminal` reverse request when `launch` arrives.
|
||||
@@ -33,6 +120,12 @@ struct Script {
|
||||
supports_terminate: bool,
|
||||
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
|
||||
hold_set_breakpoints: usize,
|
||||
initialized_delay: Duration,
|
||||
launch_delay: Duration,
|
||||
/// Leave startup pending before `initialized` (false) or after configuration (true).
|
||||
pending_startup: Option<bool>,
|
||||
startup_error: Option<&'static str>,
|
||||
close_during_startup: bool,
|
||||
}
|
||||
|
||||
impl Default for Script {
|
||||
@@ -47,6 +140,11 @@ impl Default for Script {
|
||||
output_after_terminate: false,
|
||||
supports_terminate: true,
|
||||
hold_set_breakpoints: 0,
|
||||
initialized_delay: Duration::ZERO,
|
||||
launch_delay: Duration::ZERO,
|
||||
pending_startup: None,
|
||||
startup_error: None,
|
||||
close_during_startup: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +218,15 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
})
|
||||
),
|
||||
"launch" | "attach" => {
|
||||
if script.close_during_startup {
|
||||
return;
|
||||
}
|
||||
if let Some(message) = script.startup_error {
|
||||
send!(
|
||||
json!({"type": "response", "request_seq": msg["seq"], "command": msg["command"], "success": false, "message": message})
|
||||
);
|
||||
continue;
|
||||
}
|
||||
pending_launch = msg["seq"].as_i64();
|
||||
if let Some(args) = &script.run_in_terminal {
|
||||
send!(
|
||||
@@ -131,7 +238,10 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}})
|
||||
);
|
||||
}
|
||||
send!(json!({"type": "event", "event": "initialized"}));
|
||||
if script.pending_startup != Some(false) {
|
||||
tokio::time::sleep(script.initialized_delay).await;
|
||||
send!(json!({"type": "event", "event": "initialized"}));
|
||||
}
|
||||
}
|
||||
"setBreakpoints" => {
|
||||
held.push(msg);
|
||||
@@ -182,6 +292,10 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
if !script.ignore_configuration_done {
|
||||
ok!(msg, Value::Null);
|
||||
}
|
||||
if script.pending_startup == Some(true) {
|
||||
continue;
|
||||
}
|
||||
tokio::time::sleep(script.launch_delay).await;
|
||||
if let Some(l) = pending_launch.take() {
|
||||
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
|
||||
}
|
||||
@@ -351,6 +465,66 @@ fn bp(line: u32) -> Breakpoint {
|
||||
Breakpoint { line, condition: None, hit_condition: None, log_message: None, enabled: true }
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn slow_launch_and_attach_follow_adapter_startup() {
|
||||
for request in ["launch", "attach"] {
|
||||
for before_initialized in [true, false] {
|
||||
let delay = Duration::from_secs(125);
|
||||
let h = harness(Script {
|
||||
initialized_delay: if before_initialized { delay } else { Duration::ZERO },
|
||||
launch_delay: if before_initialized { Duration::ZERO } else { delay },
|
||||
..Script::default()
|
||||
});
|
||||
let config = LaunchConfig { request: request.into(), ..config() };
|
||||
let id = h.start(config).await.expect("a slow adapter can finish starting");
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
assert!(!h.commands().iter().any(|c| c == "terminate" || c == "disconnect"));
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn stop_cancels_pending_launch_and_attach() {
|
||||
for request in ["launch", "attach"] {
|
||||
for configured in [false, true] {
|
||||
let h = harness(Script { pending_startup: Some(configured), ..Script::default() });
|
||||
let config = LaunchConfig { request: request.into(), ..config() };
|
||||
let start = h.start(config);
|
||||
tokio::pin!(start);
|
||||
let stop = async {
|
||||
loop {
|
||||
let command = if configured { "configurationDone" } else { request };
|
||||
if h.commands().iter().any(|c| c == command) {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
let id = h.manager.sessions(None)[0].id;
|
||||
h.manager.stop(id).await.unwrap();
|
||||
};
|
||||
let (result, ()) = tokio::join!(&mut start, stop);
|
||||
assert_eq!(result.unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn startup_still_reports_adapter_failure_and_connection_closure() {
|
||||
for request in ["launch", "attach"] {
|
||||
let h = harness(Script { startup_error: Some("interpreter could not start"), ..Script::default() });
|
||||
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
|
||||
assert_eq!(error.message, format!("{request} failed: interpreter could not start"));
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
|
||||
let h = harness(Script { close_during_startup: true, ..Script::default() });
|
||||
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
|
||||
assert!(error.message.contains("debug adapter `debugpy` exited during startup"), "{error}");
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_protocol_flow() {
|
||||
let h = harness(Script::default());
|
||||
@@ -554,6 +728,22 @@ async fn run_in_terminal_program_reads_typed_input() {
|
||||
assert!(h.manager.send_input(session, "x\n").is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn blocked_program_input_does_not_hold_up_shutdown() {
|
||||
let h = harness(Script {
|
||||
run_in_terminal: Some(vec!["sh".into(), "-c".into(), "stty raw -echo; printf READY; exec sleep 60".into()]),
|
||||
..Script::default()
|
||||
});
|
||||
let session = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("READY"))).await;
|
||||
let started = std::time::Instant::now();
|
||||
h.manager.send_input(session, &"x".repeat(512 * 1024)).unwrap();
|
||||
assert!(started.elapsed() < std::time::Duration::from_millis(250));
|
||||
h.manager.stop(session).await.unwrap();
|
||||
assert!(h.manager.send_input(session, "later").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_merge_and_defaults() {
|
||||
let mut c = config();
|
||||
@@ -1063,6 +1253,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
tree_sitter: None,
|
||||
language_servers: vec![],
|
||||
debugger: Some(ide_language::DebuggerConfig {
|
||||
target_placement: None,
|
||||
adapter: "godot".into(),
|
||||
command: command.into(),
|
||||
args: args.iter().map(|a| (*a).to_owned()).collect(),
|
||||
@@ -1391,6 +1582,109 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_runs_the_debuggee_after_its_options_with_the_configuration_environment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
let config = LaunchConfig {
|
||||
adapter: "rdbg".into(),
|
||||
program: Some("ruby".into()),
|
||||
args: ["-Ilib", "app with spaces.rb", "$(touch sentinel); literal"].map(String::from).to_vec(),
|
||||
env: BTreeMap::from([("RAILS_ENV".into(), "test".into())]),
|
||||
extra_json: None,
|
||||
..config()
|
||||
};
|
||||
let mut spec = adapter::resolve(&rdbg, dir.path().into());
|
||||
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
|
||||
assert_eq!(spec.command, "rdbg");
|
||||
assert_eq!(
|
||||
spec.args,
|
||||
["-O", "--port", "${port}", "-c", "--", "ruby", "-Ilib", "app with spaces.rb", "$(touch sentinel); literal"]
|
||||
);
|
||||
assert!(spec.env.contains(&("RAILS_ENV".into(), "test".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_runs_from_a_bundle_that_has_debug_and_refuses_one_without() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = dir.path().join("app");
|
||||
std::fs::create_dir_all(app.join("lib")).unwrap();
|
||||
std::fs::write(app.join("Gemfile"), "source \"https://rubygems.org\"\n").unwrap();
|
||||
// A dependency of another gem is not a gem of the bundle.
|
||||
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n irb (1.14.0)\n debug (>= 1)\n").unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
let config = LaunchConfig {
|
||||
adapter: "rdbg".into(),
|
||||
program: Some("bundle".into()),
|
||||
args: ["exec", "rspec"].map(String::from).to_vec(),
|
||||
extra_json: None,
|
||||
..config()
|
||||
};
|
||||
let mut spec = adapter::resolve(&rdbg, app.clone());
|
||||
let err = rdbg_command_line(&mut spec, &config, &app).unwrap_err();
|
||||
assert!(err.message.contains("gem \"debug\""), "{}", err.message);
|
||||
|
||||
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n debug (1.9.2)\n irb (~> 1.10)\n").unwrap();
|
||||
// From a subdirectory too: Bundler finds the Gemfile above it.
|
||||
for cwd in [app.clone(), app.join("lib")] {
|
||||
let mut spec = adapter::resolve(&rdbg, cwd.clone());
|
||||
rdbg_command_line(&mut spec, &config, &cwd).unwrap();
|
||||
assert_eq!(spec.command, "bundle");
|
||||
assert_eq!(spec.args, ["exec", "rdbg", "-O", "--port", "${port}", "-c", "--", "bundle", "exec", "rspec"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_commands_without_arguments_are_ruby_entry_points_not_shell_text() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
for program in ["rspec", "/app with spaces/bin/rake", "$(touch sentinel); literal"] {
|
||||
let config =
|
||||
LaunchConfig { adapter: "rdbg".into(), program: Some(program.into()), args: Vec::new(), ..config() };
|
||||
let mut spec = adapter::resolve(&rdbg, dir.path().into());
|
||||
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
|
||||
assert_eq!(&spec.args[4..], ["--", "ruby", "-S", "--", program]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_remote_attach_does_not_check_the_local_bundle_or_append_a_program() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("Gemfile"), "").unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
let config = LaunchConfig { adapter: "rdbg".into(), request: "attach".into(), ..config() };
|
||||
let mut spec = adapter::resolve(&rdbg, dir.path().into());
|
||||
let args = spec.args.clone();
|
||||
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
|
||||
assert_eq!(spec.command, "rdbg");
|
||||
assert_eq!(spec.args, args);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundler_prefers_gems_rb_when_both_gemfiles_exist() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in ["Gemfile", "gems.rb"] {
|
||||
std::fs::write(dir.path().join(name), "").unwrap();
|
||||
}
|
||||
assert_eq!(bundler_gemfile(dir.path(), &BTreeMap::new()), Some(dir.path().join("gems.rb")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundler_gemfile_and_lockfile_follow_bundle_gemfile_and_gems_rb() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("gems")).unwrap();
|
||||
let none = BTreeMap::new();
|
||||
assert_eq!(bundler_gemfile(root, &none), None);
|
||||
std::fs::write(root.join("gems/gems.rb"), "").unwrap();
|
||||
std::fs::write(root.join("gems/gems.locked"), "GEM\n specs:\n debug (1.9.2)\n").unwrap();
|
||||
let gems_rb = bundler_gemfile(&root.join("gems"), &none).unwrap();
|
||||
assert_eq!(gems_rb, root.join("gems/gems.rb"));
|
||||
assert!(bundles_debug(&gems_rb));
|
||||
let env = BTreeMap::from([("BUNDLE_GEMFILE".into(), "gems/gems.rb".into())]);
|
||||
assert_eq!(bundler_gemfile(root, &env), Some(gems_rb));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stop_waits_for_the_launched_program_to_exit() {
|
||||
@@ -1608,3 +1902,116 @@ fn delve_leftovers_are_removed_without_touching_other_files() {
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
|
||||
struct Launcher;
|
||||
impl AdapterLauncher for Launcher {
|
||||
fn launch(&self, _spec: AdapterSpec, _reuse: Option<u16>) -> LaunchFuture {
|
||||
Box::pin(async {
|
||||
let (client, server) = tokio::io::duplex(1 << 16);
|
||||
tokio::spawn(fake_adapter(server, Arc::default(), Script::default()));
|
||||
let mut command = tokio::process::Command::new("/bin/sh");
|
||||
ide_core::process::own_group(command.as_std_mut());
|
||||
let mut child = command
|
||||
.args(["-c", "sleep 1; printf 'test summary\\n'"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
let mut text = String::new();
|
||||
tokio::io::AsyncReadExt::read_to_string(&mut stdout, &mut text).await.unwrap();
|
||||
let _ = tx.send(("stdout", text));
|
||||
});
|
||||
let (reader, writer) = tokio::io::split(client);
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(reader),
|
||||
writer: Box::new(writer),
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr: Arc::default(),
|
||||
program_output: Some(rx),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(Launcher));
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let collected = events.clone();
|
||||
let owner = SessionOwner::new("rdbg-test", Arc::new(move |e| collected.lock().push(e)));
|
||||
let id = manager
|
||||
.start(
|
||||
LaunchConfig { adapter: "rdbg".into(), cwd: Some(dir.path().into()), extra_json: None, ..config() },
|
||||
owner,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
manager.session(id).unwrap().conn.close();
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !manager.sessions(None).is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(events.lock().iter().any(|e| matches!(e,
|
||||
IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } if output == "test summary\n"
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn cleanup_ends_the_adapter_tree_without_a_process_event() {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
let h = harness(Script::default());
|
||||
let id = h.start(config()).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
let mut command = tokio::process::Command::new("python3");
|
||||
ide_core::process::own_group(command.as_std_mut());
|
||||
command
|
||||
.args([
|
||||
"-u",
|
||||
"-c",
|
||||
"import subprocess,sys,time; subprocess.Popen([sys.executable, '-u', '-c', \
|
||||
\"import socket,time; s=socket.socket(); s.bind(('127.0.0.1',0)); s.listen(); \
|
||||
print(s.getsockname()[1],flush=True); time.sleep(60)\"]); time.sleep(60)",
|
||||
])
|
||||
.stdout(std::process::Stdio::piped());
|
||||
let mut child = command.spawn().unwrap();
|
||||
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
|
||||
let port: u16 = lines.next_line().await.unwrap().unwrap().parse().unwrap();
|
||||
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the descendant holds the port");
|
||||
let pid = child.id().unwrap();
|
||||
*session.adapter_process.lock() = Some(child);
|
||||
session.kill_processes();
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while std::net::TcpListener::bind(("127.0.0.1", port)).is_err() {
|
||||
if Instant::now() >= deadline {
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
panic!("adapter cleanup left its descendant holding the port");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stop_detaches_from_an_attached_program() {
|
||||
let mut child = std::process::Command::new("sleep").arg("60").spawn().unwrap();
|
||||
let h = harness(Script { debuggee_pid: Some(child.id()), ..Default::default() });
|
||||
let id = h.start(LaunchConfig { request: "attach".into(), ..config() }).await.unwrap();
|
||||
h.manager.stop(id).await.unwrap();
|
||||
let alive = child.try_wait().unwrap().is_none();
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
assert!(alive, "Stop killed the attached program");
|
||||
assert!(!h.commands().contains(&"terminate".to_owned()));
|
||||
assert_eq!(h.request("disconnect")["arguments"]["terminateDebuggee"], false);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import sys,json,os,socket
|
||||
root=os.environ.get("FAKE_ROOT","/app")
|
||||
r=sys.stdin.buffer; w=sys.stdout.buffer
|
||||
seq=0
|
||||
def send(v):
|
||||
global seq
|
||||
seq+=1; v['seq']=seq
|
||||
b=json.dumps(v).encode(); w.write(('Content-Length: %d\r\n\r\n'%len(b)).encode()+b); w.flush()
|
||||
while True:
|
||||
line=r.readline()
|
||||
if not line: break
|
||||
n=int(line.split(b':')[1]); assert r.readline()==b'\r\n'
|
||||
q=json.loads(r.read(n)); c=q['command']; a=q.get('arguments',{}); body={}
|
||||
if c=='initialize': body={'supportsConfigurationDoneRequest':True}
|
||||
if c=='launch':
|
||||
if 'program' in a: assert a['program']==root+'/main.py',a
|
||||
if os.environ.get('FAKE_CONNECT_BACK'):
|
||||
listener=socket.socket(); listener.bind(('127.0.0.1',a['port'])); listener.listen()
|
||||
assert a['cwd']==root,a
|
||||
send({'type':'event','event':'initialized'})
|
||||
if c=='setBreakpoints':
|
||||
assert a['source']['path']==root+'/main.py',a
|
||||
body={'breakpoints':[{'verified':True,'line':3}]}
|
||||
if c=='threads': body={'threads':[{'id':1,'name':'target main'}]}
|
||||
if c=='configurationDone': send({'type':'event','event':'stopped','body':{'reason':'breakpoint','threadId':1,'allThreadsStopped':True}})
|
||||
if c=='stackTrace' and os.environ.get('FAKE_CONNECT_BACK'):
|
||||
client,_=listener.accept(); assert client.recv(4096).decode()==root+'/main.py'; client.close()
|
||||
if c=='stackTrace': body={'stackFrames':[{'id':1,'name':'main','line':3,'column':1,'source':{'path':root+'/main.py'}}]}
|
||||
send({'type':'response','request_seq':q['seq'],'command':c,'success':True,'body':body})
|
||||
if c=='disconnect': break
|
||||
@@ -93,9 +93,23 @@ async fn listens_for_xdebug_until_stopped_with_path_mappings() {
|
||||
.await
|
||||
.unwrap();
|
||||
let id = manager
|
||||
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
|
||||
Some(adapter.clone())
|
||||
})
|
||||
.start_prepared(
|
||||
listen_config(port, &served, &served),
|
||||
SessionOwner::new("main", sink.clone()),
|
||||
|_| None,
|
||||
|spec, _| {
|
||||
spec.command = adapter.clone();
|
||||
spec.cwd = local.clone();
|
||||
spec.target = Some(ide_dap::target::TargetMapping {
|
||||
paths: ide_core::path_mapping::PathMapping::new(
|
||||
&local.to_string_lossy(),
|
||||
&served.to_string_lossy(),
|
||||
),
|
||||
external_prefix: "openidea-target://php/0/".into(),
|
||||
});
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("start: {e:?}"));
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Real stdio transport through a target wrapper, without Docker or installed adapters.
|
||||
#![cfg(unix)]
|
||||
use ide_core::{SharedSink, path_mapping::PathMapping};
|
||||
use ide_dap::{target::TargetMapping, *};
|
||||
use ide_language::LanguageRegistry;
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
const ADAPTER: &str = include_str!("fixtures/target_adapter.py");
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdio_adapter_through_target_maps_launch_breakpoints_and_frames() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let adapter = root.join("adapter.py");
|
||||
std::fs::write(&adapter, ADAPTER).unwrap();
|
||||
let wrapper = root.join("wrapper");
|
||||
std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' \"$@\" > wrapper-argv\nexec \"$@\"\n").unwrap();
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let target = ide_core::remote_settings::PluginRunTarget {
|
||||
id: "fake".into(),
|
||||
name: "Fake".into(),
|
||||
program: "wrapper".into(),
|
||||
args: vec![],
|
||||
workdir_args: vec![],
|
||||
container_root: Some("/app".into()),
|
||||
commands: vec![],
|
||||
};
|
||||
// Keep the fixture adapter outside mapped argv; this is the container's executable.
|
||||
let run: ide_run::RunConfig = serde_json::from_value(serde_json::json!({"id":"adapter","name":"Adapter","kind":"shell","command":"/usr/bin/python3","args":["-u","-c",ADAPTER],"cwd":".","env":{},"detected":false,"isTest":false})).unwrap();
|
||||
let run = ide_remote::plugin_target::prepare(&root, &target, &wrapper, &run).unwrap();
|
||||
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
|
||||
manager
|
||||
.set_breakpoints(
|
||||
root.join("main.py"),
|
||||
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let config = LaunchConfig {
|
||||
name: "Target".into(),
|
||||
adapter: "debugpy".into(),
|
||||
request: "launch".into(),
|
||||
program: Some(root.join("main.py").to_string_lossy().into_owned()),
|
||||
args: vec![],
|
||||
cwd: Some(root.clone().into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: None,
|
||||
};
|
||||
let sink: SharedSink = Arc::new(|_| {});
|
||||
let mapping = TargetMapping {
|
||||
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
|
||||
external_prefix: "openidea-target://fake/0/".into(),
|
||||
};
|
||||
let id = manager
|
||||
.start_prepared(
|
||||
config,
|
||||
SessionOwner::new("test", sink),
|
||||
|_| None,
|
||||
move |spec, _| {
|
||||
spec.command = run.command;
|
||||
spec.args = run.args;
|
||||
spec.cwd = root;
|
||||
spec.target = Some(mapping);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let frames = manager.stack_trace(id, 1).await.unwrap();
|
||||
assert_eq!(
|
||||
frames[0].path.as_ref().unwrap().to_string_lossy(),
|
||||
tmp.path().canonicalize().unwrap().join("main.py").to_string_lossy()
|
||||
);
|
||||
assert_eq!(frames[0].line, 2);
|
||||
assert!(std::fs::read_to_string(tmp.path().join("wrapper-argv")).unwrap().starts_with("python3\n-u\n-c\n"));
|
||||
manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_adapter_accepts_connect_back_from_target_with_mapped_sources() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let port = ide_dap::adapter::free_port().unwrap();
|
||||
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
|
||||
manager
|
||||
.set_breakpoints(
|
||||
root.join("main.py"),
|
||||
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let config = LaunchConfig {
|
||||
name: "Connect back".into(),
|
||||
adapter: "php-debug".into(),
|
||||
request: "launch".into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: Some(root.clone().into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(serde_json::json!({"port":port,"pathMappings":{"/app":"/app"}}).to_string()),
|
||||
};
|
||||
let sink: SharedSink = Arc::new(|_| {});
|
||||
let mapping = TargetMapping {
|
||||
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
|
||||
external_prefix: "openidea-target://fake/0/".into(),
|
||||
};
|
||||
let id = manager
|
||||
.start_prepared(
|
||||
config,
|
||||
SessionOwner::new("test", sink),
|
||||
|_| None,
|
||||
move |spec, _| {
|
||||
spec.command = "/usr/bin/python3".into();
|
||||
spec.args = vec!["-u".into(), "-c".into(), ADAPTER.into()];
|
||||
spec.env.push(("FAKE_CONNECT_BACK".into(), "1".into()));
|
||||
spec.target = Some(mapping);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let code =
|
||||
format!("import socket;s=socket.create_connection(('127.0.0.1', {port}));s.sendall(b'/app/main.py');s.close()");
|
||||
let mut child = tokio::process::Command::new("/usr/bin/python3").args(["-c", &code]).spawn().unwrap();
|
||||
let frames = manager.stack_trace(id, 1).await.unwrap();
|
||||
assert_eq!(frames[0].path.as_ref().unwrap().to_path_buf(), root.join("main.py"));
|
||||
assert!(child.wait().await.unwrap().success());
|
||||
manager.stop(id).await.unwrap();
|
||||
}
|
||||
@@ -7,10 +7,13 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
dunce.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
specta.workspace = true
|
||||
toml.workspace = true
|
||||
toml_edit.workspace = true
|
||||
blake3.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
|
||||
+26
-14
@@ -7,6 +7,7 @@ pub mod conn;
|
||||
pub mod history;
|
||||
mod oracle;
|
||||
pub mod plan;
|
||||
pub mod plugin;
|
||||
pub mod secrets;
|
||||
pub mod source;
|
||||
pub mod split;
|
||||
@@ -91,7 +92,7 @@ pub struct SequenceInfo {
|
||||
pub data_type: String,
|
||||
}
|
||||
|
||||
/// A schema's routines and sequences (the tree's `routines` / `sequences` folders).
|
||||
/// A schema's routines (procedures and functions) and sequences.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSchemaObjects")]
|
||||
@@ -381,7 +382,9 @@ impl DbManager {
|
||||
/// Where the saved passwords of data source `id` live: app-wide for a global one.
|
||||
fn saved_for(&self, id: &str) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
|
||||
Some((_, store)) if self.sources().is_ok_and(|sources| sources.iter().any(|s| s.id == id && s.global)) => {
|
||||
store
|
||||
}
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
}
|
||||
@@ -412,19 +415,28 @@ impl DbManager {
|
||||
/// Saves the data source list; connections of removed or changed sources are closed.
|
||||
/// Global ones (`global`) go to the app-wide list, the others to the project's.
|
||||
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
let old = self.sources().unwrap_or_default();
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
}
|
||||
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
|
||||
None => {}
|
||||
if sources.iter().any(|s| s.global && s.plugin_owner.is_some()) {
|
||||
return Err(IdeError::invalid("plugin-owned data sources must stay project-scoped"));
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
let old = {
|
||||
let _guard = plugin::EDIT.lock();
|
||||
let old = self.sources().unwrap_or_default();
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
}
|
||||
None if !global.is_empty() => {
|
||||
return Err(IdeError::invalid("global data sources are not available here"));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
old
|
||||
};
|
||||
for o in old {
|
||||
match sources.iter().find(|s| s.id == o.id) {
|
||||
Some(n) if *n == o => {}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Ownership checks and surgical updates of plugin-created project sources.
|
||||
use crate::{DataSource, DbManager, source};
|
||||
use ide_core::{IdeError, IdeResult, project_dir};
|
||||
use std::path::Path;
|
||||
|
||||
// All DbManagers of the same root share this file. Never hold this across an await.
|
||||
pub(crate) static EDIT: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
||||
|
||||
/// Host-owned connection id, unambiguous and safe for console paths.
|
||||
pub fn source_id(plugin: &str, id: &str) -> String {
|
||||
format!("plugin-{}", blake3::hash(format!("{plugin}\0{id}").as_bytes()).to_hex())
|
||||
}
|
||||
|
||||
fn owned(source: &DataSource, plugin: &str, id: &str) -> bool {
|
||||
source.id == source_id(plugin, id)
|
||||
&& source.plugin_owner.as_deref() == Some(plugin)
|
||||
&& source.plugin_source_id.as_deref() == Some(id)
|
||||
&& !source.global
|
||||
}
|
||||
|
||||
/// Edits just matching array tables, retaining unrelated entries, comments and formatting.
|
||||
fn update(root: &Path, remove: impl Fn(&DataSource) -> bool, add: Option<&DataSource>) -> IdeResult<Vec<DataSource>> {
|
||||
let path = project_dir::path(root, "data-sources.toml");
|
||||
if let Some(ancestor) = path.ancestors().find(|p| p.exists())
|
||||
&& root.exists()
|
||||
&& !dunce::canonicalize(ancestor)?.starts_with(dunce::canonicalize(root)?)
|
||||
{
|
||||
return Err(IdeError::invalid("data sources file is outside the project"));
|
||||
}
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let old = source::load(root)?;
|
||||
let doc = toml_edit::Document::parse(text.as_str()).map_err(|e| IdeError::invalid(format!("data sources: {e}")))?;
|
||||
let removed: Vec<_> = old.iter().filter(|s| remove(s)).cloned().collect();
|
||||
fn line_span(text: &str, span: std::ops::Range<usize>, out: &mut Vec<std::ops::Range<usize>>) {
|
||||
let start = text[..span.start].rfind('\n').map_or(0, |n| n + 1);
|
||||
let mut end = text[span.end..].find('\n').map_or(text.len(), |n| span.end + n + 1);
|
||||
while end < text.len() {
|
||||
let next = text[end..].find('\n').map_or(text.len(), |n| end + n + 1);
|
||||
if !text[end..next].trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
}
|
||||
out.push(start..end);
|
||||
}
|
||||
fn spans(table: &toml_edit::Table, text: &str, out: &mut Vec<std::ops::Range<usize>>) {
|
||||
if !table.is_implicit()
|
||||
&& let Some(span) = table.span()
|
||||
{
|
||||
line_span(text, span, out);
|
||||
}
|
||||
for (_, item) in table.iter() {
|
||||
if let Some(span) = item.as_value().and_then(toml_edit::Value::span) {
|
||||
line_span(text, span, out);
|
||||
}
|
||||
if let Some(table) = item.as_table() {
|
||||
spans(table, text, out);
|
||||
}
|
||||
if let Some(tables) = item.as_array_of_tables() {
|
||||
for table in tables.iter() {
|
||||
spans(table, text, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if doc.get("data-source").is_some_and(|i| i.as_array_of_tables().is_none()) {
|
||||
return Err(IdeError::invalid("plugin sources require [[data-source]] tables"));
|
||||
}
|
||||
let mut ranges = vec![];
|
||||
if let Some(tables) = doc.get("data-source").and_then(toml_edit::Item::as_array_of_tables) {
|
||||
for (table, source) in tables.iter().zip(&old) {
|
||||
if remove(source) {
|
||||
spans(table, &text, &mut ranges);
|
||||
}
|
||||
}
|
||||
}
|
||||
ranges.sort_by_key(|r| r.start);
|
||||
let mut merged: Vec<std::ops::Range<usize>> = vec![];
|
||||
for range in ranges {
|
||||
if let Some(last) = merged.last_mut()
|
||||
&& range.start <= last.end
|
||||
{
|
||||
last.end = last.end.max(range.end);
|
||||
continue;
|
||||
}
|
||||
merged.push(range);
|
||||
}
|
||||
let mut output = text.clone();
|
||||
for range in merged.into_iter().rev() {
|
||||
output.replace_range(range, "");
|
||||
}
|
||||
if let Some(add) = add {
|
||||
let mut value = toml::Value::try_from(add).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
value.as_table_mut().unwrap().remove("global");
|
||||
let value =
|
||||
toml::Value::Table(toml::Table::from_iter([("data-source".into(), toml::Value::Array(vec![value]))]));
|
||||
if !output.is_empty() && !output.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(&toml::to_string_pretty(&value).map_err(|e| IdeError::internal(e.to_string()))?);
|
||||
}
|
||||
if !removed.is_empty() || add.is_some() {
|
||||
let dir = path.parent().unwrap();
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let tmp = dir.join(format!(".data-sources-{}.tmp", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&tmp, output)?;
|
||||
if let Err(e) = std::fs::rename(&tmp, &path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Removes only this plugin's sources, including when their project is closed at uninstall.
|
||||
pub fn remove_all(root: &Path, plugin: &str) -> IdeResult<Vec<DataSource>> {
|
||||
let _guard = EDIT.lock();
|
||||
update(root, |s| s.plugin_owner.as_deref() == Some(plugin), None)
|
||||
}
|
||||
|
||||
impl DbManager {
|
||||
/// Lists private source ids without exposing any connection settings.
|
||||
pub fn plugin_sources(&self, plugin: &str) -> IdeResult<Vec<String>> {
|
||||
Ok(source::load(&self.root)?
|
||||
.into_iter()
|
||||
.filter(|s| s.plugin_owner.as_deref() == Some(plugin))
|
||||
.filter_map(|s| s.plugin_source_id)
|
||||
.collect())
|
||||
}
|
||||
/// Checks the stored owner, even if a user-created source uses the host's id format.
|
||||
pub fn plugin_source(&self, plugin: &str, id: &str) -> IdeResult<DataSource> {
|
||||
source::load(&self.root)?
|
||||
.into_iter()
|
||||
.find(|s| owned(s, plugin, id))
|
||||
.ok_or_else(|| IdeError::not_found("no owned data source with this id"))
|
||||
}
|
||||
/// Replaces only an owned source and disconnects its old pools.
|
||||
pub async fn upsert_plugin_source(&self, plugin: &str, id: &str, mut source: DataSource) -> IdeResult<()> {
|
||||
source.id = source_id(plugin, id);
|
||||
source.plugin_owner = Some(plugin.into());
|
||||
source.plugin_source_id = Some(id.into());
|
||||
source.global = false;
|
||||
{
|
||||
let _guard = EDIT.lock();
|
||||
let all = self.sources()?;
|
||||
if all.iter().any(|s| s.id == source.id && !owned(s, plugin, id)) {
|
||||
return Err(IdeError::not_found("no owned data source with this id"));
|
||||
}
|
||||
if !all.iter().any(|s| owned(s, plugin, id)) && self.plugin_sources(plugin)?.len() >= 64 {
|
||||
return Err(IdeError::invalid("at most 64 sources per plugin and project"));
|
||||
}
|
||||
update(&self.root, |s| owned(s, plugin, id), Some(&source))?;
|
||||
}
|
||||
self.disconnect_source(&source.id).await;
|
||||
Ok(())
|
||||
}
|
||||
/// Deletes an owned source and all its saved/session credentials.
|
||||
pub async fn remove_plugin_source(&self, plugin: &str, id: &str) -> IdeResult<()> {
|
||||
let source = {
|
||||
let _guard = EDIT.lock();
|
||||
let source = self.plugin_source(plugin, id)?;
|
||||
update(&self.root, |s| owned(s, plugin, id), None)?;
|
||||
source
|
||||
};
|
||||
self.forget_plugin_source(&source.id).await;
|
||||
Ok(())
|
||||
}
|
||||
/// Completes cleanup after removal from the file.
|
||||
pub async fn forget_plugin_source(&self, id: &str) {
|
||||
for key in [id.to_owned(), crate::secrets::ssh_key(id)] {
|
||||
self.session_passwords.lock().remove(&key);
|
||||
self.saved_passwords.delete(&key);
|
||||
}
|
||||
self.disconnect_source(id).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{DbDriver, secrets::SecretStore};
|
||||
#[tokio::test]
|
||||
async fn ownership_preserves_unrelated_bytes_and_cleans_credentials() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let original = "# user comment\n[[data-source]] # keep\nid = 'user'\nname='Mine'\ndriver='postgres'\n\n[data-source.options]\ntimeZone = 'UTC' # retain\n";
|
||||
let file = root.join(".openidea/data-sources.toml");
|
||||
std::fs::write(&file, original).unwrap();
|
||||
let secrets = tmp.path().join("passwords.json");
|
||||
let db = DbManager::new(
|
||||
root.clone(),
|
||||
secrets.clone(),
|
||||
tmp.path().join("history.json"),
|
||||
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
|
||||
);
|
||||
let settings =
|
||||
DataSource { name: "Plugin".into(), driver: DbDriver::Postgres, save_password: true, ..Default::default() };
|
||||
db.upsert_plugin_source("a", "local", settings.clone()).await.unwrap();
|
||||
assert!(std::fs::read_to_string(&file).unwrap().starts_with(original));
|
||||
db.upsert_plugin_source("b", "local", settings.clone()).await.unwrap();
|
||||
assert_eq!(db.plugin_sources("a").unwrap(), ["local"]);
|
||||
assert!(db.plugin_source("a", "user").is_err());
|
||||
assert!(db.remove_plugin_source("a", "user").await.is_err());
|
||||
let source = db.plugin_source("a", "local").unwrap();
|
||||
db.set_password(&source.id, Some("top-secret".into()), true).await.unwrap();
|
||||
let store = SecretStore::new(secrets, &root);
|
||||
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
|
||||
db.upsert_plugin_source("a", "local", DataSource { name: "Updated".into(), ..settings }).await.unwrap();
|
||||
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
|
||||
db.remove_plugin_source("a", "local").await.unwrap();
|
||||
assert!(store.get(&source.id).is_none());
|
||||
assert_eq!(db.plugin_sources("b").unwrap(), ["local"]);
|
||||
remove_all(&root, "b").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn project_owned_credentials_do_not_replace_a_shadowed_global_sources_password() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let global = tmp.path().join("global");
|
||||
let passwords = tmp.path().join("passwords.json");
|
||||
let db = DbManager::new(
|
||||
root.clone(),
|
||||
passwords.clone(),
|
||||
tmp.path().join("history.json"),
|
||||
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
|
||||
)
|
||||
.with_global_sources(global.clone());
|
||||
db.upsert_plugin_source(
|
||||
"plugin",
|
||||
"local",
|
||||
DataSource { name: "Owned".into(), save_password: true, ..Default::default() },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let owned = db.plugin_source("plugin", "local").unwrap();
|
||||
source::save_global(
|
||||
&global,
|
||||
&[DataSource { id: owned.id.clone(), name: "Global user source".into(), ..Default::default() }],
|
||||
)
|
||||
.unwrap();
|
||||
let global_store = SecretStore::new(passwords.clone(), &global);
|
||||
global_store.set(&owned.id, "user-secret").unwrap();
|
||||
db.set_password(&owned.id, Some("plugin-secret".into()), true).await.unwrap();
|
||||
assert_eq!(SecretStore::new(passwords, &root).get(&owned.id).as_deref(), Some("plugin-secret"));
|
||||
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
|
||||
db.remove_plugin_source("plugin", "local").await.unwrap();
|
||||
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn a_user_source_cannot_be_claimed_by_predicting_its_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let db = DbManager::new(
|
||||
tmp.path().join("project"),
|
||||
tmp.path().join("passwords.json"),
|
||||
tmp.path().join("history.json"),
|
||||
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
|
||||
);
|
||||
let source = DataSource { id: source_id("plugin", "x"), name: "User".into(), ..Default::default() };
|
||||
db.save_sources(vec![source.clone()]).await.unwrap();
|
||||
assert!(db.upsert_plugin_source("plugin", "x", source.clone()).await.is_err());
|
||||
assert_eq!(db.sources().unwrap(), [source]);
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,12 @@ pub struct DataSource {
|
||||
/// Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
/// so not written to it.
|
||||
pub global: bool,
|
||||
/// Plugin id owning this source; absent for user-managed sources.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_owner: Option<String>,
|
||||
/// Id private to the owner, never used as a connection target.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_source_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DataSource {
|
||||
@@ -219,6 +225,8 @@ impl Default for DataSource {
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
global: false,
|
||||
plugin_owner: None,
|
||||
plugin_source_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,32 @@ pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult
|
||||
}
|
||||
}
|
||||
|
||||
fn check_id(id: &str) -> IdeResult<&str> {
|
||||
let id = id.trim();
|
||||
if id.is_empty() || id.starts_with('-') { Err(IdeError::invalid("invalid Docker identifier")) } else { Ok(id) }
|
||||
}
|
||||
|
||||
/// `docker image history --no-trunc --format '{{json .}}'` of one image.
|
||||
pub fn image_history(ep: &DockerEndpoint, id: &str) -> IdeResult<Vec<DockerImageLayer>> {
|
||||
let id = check_id(id)?;
|
||||
let out = run(ep, &["image", "history", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
|
||||
Ok(parse_image_history(&out))
|
||||
}
|
||||
|
||||
/// `docker top <id>` of a running container.
|
||||
pub fn container_top(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerProcessList> {
|
||||
let id = check_id(id)?;
|
||||
let out = run(ep, &["top", id], QUICK)?;
|
||||
Ok(parse_container_top(&out))
|
||||
}
|
||||
|
||||
/// `docker stats --no-stream --no-trunc --format '{{json .}}' <id>` of a running container.
|
||||
pub fn container_stats(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerContainerStats> {
|
||||
let id = check_id(id)?;
|
||||
let out = run(ep, &["stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
|
||||
parse_container_stats(&out).ok_or_else(|| IdeError::not_found("no container stats returned"))
|
||||
}
|
||||
|
||||
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
|
||||
/// until it ends or `cancel` fires.
|
||||
pub fn logs(
|
||||
|
||||
+181
-19
@@ -41,6 +41,15 @@ pub struct DockerContainer {
|
||||
pub compose_working_dir: Option<String>,
|
||||
/// `com.docker.compose.project.config_files` label (comma-separated in docker).
|
||||
pub compose_files: Vec<String>,
|
||||
/// Mounted volume names or host paths reported by `docker ps` (`Mounts`).
|
||||
#[serde(default)]
|
||||
pub mounts: Vec<String>,
|
||||
/// Connected network names reported by `docker ps` (`Networks`).
|
||||
#[serde(default)]
|
||||
pub networks: Vec<String>,
|
||||
/// User and tool labels assigned to the container.
|
||||
#[serde(default)]
|
||||
pub labels: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl DockerContainer {
|
||||
@@ -80,6 +89,10 @@ pub struct DockerVolume {
|
||||
pub driver: String,
|
||||
pub mountpoint: String,
|
||||
pub compose_project: Option<String>,
|
||||
#[serde(default)]
|
||||
pub scope: String,
|
||||
#[serde(default)]
|
||||
pub labels: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -90,6 +103,46 @@ pub struct DockerNetwork {
|
||||
pub driver: String,
|
||||
pub scope: String,
|
||||
pub compose_project: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub ipv6: bool,
|
||||
#[serde(default)]
|
||||
pub internal: bool,
|
||||
#[serde(default)]
|
||||
pub labels: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// One layer in `docker image history --no-trunc --format '{{json .}}'`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerImageLayer {
|
||||
pub id: String,
|
||||
pub created_by: String,
|
||||
pub size: String,
|
||||
pub created_since: String,
|
||||
pub created_at: String,
|
||||
pub comment: String,
|
||||
}
|
||||
|
||||
/// Process table returned by `docker top <container>`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerProcessList {
|
||||
pub titles: Vec<String>,
|
||||
pub processes: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerContainerStats {
|
||||
pub cpu_perc: String,
|
||||
pub mem_usage: String,
|
||||
pub mem_perc: String,
|
||||
pub net_io: String,
|
||||
pub block_io: String,
|
||||
pub pids: String,
|
||||
}
|
||||
|
||||
/// Everything the Services tree shows, fetched in one go.
|
||||
@@ -186,17 +239,20 @@ struct RawContainer {
|
||||
ports: String,
|
||||
created_at: String,
|
||||
labels: String,
|
||||
mounts: String,
|
||||
networks: String,
|
||||
}
|
||||
|
||||
fn split_csv(raw: &str) -> Vec<String> {
|
||||
raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
/// `docker ps -a --no-trunc --format '{{json .}}'`.
|
||||
pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
|
||||
let mut out: Vec<DockerContainer> = lines::<RawContainer>(text)
|
||||
.map(|r| {
|
||||
let mut labels = parse_labels(&r.labels);
|
||||
let files = labels
|
||||
.remove(COMPOSE_FILES)
|
||||
.map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect())
|
||||
.unwrap_or_default();
|
||||
let all_labels = parse_labels(&r.labels);
|
||||
let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).unwrap_or_default();
|
||||
// Older docker versions derive `State` from nothing: fall back on the status text.
|
||||
let state = if r.state.is_empty() {
|
||||
if r.status.starts_with("Up") { "running".into() } else { "exited".into() }
|
||||
@@ -212,10 +268,13 @@ pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
|
||||
status: r.status,
|
||||
ports: r.ports,
|
||||
created_at: r.created_at,
|
||||
compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
|
||||
compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()),
|
||||
compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()),
|
||||
compose_project: all_labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
|
||||
compose_service: all_labels.get(COMPOSE_SERVICE).filter(|s| !s.is_empty()).cloned(),
|
||||
compose_working_dir: all_labels.get(COMPOSE_DIR).filter(|s| !s.is_empty()).cloned(),
|
||||
compose_files: files,
|
||||
mounts: split_csv(&r.mounts),
|
||||
networks: split_csv(&r.networks),
|
||||
labels: all_labels,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -258,17 +317,23 @@ struct RawVolume {
|
||||
name: String,
|
||||
driver: String,
|
||||
mountpoint: String,
|
||||
scope: String,
|
||||
labels: String,
|
||||
}
|
||||
|
||||
/// `docker volume ls --format '{{json .}}'`.
|
||||
pub fn parse_volumes(text: &str) -> Vec<DockerVolume> {
|
||||
let mut out: Vec<DockerVolume> = lines::<RawVolume>(text)
|
||||
.map(|r| DockerVolume {
|
||||
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
|
||||
name: r.name,
|
||||
driver: r.driver,
|
||||
mountpoint: r.mountpoint,
|
||||
.map(|r| {
|
||||
let labels = parse_labels(&r.labels);
|
||||
DockerVolume {
|
||||
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
|
||||
name: r.name,
|
||||
driver: r.driver,
|
||||
mountpoint: r.mountpoint,
|
||||
scope: r.scope,
|
||||
labels,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
@@ -283,24 +348,121 @@ struct RawNetwork {
|
||||
name: String,
|
||||
driver: String,
|
||||
scope: String,
|
||||
created_at: String,
|
||||
#[serde(rename = "IPv6")]
|
||||
ipv6: String,
|
||||
internal: String,
|
||||
labels: String,
|
||||
}
|
||||
|
||||
/// `docker network ls --no-trunc --format '{{json .}}'`.
|
||||
pub fn parse_networks(text: &str) -> Vec<DockerNetwork> {
|
||||
let mut out: Vec<DockerNetwork> = lines::<RawNetwork>(text)
|
||||
.map(|r| DockerNetwork {
|
||||
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
driver: r.driver,
|
||||
scope: r.scope,
|
||||
.map(|r| {
|
||||
let labels = parse_labels(&r.labels);
|
||||
DockerNetwork {
|
||||
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
driver: r.driver,
|
||||
scope: r.scope,
|
||||
created_at: r.created_at,
|
||||
ipv6: r.ipv6.eq_ignore_ascii_case("true"),
|
||||
internal: r.internal.eq_ignore_ascii_case("true"),
|
||||
labels,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "PascalCase", default)]
|
||||
struct RawImageLayer {
|
||||
#[serde(rename = "ID")]
|
||||
id: String,
|
||||
created_by: String,
|
||||
size: String,
|
||||
created_since: String,
|
||||
created_at: String,
|
||||
comment: String,
|
||||
}
|
||||
|
||||
/// `docker image history --no-trunc --format '{{json .}}'`.
|
||||
pub fn parse_image_history(text: &str) -> Vec<DockerImageLayer> {
|
||||
lines::<RawImageLayer>(text)
|
||||
.map(|r| DockerImageLayer {
|
||||
id: r.id,
|
||||
created_by: r.created_by,
|
||||
size: r.size,
|
||||
created_since: r.created_since,
|
||||
created_at: r.created_at,
|
||||
comment: r.comment,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `docker top <container>` tabular output.
|
||||
pub fn parse_container_top(text: &str) -> DockerProcessList {
|
||||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
let Some(header) = lines.next() else {
|
||||
return DockerProcessList::default();
|
||||
};
|
||||
let titles: Vec<String> = header.split_whitespace().map(str::to_owned).collect();
|
||||
if titles.is_empty() {
|
||||
return DockerProcessList::default();
|
||||
}
|
||||
let cols = titles.len();
|
||||
let processes = lines
|
||||
.map(|line| {
|
||||
let mut row = Vec::with_capacity(cols);
|
||||
let mut rest = line.trim_start();
|
||||
for _ in 0..cols.saturating_sub(1) {
|
||||
match rest.split_once(char::is_whitespace) {
|
||||
Some((field, tail)) => {
|
||||
row.push(field.to_owned());
|
||||
rest = tail.trim_start();
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() || row.len() < cols {
|
||||
row.push(rest.to_owned());
|
||||
}
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
DockerProcessList { titles, processes }
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "PascalCase", default)]
|
||||
struct RawStats {
|
||||
#[serde(rename = "CPUPerc")]
|
||||
cpu_perc: String,
|
||||
mem_usage: String,
|
||||
mem_perc: String,
|
||||
#[serde(rename = "NetIO")]
|
||||
net_io: String,
|
||||
#[serde(rename = "BlockIO")]
|
||||
block_io: String,
|
||||
#[serde(rename = "PIDs")]
|
||||
pids: String,
|
||||
}
|
||||
|
||||
/// `docker stats --no-stream --no-trunc --format '{{json .}}'`.
|
||||
pub fn parse_container_stats(text: &str) -> Option<DockerContainerStats> {
|
||||
lines::<RawStats>(text).next().map(|r| DockerContainerStats {
|
||||
cpu_perc: r.cpu_perc,
|
||||
mem_usage: r.mem_usage,
|
||||
mem_perc: r.mem_perc,
|
||||
net_io: r.net_io,
|
||||
block_io: r.block_io,
|
||||
pids: r.pids,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct RawVersion {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Parsing of recorded `docker … --format '{{json .}}'` output.
|
||||
|
||||
use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes};
|
||||
use ide_docker::{
|
||||
parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks,
|
||||
parse_volumes,
|
||||
};
|
||||
|
||||
const PS: &str = include_str!("fixtures/ps.jsonl");
|
||||
const IMAGES: &str = include_str!("fixtures/images.jsonl");
|
||||
@@ -22,11 +25,16 @@ fn containers_with_compose_labels() {
|
||||
assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop"));
|
||||
assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]);
|
||||
assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp");
|
||||
assert_eq!(db.mounts, ["shop_pgdata"]);
|
||||
assert_eq!(db.networks, ["shop_default"]);
|
||||
assert_eq!(db.labels.get("com.docker.compose.project").map(String::as_str), Some("shop"));
|
||||
|
||||
let web = &c[1];
|
||||
assert_eq!(web.name, "web", "leading slash of legacy names is dropped");
|
||||
assert!(!web.running());
|
||||
assert_eq!(web.compose_project, None);
|
||||
assert!(web.mounts.is_empty());
|
||||
assert_eq!(web.networks, ["bridge"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -44,9 +52,44 @@ fn volumes_and_networks() {
|
||||
assert_eq!(v.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["0a1b", "shop_pgdata"]);
|
||||
assert_eq!(v[1].compose_project.as_deref(), Some("shop"));
|
||||
assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data");
|
||||
assert_eq!(v[1].scope, "local");
|
||||
assert_eq!(v[1].labels.get("com.docker.compose.volume").map(String::as_str), Some("pgdata"));
|
||||
|
||||
let n = parse_networks(NETWORKS);
|
||||
assert_eq!(n.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["bridge", "host", "shop_default"]);
|
||||
assert_eq!(n[2].compose_project.as_deref(), Some("shop"));
|
||||
assert_eq!(n[1].driver, "host");
|
||||
assert!(!n[2].ipv6);
|
||||
assert!(!n[2].internal);
|
||||
assert_eq!(n[2].labels.get("com.docker.compose.network").map(String::as_str), Some("default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_history_top_and_stats() {
|
||||
let history = [
|
||||
r#"{"Comment":"buildkit.dockerfile.v0","CreatedAt":"2026-10-01 09:00:00 +0000 UTC","CreatedBy":"CMD [\"postgres\"]","CreatedSince":"5 weeks ago","ID":"sha256:1234567890ab","Size":"0B"}"#,
|
||||
r#"{"Comment":"","CreatedAt":"2026-10-01 08:59:00 +0000 UTC","CreatedBy":"/bin/sh -c #(nop) COPY file:abc in /usr/local/bin/ ","CreatedSince":"5 weeks ago","ID":"<missing>","Size":"42.5MB"}"#,
|
||||
]
|
||||
.join("\n");
|
||||
let layers = parse_image_history(&history);
|
||||
assert_eq!(layers.len(), 2);
|
||||
assert_eq!(layers[0].created_by, "CMD [\"postgres\"]");
|
||||
assert_eq!(layers[1].size, "42.5MB");
|
||||
|
||||
let top = "UID PID PPID C STIME TTY TIME CMD\nroot 1234 1200 0 09:12 ? 00:00:01 postgres -c config_file=/etc/postgresql.conf\n";
|
||||
let parsed_top = parse_container_top(top);
|
||||
assert_eq!(parsed_top.titles, ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]);
|
||||
assert_eq!(parsed_top.processes.len(), 1);
|
||||
assert_eq!(parsed_top.processes[0][0], "root");
|
||||
assert_eq!(parsed_top.processes[0][1], "1234");
|
||||
assert_eq!(parsed_top.processes[0][7], "postgres -c config_file=/etc/postgresql.conf");
|
||||
|
||||
let stats_line = r#"{"BlockIO":"1.2MB / 0B","CPUPerc":"1.25%","Container":"shop-db-1","ID":"4f2b8c1d9e0a","MemPerc":"2.50%","MemUsage":"50MiB / 2GiB","Name":"shop-db-1","NetIO":"12kB / 8kB","PIDs":"14"}"#;
|
||||
let stats = parse_container_stats(stats_line).expect("stats parsed");
|
||||
assert_eq!(stats.cpu_perc, "1.25%");
|
||||
assert_eq!(stats.mem_usage, "50MiB / 2GiB");
|
||||
assert_eq!(stats.mem_perc, "2.50%");
|
||||
assert_eq!(stats.net_io, "12kB / 8kB");
|
||||
assert_eq!(stats.block_io, "1.2MB / 0B");
|
||||
assert_eq!(stats.pids, "14");
|
||||
}
|
||||
@@ -20,6 +20,10 @@ pub enum Capability {
|
||||
EditorWrite,
|
||||
/// Create and replace files inside the project root.
|
||||
ProjectWrite,
|
||||
/// Run arbitrary IDE actions and other plugins' commands.
|
||||
IdeCommands,
|
||||
/// Manage only its own project database sources and write their credentials.
|
||||
DatabaseSources,
|
||||
VcsRead,
|
||||
VcsWrite,
|
||||
/// The plugin's own key-value store and data directory.
|
||||
@@ -32,8 +36,12 @@ pub enum Capability {
|
||||
InputEvents,
|
||||
/// The system clipboard, from the plugin's web views.
|
||||
Clipboard,
|
||||
/// HTTP(S) to one host (`*.example.com` matches subdomains).
|
||||
/// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains).
|
||||
Net(String),
|
||||
/// Unencrypted HTTP to one host.
|
||||
NetHttp(String),
|
||||
/// A local socket or named pipe on the project machine.
|
||||
Ipc(String),
|
||||
/// Run one external program. Full code execution: never fine-grained.
|
||||
Process(String),
|
||||
/// Read files outside the project under one path.
|
||||
@@ -60,12 +68,14 @@ impl Capability {
|
||||
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
|
||||
Self::EditorWrite
|
||||
| Self::ProjectWrite
|
||||
| Self::DatabaseSources
|
||||
| Self::VcsWrite
|
||||
| Self::Net(_)
|
||||
| Self::NetHttp(_)
|
||||
| Self::Secrets
|
||||
| Self::InputEvents
|
||||
| Self::Clipboard => Risk::Medium,
|
||||
Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
|
||||
Self::IdeCommands | Self::Ipc(_) | Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +92,8 @@ impl Capability {
|
||||
Self::EditorRead => "Read the focused editor's file and selection".into(),
|
||||
Self::EditorWrite => "Change text in the editor".into(),
|
||||
Self::ProjectWrite => "Create and change files in the open project".into(),
|
||||
Self::DatabaseSources => "Manage its own database sources and store their passwords in trusted projects".into(),
|
||||
Self::IdeCommands => "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing".into(),
|
||||
Self::VcsRead => "Read version control state".into(),
|
||||
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
|
||||
Self::Storage => "Store its own data".into(),
|
||||
@@ -92,6 +104,12 @@ impl Capability {
|
||||
"Copy to the clipboard from its commands, and read and write it from its web views".into()
|
||||
}
|
||||
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
|
||||
Self::NetHttp(host) => {
|
||||
format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)")
|
||||
}
|
||||
Self::Ipc(path) => {
|
||||
format!("Connect to local socket {path} — equivalent to running arbitrary code on the project machine")
|
||||
}
|
||||
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
|
||||
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
|
||||
Self::FsWrite(path) => format!("Change files under {path}, outside the project"),
|
||||
@@ -100,8 +118,14 @@ impl Capability {
|
||||
|
||||
/// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`).
|
||||
pub fn covers(&self, wanted: &Capability) -> bool {
|
||||
if self == wanted {
|
||||
return true;
|
||||
}
|
||||
match (self, wanted) {
|
||||
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
|
||||
(Self::Ipc(pattern), Self::Ipc(path)) => ipc_matches(pattern, path),
|
||||
(Self::Net(pattern), Self::Net(host)) | (Self::NetHttp(pattern), Self::NetHttp(host)) => {
|
||||
host_matches(pattern, host)
|
||||
}
|
||||
(Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => {
|
||||
std::path::Path::new(path).starts_with(std::path::Path::new(base))
|
||||
}
|
||||
@@ -120,6 +144,75 @@ pub fn host_matches(pattern: &str, host: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a socket path; a grant may use `*` in its last segment only.
|
||||
pub fn validate_ipc_name(name: &str, pattern: bool) -> Result<(), String> {
|
||||
let pipe = name.starts_with(r"\\.\pipe\");
|
||||
let unix = name.starts_with('/')
|
||||
|| name.starts_with("~/")
|
||||
|| name.starts_with("$TMPDIR/")
|
||||
|| name.starts_with("$XDG_RUNTIME_DIR/");
|
||||
let parts: Vec<_> = name.strip_prefix(r"\\.\pipe\").unwrap_or(name).split(if pipe { '\\' } else { '/' }).collect();
|
||||
if !(pipe || unix)
|
||||
|| name.contains(['\0', '?'])
|
||||
|| name.len() > 4096
|
||||
|| parts.last().is_none_or(|p| p.is_empty())
|
||||
|| parts.iter().any(|p| matches!(*p, "." | ".."))
|
||||
|| parts[..parts.len().saturating_sub(1)].iter().any(|p| p.contains('*'))
|
||||
|| (!pattern && name.contains('*'))
|
||||
{
|
||||
return Err(
|
||||
"ipc: use an absolute socket path or \\\\.\\pipe\\name; only the last grant segment may contain '*'".into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Expands the three supported host-local prefixes; no shell is involved.
|
||||
pub fn expand_ipc_name(name: &str) -> Result<String, String> {
|
||||
validate_ipc_name(name, true)?;
|
||||
for (prefix, key) in [("~/", "HOME"), ("$TMPDIR/", "TMPDIR"), ("$XDG_RUNTIME_DIR/", "XDG_RUNTIME_DIR")] {
|
||||
if let Some(rest) = name.strip_prefix(prefix) {
|
||||
let base = std::env::var(key).map_err(|_| format!("{key} is not set"))?;
|
||||
let expanded = format!("{}/{rest}", base.trim_end_matches('/'));
|
||||
validate_ipc_name(&expanded, true)?;
|
||||
return Ok(expanded);
|
||||
}
|
||||
}
|
||||
Ok(name.into())
|
||||
}
|
||||
|
||||
fn ipc_matches(pattern: &str, name: &str) -> bool {
|
||||
let (Ok(pattern), Ok(name)) = (expand_ipc_name(pattern), expand_ipc_name(name)) else { return false };
|
||||
if validate_ipc_name(&name, false).is_err() {
|
||||
return false;
|
||||
}
|
||||
let separator = if pattern.starts_with(r"\\.\pipe\") { '\\' } else { '/' };
|
||||
let (Some((parent, pattern)), Some((other, name))) = (pattern.rsplit_once(separator), name.rsplit_once(separator))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if parent != other {
|
||||
return false;
|
||||
}
|
||||
let mut rest = name;
|
||||
let mut pieces = pattern.split('*');
|
||||
let first = pieces.next().unwrap_or_default();
|
||||
let Some(tail) = rest.strip_prefix(first) else { return false };
|
||||
rest = tail;
|
||||
let pieces: Vec<_> = pieces.collect();
|
||||
if pieces.is_empty() {
|
||||
return rest.is_empty();
|
||||
}
|
||||
for (i, piece) in pieces.iter().enumerate() {
|
||||
if i + 1 == pieces.len() {
|
||||
return rest.ends_with(piece);
|
||||
}
|
||||
let Some(at) = rest.find(piece) else { return false };
|
||||
rest = &rest[at + piece.len()..];
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
impl fmt::Display for Capability {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -127,6 +220,8 @@ impl fmt::Display for Capability {
|
||||
Self::EditorRead => f.write_str("editor.read"),
|
||||
Self::EditorWrite => f.write_str("editor.write"),
|
||||
Self::ProjectWrite => f.write_str("project.write"),
|
||||
Self::IdeCommands => f.write_str("ide.commands"),
|
||||
Self::DatabaseSources => f.write_str("database.sources"),
|
||||
Self::VcsRead => f.write_str("vcs.read"),
|
||||
Self::VcsWrite => f.write_str("vcs.write"),
|
||||
Self::Storage => f.write_str("storage"),
|
||||
@@ -135,6 +230,8 @@ impl fmt::Display for Capability {
|
||||
Self::InputEvents => f.write_str("events.input"),
|
||||
Self::Clipboard => f.write_str("clipboard"),
|
||||
Self::Net(h) => write!(f, "net:{h}"),
|
||||
Self::NetHttp(h) => write!(f, "net.http:{h}"),
|
||||
Self::Ipc(p) => write!(f, "ipc:{p}"),
|
||||
Self::Process(b) => write!(f, "process:{b}"),
|
||||
Self::FsRead(p) => write!(f, "fs.read:{p}"),
|
||||
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
|
||||
@@ -153,7 +250,7 @@ impl FromStr for Capability {
|
||||
return Err(format!("capability {s:?} needs a value after ':'"));
|
||||
}
|
||||
return match kind {
|
||||
"net" => {
|
||||
"net" | "net.http" => {
|
||||
let host = arg.strip_prefix("*.").unwrap_or(arg);
|
||||
let valid = !host.is_empty()
|
||||
&& host.len() <= 253
|
||||
@@ -162,7 +259,19 @@ impl FromStr for Capability {
|
||||
&& label.len() <= 63
|
||||
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
});
|
||||
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
|
||||
if valid {
|
||||
Ok(if kind == "net" {
|
||||
Self::Net(arg.to_ascii_lowercase())
|
||||
} else {
|
||||
Self::NetHttp(arg.to_ascii_lowercase())
|
||||
})
|
||||
} else {
|
||||
Err(format!("invalid host in {s:?}"))
|
||||
}
|
||||
}
|
||||
"ipc" => {
|
||||
validate_ipc_name(arg, true)?;
|
||||
Ok(Self::Ipc(arg.to_owned()))
|
||||
}
|
||||
"process" => {
|
||||
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
|
||||
@@ -184,6 +293,8 @@ impl FromStr for Capability {
|
||||
"editor.read" | "editor-read" => Self::EditorRead,
|
||||
"editor.write" | "editor-write" => Self::EditorWrite,
|
||||
"project.write" => Self::ProjectWrite,
|
||||
"ide.commands" => Self::IdeCommands,
|
||||
"database.sources" => Self::DatabaseSources,
|
||||
"vcs.read" => Self::VcsRead,
|
||||
"vcs.write" => Self::VcsWrite,
|
||||
"storage" => Self::Storage,
|
||||
@@ -292,3 +403,71 @@ mod tests {
|
||||
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod api14_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn http_grants_do_not_imply_https_and_socket_patterns_stay_in_one_directory() {
|
||||
let http: Capability = "net.http:*.example.com".parse().unwrap();
|
||||
assert_eq!(http.to_string(), "net.http:*.example.com");
|
||||
assert_eq!(http.risk(), Risk::Medium);
|
||||
assert!(http.describe().contains("unencrypted"));
|
||||
assert!(http.covers(&"net.http:a.example.com".parse().unwrap()));
|
||||
assert!(!http.covers(&"net:a.example.com".parse().unwrap()));
|
||||
assert!(!"net:*.example.com".parse::<Capability>().unwrap().covers(&"net.http:a.example.com".parse().unwrap()));
|
||||
let socket: Capability = "ipc:/tmp/discord-ipc-*-socket".parse().unwrap();
|
||||
assert_eq!(socket.risk(), Risk::High);
|
||||
assert!(socket.covers(&Capability::Ipc("/tmp/discord-ipc-42-socket".into())));
|
||||
assert!(!socket.covers(&Capability::Ipc("/tmp/discord-ipc-dir/evil-socket".into())));
|
||||
assert!(!socket.covers(&Capability::Ipc("/tmp/../tmp/discord-ipc-42-socket".into())));
|
||||
assert_eq!("ipc:$TMPDIR/discord-ipc-*".parse::<Capability>().unwrap().to_string(), "ipc:$TMPDIR/discord-ipc-*");
|
||||
assert!(
|
||||
r"ipc:\\.\pipe\discord-*"
|
||||
.parse::<Capability>()
|
||||
.unwrap()
|
||||
.covers(&Capability::Ipc(r"\\.\pipe\discord-0".into()))
|
||||
);
|
||||
for invalid in
|
||||
["ipc:relative", "ipc:/tmp/*/socket", "ipc:/tmp/../socket", "ipc:/tmp/", "ipc:/tmp/socket?", "net.http:a b"]
|
||||
{
|
||||
assert!(invalid.parse::<Capability>().is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod command_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn ide_commands_is_explicit_and_high_risk() {
|
||||
let cap: Capability = "ide.commands".parse().unwrap();
|
||||
assert_eq!(cap, Capability::IdeCommands);
|
||||
assert_eq!(cap.to_string(), "ide.commands");
|
||||
assert_eq!(cap.risk(), Risk::High);
|
||||
assert!(!cap.auto_migrates());
|
||||
assert!(!Capability::VcsWrite.covers(&cap));
|
||||
assert!(!Capability::Process("git".into()).covers(&cap));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod database_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn database_sources_is_a_medium_risk_exact_grant_requiring_api4() {
|
||||
let cap: Capability = "database.sources".parse().unwrap();
|
||||
assert_eq!(cap, Capability::DatabaseSources);
|
||||
assert_eq!(cap.to_string(), "database.sources");
|
||||
assert_eq!(cap.risk(), Risk::Medium);
|
||||
assert!(!cap.auto_migrates());
|
||||
assert!(!Capability::ProjectRead.covers(&cap));
|
||||
assert!(!Capability::ProjectWrite.covers(&cap));
|
||||
for (api, valid) in [("^1.3", false), ("^1.4", true)] {
|
||||
let text = format!(
|
||||
"id='db-test'\nname='DB'\nversion='1.0.0'\nengines.openidea='{api}'\n[capabilities]\nrequired=['database.sources']"
|
||||
);
|
||||
assert_eq!(crate::Manifest::parse(&text).is_ok(), valid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,12 @@ pub struct Grant {
|
||||
/// `process:<program>` → the program confirmed for it.
|
||||
#[serde(default)]
|
||||
pub binaries: BTreeMap<String, BinaryGrant>,
|
||||
/// Project root → process grant → the executable approved for that project.
|
||||
#[serde(default)]
|
||||
pub project_binaries: BTreeMap<PathBuf, BTreeMap<String, BinaryGrant>>,
|
||||
/// Explicit global executable choices, retained for the next re-approval.
|
||||
#[serde(default)]
|
||||
pub program_choices: BTreeMap<String, PathBuf>,
|
||||
/// Migrated from 0.1: capabilities not granted yet are asked for on first
|
||||
/// use instead of blocking the plugin from loading.
|
||||
#[serde(default)]
|
||||
@@ -248,6 +254,9 @@ impl Ledger {
|
||||
} else {
|
||||
if let Capability::Process(_) = cap {
|
||||
grant.binaries.remove(&s);
|
||||
for binaries in grant.project_binaries.values_mut() {
|
||||
binaries.remove(&s);
|
||||
}
|
||||
}
|
||||
grant.granted.remove(&s)
|
||||
}
|
||||
@@ -260,6 +269,58 @@ impl Ledger {
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's explicit global executable choice, if any.
|
||||
pub fn program_choice(&self, subject: &Subject, cap: &Capability) -> Option<&Path> {
|
||||
self.grants.get(subject)?.program_choices.get(&cap.to_string()).map(PathBuf::as_path)
|
||||
}
|
||||
/// Remembers or resets an explicit global executable choice after confirmation.
|
||||
pub fn set_program_choice(&mut self, subject: &Subject, cap: &Capability, path: Option<&Path>) {
|
||||
if let Some(grant) = self.grants.get_mut(subject) {
|
||||
if let Some(path) = path {
|
||||
grant.program_choices.insert(cap.to_string(), path.into());
|
||||
} else {
|
||||
grant.program_choices.remove(&cap.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates a project-specific program choice after user confirmation.
|
||||
pub fn set_project_binary(
|
||||
&mut self,
|
||||
subject: &Subject,
|
||||
cap: &Capability,
|
||||
root: &Path,
|
||||
binary: Option<BinaryGrant>,
|
||||
) {
|
||||
if let Some(grant) = self.grants.get_mut(subject) {
|
||||
if let Some(binary) = binary {
|
||||
grant.project_binaries.entry(root.into()).or_default().insert(cap.to_string(), binary);
|
||||
} else if let Some(binaries) = grant.project_binaries.get_mut(root) {
|
||||
binaries.remove(&cap.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The project override when present, otherwise the global approved program.
|
||||
pub fn check_binary_in_project(
|
||||
&self,
|
||||
subject: &Subject,
|
||||
cap: &Capability,
|
||||
root: Option<&Path>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
|
||||
if !grant.has(cap) {
|
||||
return Err(format!("{cap} was not granted"));
|
||||
}
|
||||
if let Some(bin) = root.and_then(|root| grant.project_binaries.get(root)).and_then(|b| b.get(&cap.to_string()))
|
||||
{
|
||||
return match file_hash(&bin.path) {
|
||||
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
|
||||
_ => Err(format!("{} changed or is missing; confirm {cap} again", bin.path.display())),
|
||||
};
|
||||
}
|
||||
self.check_binary(subject, cap)
|
||||
}
|
||||
|
||||
/// Checks a `process:` grant at call time: the program confirmed for it,
|
||||
/// unchanged since. Errors name what to do.
|
||||
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
|
||||
@@ -306,6 +367,16 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
|
||||
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
|
||||
}
|
||||
|
||||
/// Pins an explicitly selected executable, retaining a multicall shim's filename.
|
||||
pub fn approve_program(path: &Path) -> Result<BinaryGrant, String> {
|
||||
if !path.is_absolute() || !is_executable(path) {
|
||||
return Err("choose an absolute executable file".into());
|
||||
}
|
||||
let path = canonical_dir(path);
|
||||
let hash = file_hash(&path).map_err(|e| e.to_string())?;
|
||||
Ok(BinaryGrant { path, hash })
|
||||
}
|
||||
|
||||
/// Resolves a `process:` program to an absolute path once, at grant time:
|
||||
/// absolute names are taken as is, others are looked up in `path_var`.
|
||||
/// The directory is canonicalized but the file name kept as found: `cargo`,
|
||||
|
||||
@@ -24,7 +24,7 @@ mod registry;
|
||||
mod ui;
|
||||
mod when;
|
||||
|
||||
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
|
||||
pub use capability::{Capability, CapabilityInfo, Risk, expand_ipc_name, host_matches, validate_ipc_name};
|
||||
pub use index::ContributionIndex;
|
||||
pub use manifest::{
|
||||
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
|
||||
|
||||
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.3.0";
|
||||
pub const ENGINE_VERSION: &str = "1.5.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
@@ -144,8 +144,20 @@ impl ActivationEvent {
|
||||
|
||||
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
|
||||
/// `scheduled` comes from `on-schedule` instead.
|
||||
pub const EVENT_KINDS: &[&str] =
|
||||
&["file-opened", "file-saved", "files-changed", "editor-focused", "action-performed", "vcs-changed"];
|
||||
pub const EVENT_KINDS: &[&str] = &[
|
||||
"run-exited",
|
||||
"terminal-exited",
|
||||
"content-tab-action",
|
||||
"file-closed",
|
||||
"settings-changed",
|
||||
"input-mode-changed",
|
||||
"file-opened",
|
||||
"file-saved",
|
||||
"files-changed",
|
||||
"editor-focused",
|
||||
"action-performed",
|
||||
"vcs-changed",
|
||||
];
|
||||
|
||||
impl std::fmt::Display for ActivationEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -317,6 +329,12 @@ impl Manifest {
|
||||
check_relative(icon, "icon")?;
|
||||
}
|
||||
for cap in self.capabilities.required.iter().chain(&self.capabilities.optional) {
|
||||
if matches!(
|
||||
cap,
|
||||
Capability::IdeCommands | Capability::DatabaseSources | Capability::NetHttp(_) | Capability::Ipc(_)
|
||||
) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 4)?;
|
||||
}
|
||||
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 3)?;
|
||||
}
|
||||
@@ -332,6 +350,10 @@ impl Manifest {
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for event in &self.activation {
|
||||
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
|
||||
{
|
||||
self.needs_api(&format!("`{event}`"), 4)?;
|
||||
}
|
||||
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
|
||||
self.needs_api("`on-event:files-changed`", 3)?;
|
||||
}
|
||||
@@ -655,3 +677,44 @@ mod tests {
|
||||
assert!(!engine_compatible("0.10", true));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod api14_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn new_capabilities_and_activations_require_api_1_4() {
|
||||
for declaration in [
|
||||
"[capabilities]\nrequired = [\"net.http:example.com\"]",
|
||||
"[capabilities]\noptional = [\"ipc:$TMPDIR/test-*\"]",
|
||||
"activation = [\"on-event:file-closed\"]",
|
||||
"activation = [\"on-event:settings-changed\"]",
|
||||
"activation = [\"on-event:input-mode-changed\"]",
|
||||
"activation = [\"on-event:run-exited\"]",
|
||||
"activation = [\"on-event:terminal-exited\"]",
|
||||
"activation = [\"on-event:content-tab-action\"]",
|
||||
] {
|
||||
for engine in ["^1.0", "^1.3", "^1.4"] {
|
||||
let manifest = format!(
|
||||
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
|
||||
);
|
||||
let parsed = Manifest::parse(&manifest);
|
||||
if engine == "^1.4" {
|
||||
parsed.unwrap();
|
||||
} else {
|
||||
assert!(parsed.unwrap_err().contains("new in plugin API 1.4"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod commands_api_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn ide_commands_requires_api_1_4() {
|
||||
let manifest = "id = \"test\"\nname = \"Test\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.3\"\n[capabilities]\nrequired = [\"ide.commands\"]\n";
|
||||
assert!(Manifest::parse(manifest).unwrap_err().contains("1.4"));
|
||||
assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -627,8 +627,8 @@ mod tests {
|
||||
#[test]
|
||||
fn finds_remote_by_repo() {
|
||||
let remotes = vec![
|
||||
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into() },
|
||||
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into() },
|
||||
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into(), resolved_url: None },
|
||||
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into(), resolved_url: None },
|
||||
];
|
||||
assert_eq!(remote_for(&remotes, "me/app").map(|r| r.name.as_str()), Some("origin"));
|
||||
assert_eq!(remote_for(&remotes, "org/app").map(|r| r.name.as_str()), Some("upstream"));
|
||||
|
||||
@@ -10,6 +10,7 @@ name = "openidea-server"
|
||||
path = "src/bin/server.rs"
|
||||
|
||||
[dependencies]
|
||||
percent-encoding = "2"
|
||||
encoding_rs.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-mcp.workspace = true
|
||||
@@ -52,6 +53,7 @@ ide-tools = { path = "../ide-tools" }
|
||||
ide-docker = { path = "../ide-docker" }
|
||||
semver = "1"
|
||||
serde.workspace = true
|
||||
jsonschema = { version = "0.33", default-features = false }
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
toml.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
//! Storage policy and best-effort migration of rebuildable desktop data.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
|
||||
/// configuration and roaming data keep following the OS.
|
||||
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
|
||||
|
||||
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
|
||||
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
|
||||
/// debug builds apart under one root. A set but unusable path is an error, never a
|
||||
/// silent fallback to the OS location.
|
||||
pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
|
||||
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
|
||||
}
|
||||
|
||||
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
|
||||
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
|
||||
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
|
||||
};
|
||||
// The working directory depends on how the IDE was launched.
|
||||
if !root.is_absolute() {
|
||||
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
|
||||
}
|
||||
let dir = root.join(identifier);
|
||||
fs::create_dir_all(&dir)
|
||||
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
|
||||
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Unknown entries and recovery/user content deliberately stay in roaming data.
|
||||
pub fn is_local(entry: &Path) -> bool {
|
||||
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
|
||||
@@ -276,6 +304,102 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_dir_follows_the_os_unless_the_variable_is_set() {
|
||||
let os = Some(PathBuf::from("/os-local"));
|
||||
for var in [None, Some(OsString::new())] {
|
||||
assert_eq!(
|
||||
resolve_local(var, os.clone(), "ai.l0ng.openidea").unwrap(),
|
||||
Path::new("/os-local/ai.l0ng.openidea")
|
||||
);
|
||||
}
|
||||
assert!(resolve_local(None, None, "id").is_err());
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("missing/nested");
|
||||
let dir = resolve_local(Some(root.clone().into()), os.clone(), "ai.l0ng.openidea").unwrap();
|
||||
assert_eq!(dir, root.join("ai.l0ng.openidea"));
|
||||
assert!(dir.is_dir());
|
||||
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
|
||||
// Debug worktrees have their own identifiers and stay apart under one root.
|
||||
assert_ne!(resolve_local(Some(root.clone().into()), None, "dev-a").unwrap(), dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_dir_preserves_an_existing_probe_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("id");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
let probe = dir.join(".openidea-write-check");
|
||||
fs::write(&probe, "keep").unwrap();
|
||||
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
|
||||
assert_eq!(fs::read_to_string(probe).unwrap(), "keep");
|
||||
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_dir_preserves_an_existing_probe_symlink_and_its_target() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("id");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
let target = tmp.path().join("target");
|
||||
fs::write(&target, "keep").unwrap();
|
||||
let probe = dir.join(".openidea-write-check");
|
||||
std::os::unix::fs::symlink(&target, &probe).unwrap();
|
||||
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
|
||||
assert_eq!(fs::read_to_string(target).unwrap(), "keep");
|
||||
assert!(fs::symlink_metadata(probe).unwrap().file_type().is_symlink());
|
||||
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_dir_allows_concurrent_probes_for_the_same_identifier() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let barrier = std::sync::Barrier::new(8);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..8 {
|
||||
let barrier = &barrier;
|
||||
let root = tmp.path();
|
||||
scope.spawn(move || {
|
||||
barrier.wait();
|
||||
for _ in 0..16 {
|
||||
assert_eq!(resolve_local(Some(root.into()), None, "id").unwrap(), root.join("id"));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
assert_eq!(fs::read_dir(tmp.path().join("id")).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_dir_rejects_relative_and_unwritable_paths() {
|
||||
for relative in ["local", "./local", "../local"] {
|
||||
let error = resolve_local(Some(relative.into()), Some("/os".into()), "id").unwrap_err();
|
||||
assert!(error.contains(LOCAL_DATA_ENV) && error.contains("absolute"), "{error}");
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let blocker = tmp.path().join("file");
|
||||
fs::write(&blocker, "").unwrap();
|
||||
let error = resolve_local(Some(blocker.into()), Some("/os".into()), "id").unwrap_err();
|
||||
assert!(error.contains("cannot write"), "{error}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn local_dir_rejects_an_existing_read_only_directory() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("id");
|
||||
fs::create_dir(&dir).unwrap();
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
|
||||
// Root ignores permission bits.
|
||||
if fs::write(dir.join("probe"), "").is_err() {
|
||||
assert!(resolve_local(Some(tmp.path().into()), None, "id").unwrap_err().contains("cannot write"));
|
||||
}
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
|
||||
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
|
||||
|
||||
@@ -130,6 +130,38 @@ pub async fn format(
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let settings = project.settings.effective();
|
||||
let language = project.languages.get(&snap.language);
|
||||
if options.tool.is_none()
|
||||
&& let Some(id) = settings.languages.plugin_formatters.get(snap.language.as_str()).filter(|s| !s.is_empty())
|
||||
{
|
||||
if options.on_save && !settings.editor.format_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if snap.read_only {
|
||||
return Err(IdeError::invalid("The file is read-only"));
|
||||
}
|
||||
let plugins = crate::plugins::editor_plugin_host()
|
||||
.ok_or_else(|| IdeError::not_found("The selected plugin formatter is unavailable"))?;
|
||||
let d = ide_plugin::ProviderDocument {
|
||||
path: snap.path.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
let id = id.clone();
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
plugins.language_format(
|
||||
&id,
|
||||
&d,
|
||||
range,
|
||||
ide_plugin::language_features::FormatOptions {
|
||||
tab_size: options.tab_size,
|
||||
insert_spaces: options.insert_spaces,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
|
||||
if options.tool.is_none()
|
||||
&& snap.path.extension().is_some_and(|e| e == "php")
|
||||
|
||||
@@ -55,6 +55,8 @@ pub struct HostConfig {
|
||||
}
|
||||
|
||||
pub struct Host {
|
||||
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
|
||||
plugin_host: Arc<RwLock<std::sync::Weak<plugins::PluginHost>>>,
|
||||
pub mcp_reports: mcp::Reports,
|
||||
pub mcp_requests: mcp::Requests,
|
||||
pub trust: trust::TrustStore,
|
||||
@@ -99,6 +101,14 @@ pub struct Host {
|
||||
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
impl Host {
|
||||
/// Windows whose current project has this root.
|
||||
pub(crate) fn windows_for_root(&self, root: &std::path::Path) -> Vec<String> {
|
||||
let mut windows: Vec<_> =
|
||||
self.projects.read().iter().filter(|(_, p)| p.root == root).map(|(w, _)| w.clone()).collect();
|
||||
windows.sort();
|
||||
windows
|
||||
}
|
||||
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig {
|
||||
config_dir,
|
||||
@@ -115,6 +125,8 @@ impl Host {
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
let host = Self {
|
||||
plugin_host: Arc::default(),
|
||||
mcp_executable: RwLock::default(),
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
@@ -166,6 +178,19 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Associates the plugin runtime that executes this host's project operations.
|
||||
/// Publishes only the executable of the running built-in MCP service.
|
||||
pub fn set_mcp_executable(&self, executable: Option<PathBuf>) {
|
||||
*self.mcp_executable.write() = executable;
|
||||
}
|
||||
|
||||
pub fn set_plugin_host(&self, plugins: &Arc<plugins::PluginHost>) {
|
||||
*self.plugin_host.write() = Arc::downgrade(plugins);
|
||||
}
|
||||
pub(crate) fn plugin_host(&self) -> Option<Arc<plugins::PluginHost>> {
|
||||
self.plugin_host.read().upgrade()
|
||||
}
|
||||
|
||||
/// Every open project (all windows).
|
||||
pub fn projects(&self) -> Vec<Arc<Project>> {
|
||||
self.projects.read().values().cloned().collect()
|
||||
@@ -231,6 +256,11 @@ impl Host {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
let project = self.projects.write().remove(window)?;
|
||||
if self.windows_for_root(&project.root).is_empty()
|
||||
&& let Some(plugins) = self.plugin_host()
|
||||
{
|
||||
plugins.withdraw_project_run_targets(&project.root);
|
||||
}
|
||||
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
|
||||
project.plugin_diagnostics.close();
|
||||
if let Some(plugins) = plugins::editor_plugin_host() {
|
||||
|
||||
@@ -75,7 +75,11 @@ impl ToolRegistry for Registry {
|
||||
}
|
||||
fn tools(&self, root: &Path) -> Result<Vec<ide_mcp::Tool>, String> {
|
||||
let (_, project) = self.target(root)?;
|
||||
Ok(tools().into_iter().map(|t| t.metadata).filter(|t| allowed(t, project.is_trusted())).collect())
|
||||
let mut all: Vec<_> = tools().into_iter().map(|t| t.metadata).collect();
|
||||
if let Some(plugins) = self.0.plugin_host() {
|
||||
all.extend(plugins.mcp_tools(root).into_iter().map(|(_, _, tool)| tool));
|
||||
}
|
||||
Ok(all.into_iter().filter(|t| allowed(t, project.is_trusted())).collect())
|
||||
}
|
||||
fn call(
|
||||
&self,
|
||||
@@ -95,6 +99,12 @@ impl ToolRegistry for Registry {
|
||||
client: &str,
|
||||
) -> Result<String, ToolCallError> {
|
||||
let (window, project) = self.target(root).map_err(ToolCallError::Failed)?;
|
||||
if let Some(plugins) = self.0.plugin_host()
|
||||
&& let Some((plugin, tool, metadata)) =
|
||||
plugins.mcp_tools(root).into_iter().find(|(_, _, t)| t.name == name && allowed(t, project.is_trusted()))
|
||||
{
|
||||
return self.call_plugin(window, project, plugins, plugin, tool, metadata, arguments, cancel, client);
|
||||
}
|
||||
let tool = tools()
|
||||
.into_iter()
|
||||
.find(|t| t.metadata.name == name && allowed(&t.metadata, project.is_trusted()))
|
||||
@@ -129,6 +139,51 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn call_plugin(
|
||||
&self,
|
||||
window: String,
|
||||
project: Arc<crate::Project>,
|
||||
plugins: Arc<crate::plugins::PluginHost>,
|
||||
plugin: String,
|
||||
tool: String,
|
||||
metadata: ide_mcp::Tool,
|
||||
arguments: &str,
|
||||
cancel: CancellationToken,
|
||||
client: &str,
|
||||
) -> Result<String, ToolCallError> {
|
||||
let run = || -> ide_core::IdeResult<String> {
|
||||
let args = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let call = tool::Call {
|
||||
host: self.0.clone(),
|
||||
project: project.clone(),
|
||||
window: window.clone(),
|
||||
args,
|
||||
cancel: cancel.clone(),
|
||||
client: client.into(),
|
||||
tool: metadata.name,
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if metadata.destructive {
|
||||
self.0.runtime.block_on(call.confirm(&metadata.description, None))?;
|
||||
}
|
||||
if !metadata.read_only {
|
||||
project.require_trust()?;
|
||||
}
|
||||
let invocation = ide_plugin::Invocation {
|
||||
client: Some(window),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
plugins.call_mcp_tool(&plugin, &tool, arguments, invocation, &cancel)
|
||||
};
|
||||
run().map_err(|e| ToolCallError::Failed(e.message))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -28,6 +28,8 @@ pub struct Reports(RwLock<State>);
|
||||
struct State {
|
||||
windows: HashMap<String, Window>,
|
||||
focused: Option<String>,
|
||||
/// Windows in the order they were last focused, the most recent last.
|
||||
focus_order: Vec<String>,
|
||||
remote: HashMap<String, PathBuf>,
|
||||
libraries: HashMap<String, Vec<PathBuf>>,
|
||||
}
|
||||
@@ -46,8 +48,16 @@ impl Reports {
|
||||
state.windows.entry(window.into()).or_default().focused = editor;
|
||||
if focused {
|
||||
state.focused = Some(window.into());
|
||||
state.focus_order.retain(|w| w != window);
|
||||
state.focus_order.push(window.into());
|
||||
}
|
||||
}
|
||||
/// Sorts `windows` by when they were last focused, the most recent first; windows
|
||||
/// never focused keep their order after them.
|
||||
pub(crate) fn most_recently_focused_first(&self, windows: &mut [String]) {
|
||||
let order = &self.0.read().focus_order;
|
||||
windows.sort_by_key(|w| std::cmp::Reverse(order.iter().position(|o| o == w)));
|
||||
}
|
||||
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
|
||||
let mut state = self.0.write();
|
||||
let problems = &mut state.windows.entry(window.into()).or_default().problems;
|
||||
@@ -68,6 +78,7 @@ impl Reports {
|
||||
state.windows.remove(window);
|
||||
state.remote.remove(window);
|
||||
state.libraries.remove(window);
|
||||
state.focus_order.retain(|w| w != window);
|
||||
if state.focused.as_deref() == Some(window) {
|
||||
state.focused = None;
|
||||
}
|
||||
@@ -91,7 +102,7 @@ impl Reports {
|
||||
pub(super) fn focused_window(&self) -> Option<String> {
|
||||
self.0.read().focused.clone()
|
||||
}
|
||||
pub(super) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
|
||||
pub(crate) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
|
||||
let state = self.0.read();
|
||||
let mut paths = Vec::new();
|
||||
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
|
||||
@@ -103,10 +114,10 @@ impl Reports {
|
||||
}
|
||||
paths
|
||||
}
|
||||
pub(super) fn focused(&self, window: &str) -> Option<FocusedEditor> {
|
||||
pub(crate) fn focused(&self, window: &str) -> Option<FocusedEditor> {
|
||||
self.0.read().windows.get(window).and_then(|w| w.focused.clone())
|
||||
}
|
||||
pub(super) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
|
||||
pub(crate) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
|
||||
let state = self.0.read();
|
||||
let mut files: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
|
||||
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
|
||||
@@ -122,3 +133,24 @@ impl Reports {
|
||||
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn windows_sort_by_their_last_focus() {
|
||||
let reports = Reports::default();
|
||||
for window in ["a", "b", "a", "c"] {
|
||||
reports.set_focused_editor(window, None, true);
|
||||
}
|
||||
reports.set_focused_editor("b", None, false);
|
||||
let mut windows: Vec<String> = ["a", "b", "c", "never"].map(String::from).into();
|
||||
reports.most_recently_focused_first(&mut windows);
|
||||
assert_eq!(windows, ["c", "a", "b", "never"]);
|
||||
reports.forget_window("c");
|
||||
let mut windows: Vec<String> = ["never", "c", "b", "a"].map(String::from).into();
|
||||
reports.most_recently_focused_first(&mut windows);
|
||||
assert_eq!(windows, ["a", "b", "never", "c"]);
|
||||
}
|
||||
}
|
||||
@@ -134,21 +134,22 @@ pub async fn inspect_code(
|
||||
let cancel = host.begin_request(request);
|
||||
let listed = {
|
||||
let (project, cancel) = (project.clone(), cancel.clone());
|
||||
blocking(move || files_in_scope(&project, &scope, &cancel)).await
|
||||
blocking(move || {
|
||||
let files = files_in_scope(&project, &scope, &cancel)?;
|
||||
let total = files.len() as u32;
|
||||
let rel: std::collections::HashMap<_, _> =
|
||||
files.into_iter().filter(|f| project.lsp.serves_path(&f.path)).map(|f| (f.path, f.rel_path)).collect();
|
||||
Ok((total, rel))
|
||||
})
|
||||
.await
|
||||
};
|
||||
let files = match listed {
|
||||
let (total_in_scope, rel) = match listed {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
host.end_request(request);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let total_in_scope = files.len() as u32;
|
||||
let rel: std::collections::HashMap<_, _> = files
|
||||
.iter()
|
||||
.filter(|f| project.lsp.serves_path(&f.path))
|
||||
.map(|f| (f.path.clone(), f.rel_path.clone()))
|
||||
.collect();
|
||||
let mut summary = InspectSummary { skipped: total_in_scope - rel.len() as u32, ..Default::default() };
|
||||
let _ = on_event.send(InspectEvent::Started { total: rel.len() as u32 });
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Maven / Gradle tool windows (the build model and its background sync) and
|
||||
//! Build › Build Project (compiling with the project's build system).
|
||||
mod workspace;
|
||||
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
use crate::{Host, Project, apply_workspace_edit};
|
||||
@@ -10,7 +12,6 @@ use ide_build::pom_edit::{ArtifactKey, Exclusion};
|
||||
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
fn join_err(e: tokio::task::JoinError) -> IdeError {
|
||||
@@ -125,8 +126,8 @@ fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel {
|
||||
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
let config = host.config_dir.clone();
|
||||
tokio::task::spawn_blocking(move || workspace::load_project(&project, &config)).await.map_err(join_err)?
|
||||
}
|
||||
|
||||
fn linked_gradle_paths(
|
||||
@@ -224,84 +225,89 @@ pub async fn build_sync(
|
||||
) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let bridge_profiles = profiles.clone();
|
||||
// What the Java language server and Build Project resolve the project with from now on.
|
||||
let profiles_changed = tool == BuildTool::Maven
|
||||
&& ide_build::save_maven_profiles(&cache, &profiles).unwrap_or_else(|e| {
|
||||
tracing::warn!("Maven profiles not saved: {e}");
|
||||
false
|
||||
});
|
||||
if profiles_changed {
|
||||
// The modules of the chosen profiles are read from the POMs with them from now on (the
|
||||
// project tree's module folders too), whether or not Maven itself can run.
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
}
|
||||
let token = host.begin_project_request(window, request);
|
||||
let completion_token = token.clone();
|
||||
let original_links = settings.languages.java.gradle.linked_projects.clone();
|
||||
let bridge_profiles = profiles.clone();
|
||||
let p = project.clone();
|
||||
let config = host.config_dir.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
BuildTool::Maven => current
|
||||
let scopes = workspace::scopes(&p, &config)?;
|
||||
let selected: Vec<_> =
|
||||
scopes.iter().filter(|scope| workspace::selected(scope, &p.root, tool, build_dir.as_deref())).collect();
|
||||
if selected.is_empty() {
|
||||
return Err(IdeError::not_found("Build project is no longer linked"));
|
||||
}
|
||||
let mut profiles_changed = false;
|
||||
for scope in selected {
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let (root, cache, settings) = (&scope.root, &scope.cache, &scope.settings);
|
||||
let current = load(root, cache, settings);
|
||||
if tool == BuildTool::Maven && current.maven.is_some() {
|
||||
let changed = ide_build::save_maven_profiles(cache, &profiles).unwrap_or_else(|e| {
|
||||
tracing::warn!("Maven profiles not saved: {e}");
|
||||
false
|
||||
});
|
||||
if changed {
|
||||
p.sink.emit(IdeEvent::ProjectRootsChanged { root: p.root.clone().into() });
|
||||
}
|
||||
profiles_changed |= changed;
|
||||
}
|
||||
let env = current
|
||||
.maven
|
||||
.as_ref()
|
||||
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
|
||||
.map(|p| p.env.clone())
|
||||
.unwrap_or_default(),
|
||||
BuildTool::Gradle => BTreeMap::new(),
|
||||
};
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
profiles,
|
||||
env,
|
||||
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(&root, &settings),
|
||||
maven_settings: settings.languages.java.maven.clone(),
|
||||
gradle_command: None,
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => {
|
||||
if let Some(dir) = &build_dir
|
||||
&& !current.gradle.iter().any(|g| &g.dir == dir)
|
||||
{
|
||||
return Err(IdeError::not_found("Gradle project is no longer linked"));
|
||||
.unwrap_or_default();
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
profiles: profiles.clone(),
|
||||
env,
|
||||
maven_command: ide_sdk::apply::maven_command(root, settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(root, settings),
|
||||
maven_settings: settings.languages.java.maven.clone(),
|
||||
gradle_command: None,
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven if current.maven.is_some() => {
|
||||
ide_build::sync_maven(root, cache, &options, &token, &progress)?
|
||||
}
|
||||
for g in current.gradle.iter().filter(|g| build_dir.as_ref().is_none_or(|dir| dir == &g.dir)) {
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
BuildTool::Maven => {}
|
||||
BuildTool::Gradle => {
|
||||
for g in current
|
||||
.gradle
|
||||
.iter()
|
||||
.filter(|g| build_dir.as_ref().is_none_or(|dir| p.root.join(dir) == root.join(&g.dir)))
|
||||
{
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let options = SyncOptions {
|
||||
env: g.env.clone(),
|
||||
gradle_command: Some(g.command.clone()),
|
||||
..options.clone()
|
||||
};
|
||||
ide_build::sync_gradle_model(root, cache, g, &options, &token, &progress)?;
|
||||
}
|
||||
let options =
|
||||
SyncOptions { env: g.env.clone(), gradle_command: Some(g.command.clone()), ..options.clone() };
|
||||
ide_build::sync_gradle_model(&root, &cache, g, &options, &token, &progress)?;
|
||||
}
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
|
||||
let scopes = workspace::scopes(&p, &config)?;
|
||||
let model = workspace::load_scopes(&p.root, &scopes);
|
||||
let updates = workspace::update_files(&p.root, &scopes, &model, tool);
|
||||
Ok::<_, IdeError>((model, profiles_changed, updates))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
let model = result??;
|
||||
let (model, profiles_changed, updates) = result??;
|
||||
if completion_token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if tool == BuildTool::Gradle && original_links != project.settings.effective().languages.java.gradle.linked_projects
|
||||
{
|
||||
// A late sync must not re-import a build that was unlinked while it ran.
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
load(&project.root, &project.build_cache_dir(), &project.settings.effective())
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
}
|
||||
if tool == BuildTool::Gradle {
|
||||
// The sync may report project folders outside the project (`includeFlat`): index and list them.
|
||||
project.refresh_index_scope();
|
||||
@@ -320,30 +326,17 @@ pub async fn build_sync(
|
||||
// to update the project configurations re-resolves them (#436). The same
|
||||
// holds for Gradle: it reuses its cached classpath on restart, so a
|
||||
// dependency added to `build.gradle` stays unresolved until told to sync.
|
||||
let build_files: Vec<String> = match tool {
|
||||
BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(),
|
||||
BuildTool::Gradle => {
|
||||
model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect()
|
||||
}
|
||||
};
|
||||
let lsp = project.lsp.clone();
|
||||
// With a module of a reactor unlinked, jdtls imports the other modules one by one and
|
||||
// not the reactor: naming the reactor here would import it again, all modules included.
|
||||
let imported = match tool {
|
||||
BuildTool::Maven => crate::project::jdtls_maven_import(&project.root, &project.settings.effective()).1,
|
||||
BuildTool::Gradle => None,
|
||||
};
|
||||
let uris = match imported {
|
||||
Some(files) => files.into_iter().filter(|f| f.ends_with("/pom.xml")).collect(),
|
||||
None => build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect(),
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if profiles_changed {
|
||||
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
|
||||
lsp.reconfigure().await;
|
||||
}
|
||||
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
for file in updates {
|
||||
let uri = ide_lsp::path_to_uri_string(&file);
|
||||
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(model)
|
||||
@@ -353,12 +346,25 @@ pub async fn build_sync(
|
||||
/// module's own, else its nearest parent in the project; its path, text and declaration.
|
||||
fn declaring_pom(
|
||||
project: &Project,
|
||||
config: &Path,
|
||||
pom: &str,
|
||||
dependency: &ArtifactKey,
|
||||
) -> IdeResult<(std::path::PathBuf, String, Range)> {
|
||||
let model = ide_build::maven::load(&project.root);
|
||||
for rel in ide_build::pom_edit::declaring_poms(&model, pom) {
|
||||
let path = project.root.join(&rel);
|
||||
let scopes = workspace::scopes(project, config)?;
|
||||
let path = project.root.join(pom);
|
||||
let scope = scopes
|
||||
.iter()
|
||||
.filter(|s| path.starts_with(&s.root))
|
||||
.max_by_key(|s| s.root.components().count())
|
||||
.ok_or_else(|| IdeError::not_found("Maven project is no longer linked"))?;
|
||||
let model = load(&scope.root, &scope.cache, &scope.settings).maven.unwrap_or_default();
|
||||
let pom = model
|
||||
.projects
|
||||
.iter()
|
||||
.find(|p| scope.root.join(&p.pom) == path)
|
||||
.ok_or_else(|| IdeError::not_found("Maven project is no longer linked"))?;
|
||||
for rel in ide_build::pom_edit::declaring_poms(&model, &pom.pom) {
|
||||
let path = scope.root.join(&rel);
|
||||
let text = project.text_of(&path)?;
|
||||
if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) {
|
||||
return Ok((path, text, range));
|
||||
@@ -398,14 +404,19 @@ pub async fn gradle_dependencies(
|
||||
) -> IdeResult<Vec<ide_build::GradleDependency>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let config = host.config_dir.clone();
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let model = load(&root, &cache, &settings);
|
||||
let scopes = workspace::scopes(&project, &config)?;
|
||||
let scope = scopes
|
||||
.iter()
|
||||
.find(|s| workspace::selected(s, &project.root, BuildTool::Gradle, build_dir.as_deref()))
|
||||
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
|
||||
let model = load(&scope.root, &scope.cache, &scope.settings);
|
||||
let gradle = model
|
||||
.gradle
|
||||
.iter()
|
||||
.find(|g| build_dir.as_ref().is_none_or(|dir| &g.dir == dir))
|
||||
.find(|g| build_dir.as_ref().is_none_or(|dir| project.root.join(dir) == scope.root.join(&g.dir)))
|
||||
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
@@ -413,7 +424,7 @@ pub async fn gradle_dependencies(
|
||||
gradle_command: Some(gradle.command.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
|
||||
ide_build::gradle_dependencies(&scope.root, gradle, &options, &gradle_path, &configuration, &token)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
@@ -429,8 +440,9 @@ pub async fn maven_dependency_declaration(
|
||||
dependency: ArtifactKey,
|
||||
) -> IdeResult<Location> {
|
||||
let project = host.project(window)?;
|
||||
let config = host.config_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let (path, _, range) = declaring_pom(&project, &pom, &dependency)?;
|
||||
let (path, _, range) = declaring_pom(&project, &config, &pom, &dependency)?;
|
||||
Ok(Location::file(path, range))
|
||||
})
|
||||
.await
|
||||
@@ -469,8 +481,9 @@ pub async fn maven_exclude_dependency(
|
||||
exclusion: ArtifactKey,
|
||||
) -> IdeResult<Location> {
|
||||
let project = host.project(window)?;
|
||||
let config = host.config_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
|
||||
let (path, text, range) = declaring_pom(&project, &config, &pom, &dependency)?;
|
||||
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
|
||||
Some(Exclusion::Edit(edit)) => {
|
||||
let edit = WorkspaceEdit {
|
||||
@@ -539,7 +552,8 @@ pub async fn build_run(
|
||||
let mut combined: Option<compile::BuildPlan> = None;
|
||||
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
|
||||
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
|
||||
let member_cache = cache.join(crate::project::project_key(member));
|
||||
let member_cache =
|
||||
if member == &root { cache.clone() } else { cache.join(crate::project::project_key(member)) };
|
||||
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
|
||||
let plan = prepare_plan(
|
||||
compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(super) struct Scope {
|
||||
pub root: PathBuf,
|
||||
pub cache: PathBuf,
|
||||
pub settings: Settings,
|
||||
}
|
||||
|
||||
pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult<Vec<Scope>> {
|
||||
scopes_at(&project.root, &project.build_cache_dir(), config, project.settings.effective())
|
||||
}
|
||||
|
||||
fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult<Vec<Scope>> {
|
||||
let window_root = root;
|
||||
Ok(match ide_workspace::workspace::Workspace::load(root)? {
|
||||
Some(workspace) => workspace
|
||||
.projects
|
||||
.into_iter()
|
||||
.filter(|p| p.is_dir())
|
||||
.map(|root| Scope {
|
||||
cache: if root == window_root {
|
||||
cache.to_owned()
|
||||
} else {
|
||||
cache.join(crate::project::project_key(&root))
|
||||
},
|
||||
settings: ide_workspace::SettingsStore::load(config, Some(&root)).effective(),
|
||||
root,
|
||||
})
|
||||
.collect(),
|
||||
None => vec![Scope { root: root.to_owned(), cache: cache.to_owned(), settings }],
|
||||
})
|
||||
}
|
||||
|
||||
fn absolute(root: &Path, path: &str) -> String {
|
||||
let path = if path.is_empty() { root.to_owned() } else { root.join(path) };
|
||||
ide_core::ipc_path::to_ipc(&path)
|
||||
}
|
||||
|
||||
pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
|
||||
let mut combined = BuildModel::default();
|
||||
for scope in scopes {
|
||||
let mut model = load(&scope.root, &scope.cache, &scope.settings);
|
||||
if scopes.len() == 1 && scope.root == root {
|
||||
return model;
|
||||
}
|
||||
if scope.root != root
|
||||
&& let Some(maven) = &mut model.maven
|
||||
{
|
||||
let owner = ide_core::ipc_path::to_ipc(&scope.root);
|
||||
for p in &mut maven.projects {
|
||||
p.project_root = Some(owner.clone());
|
||||
p.pom = absolute(&scope.root, &p.pom);
|
||||
p.dir = absolute(&scope.root, &p.dir);
|
||||
for module in &mut p.modules {
|
||||
*module = absolute(&scope.root, module);
|
||||
}
|
||||
}
|
||||
for profile in &mut maven.profiles {
|
||||
profile.project_root = Some(owner.clone());
|
||||
}
|
||||
}
|
||||
if let Some(maven) = model.maven {
|
||||
match &mut combined.maven {
|
||||
None => combined.maven = Some(maven),
|
||||
Some(all) => {
|
||||
all.projects.extend(maven.projects);
|
||||
all.profiles.extend(maven.profiles);
|
||||
all.synced_at = match (all.synced_at, maven.synced_at) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
_ => None,
|
||||
};
|
||||
all.stale |= maven.stale;
|
||||
if let Some(error) = maven.sync_error {
|
||||
all.sync_error = Some(match all.sync_error.take() {
|
||||
Some(previous) => format!("{previous}\n{error}"),
|
||||
None => error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for g in model.gradle.iter_mut().filter(|_| scope.root != root) {
|
||||
g.dir = absolute(&scope.root, &g.dir);
|
||||
for p in &mut g.projects {
|
||||
p.dir = absolute(&scope.root, &p.dir);
|
||||
p.build_file = p.build_file.as_ref().map(|f| absolute(&scope.root, f));
|
||||
}
|
||||
}
|
||||
combined.gradle.extend(model.gradle);
|
||||
}
|
||||
combined
|
||||
}
|
||||
|
||||
pub(super) fn load_project(project: &Project, config: &Path) -> IdeResult<BuildModel> {
|
||||
Ok(load_scopes(&project.root, &scopes(project, config)?))
|
||||
}
|
||||
|
||||
pub(super) fn selected(scope: &Scope, window_root: &Path, tool: BuildTool, dir: Option<&str>) -> bool {
|
||||
let Some(dir) = dir else { return true };
|
||||
match tool {
|
||||
BuildTool::Maven => scope.root == Path::new(dir),
|
||||
BuildTool::Gradle => load(&scope.root, &scope.cache, &scope.settings)
|
||||
.gradle
|
||||
.iter()
|
||||
.any(|g| window_root.join(dir) == scope.root.join(&g.dir)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build files select their member's language server, including independent nested builds.
|
||||
pub(super) fn update_files(root: &Path, scopes: &[Scope], model: &BuildModel, tool: BuildTool) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
match tool {
|
||||
BuildTool::Maven => {
|
||||
for scope in scopes {
|
||||
// A reactor with unlinked modules must not be imported as a whole again.
|
||||
let imported = crate::project::jdtls_maven_import(&scope.root, &scope.settings).1;
|
||||
let paths: Vec<_> = match imported {
|
||||
Some(uris) => uris
|
||||
.iter()
|
||||
.filter_map(|u| ide_lsp::uri_to_path(u))
|
||||
.filter(|p| p.file_name().is_some_and(|n| n == "pom.xml"))
|
||||
.collect(),
|
||||
None => model
|
||||
.maven
|
||||
.iter()
|
||||
.flat_map(|m| &m.projects)
|
||||
.filter(|p| p.project_root.as_ref().is_none_or(|owner| Path::new(owner) == scope.root))
|
||||
.map(|p| root.join(&p.pom))
|
||||
.collect(),
|
||||
};
|
||||
for path in paths {
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BuildTool::Gradle => {
|
||||
for file in model.gradle.iter().flat_map(|g| &g.projects).filter_map(|p| p.build_file.as_ref()) {
|
||||
let path = root.join(file);
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attached_primary_keeps_its_cache_and_combines_build_models() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let root = base.join("primary");
|
||||
let other = base.join("other");
|
||||
for path in [&root, &other] {
|
||||
std::fs::create_dir(path).unwrap();
|
||||
std::fs::write(path.join("pom.xml"), "<project><artifactId>app</artifactId></project>").unwrap();
|
||||
}
|
||||
let cache = base.join("cache");
|
||||
ide_build::save_maven_profiles(&cache, &["kept".into()]).unwrap();
|
||||
ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap();
|
||||
let scopes = scopes_at(&root, &cache, &base.join("config"), Settings::default()).unwrap();
|
||||
assert_eq!(scopes[0].cache, cache);
|
||||
assert_eq!(ide_build::maven_profiles(&scopes[0].cache), ["kept"]);
|
||||
assert_eq!(scopes[1].cache, cache.join(crate::project::project_key(&other)));
|
||||
let model = load_scopes(&root, &scopes);
|
||||
let projects = model.maven.unwrap().projects;
|
||||
assert_eq!(projects.len(), 2);
|
||||
assert_eq!(projects[0].pom, "pom.xml");
|
||||
assert_eq!(projects[1].pom, absolute(&other, "pom.xml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_models_use_member_settings_paths_and_caches() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let workspace = base.join("workspace");
|
||||
let config = base.join("config");
|
||||
let cache = base.join("cache");
|
||||
let members: Vec<_> = ["first", "second"].into_iter().map(|name| base.join(name)).collect();
|
||||
for member in &members {
|
||||
std::fs::create_dir_all(member.join("extra")).unwrap();
|
||||
std::fs::write(
|
||||
member.join("pom.xml"),
|
||||
r#"<project><artifactId>app</artifactId>
|
||||
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles>
|
||||
</project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(member.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
|
||||
std::fs::write(member.join("settings.gradle"), "rootProject.name = 'app'").unwrap();
|
||||
std::fs::write(member.join("build.gradle"), "").unwrap();
|
||||
}
|
||||
ide_workspace::workspace::Workspace::save(&workspace, "Workspace", members.clone(), true).unwrap();
|
||||
let first_cache = cache.join(crate::project::project_key(&members[0]));
|
||||
ide_build::save_maven_profiles(&first_cache, &["extra".into()]).unwrap();
|
||||
let settings = ide_workspace::SettingsStore::load(&config, Some(&members[1]));
|
||||
settings
|
||||
.set(
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
"languages.java.gradle.linkedProjects",
|
||||
serde_json::json!([]),
|
||||
)
|
||||
.unwrap();
|
||||
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
|
||||
assert_eq!(scopes[0].cache, first_cache);
|
||||
let model = load_scopes(&workspace, &scopes);
|
||||
let maven = model.maven.as_ref().unwrap();
|
||||
assert_eq!(maven.projects.len(), 3);
|
||||
assert_eq!(maven.projects[0].modules, [absolute(&members[0], "extra/pom.xml")]);
|
||||
assert!(maven.projects.iter().all(|p| Path::new(&p.pom).is_absolute() && Path::new(&p.dir).is_absolute()));
|
||||
assert_eq!(maven.profiles.len(), 2);
|
||||
assert_ne!(maven.profiles[0].project_root, maven.profiles[1].project_root);
|
||||
let updates = update_files(&workspace, &scopes, &model, BuildTool::Maven);
|
||||
assert_eq!(updates.len(), 3);
|
||||
assert!(updates.iter().all(|p| members.iter().any(|root| p.starts_with(root))));
|
||||
assert_eq!(update_files(&workspace, &scopes, &model, BuildTool::Gradle), [members[0].join("build.gradle")]);
|
||||
assert_eq!(model.gradle.len(), 1);
|
||||
assert_eq!(model.gradle[0].dir, absolute(&members[0], ""));
|
||||
assert!(selected(&scopes[0], &workspace, BuildTool::Maven, Some(&absolute(&members[0], ""))));
|
||||
assert!(!selected(&scopes[1], &workspace, BuildTool::Maven, Some(&absolute(&members[0], ""))));
|
||||
assert!(selected(&scopes[0], &workspace, BuildTool::Gradle, Some(&model.gradle[0].dir)));
|
||||
assert!(!selected(&scopes[1], &workspace, BuildTool::Gradle, Some(&model.gradle[0].dir)));
|
||||
assert_eq!(ide_build::maven_profiles(&scopes[1].cache), Vec::<String>::new());
|
||||
let standalone = scopes_at(&members[0], &cache, &config, Settings::default()).unwrap();
|
||||
assert_eq!(load_scopes(&members[0], &standalone), load(&members[0], &cache, &Settings::default()));
|
||||
ide_workspace::workspace::Workspace::save(&workspace, "Workspace", vec![members[1].clone()], false).unwrap();
|
||||
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
|
||||
let model = load_scopes(&workspace, &scopes);
|
||||
assert_eq!(model.maven.unwrap().projects.len(), 1);
|
||||
assert!(model.gradle.is_empty());
|
||||
std::fs::remove_dir_all(&members[1]).unwrap();
|
||||
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
|
||||
assert_eq!(load_scopes(&workspace, &scopes), BuildModel::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_keeps_unlinked_modules_out_of_member_language_servers() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(temp.path()).unwrap();
|
||||
for name in ["enabled", "hidden"] {
|
||||
std::fs::create_dir(root.join(name)).unwrap();
|
||||
std::fs::write(
|
||||
root.join(name).join("pom.xml"),
|
||||
format!("<project><artifactId>{name}</artifactId></project>"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::write(root.join("pom.xml"), "<project><artifactId>parent</artifactId><packaging>pom</packaging><modules><module>enabled</module><module>hidden</module></modules></project>").unwrap();
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.maven.unlinked_projects = vec!["hidden/pom.xml".into()];
|
||||
let scope = Scope { root: root.clone(), cache: root.join("cache"), settings };
|
||||
let model = load(&scope.root, &scope.cache, &scope.settings);
|
||||
assert_eq!(update_files(&root, &[scope], &model, BuildTool::Maven), [root.join("enabled/pom.xml")]);
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,12 @@ pub fn sources(host: &Host, window: &str) -> IdeResult<Vec<DataSource>> {
|
||||
/// Replaces the project's data sources with `sources`.
|
||||
pub async fn save_sources(host: &Host, window: &str, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.save_sources(sources).await
|
||||
db.save_sources(sources).await?;
|
||||
let root = host.project(window)?.root.clone();
|
||||
for project in host.projects().into_iter().filter(|p| p.root == root) {
|
||||
project.sink.emit(ide_core::IdeEvent::DbSourcesChanged);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remembers the password of data source `id` for the session, and in the OS
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod output;
|
||||
mod target;
|
||||
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
@@ -8,7 +9,42 @@ use std::path::PathBuf;
|
||||
|
||||
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
|
||||
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
|
||||
debug_start_request(host, window, config, None).await
|
||||
}
|
||||
|
||||
pub async fn debug_start_request(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
config: LaunchConfig,
|
||||
request: Option<ide_core::RequestId>,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
|
||||
let result = debug_start_inner(host, window, config, &cancel).await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn debug_start_inner(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
config: LaunchConfig,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
host.require_trust(window)?;
|
||||
let target_id = config
|
||||
.extra_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||
.and_then(|v| v.get("__runTarget").and_then(|v| v.as_str()).map(str::to_owned));
|
||||
if let Some(id) = target_id {
|
||||
let owner = SessionOwner::new(host.owner_window(window), host.window_sink(window));
|
||||
return target::start(host, window, config, owner, id, cancel).await;
|
||||
}
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
@@ -75,8 +111,20 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
config.extra_json = Some(extra.to_string());
|
||||
}
|
||||
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
let mut ruby_tools = Vec::new();
|
||||
if config.adapter == "rdbg" && config.request == "launch" {
|
||||
let project_root = host.project(window).map(|p| p.root.clone()).unwrap_or_else(|_| root.clone());
|
||||
let (dir, settings) = (root.clone(), settings.clone());
|
||||
(config, ruby_tools) = host
|
||||
.blocking(move || {
|
||||
let tools = ide_sdk::ruby::debug_environment(&project_root, &dir, &settings, &mut config.env);
|
||||
Ok((config, tools))
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
let custom = |command: &str| {
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)
|
||||
.or_else(|| ruby_tools.into_iter().find(|(name, _)| *name == command).map(|(_, path)| path))?;
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
};
|
||||
host.require_trust(window)?;
|
||||
@@ -339,6 +387,14 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
|
||||
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
|
||||
}
|
||||
|
||||
/// A loopback port that is free right now, for a debug agent the launch line starts
|
||||
/// (`address=127.0.0.1:<port>`) — used when its "Listening for transport" line never reaches
|
||||
/// the console (a Surefire fork holds it until the tests run, which `suspend=y` prevents).
|
||||
pub async fn debug_free_port(host: &Host, window: &str) -> IdeResult<u16> {
|
||||
host.require_trust(window)?;
|
||||
ide_dap::adapter::free_port().map_err(ide_core::IdeError::from)
|
||||
}
|
||||
|
||||
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
|
||||
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
|
||||
pub async fn debug_listen_for_jvm(
|
||||
@@ -362,7 +418,9 @@ pub async fn debug_redefine_classes(host: &Host, _window: &str, session: DebugSe
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("debug_start", |c| async move { debug_start(&c.host, &c.window, c.arg("config")?).await });
|
||||
d.add("debug_start", |c| async move {
|
||||
debug_start_request(&c.host, &c.window, c.arg("config")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("debug_set_breakpoints", |c| async move {
|
||||
debug_set_breakpoints(&c.host, &c.window, c.arg("path")?, c.arg("breakpoints")?).await
|
||||
});
|
||||
@@ -480,6 +538,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
.await
|
||||
});
|
||||
d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await });
|
||||
d.add("debug_free_port", |c| async move { debug_free_port(&c.host, &c.window).await });
|
||||
d.add("debug_listen_for_jvm", |c| async move {
|
||||
debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
use crate::Host;
|
||||
use ide_core::path_mapping::PathMapping;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_dap::target::TargetMapping;
|
||||
use ide_dap::{LaunchConfig, SessionOwner};
|
||||
use ide_language::ProgramPin;
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) async fn start(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
mut config: LaunchConfig,
|
||||
owner: SessionOwner,
|
||||
id: String,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<ide_core::DebugSessionId> {
|
||||
let project = host.project(window)?;
|
||||
let plugins =
|
||||
host.plugin_host().ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
|
||||
let registration = plugins
|
||||
.run_targets(&project.root)
|
||||
.into_iter()
|
||||
.find(|t| t.id == id)
|
||||
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
|
||||
let container = registration
|
||||
.target
|
||||
.container_root
|
||||
.ok_or_else(|| IdeError::invalid("Debugging a plugin target requires a container root"))?;
|
||||
let dbg = ide_dap::adapter::find_config(&host.languages, &config.adapter)
|
||||
.ok_or_else(|| IdeError::invalid("unknown debug adapter"))?;
|
||||
let placement = dbg.target_placement.as_deref().unwrap_or("");
|
||||
if !matches!(placement, "connect-back" | "adapter-in-target")
|
||||
|| (placement == "adapter-in-target" && dbg.transport != "stdio")
|
||||
{
|
||||
return Err(IdeError::invalid("This debugger does not support plugin run targets"));
|
||||
}
|
||||
let program = config
|
||||
.extra_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||
.and_then(|v| v.get("__runTargetProgram").and_then(|p| p.as_str()).map(str::to_owned))
|
||||
.or_else(|| config.program.clone())
|
||||
.unwrap_or_else(|| dbg.command.clone());
|
||||
let metadata = serde_json::from_value(json!({"id":"debug", "name":config.name, "kind":"shell", "command":program, "args":[], "cwd":config.cwd, "env":{}, "detected":false, "isTest":false}))
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let (validator, root, target, token) = (plugins.clone(), project.root.clone(), id.clone(), cancel.clone());
|
||||
host.blocking(move || {
|
||||
validator.prepare_run_target_launch(&root, &target, &metadata, ide_plugin::RunTargetOperation::Debug, &token)
|
||||
})
|
||||
.await?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mapping = TargetMapping {
|
||||
paths: PathMapping::new(&project.root.to_string_lossy(), &container),
|
||||
external_prefix: format!(
|
||||
"openidea-target://{}/0/",
|
||||
percent_encoding::utf8_percent_encode(&id, percent_encoding::NON_ALPHANUMERIC)
|
||||
),
|
||||
};
|
||||
let mut extra: serde_json::Value = config
|
||||
.extra_json
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
.unwrap_or(json!({}));
|
||||
extra.as_object_mut().ok_or_else(|| IdeError::invalid("invalid debug launch arguments"))?.remove("__runTarget");
|
||||
extra.as_object_mut().unwrap().remove("__runTargetProgram");
|
||||
// DAP paths already use target coordinates; adapter-native mappings stay identity.
|
||||
if placement == "connect-back" {
|
||||
config.program = None;
|
||||
config.args.clear();
|
||||
for key in ["program", "runtimeExecutable", "runtimeArgs"] {
|
||||
extra.as_object_mut().unwrap().remove(key);
|
||||
}
|
||||
if extra.get("port").and_then(|v| v.as_u64()).unwrap_or(0) == 0 {
|
||||
extra["port"] = json!(9003);
|
||||
}
|
||||
extra["hostname"] = json!("0.0.0.0");
|
||||
extra["pathMappings"] = json!({container.clone(): container.clone()});
|
||||
} else {
|
||||
for key in ["debugLauncherPython", "pythonPath"] {
|
||||
extra.as_object_mut().unwrap().remove(key);
|
||||
}
|
||||
extra["console"] = json!("internalConsole");
|
||||
extra["python"] = json!(dbg.command);
|
||||
extra["pathMappings"] = json!([{"localRoot":container,"remoteRoot":container}]);
|
||||
}
|
||||
config.extra_json = Some(extra.to_string());
|
||||
let wrapped = if placement == "adapter-in-target" {
|
||||
let run = serde_json::from_value(json!({"id":"debug-adapter","name":"Debug adapter","kind":"shell","command":dbg.command,"args":dbg.args,"cwd":config.cwd,"env":config.env,"detected":false,"isTest":false})).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let root = project.root.clone();
|
||||
Some(host.blocking(move || plugins.prepare_run_target(&root, &id, &run)).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
host.require_trust(window)?;
|
||||
let root = project.root.clone();
|
||||
let settings = project.settings.effective();
|
||||
host.debugger
|
||||
.start_prepared(
|
||||
config,
|
||||
owner,
|
||||
|_| None,
|
||||
move |spec, _| {
|
||||
if let Some(run) = wrapped {
|
||||
spec.command = run.command;
|
||||
spec.args = run.args;
|
||||
spec.cwd = run.cwd.map(Into::into).unwrap_or(root);
|
||||
spec.env = run.env.into_iter().collect();
|
||||
spec.pinned = Some(ProgramPin::approve(&spec.command)?);
|
||||
} else if let Some(command) = ide_sdk::apply::tool_command(&root, &settings, &spec.command) {
|
||||
spec.command = command.to_string_lossy().into_owned();
|
||||
}
|
||||
spec.target = Some(mapping);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -82,7 +82,7 @@ fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeRes
|
||||
workspace
|
||||
.projects
|
||||
.into_iter()
|
||||
.filter(|root| path.starts_with(root))
|
||||
.filter(|root| **root != project.root && path.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
});
|
||||
let settings = match &member {
|
||||
|
||||
@@ -52,6 +52,20 @@ pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: L
|
||||
pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
|
||||
let project = host.project(window)?;
|
||||
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
|
||||
let lsp = if lsp.as_ref().is_none_or(|v| v.is_empty()) {
|
||||
if let Some((plugins, d)) =
|
||||
crate::ops::lsp::plugin_providers(host, window, doc, crate::plugins::ProviderKind::DocumentSymbols)
|
||||
{
|
||||
let items =
|
||||
crate::ops::lsp::plugin_call(move || plugins.language_document_symbols(&d)).await.unwrap_or_default();
|
||||
if items.is_empty() { lsp } else { Some(crate::plugins::language_features::outline(items)) }
|
||||
} else {
|
||||
lsp
|
||||
}
|
||||
} else {
|
||||
lsp
|
||||
};
|
||||
let lsp = lsp.filter(|v| !v.is_empty());
|
||||
blocking(move || {
|
||||
let snap = match project.documents.text_snapshot(doc) {
|
||||
Ok(snap) => snap,
|
||||
@@ -80,7 +94,27 @@ pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
|
||||
let mut folds = project.syntax.folding(doc, &snap.language, snap.version, &snap.text);
|
||||
if let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::Folding) {
|
||||
let d = ide_plugin::ProviderDocument {
|
||||
path: project.documents.identity(doc)?.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
folds.extend(plugins.language_folding(&d).into_iter().map(|f| FoldRange {
|
||||
start_line: f.start_line,
|
||||
end_line: f.end_line,
|
||||
kind: match f.kind.as_str() {
|
||||
"comment" => ide_language::FoldKind::Comment,
|
||||
"imports" => ide_language::FoldKind::Imports,
|
||||
_ => ide_language::FoldKind::Region,
|
||||
},
|
||||
}));
|
||||
folds.sort_by_key(|f| (f.start_line, f.end_line));
|
||||
folds.dedup_by_key(|f| (f.start_line, f.end_line));
|
||||
}
|
||||
Ok(folds)
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -105,6 +139,21 @@ pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset:
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if !snap.large
|
||||
&& let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::SelectionRanges)
|
||||
{
|
||||
let at = ide_text::Document::new(&snap.text).offset_to_pos(offset);
|
||||
let d = ide_plugin::ProviderDocument {
|
||||
path: project.documents.identity(doc)?.0,
|
||||
language: snap.language.0.clone(),
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text.clone(),
|
||||
};
|
||||
let ranges = plugins.language_selection_ranges(&d, at);
|
||||
if !ranges.is_empty() {
|
||||
return Ok(ranges);
|
||||
}
|
||||
}
|
||||
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
|
||||
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::build::load;
|
||||
use ide_build::compile;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
|
||||
use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo};
|
||||
use ide_run::BuildPolicy;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cancellable with `cancel_request(request)`.
|
||||
@@ -19,12 +20,12 @@ pub async fn java_resolve_launch(
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
build: bool,
|
||||
policy: BuildPolicy,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = resolve_launch(host, window, module, test, build, &cancel).await;
|
||||
let result = resolve_launch(host, window, module, test, policy, &cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
@@ -34,13 +35,17 @@ async fn resolve_launch(
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
build: bool,
|
||||
policy: BuildPolicy,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let started = std::time::Instant::now();
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
// A launch builds incrementally unless the configuration asks for Rebuild or NoBuild.
|
||||
let build = policy != BuildPolicy::NoBuild;
|
||||
let full = policy == BuildPolicy::Rebuild;
|
||||
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
|
||||
let (model, external, delegated) = {
|
||||
let project = project.clone();
|
||||
let settings = settings.clone();
|
||||
@@ -59,7 +64,7 @@ async fn resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
};
|
||||
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, delegated, cancel).await?;
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, full, delegated, cancel).await?;
|
||||
let lsp_ms = started.elapsed().as_millis();
|
||||
let data_dir = host.local_data_dir.clone();
|
||||
let cancel = cancel.clone();
|
||||
@@ -70,17 +75,23 @@ async fn resolve_launch(
|
||||
&project.root,
|
||||
&model,
|
||||
&module,
|
||||
test,
|
||||
cache::BuildOptions { test, policy },
|
||||
ide_sdk::apply::maven_local_repository(&project.root, &settings),
|
||||
&cancel,
|
||||
)?;
|
||||
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
|
||||
info.build = Some(JavaBuildStatus::Succeeded);
|
||||
info.errors.clear();
|
||||
info.error_count = 0;
|
||||
apply_external_build_result(&mut info, outputs.failure);
|
||||
copies
|
||||
} else if external {
|
||||
compile::build_for_launch(&project.root, &model, &module, test, &cancel)?
|
||||
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
|
||||
if let Some(failure) = built.failure {
|
||||
if policy != BuildPolicy::MakeAndContinueOnErrors {
|
||||
return Err(IdeError::internal(failure));
|
||||
}
|
||||
info.build = Some(JavaBuildStatus::Failed);
|
||||
info.hints.push(failure);
|
||||
}
|
||||
built.class_dirs
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -138,6 +149,18 @@ async fn resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>) {
|
||||
info.build = Some(if let Some(failure) = failure {
|
||||
info.hints.push(failure);
|
||||
JavaBuildStatus::Failed
|
||||
} else if info.error_count > 0 {
|
||||
// Incremental Maven compilation can accept error-stub classes written by JDT.
|
||||
JavaBuildStatus::WithErrors
|
||||
} else {
|
||||
JavaBuildStatus::Succeeded
|
||||
});
|
||||
}
|
||||
|
||||
/// A classpath entry as a comparable key. Entries come from JDT LS and from our own
|
||||
/// build model, which spell one directory differently on Windows (`\\?\` prefix,
|
||||
/// `/` or `\\`, drive-letter case); compared as text, a module output stayed on the
|
||||
@@ -235,27 +258,63 @@ pub async fn java_build_workspace(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_build(&module, &CancellationToken::new()).await
|
||||
// HotSwap may cancel the compile (the request id is allocated by the caller).
|
||||
let cancel = request.map_or_else(CancellationToken::new, |id| host.begin_request(id));
|
||||
let result = project.lsp.java_build(&module, false, &cancel).await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("java_resolve_launch", |c| async move {
|
||||
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?, c.arg("request")?)
|
||||
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("policy")?, c.arg("request")?)
|
||||
.await
|
||||
});
|
||||
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move {
|
||||
java_build_workspace(&c.host, &c.window, c.arg("module")?, c.arg("request")?).await
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn successful_external_compilation_does_not_erase_scoped_jdt_errors() {
|
||||
for (failure, expected) in
|
||||
[(None, JavaBuildStatus::WithErrors), (Some("compile failed"), JavaBuildStatus::Failed)]
|
||||
{
|
||||
let mut info = JavaLaunchInfo {
|
||||
error_count: 1,
|
||||
errors: vec![ide_lsp::jdtls::JavaCompileError {
|
||||
path: "src/main/java/Main.java".into(),
|
||||
line: 4,
|
||||
column: 28,
|
||||
message: "missingName cannot be resolved to a variable".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let errors = info.errors.clone();
|
||||
apply_external_build_result(&mut info, failure.map(str::to_owned));
|
||||
assert_eq!(info.build, Some(expected));
|
||||
assert_eq!(info.error_count, 1);
|
||||
assert_eq!(info.errors, errors);
|
||||
assert_eq!(info.hints, failure.into_iter().collect::<Vec<_>>());
|
||||
}
|
||||
let mut info = JavaLaunchInfo::default();
|
||||
apply_external_build_result(&mut info, None);
|
||||
assert_eq!(info.build, Some(JavaBuildStatus::Succeeded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_scopes_filter_jdt_classpaths_and_module_paths() {
|
||||
use ide_core::settings::{DependencyScope, ProjectLibrary};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Immutable Maven outputs retained in the project cache, including across close/reopen.
|
||||
use ide_build::compile::{self, LaunchFingerprint};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_run::BuildPolicy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
@@ -11,6 +12,8 @@ pub(super) struct Outputs {
|
||||
fingerprint: Option<LaunchFingerprint>,
|
||||
pub snapshot: PathBuf,
|
||||
pub originals: Vec<PathBuf>,
|
||||
#[serde(skip)]
|
||||
pub failure: Option<String>,
|
||||
}
|
||||
|
||||
impl Outputs {
|
||||
@@ -35,6 +38,12 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
|
||||
[false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file()))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct BuildOptions {
|
||||
pub test: bool,
|
||||
pub policy: BuildPolicy,
|
||||
}
|
||||
|
||||
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
|
||||
pub(crate) struct Session {
|
||||
cache: PathBuf,
|
||||
@@ -61,11 +70,12 @@ impl Session {
|
||||
root: &Path,
|
||||
model: &ide_build::BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
options: BuildOptions,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outputs> {
|
||||
self.initialize();
|
||||
let BuildOptions { test, policy } = options;
|
||||
let cache = &self.cache;
|
||||
let record =
|
||||
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
|
||||
@@ -81,6 +91,7 @@ impl Session {
|
||||
let before = fingerprint()?;
|
||||
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
|
||||
if let Some(outputs) = previous.as_ref()
|
||||
&& policy != BuildPolicy::Rebuild
|
||||
&& outputs.reusable(&before, &snapshots)
|
||||
{
|
||||
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
|
||||
@@ -90,10 +101,27 @@ impl Session {
|
||||
// source still delegates to Maven to clean its old classes on the next launch.
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
fs::write(&record, b"null")?;
|
||||
if let Some(previous) = previous {
|
||||
self.unreferenced.push(previous.snapshot);
|
||||
if let Some(previous) = &previous {
|
||||
self.unreferenced.push(previous.snapshot.clone());
|
||||
}
|
||||
let originals = compile::build_for_launch(root, model, module, test, cancel)?;
|
||||
let kind = if policy == BuildPolicy::Rebuild { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
|
||||
let built = compile::build_for_launch(root, model, module, test, kind, cancel)?;
|
||||
if let Some(failure) = &built.failure {
|
||||
if policy != BuildPolicy::MakeAndContinueOnErrors {
|
||||
return Err(IdeError::internal(failure.clone()));
|
||||
}
|
||||
if let Some(mut previous) =
|
||||
previous.filter(|p| (0..p.originals.len()).all(|i| p.snapshot.join(i.to_string()).is_dir()))
|
||||
{
|
||||
previous.fingerprint = None;
|
||||
previous.failure = built.failure;
|
||||
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
|
||||
serde_json::to_writer(file.as_file_mut(), &previous).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
file.persist(&record).map_err(|e| IdeError::from(e.error))?;
|
||||
return Ok(previous);
|
||||
}
|
||||
}
|
||||
let originals = built.class_dirs;
|
||||
fs::create_dir_all(&snapshots)?;
|
||||
let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep();
|
||||
// Retain every new snapshot even if copying or publishing its record fails.
|
||||
@@ -108,8 +136,8 @@ impl Session {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut outputs = Outputs { fingerprint: None, snapshot, originals };
|
||||
if before.is_some() && before == after {
|
||||
let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure };
|
||||
if outputs.failure.is_none() && before.is_some() && before == after {
|
||||
outputs.fingerprint = before;
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
|
||||
@@ -199,13 +227,77 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let script = root.join("mvnw");
|
||||
fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
|
||||
fs::write(&script, "#!/bin/sh\necho build \"$@\" >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
|
||||
ide_build::load(root, &root.join("cache"), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_bypasses_snapshots_and_error_continuation_retains_previous_classes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(root.join("cache"));
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
let rebuilt = session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { policy: BuildPolicy::Rebuild, ..Default::default() },
|
||||
None,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(first.snapshot, rebuilt.snapshot);
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 2);
|
||||
assert!(fs::read_to_string(root.join("calls")).unwrap().lines().last().unwrap().contains("clean"));
|
||||
fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap();
|
||||
let continued = session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
|
||||
None,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(continued.snapshot, rebuilt.snapshot);
|
||||
assert!(continued.failure.unwrap().contains("Build failed"));
|
||||
assert_eq!(fs::read_to_string(continued.snapshot.join("0/Main.class")).unwrap(), "first");
|
||||
let again = session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
|
||||
None,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(again.snapshot, rebuilt.snapshot);
|
||||
assert!(again.failure.is_some());
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
|
||||
None,
|
||||
&cancel
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -214,7 +306,7 @@ mod tests {
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let mut build = || session.build(root, &model, root, false, None, &cancel);
|
||||
let mut build = || session.build(root, &model, root, BuildOptions::default(), None, &cancel);
|
||||
let first = build().unwrap();
|
||||
let snapshot = first.snapshot.clone();
|
||||
drop(first);
|
||||
@@ -253,14 +345,14 @@ mod tests {
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
|
||||
|
||||
let source = root.join("src/main/kotlin/Main.kt");
|
||||
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
|
||||
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
|
||||
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
|
||||
let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let rebuilt = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild");
|
||||
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
|
||||
}
|
||||
@@ -273,9 +365,9 @@ mod tests {
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
|
||||
let second = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let second = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(second.snapshot.is_dir());
|
||||
// Another mode can keep a superseded snapshot alive.
|
||||
@@ -286,10 +378,10 @@ mod tests {
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(second.snapshot.is_dir());
|
||||
let mut session = Session::new(cache.clone());
|
||||
let reused = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let reused = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert_eq!(reused.snapshot, second.snapshot);
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap();
|
||||
let third = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let third = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert!(second.snapshot.is_dir());
|
||||
drop(session);
|
||||
assert!(!second.snapshot.exists());
|
||||
@@ -316,7 +408,7 @@ mod tests {
|
||||
fs::write(snapshots.join("launch-file"), "keep").unwrap();
|
||||
let record = cache.join("java-launch/records/deep/module/test.json");
|
||||
fs::create_dir_all(record.parent().unwrap()).unwrap();
|
||||
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() };
|
||||
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None };
|
||||
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
|
||||
let mut session = Session::new(cache);
|
||||
session.initialize();
|
||||
@@ -347,7 +439,7 @@ mod tests {
|
||||
fs::write(script, text).unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut session = Session::new(cache.clone());
|
||||
assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err());
|
||||
assert!(session.build(root, &model, root, BuildOptions::default(), None, &CancellationToken::new()).is_err());
|
||||
assert_eq!(session.unreferenced.len(), 1);
|
||||
let snapshot = session.unreferenced[0].clone();
|
||||
assert!(snapshot.join("0/Main.class").is_file());
|
||||
@@ -366,7 +458,8 @@ mod tests {
|
||||
fs::write(script, text).unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut session = Session::new(cache.clone());
|
||||
let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap();
|
||||
let outputs =
|
||||
session.build(root, &model, root, BuildOptions::default(), None, &CancellationToken::new()).unwrap();
|
||||
assert!(outputs.fingerprint.is_none());
|
||||
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
|
||||
assert!(outputs.snapshot.join("0/Main.class").is_file());
|
||||
@@ -381,14 +474,14 @@ mod tests {
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap();
|
||||
assert!(!compile::launch_has_non_java_sources(root, &model, root, false));
|
||||
assert!(has_record(root, &cache, root));
|
||||
assert!(session.build(root, &model, root, false, None, &cancel).is_err());
|
||||
assert!(session.build(root, &model, root, BuildOptions::default(), None, &cancel).is_err());
|
||||
assert!(has_record(root, &cache, root));
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap();
|
||||
let restored = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
let restored = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert_ne!(first.snapshot, restored.snapshot);
|
||||
}
|
||||
}
|
||||
+636
-35
@@ -4,11 +4,39 @@ use crate::apply_workspace_edit as apply_edit;
|
||||
use crate::plugins::{ProviderKind, editor_providers_for as editor_providers};
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
use ide_core::{DocId, IdeError, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
use ide_lsp::types::*;
|
||||
use ide_plugin::ProviderDocument;
|
||||
use std::path::Path;
|
||||
|
||||
/// Select an embedded server only for point requests; formatting and rename keep the host.
|
||||
async fn position_document(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<DocId> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
lsp.refresh_injections(doc).await?;
|
||||
Ok(lsp.injected_document(doc, pos))
|
||||
}
|
||||
|
||||
async fn begin_position_request(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
) -> IdeResult<(DocId, ide_core::CancellationToken)> {
|
||||
let cancel = host.begin_request(request);
|
||||
let target = tokio::select! {
|
||||
result = position_document(host, window, doc, pos) => result,
|
||||
_ = cancel.cancelled() => Err(ide_core::IdeError::cancelled()),
|
||||
};
|
||||
match target {
|
||||
Ok(target) => Ok((target, cancel)),
|
||||
Err(e) => {
|
||||
host.end_request(request);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_actions(
|
||||
project: std::sync::Arc<crate::Project>,
|
||||
doc: DocId,
|
||||
@@ -41,8 +69,8 @@ pub async fn lsp_completion(
|
||||
trigger: Option<String>,
|
||||
) -> IdeResult<CompletionList> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.completion(doc, pos, trigger.clone(), cancel).await;
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
|
||||
let result = lsp.completion(target, pos, trigger.clone(), cancel).await;
|
||||
host.end_request(request);
|
||||
// Plugin providers (`[[contributes.providers]]`) add theirs.
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
|
||||
@@ -56,7 +84,7 @@ pub async fn lsp_completion(
|
||||
}
|
||||
|
||||
/// The plugin host and the document, when some plugin provides `kind`.
|
||||
fn plugin_providers(
|
||||
pub(crate) fn plugin_providers(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
@@ -74,10 +102,48 @@ fn plugin_providers(
|
||||
}
|
||||
|
||||
/// Plugin calls block (the plugin's actor thread does the work).
|
||||
async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
|
||||
pub(crate) async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
|
||||
tokio::task::spawn_blocking(f).await.ok()
|
||||
}
|
||||
|
||||
fn provider_server<T: Default>(server: IdeResult<T>) -> IdeResult<T> {
|
||||
match server {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => Ok(T::default()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_language_locations(mut server: Vec<Location>, plugin: Vec<ide_plugin::Location>) -> Vec<Location> {
|
||||
let mut seen: std::collections::HashSet<_> = server.iter().cloned().collect();
|
||||
for l in plugin {
|
||||
let loc = Location::file(l.path, l.range);
|
||||
if seen.insert(loc.clone()) {
|
||||
server.push(loc);
|
||||
}
|
||||
}
|
||||
server
|
||||
}
|
||||
fn merge_language_highlights(
|
||||
mut server: Vec<Highlight>,
|
||||
plugin: Vec<ide_plugin::language_features::Highlight>,
|
||||
) -> Vec<Highlight> {
|
||||
let mut seen: std::collections::HashSet<_> = server.iter().map(|h| h.range).collect();
|
||||
for h in plugin {
|
||||
if seen.insert(h.range) {
|
||||
server.push(Highlight {
|
||||
range: h.range,
|
||||
kind: match h.kind.as_str() {
|
||||
"read" => HighlightKind::Read,
|
||||
"write" => HighlightKind::Write,
|
||||
_ => HighlightKind::Text,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
server
|
||||
}
|
||||
|
||||
/// Items from plugins carry no server: commands don't apply, and a code
|
||||
/// action is resolved by the plugin named in its `data` ([`PluginActionRef`]).
|
||||
const PLUGIN_SERVER: ServerId = ServerId(0);
|
||||
@@ -131,6 +197,7 @@ fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
|
||||
CompletionItem {
|
||||
label: item.label,
|
||||
label_detail: None,
|
||||
label_description: None,
|
||||
detail: item.detail,
|
||||
kind,
|
||||
documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }),
|
||||
@@ -200,14 +267,14 @@ pub async fn lsp_hover(
|
||||
pos: Pos,
|
||||
) -> IdeResult<Option<Hover>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
|
||||
let result = lsp.hover(doc, pos, cancel).await;
|
||||
let result = lsp.hover(target, pos, cancel).await;
|
||||
host.end_request(request);
|
||||
return result;
|
||||
};
|
||||
let (result, hovers) =
|
||||
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
|
||||
tokio::join!(lsp.hover(target, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
|
||||
host.end_request(request);
|
||||
let hovers = hovers.unwrap_or_default();
|
||||
if hovers.is_empty() {
|
||||
@@ -222,11 +289,28 @@ pub async fn lsp_hover(
|
||||
}
|
||||
|
||||
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
|
||||
host.project(window)?.lsp.signature_help(doc, pos).await
|
||||
let target = position_document(host, window, doc, pos).await?;
|
||||
let server = host.project(window)?.lsp.signature_help(target, pos).await;
|
||||
if matches!(&server,Ok(Some(h)) if !h.signatures.is_empty()) {
|
||||
return server;
|
||||
}
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::SignatureHelp) else {
|
||||
return server;
|
||||
};
|
||||
let help = plugin_call(move || plugins.language_signature_help(&d, pos)).await.flatten();
|
||||
match help {
|
||||
Some(h) => {
|
||||
let _ = provider_server(server)?;
|
||||
Ok(Some(crate::plugins::language_features::signature_help(h)))
|
||||
}
|
||||
None => server,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
if kind == GotoKind::Definition
|
||||
let target = position_document(host, window, doc, pos).await?;
|
||||
if target == doc
|
||||
&& kind == GotoKind::Definition
|
||||
&& let Some(plugins) = crate::plugins::editor_plugin_host()
|
||||
{
|
||||
let project = host.project(window)?;
|
||||
@@ -237,7 +321,24 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
|
||||
return Ok(found.targets);
|
||||
}
|
||||
}
|
||||
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
|
||||
let result = host.project(window)?.lsp.goto(target, pos, kind).await;
|
||||
if matches!(kind, GotoKind::Implementation | GotoKind::TypeDefinition) {
|
||||
let k =
|
||||
if kind == GotoKind::Implementation { ProviderKind::Implementation } else { ProviderKind::TypeDefinition };
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, k) else {
|
||||
return result;
|
||||
};
|
||||
let locations = plugin_call(move || {
|
||||
if kind == GotoKind::Implementation {
|
||||
plugins.language_implementation(&d, pos)
|
||||
} else {
|
||||
plugins.language_type_definition(&d, pos)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
return Ok(merge_language_locations(provider_server(result)?, locations));
|
||||
}
|
||||
if kind != GotoKind::Definition {
|
||||
return result;
|
||||
}
|
||||
@@ -271,6 +372,26 @@ fn string_at(structure: &ide_syntax::StructureService, snap: &ide_syntax::Snapsh
|
||||
stringy(&at.node.kind) || (part(&at.node.kind) && at.ancestors.first().is_some_and(|parent| stringy(&parent.kind)))
|
||||
}
|
||||
|
||||
fn identifier_at(
|
||||
structure: &ide_syntax::StructureService,
|
||||
snap: &ide_syntax::Snapshot,
|
||||
range: Range,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> bool {
|
||||
let Ok(Some(at)) = structure.node_at(snap, range, cancel) else { return false };
|
||||
at.node.kind.contains("identifier")
|
||||
|| matches!(
|
||||
at.node.kind.as_str(),
|
||||
"name"
|
||||
| "constant"
|
||||
| "label_name"
|
||||
| "variable_name"
|
||||
| "instance_variable"
|
||||
| "class_variable"
|
||||
| "global_variable"
|
||||
)
|
||||
}
|
||||
|
||||
fn definition_targets(
|
||||
server: IdeResult<Vec<Location>>,
|
||||
plugin: Vec<Location>,
|
||||
@@ -335,9 +456,61 @@ pub async fn lsp_implementations(
|
||||
positions: Vec<Pos>,
|
||||
) -> IdeResult<Vec<Vec<Location>>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.implementations(doc, &positions, cancel).await;
|
||||
let (_, cancel) =
|
||||
begin_position_request(host, window, request, doc, positions.first().copied().unwrap_or_default()).await?;
|
||||
let mut groups = std::collections::BTreeMap::<DocId, Vec<(usize, Pos)>>::new();
|
||||
for (index, pos) in positions.iter().copied().enumerate() {
|
||||
groups.entry(lsp.injected_document(doc, pos)).or_default().push((index, pos));
|
||||
}
|
||||
if groups.is_empty() {
|
||||
groups.insert(doc, Vec::new());
|
||||
}
|
||||
let results = futures::future::join_all(groups.into_iter().map(|(target, entries)| {
|
||||
let (lsp, cancel) = (lsp.clone(), cancel.clone());
|
||||
async move {
|
||||
let positions: Vec<_> = entries.iter().map(|(_, p)| *p).collect();
|
||||
(entries, lsp.implementations(target, &positions, cancel).await)
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
let mut out = vec![Vec::new(); positions.len()];
|
||||
let mut error = None;
|
||||
let mut succeeded = false;
|
||||
for (entries, result) in results {
|
||||
match result {
|
||||
Ok(found) => {
|
||||
succeeded = true;
|
||||
for ((index, _), locations) in entries.into_iter().zip(found) {
|
||||
out[index] = locations;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = if cancel.is_cancelled() {
|
||||
Err(ide_core::IdeError::cancelled())
|
||||
} else if succeeded {
|
||||
Ok(out)
|
||||
} else {
|
||||
Err(error.expect("at least one request"))
|
||||
};
|
||||
host.end_request(request);
|
||||
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
|
||||
let count = positions.len();
|
||||
let found = plugin_call(move || {
|
||||
positions.into_iter().map(|pos| plugins.language_implementation(&d, pos)).collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut out = provider_server(result)?;
|
||||
out.resize_with(count, Vec::new);
|
||||
for (items, found) in out.iter_mut().zip(found) {
|
||||
*items = merge_language_locations(std::mem::take(items), found);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -353,13 +526,17 @@ pub async fn lsp_goto_implementation(
|
||||
on_partial: super::Channel<Vec<Location>>,
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
|
||||
let result = lsp
|
||||
.goto_implementation(doc, pos, cancel, &|locs| {
|
||||
.goto_implementation(target, pos, cancel, &|locs| {
|
||||
let _ = on_partial.send(locs);
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
|
||||
let found = plugin_call(move || plugins.language_implementation(&d, pos)).await.unwrap_or_default();
|
||||
return Ok(merge_language_locations(provider_server(result)?, found));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -369,9 +546,121 @@ pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> Ide
|
||||
if uri.starts_with("git-revision:") {
|
||||
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
|
||||
}
|
||||
if uri.starts_with("openidea-target:") && uri.contains("/0/") {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let plugins =
|
||||
host.plugin_host().ok_or_else(|| IdeError::unavailable("container document reader is unavailable"))?;
|
||||
for registration in plugins.run_targets(&project.root) {
|
||||
let prefix = format!(
|
||||
"openidea-target://{}/0/",
|
||||
percent_encoding::utf8_percent_encode(®istration.id, percent_encoding::NON_ALPHANUMERIC)
|
||||
);
|
||||
if let Some(suffix) = uri.strip_prefix(&prefix) {
|
||||
let path = ide_core::path_mapping::file_path(&format!("file:///{suffix}"))
|
||||
.filter(|p| p.starts_with('/') && !p.split('/').any(|s| s == ".." || s == "."))
|
||||
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
|
||||
let container_root = registration
|
||||
.target
|
||||
.container_root
|
||||
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
|
||||
let target = ide_language::ServerTarget {
|
||||
id: registration.id,
|
||||
origin: None,
|
||||
project_root: project.root.clone(),
|
||||
mapping: ide_core::path_mapping::PathMapping::new(&project.root.to_string_lossy(), &container_root),
|
||||
container_root,
|
||||
};
|
||||
let title = std::path::Path::new(&path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let language_id = host.languages.detect(std::path::Path::new(&path), None);
|
||||
let text = host.blocking(move || super::lsp_target::read(&plugins, &target, &path)).await?;
|
||||
return Ok(VirtualDocument { uri, text, title, language_id, description: None, decompiled: false });
|
||||
}
|
||||
}
|
||||
return Err(IdeError::unavailable("plugin run target is no longer available"));
|
||||
}
|
||||
host.project(window)?.lsp.virtual_document(&uri).await
|
||||
}
|
||||
|
||||
/// Whether this class belongs to the project's Maven local repository.
|
||||
pub async fn lsp_can_download_sources(host: &Host, window: &str, uri: String) -> IdeResult<bool> {
|
||||
let project = host.project(window)?;
|
||||
if !project.is_trusted() {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(jar) = ide_lsp::LspManager::library_archive(&uri) else { return Ok(false) };
|
||||
host.blocking(move || {
|
||||
let settings = project.settings.effective();
|
||||
let repository = ide_sdk::apply::maven_local_repository(&project.root, &settings)
|
||||
.or_else(ide_build::maven::default_local_repo);
|
||||
Ok(repository.is_some_and(|repo| ide_build::maven::sources::artifact(&jar, &repo).is_some())
|
||||
&& ide_build::maven::has_projects(&project.root, &settings.languages.java.maven))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Download with the project's Maven configuration, or attach an archive selected by the user.
|
||||
pub async fn lsp_library_sources(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
uri: String,
|
||||
pos: Pos,
|
||||
sources: Option<std::path::PathBuf>,
|
||||
) -> IdeResult<Option<Location>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = async {
|
||||
let sources = match sources {
|
||||
Some(sources) => Some(sources),
|
||||
None => {
|
||||
let jar = ide_lsp::LspManager::library_archive(&uri)
|
||||
.ok_or_else(|| IdeError::invalid("not a library class"))?;
|
||||
let project = project.clone();
|
||||
let cancel = cancel.clone();
|
||||
host.blocking(move || {
|
||||
let settings = project.settings.effective();
|
||||
let repository = ide_sdk::apply::maven_local_repository(&project.root, &settings)
|
||||
.or_else(ide_build::maven::default_local_repo)
|
||||
.ok_or_else(|| IdeError::unavailable("Maven local repository is unavailable"))?;
|
||||
let artifact = ide_build::maven::sources::artifact(&jar, &repository)
|
||||
.ok_or_else(|| IdeError::invalid("not an artifact in the Maven local repository"))?;
|
||||
let model = super::build::load(&project.root, &project.build_cache_dir(), &settings);
|
||||
let maven = model.maven.ok_or_else(|| IdeError::unavailable("no Maven project"))?;
|
||||
let owns = |p: &&ide_build::maven::MavenProject| {
|
||||
p.dependencies.iter().any(|d| d.group_id == artifact.group && d.artifact_id == artifact.name)
|
||||
};
|
||||
let mut owner = maven
|
||||
.projects
|
||||
.iter()
|
||||
.find(owns)
|
||||
.or_else(|| maven.projects.first())
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::unavailable("no Maven project"))?;
|
||||
let profiles =
|
||||
settings.languages.java.maven.profiles(&ide_build::maven_profiles(&project.build_cache_dir()));
|
||||
if !profiles.is_empty() {
|
||||
owner.options.push(format!("-P{}", profiles.join(",")));
|
||||
}
|
||||
ide_build::maven::sources::download(&project.root, &owner, &artifact, &repository, &cancel)
|
||||
})
|
||||
.await?
|
||||
}
|
||||
};
|
||||
match sources {
|
||||
Some(sources) => project.lsp.library_source_location(&uri, sources, pos, &cancel).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Opens compiled classes through JDT, with project source taking priority on disk.
|
||||
pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::PathBuf) -> IdeResult<String> {
|
||||
let archive = ide_vfs::split_archive_path(&path);
|
||||
@@ -380,8 +669,8 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::
|
||||
|
||||
/// References of the symbol at `pos`. `project_wide` also searches project files
|
||||
/// outside the language server's analysis scope; bulk scans over many symbols
|
||||
/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
|
||||
/// are not searched; with it, those found in the sources arrive on `on_partial` before
|
||||
/// leave it off. Java: without `libraries`, the dependencies and the JDK are not
|
||||
/// searched; with it and `project_wide`, those found in the sources arrive on `on_partial` before
|
||||
/// the slower search for the rest, and the result has them all. Cancel with
|
||||
/// `cancel_request(request)`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -398,9 +687,9 @@ pub async fn lsp_references(
|
||||
) -> IdeResult<References> {
|
||||
let project = host.project(window)?;
|
||||
let lsp = project.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
|
||||
let result = async {
|
||||
let symbol = indexed_symbol(host, window, doc, pos).await?;
|
||||
let symbol = if target == doc { indexed_symbol(host, window, doc, pos).await? } else { None };
|
||||
if let Some(symbol) = &symbol {
|
||||
let project = project.clone();
|
||||
let snapshot = symbol.clone();
|
||||
@@ -410,7 +699,7 @@ pub async fn lsp_references(
|
||||
}
|
||||
let result = lsp
|
||||
.references_streaming(
|
||||
doc,
|
||||
target,
|
||||
pos,
|
||||
include_declaration,
|
||||
project_wide,
|
||||
@@ -485,10 +774,11 @@ fn validate_indexed_symbol(
|
||||
let stale = || {
|
||||
ide_core::IdeError::new(ErrorKind::OutOfSync, "indexed document changed; wait for indexing and try again")
|
||||
};
|
||||
if ide_plugin::resolve_in_root(&project.root, &file.path.to_string_lossy()).ok().as_ref() != Some(&file.path) {
|
||||
let path = dunce::simplified(&file.path);
|
||||
if ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()).ok().as_deref() != Some(path) {
|
||||
return Err(stale());
|
||||
}
|
||||
let text = if let Some(doc) = project.documents.by_path(&file.path) {
|
||||
let text = if let Some(doc) = project.documents.by_path(path) {
|
||||
let snapshot = project.documents.text_snapshot(doc)?;
|
||||
if snapshot.large || file.version.is_some_and(|v| v != snapshot.version) {
|
||||
return Err(stale());
|
||||
@@ -497,7 +787,7 @@ fn validate_indexed_symbol(
|
||||
} else {
|
||||
let loaded = project
|
||||
.documents
|
||||
.load_file_bounded(&file.path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
|
||||
.load_file_bounded(path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
|
||||
.map_err(|_| stale())?;
|
||||
if loaded.binary || loaded.large {
|
||||
return Err(stale());
|
||||
@@ -535,7 +825,13 @@ fn merge_indexed_references(mut server: References, symbol: &crate::plugins::nav
|
||||
}
|
||||
|
||||
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
|
||||
host.project(window)?.lsp.document_highlights(doc, pos).await
|
||||
let target = position_document(host, window, doc, pos).await?;
|
||||
let server = host.project(window)?.lsp.document_highlights(target, pos).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentHighlights) else {
|
||||
return server;
|
||||
};
|
||||
let items = plugin_call(move || plugins.language_document_highlights(&d, pos)).await.unwrap_or_default();
|
||||
Ok(merge_language_highlights(provider_server(server)?, items))
|
||||
}
|
||||
|
||||
pub async fn lsp_workspace_symbols(
|
||||
@@ -561,7 +857,9 @@ pub async fn lsp_code_actions(
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let result = project.lsp.code_actions(doc, range, only.clone()).await;
|
||||
let target = position_document(host, window, doc, range.start).await?;
|
||||
let range = project.lsp.injected_request_range(target, range);
|
||||
let result = project.lsp.code_actions(target, range, only.clone()).await;
|
||||
let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) {
|
||||
Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
@@ -577,7 +875,7 @@ pub async fn lsp_code_actions(
|
||||
let actions = if plugin_actions.is_empty() {
|
||||
result?
|
||||
} else {
|
||||
let mut out = result.unwrap_or_default();
|
||||
let mut out = provider_server(result)?;
|
||||
out.extend(plugin_actions);
|
||||
out
|
||||
};
|
||||
@@ -601,6 +899,8 @@ fn plugin_code_action(plugin: &str, root: &Path, a: ide_plugin::CodeAction) -> C
|
||||
.and_then(|r| serde_json::to_value(r).ok());
|
||||
CodeAction {
|
||||
title: a.title,
|
||||
diagnostics: Vec::new(),
|
||||
original_diagnostics: None,
|
||||
kind: a.kind,
|
||||
is_preferred: a.preferred,
|
||||
disabled_reason,
|
||||
@@ -698,8 +998,9 @@ pub async fn lsp_code_action_query(
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = project.lsp.code_action_query(doc, range, only, cancel).await;
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, range.start).await?;
|
||||
let range = project.lsp.injected_request_range(target, range);
|
||||
let result = project.lsp.code_action_query(target, range, only, cancel).await;
|
||||
host.end_request(request);
|
||||
let mut result = result?;
|
||||
result.actions = prepare_actions(project, doc, version, result.actions).await?;
|
||||
@@ -788,6 +1089,12 @@ async fn is_client_follow_up(
|
||||
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
|
||||
if let Some(symbol) = indexed_symbol(host, window, doc, pos).await? {
|
||||
let project = host.project(window)?;
|
||||
if let Some(refusal) = indexed_code_identifier(host, window, doc, symbol.range, None).await?
|
||||
&& let Err(e) = project.lsp.prepare_rename(doc, pos).await
|
||||
&& indexed_lsp_error(e)?.is_none()
|
||||
{
|
||||
return Err(refusal);
|
||||
}
|
||||
return blocking(move || {
|
||||
validate_indexed_symbol(&project, &symbol, None)?;
|
||||
Ok(Some(symbol.range))
|
||||
@@ -883,7 +1190,15 @@ pub async fn lsp_rename(
|
||||
let mut renamed = match result {
|
||||
Ok(renamed) => renamed,
|
||||
Err(e) if symbol.is_some() && !native_owned => {
|
||||
Renamed { incomplete: indexed_lsp_error(e)?, ..Default::default() }
|
||||
let incomplete = indexed_lsp_error(e)?;
|
||||
if incomplete.is_none()
|
||||
&& let Some(symbol) = &symbol
|
||||
&& let Some(refusal) =
|
||||
indexed_code_identifier(host, window, doc, symbol.range, Some(cancel.clone())).await?
|
||||
{
|
||||
return Err(refusal);
|
||||
}
|
||||
Renamed { incomplete, ..Default::default() }
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
@@ -921,6 +1236,39 @@ pub async fn lsp_rename(
|
||||
result
|
||||
}
|
||||
|
||||
async fn indexed_code_identifier(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
cancel: Option<ide_core::CancellationToken>,
|
||||
) -> IdeResult<Option<ide_core::IdeError>> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = cancel.unwrap_or_default();
|
||||
blocking(move || {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(None);
|
||||
}
|
||||
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
|
||||
let identifier = identifier_at(&project.structure, &s, range, &cancel);
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
if !identifier {
|
||||
return Ok(None);
|
||||
}
|
||||
let language = project.languages.get(&snap.language).map_or_else(|| snap.language.to_string(), |l| l.name);
|
||||
Ok(Some(ide_core::IdeError::invalid(format!(
|
||||
"The {language} language server can't rename this symbol; renaming only the plugin's indexed names would leave it half renamed"
|
||||
))))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn merge_indexed_rename(
|
||||
renamed: &mut Renamed,
|
||||
symbol: &crate::plugins::navigation::IndexedSymbol,
|
||||
@@ -1038,12 +1386,52 @@ pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeRe
|
||||
}
|
||||
|
||||
pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<CodeLens>> {
|
||||
host.project(window)?.lsp.code_lens(doc).await
|
||||
let server = host.project(window)?.lsp.code_lens(doc).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::CodeLenses) else {
|
||||
return server;
|
||||
};
|
||||
let items = plugin_call(move || plugins.language_code_lenses(&d)).await.unwrap_or_default();
|
||||
let mut out = provider_server(server)?;
|
||||
out.extend(items.into_iter().map(|(plugin, i)| CodeLens {
|
||||
range: i.range,
|
||||
command: Some(Command {
|
||||
title: i.title,
|
||||
command: format!("openidea.plugin-language:{plugin}:{}", i.command),
|
||||
arguments: vec![serde_json::json!(serde_json::to_string(&i.argument).unwrap_or_default())],
|
||||
}),
|
||||
}));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
|
||||
pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<DocumentLink>> {
|
||||
host.project(window)?.lsp.document_links(doc).await
|
||||
let server = host.project(window)?.lsp.document_links(doc).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentLinks) else {
|
||||
return server;
|
||||
};
|
||||
let root = host.project(window)?.root.clone();
|
||||
let items = plugin_call(move || plugins.language_document_links(&d)).await.unwrap_or_default();
|
||||
let mut out = provider_server(server)?;
|
||||
out.extend(items.into_iter().filter_map(|i| {
|
||||
let url = i.target.starts_with("https://") || i.target.starts_with("http://");
|
||||
if !url && ide_core::is_virtual_uri(&i.target) {
|
||||
return None;
|
||||
}
|
||||
Some(DocumentLink {
|
||||
range: i.range,
|
||||
path: (!url).then(|| {
|
||||
{
|
||||
let path = std::path::PathBuf::from(&i.target);
|
||||
if path.is_absolute() { path } else { root.join(path) }
|
||||
}
|
||||
.into()
|
||||
}),
|
||||
url: url.then_some(i.target),
|
||||
fragment: None,
|
||||
tooltip: None,
|
||||
})
|
||||
}));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn lsp_call_hierarchy(
|
||||
@@ -1133,6 +1521,34 @@ pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResul
|
||||
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
|
||||
/// follow-up command. Edits the server requests arrive via `workspace/applyEdit`.
|
||||
pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: Command) -> IdeResult<()> {
|
||||
if let Some(rest) = command.command.strip_prefix("openidea.plugin-language:") {
|
||||
let (plugin, command_id) =
|
||||
rest.split_once(':').ok_or_else(|| ide_core::IdeError::invalid("Invalid plugin command"))?;
|
||||
let plugins = crate::plugins::editor_plugin_host()
|
||||
.ok_or_else(|| ide_core::IdeError::not_found("Plugin host unavailable"))?;
|
||||
let argument = command
|
||||
.arguments
|
||||
.first()
|
||||
.and_then(|a| a.as_str())
|
||||
.and_then(|s| serde_json::from_str::<Option<String>>(s).ok())
|
||||
.flatten();
|
||||
let project = host.project(window)?;
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let invocation = ide_plugin::Invocation {
|
||||
client: Some(window.into()),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
editor: Some(ide_plugin::EditorContext {
|
||||
path: snap.path.0.clone(),
|
||||
language: snap.language.0,
|
||||
selections: vec![],
|
||||
}),
|
||||
selected_paths: vec![snap.path.0],
|
||||
..Default::default()
|
||||
};
|
||||
let (plugin, command_id) = (plugin.to_owned(), command_id.to_owned());
|
||||
return blocking(move || plugins.run_language_command(&plugin, &command_id, argument.as_deref(), invocation))
|
||||
.await;
|
||||
}
|
||||
host.project(window)?.lsp.execute_command(doc, command).await
|
||||
}
|
||||
|
||||
@@ -1195,7 +1611,88 @@ pub async fn lsp_execute_code_action(
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
/// Versioned plugin visuals, refreshed after the editor synchronizes its buffer.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginLanguageVisuals {
|
||||
pub version: u64,
|
||||
pub injections: Vec<ide_plugin::language_features::Injection>,
|
||||
pub colors: Vec<(String, ide_plugin::language_features::ColorInfo)>,
|
||||
pub gutter_actions: Vec<(String, ide_plugin::language_features::GutterAction)>,
|
||||
}
|
||||
pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> IdeResult<PluginLanguageVisuals> {
|
||||
let project = host.project(window)?;
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let generation = project.lsp.injection_generation();
|
||||
blocking(move || {
|
||||
let d = ProviderDocument {
|
||||
path: snap.path.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
let mut out =
|
||||
PluginLanguageVisuals { version: d.version, injections: vec![], colors: vec![], gutter_actions: vec![] };
|
||||
if d.text.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES
|
||||
&& let Some(plugins) = crate::plugins::editor_plugin_host()
|
||||
{
|
||||
out.injections = plugins.provide_injections(&d);
|
||||
out.colors = plugins.language_colors(&d);
|
||||
out.gutter_actions = plugins.language_gutter_actions(&d);
|
||||
}
|
||||
project.lsp.set_injections_at(
|
||||
doc,
|
||||
d.version as u32,
|
||||
out.injections
|
||||
.iter()
|
||||
.map(|i| ide_lsp::InjectedRange {
|
||||
range: i.range,
|
||||
language: ide_core::LanguageId::new(i.language.clone()),
|
||||
})
|
||||
.collect(),
|
||||
generation,
|
||||
);
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub async fn plugin_color_presentations(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
plugin: String,
|
||||
color: ide_plugin::language_features::ColorInfo,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let snap = host.project(window)?.documents.snapshot(doc)?;
|
||||
blocking(move || {
|
||||
let d = ProviderDocument {
|
||||
path: snap.path.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
if !crate::plugins::language_features::valid_range(&d, color.range) {
|
||||
return Err(ide_core::IdeError::invalid("Invalid color range"));
|
||||
}
|
||||
Ok(crate::plugins::editor_plugin_host()
|
||||
.map(|p| p.language_color_presentations(&plugin, &d, color))
|
||||
.unwrap_or_default())
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub fn plugin_formatters() -> Vec<crate::plugins::language_features::FormatterChoice> {
|
||||
crate::plugins::editor_plugin_host().map(|p| p.formatter_choices()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add(
|
||||
"plugin_language_visuals",
|
||||
|c| async move { plugin_language_visuals(&c.host, &c.window, c.arg("doc")?).await },
|
||||
);
|
||||
d.add("plugin_color_presentations", |c| async move {
|
||||
plugin_color_presentations(&c.host, &c.window, c.arg("doc")?, c.arg("plugin")?, c.arg("color")?).await
|
||||
});
|
||||
d.add("plugin_formatters", |_c| async move { Ok(plugin_formatters()) });
|
||||
d.add("lsp_completion", |c| async move {
|
||||
lsp_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("trigger")?).await
|
||||
});
|
||||
@@ -1228,6 +1725,14 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
|
||||
);
|
||||
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
|
||||
d.add(
|
||||
"lsp_can_download_sources",
|
||||
|c| async move { lsp_can_download_sources(&c.host, &c.window, c.arg("uri")?).await },
|
||||
);
|
||||
d.add("lsp_library_sources", |c| async move {
|
||||
lsp_library_sources(&c.host, &c.window, c.arg("request")?, c.arg("uri")?, c.arg("pos")?, c.arg("sources")?)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_references", |c| async move {
|
||||
let on_partial = c.sender("onPartial")?;
|
||||
lsp_references(
|
||||
@@ -1341,6 +1846,32 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn language_features_merge_without_overwriting_lsp_ranges_or_masking_errors() {
|
||||
use super::*;
|
||||
let range = Range::new(Pos::default(), Pos::new(0, 1));
|
||||
let existing = Location::file("/p/a".into(), range);
|
||||
let merged = merge_language_locations(
|
||||
vec![existing.clone()],
|
||||
vec![
|
||||
ide_plugin::Location { path: "/p/a".into(), range },
|
||||
ide_plugin::Location { path: "/p/b".into(), range },
|
||||
],
|
||||
);
|
||||
assert_eq!(merged.len(), 2);
|
||||
assert_eq!(merged[0], existing);
|
||||
let merged = merge_language_highlights(
|
||||
vec![Highlight { range, kind: HighlightKind::Read }],
|
||||
vec![ide_plugin::language_features::Highlight { range, kind: "write".into() }],
|
||||
);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].kind, HighlightKind::Read);
|
||||
assert!(
|
||||
provider_server::<Vec<Location>>(Err(ide_core::IdeError::unavailable("no server"))).unwrap().is_empty()
|
||||
);
|
||||
assert!(provider_server::<Vec<Location>>(Err(ide_core::IdeError::cancelled())).is_err());
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -1363,11 +1894,13 @@ mod tests {
|
||||
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
|
||||
use ide_core::{FileEdit, LanguageId};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let shared = root.join("shared.idx");
|
||||
let native = root.join("native.txt");
|
||||
std::fs::write(&shared, "old old").unwrap();
|
||||
std::fs::write(&native, "old").unwrap();
|
||||
// Exercise legacy verbatim index paths alongside ordinary native URI paths.
|
||||
let shared = std::fs::canonicalize(shared).unwrap();
|
||||
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
|
||||
let other = Range::new(Pos::new(0, 4), Pos::new(0, 7));
|
||||
let symbol = IndexedSymbol {
|
||||
@@ -1379,8 +1912,6 @@ mod tests {
|
||||
ranges: vec![range, other],
|
||||
}],
|
||||
};
|
||||
// Native file URIs have ordinary Windows paths; index snapshots retain
|
||||
// canonical verbatim paths. Both identify the same file and edit batch.
|
||||
let server_shared = dunce::simplified(&shared).to_path_buf();
|
||||
let server_native = dunce::simplified(&native).to_path_buf();
|
||||
#[cfg(windows)]
|
||||
@@ -1607,6 +2138,76 @@ mod tests {
|
||||
assert!(string_at(&structure, &snap, Pos::new(1, 53)), "inside \"users\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_names_on_code_identifiers_are_told_apart_from_strings_and_comments() {
|
||||
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
|
||||
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
let anchor = |text: &str, line: u32, name: &str| {
|
||||
let col = text.lines().nth(line as usize).unwrap().find(name).unwrap() as u32;
|
||||
Range::new(Pos::new(line, col), Pos::new(line, col + name.len() as u32))
|
||||
};
|
||||
for (language, text, name) in [
|
||||
("java", "class Order {}", "Order"),
|
||||
("kotlin", "class Order", "Order"),
|
||||
("typescript", "class Order {}", "Order"),
|
||||
("python", "class Order:\n pass", "Order"),
|
||||
("go", "package app\ntype Order struct {}", "Order"),
|
||||
("rust", "struct Order {}", "Order"),
|
||||
("cpp", "class Order {};", "Order"),
|
||||
("go", "package app\nfunc f() { retry: goto retry }", "retry"),
|
||||
("ruby", "@order = 1", "@order"),
|
||||
("ruby", "@@order = 1", "@@order"),
|
||||
("ruby", "$order = 1", "$order"),
|
||||
("php", "<?php $order = 1;", "$order"),
|
||||
] {
|
||||
let language = ide_core::LanguageId::new(language);
|
||||
let snap = ide_syntax::Snapshot::text(&language, text);
|
||||
let line = u32::from(text.starts_with("package"));
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, line, name), &cancel), "{language}: {name}");
|
||||
}
|
||||
for (language, text, name) in [
|
||||
("java", "class App { String route = \"orders.index\"; }", "orders.index"),
|
||||
("kotlin", "val route = \"orders.index\"", "orders.index"),
|
||||
("typescript", "const route = \"orders.index\";", "orders.index"),
|
||||
("python", "route = \"orders.index\"", "orders.index"),
|
||||
("go", "package app\nvar route = \"orders.index\"", "orders.index"),
|
||||
("rust", "const ROUTE: &str = \"orders.index\";", "orders.index"),
|
||||
("cpp", "const char* route = \"orders.index\";", "orders.index"),
|
||||
("json", "{\"orders.index\": \"orders.index\"}", "orders.index"),
|
||||
("yaml", "orders.index: orders.index", "orders.index"),
|
||||
("xml", "<select id=\"orders.index\" />", "id"),
|
||||
] {
|
||||
let language = ide_core::LanguageId::new(language);
|
||||
let snap = ide_syntax::Snapshot::text(&language, text);
|
||||
let line = u32::from(text.starts_with("package"));
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, line, name), &cancel), "{language}: {name}");
|
||||
}
|
||||
let php = ide_core::LanguageId::new("php");
|
||||
let text = "<?php\nuse App\\Models\\Order;\nclass Order {}\n// Order is a model\n\
|
||||
return route('users.show', Order::query());\n";
|
||||
let snap = ide_syntax::Snapshot::text(&php, text);
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 1, "Order"), &cancel), "an imported class");
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order"), &cancel), "a class declaration");
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 4, "Order"), &cancel), "a static call's class");
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 4, "users.show"), &cancel), "a route name");
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 3, "Order"), &cancel), "a comment");
|
||||
let ruby = ide_core::LanguageId::new("ruby");
|
||||
let text = "class Order\nend\nOrder.where(status: \"paid\")\n";
|
||||
let snap = ide_syntax::Snapshot::text(&ruby, text);
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 0, "Order"), &cancel), "a Ruby class name");
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order"), &cancel), "a Ruby constant reference");
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 2, "paid"), &cancel), "a Ruby string");
|
||||
let xml = ide_core::LanguageId::new("xml");
|
||||
let text = "<mapper>\n <select id=\"selectById\">select 1</select>\n</mapper>\n";
|
||||
let snap = ide_syntax::Snapshot::text(&xml, text);
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 1, "selectById"), &cancel), "a MyBatis statement id");
|
||||
let idx = ide_core::LanguageId::new("idx");
|
||||
let text = "def route\n";
|
||||
let snap = ide_syntax::Snapshot::text(&idx, text);
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 0, "route"), &cancel), "a language without a grammar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_ide_helpers_give_way_to_real_declarations() {
|
||||
let at = |path: &str| Location { path: path.into(), range: Range::default(), uri: None };
|
||||
@@ -1634,7 +2235,7 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let root = root.canonicalize().unwrap();
|
||||
let root = dunce::canonicalize(&root).unwrap();
|
||||
let action = |operations: Vec<FileOperation>| ide_plugin::CodeAction {
|
||||
title: "Create view".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Project language-server targets, shared by desktop and openidea-server.
|
||||
use crate::plugins::PluginHost;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_language::{LanguageServerConfig, ServerTarget};
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn configure(
|
||||
plugins: &PluginHost,
|
||||
root: &Path,
|
||||
id: &str,
|
||||
cfg: &mut LanguageServerConfig,
|
||||
) -> IdeResult<bool> {
|
||||
let Some(registration) = plugins.run_targets(root).into_iter().find(|t| t.id == id) else { return Ok(false) };
|
||||
let container_root = registration.target.container_root.clone().unwrap_or_default();
|
||||
cfg.target = Some(ServerTarget {
|
||||
id: id.into(),
|
||||
origin: None,
|
||||
project_root: root.into(),
|
||||
container_root: container_root.clone(),
|
||||
mapping: ide_core::path_mapping::PathMapping::new(&root.to_string_lossy(), &container_root),
|
||||
});
|
||||
if container_root.is_empty() {
|
||||
return Err(IdeError::invalid("language server targets require a container root"));
|
||||
}
|
||||
let run = command(&cfg.command, cfg.args.clone());
|
||||
let wrapped = plugins.prepare_run_target(root, id, &run)?;
|
||||
cfg.command = wrapped.command;
|
||||
cfg.args = wrapped.args;
|
||||
cfg.connect = None;
|
||||
cfg.pinned = Some(ide_language::ProgramPin::approve(&cfg.command)?);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn command(command: &str, args: Vec<String>) -> ide_run::RunConfig {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id":"language-server", "name":"Language server", "kind":"shell",
|
||||
"command":command, "args":args, "cwd":".", "env":{}, "detected":false, "isTest":false
|
||||
}))
|
||||
.expect("valid internal run configuration")
|
||||
}
|
||||
|
||||
pub(crate) fn read(plugins: &PluginHost, target: &ServerTarget, path: &str) -> IdeResult<String> {
|
||||
let registration = plugins
|
||||
.run_targets(&target.project_root)
|
||||
.into_iter()
|
||||
.find(|t| t.id == target.id)
|
||||
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
|
||||
if registration.target.container_root.as_deref() != Some(&target.container_root) {
|
||||
return Err(IdeError::unavailable("plugin run target changed; navigate again"));
|
||||
}
|
||||
let mut wrapped = plugins.prepare_run_target(&target.project_root, &target.id, &command("cat", Vec::new()))?;
|
||||
wrapped.args.extend(["--".into(), path.into()]);
|
||||
capture(&wrapped, &target.project_root)
|
||||
}
|
||||
|
||||
/// Bounds both output and time, and always kills/reaps the wrapper on failure.
|
||||
fn capture(run: &ide_run::RunConfig, root: &Path) -> IdeResult<String> {
|
||||
use std::io::Read;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
const LIMIT: u64 = 4 * 1024 * 1024;
|
||||
let mut cmd = Command::new(&run.command);
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
ide_core::process::own_group(&mut cmd);
|
||||
let mut child = cmd
|
||||
.args(&run.args)
|
||||
.current_dir(root)
|
||||
.envs(ide_core::process::shell_env())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let stderr = child.stderr.take().expect("piped stderr");
|
||||
let overflow = Arc::new(AtomicBool::new(false));
|
||||
let large = overflow.clone();
|
||||
let output = std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
let result = stdout.take(LIMIT + 1).read_to_end(&mut bytes);
|
||||
large.store(bytes.len() as u64 > LIMIT, Ordering::Release);
|
||||
result.map(|_| bytes)
|
||||
});
|
||||
let errors = std::thread::spawn(move || {
|
||||
let mut stderr = stderr;
|
||||
let mut bytes = Vec::new();
|
||||
let _ = (&mut stderr).take(65536).read_to_end(&mut bytes);
|
||||
let _ = std::io::copy(&mut stderr, &mut std::io::sink());
|
||||
bytes
|
||||
});
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let status = loop {
|
||||
if output.is_finished()
|
||||
&& errors.is_finished()
|
||||
&& let Ok(Some(status)) = child.try_wait()
|
||||
{
|
||||
break Ok(status);
|
||||
}
|
||||
match (std::time::Instant::now() < deadline, overflow.load(Ordering::Acquire)) {
|
||||
(true, false) => std::thread::sleep(std::time::Duration::from_millis(10)),
|
||||
_ => {
|
||||
ide_core::process::kill_tree_of(child.id());
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break Err(IdeError::unavailable("container document exceeds the size or time limit"));
|
||||
}
|
||||
}
|
||||
};
|
||||
let bytes = output.join().map_err(|_| IdeError::unavailable("container document reader failed"))??;
|
||||
let stderr = errors.join().unwrap_or_default();
|
||||
if !status?.success() {
|
||||
return Err(IdeError::unavailable(format!("container document: {}", String::from_utf8_lossy(&stderr))));
|
||||
}
|
||||
if bytes.len() as u64 > LIMIT {
|
||||
return Err(IdeError::invalid("container document exceeds 4 MiB"));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| IdeError::invalid("container document is not UTF-8 text"))
|
||||
}
|
||||
@@ -98,3 +98,5 @@ impl Host {
|
||||
self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod lsp_target;
|
||||
@@ -41,6 +41,11 @@ pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeRe
|
||||
Ok(host.project(window)?.index.find_files(&query, limit as usize))
|
||||
}
|
||||
|
||||
pub async fn find_files_named(host: &Host, window: &str, name: String) -> IdeResult<Vec<IpcPath>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || Ok(project.index.files_named(&name).into_iter().map(Into::into).collect())).await
|
||||
}
|
||||
|
||||
pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
|
||||
Ok(host.project(window)?.index.find_dirs(&query, limit as usize))
|
||||
}
|
||||
@@ -167,6 +172,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
|
||||
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
|
||||
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
|
||||
d.add("find_files_named", |c| async move { find_files_named(&c.host, &c.window, c.arg("name")?).await });
|
||||
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
|
||||
d.add("find_symbols", |c| async move {
|
||||
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct ProjectInfo {
|
||||
pub trusted: bool,
|
||||
pub root: ide_core::IpcPath,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub display_name: String,
|
||||
pub is_git: bool,
|
||||
pub settings: Settings,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -24,17 +26,23 @@ pub struct ProjectInfo {
|
||||
pub fn info(project: &Project) -> ProjectInfo {
|
||||
let settings = project.settings.effective();
|
||||
let custom = settings.general.project_name.trim();
|
||||
let primary = if custom.is_empty() {
|
||||
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
} else {
|
||||
custom.to_owned()
|
||||
};
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&project.root).ok().flatten();
|
||||
ProjectInfo {
|
||||
trusted: project.is_trusted(),
|
||||
name: if custom.is_empty() {
|
||||
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
} else {
|
||||
custom.to_owned()
|
||||
},
|
||||
display_name: workspace
|
||||
.as_ref()
|
||||
.map(|w| w.display_name(&project.root, &primary))
|
||||
.unwrap_or_else(|| primary.clone()),
|
||||
name: workspace.as_ref().filter(|w| w.is_container(&project.root)).map(|w| w.name.clone()).unwrap_or(primary),
|
||||
is_git: !project.repos.read().is_empty(),
|
||||
settings,
|
||||
root: project.root.clone().into(),
|
||||
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
|
||||
workspace,
|
||||
unity: ide_workspace::unity::detect(&project.root),
|
||||
}
|
||||
}
|
||||
@@ -63,38 +71,71 @@ pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots
|
||||
|
||||
pub fn save_workspace(host: &Host, window: &str, name: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<ProjectInfo> {
|
||||
let project = host.project(window)?;
|
||||
ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
|
||||
let previous = ide_workspace::workspace::Workspace::load(&project.root)?
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("This folder is not a workspace"))?;
|
||||
for path in paths.iter().filter(|p| !previous.projects.contains(p)) {
|
||||
require_attachment_trust(host, path)?;
|
||||
}
|
||||
if !previous.is_container(&project.root) && paths.first() != Some(&project.root) {
|
||||
return Err(ide_core::IdeError::invalid("The primary project cannot be detached"));
|
||||
}
|
||||
if !previous.is_container(&project.root) && paths == [project.root.clone()] {
|
||||
std::fs::remove_file(project.root.join(ide_workspace::workspace::FILE))?;
|
||||
} else {
|
||||
let workspace = ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
|
||||
keep_attachment_private(&project.root, &workspace)?;
|
||||
}
|
||||
refresh_workspace(&project);
|
||||
Ok(info(&project))
|
||||
}
|
||||
|
||||
fn require_attachment_trust(host: &Host, path: &Path) -> IdeResult<()> {
|
||||
if !host.trust.trusted(path)? {
|
||||
return Err(ide_core::IdeError::invalid("Trust the project before attaching it"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn keep_attachment_private(root: &Path, workspace: &ide_workspace::workspace::Workspace) -> IdeResult<()> {
|
||||
if !workspace.is_container(root) {
|
||||
ide_vcs::changelists::ensure_ignored(&root.join(".openidea"), &["/workspace.json"])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_workspace(project: &Project) {
|
||||
project.settings.reload();
|
||||
project.refresh_index_scope();
|
||||
project.refresh_repositories();
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
Ok(info(&project))
|
||||
}
|
||||
|
||||
/// Links a folder, or prepares an automatic workspace for the caller to open in place.
|
||||
/// Attaches a trusted folder without replacing the current project.
|
||||
pub fn attach_workspace(host: &Host, window: &str, path: &Path) -> IdeResult<ide_core::IpcPath> {
|
||||
let project = host.project(window)?;
|
||||
let path = dunce::canonicalize(path)?;
|
||||
if project.light {
|
||||
return Err(ide_core::IdeError::invalid("Open a project before attaching a folder"));
|
||||
}
|
||||
if path == project.root {
|
||||
return Ok(project.root.clone().into());
|
||||
}
|
||||
require_attachment_trust(host, &path)?;
|
||||
if let Some(mut workspace) = ide_workspace::workspace::Workspace::load(&project.root)? {
|
||||
if !workspace.projects.contains(&path) {
|
||||
workspace.projects.push(path);
|
||||
save_workspace(host, window, &workspace.name, workspace.projects)?;
|
||||
}
|
||||
return Ok(project.root.clone().into());
|
||||
} else {
|
||||
let workspace = ide_workspace::workspace::Workspace::attach(
|
||||
&project.root,
|
||||
&info(&project).name,
|
||||
vec![project.root.clone(), path],
|
||||
)?;
|
||||
keep_attachment_private(&project.root, &workspace)?;
|
||||
refresh_workspace(&project);
|
||||
}
|
||||
if path == project.root {
|
||||
return Ok(project.root.clone().into());
|
||||
}
|
||||
Ok(ide_workspace::workspace::Workspace::create_for_project(
|
||||
&host.data_dir,
|
||||
&project.root,
|
||||
&info(&project).name,
|
||||
&path,
|
||||
)?
|
||||
.into())
|
||||
Ok(project.root.clone().into())
|
||||
}
|
||||
|
||||
/// What [`set_settings`] changed; the caller tells the windows.
|
||||
@@ -181,7 +222,7 @@ pub fn set_settings(
|
||||
|| key.starts_with("editor.codeStyle.")
|
||||
|| key == "inspections"
|
||||
|| key.starts_with("inspections.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
let toolchain = ["languages", "tools", "languageServers", "languageServerTargets", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
|
||||
|
||||
+112
-16
@@ -9,6 +9,9 @@ use std::time::Duration;
|
||||
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
plugins.refresh_run_configurations(&project.root);
|
||||
}
|
||||
project_configs(&project.root, &project.settings.effective(), &host.config_dir)
|
||||
}
|
||||
|
||||
@@ -21,7 +24,7 @@ pub(super) fn project_configs(
|
||||
let root_suppressed = ide_run::load_suppressed_config_ids(root)?;
|
||||
let mut all = detected_and_saved(root, settings, &user, &root_suppressed);
|
||||
if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)? {
|
||||
for member in workspace.projects.into_iter().filter(|p| p.is_dir()) {
|
||||
for member in workspace.projects.into_iter().filter(|p| p != root && p.is_dir()) {
|
||||
let settings = ide_workspace::SettingsStore::load(config_dir, Some(&member)).effective();
|
||||
let saved = ide_run::load_user_configs(&member)?;
|
||||
let suppressed = ide_run::load_suppressed_config_ids(&member)?;
|
||||
@@ -142,20 +145,21 @@ fn settings_for(
|
||||
config: &mut RunConfig,
|
||||
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
|
||||
if workspace.is_some() {
|
||||
let owner = ide_run::scope::owner(&project.root, config);
|
||||
let member = workspace.as_ref().and_then(|workspace| {
|
||||
workspace
|
||||
.projects
|
||||
.iter()
|
||||
.filter(|root| **root != project.root && owner.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
.cloned()
|
||||
});
|
||||
if workspace.as_ref().is_some_and(|w| w.is_container(&project.root)) || member.is_some() {
|
||||
if config.extras.module.is_none() && !matches!(config.kind.as_str(), "application" | "spring-boot") {
|
||||
config.extras.module = config.cwd.as_deref().map(ide_core::ipc_path::to_ipc);
|
||||
}
|
||||
ide_run::scope::rebase(config, &project.root, None);
|
||||
}
|
||||
let member = workspace.and_then(|workspace| {
|
||||
let owner = ide_run::scope::owner(&project.root, config);
|
||||
workspace
|
||||
.projects
|
||||
.into_iter()
|
||||
.filter(|root| owner.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
});
|
||||
Ok(match member {
|
||||
Some(root) => {
|
||||
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective();
|
||||
@@ -199,8 +203,66 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
start_request(host, window, config, on_output, None).await
|
||||
}
|
||||
|
||||
pub async fn start_request(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
config: RunConfig,
|
||||
on_output: Emit<OutputChunk>,
|
||||
request: Option<ide_core::RequestId>,
|
||||
) -> IdeResult<RunInfo> {
|
||||
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
|
||||
let operation = config.extras.params.get("plugin.operation").cloned();
|
||||
let result = start_inner(host, window, config, on_output, &cancel).await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
if let (Ok(info), Some(operation), Some(plugins)) = (&result, operation, host.plugin_host()) {
|
||||
plugins.run_operation_started(window, &operation, info.id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn start_inner(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
mut config: RunConfig,
|
||||
on_output: Emit<OutputChunk>,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<RunInfo> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if config.extras.target.is_none() {
|
||||
config.extras.target = ide_sdk::apply::run_target(&config, &project.settings.effective()).map(str::to_owned);
|
||||
}
|
||||
if config.detected
|
||||
&& config.id.starts_with(ide_run::EXTERNAL_PREFIX)
|
||||
&& let Some(plugins) = host.plugin_host()
|
||||
{
|
||||
let provider = plugins.run_detection();
|
||||
let binary =
|
||||
provider.launch(&config).map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?;
|
||||
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
|
||||
provider
|
||||
.detect(&project.root)
|
||||
.into_iter()
|
||||
.find(|c| c.id == config.id)
|
||||
.ok_or_else(|| IdeError::not_found("plugin configuration no longer available"))?
|
||||
.command
|
||||
} else {
|
||||
binary.to_string_lossy().into_owned()
|
||||
};
|
||||
config.detected = false;
|
||||
}
|
||||
let mut sink = project.sink.clone();
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
sink = plugins.run_operation_sink(window, &mut config, sink)?;
|
||||
}
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
@@ -210,6 +272,41 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
}
|
||||
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
|
||||
let settings = project.settings.effective();
|
||||
if id.starts_with("plugin-target:") {
|
||||
let plugins =
|
||||
host.plugin_host().ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
|
||||
let root = project.root.clone();
|
||||
let id = id.to_owned();
|
||||
let token = cancel.clone();
|
||||
let config = host
|
||||
.blocking(move || {
|
||||
let operation =
|
||||
if config.extras.params.get("plugin.targetOperation").is_some_and(|op| op == "debug") {
|
||||
ide_plugin::RunTargetOperation::Debug
|
||||
} else if config.is_test {
|
||||
ide_plugin::RunTargetOperation::Test
|
||||
} else {
|
||||
ide_plugin::RunTargetOperation::Run
|
||||
};
|
||||
plugins.prepare_run_target_launch(&root, &id, &config, operation, &token)
|
||||
})
|
||||
.await?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
project.require_trust()?;
|
||||
return host.processes.start(
|
||||
&project.root,
|
||||
&config,
|
||||
&StartOptions {
|
||||
encoding: Some(settings.console.encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: Vec::new(),
|
||||
ruby_reporter: None,
|
||||
},
|
||||
sink,
|
||||
Box::new(move |c| on_output(c)),
|
||||
);
|
||||
}
|
||||
let target = ide_remote::service::run_target(&settings, id)?;
|
||||
if target.kind != RunTargetKind::Ssh {
|
||||
// A local run of the docker CLI: the command is resolved in the container, and
|
||||
@@ -227,15 +324,12 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
&project.root,
|
||||
&config,
|
||||
&options,
|
||||
project.sink.clone(),
|
||||
sink.clone(),
|
||||
Box::new(move |c| on_output(c)),
|
||||
);
|
||||
}
|
||||
let out = on_output.clone();
|
||||
return host
|
||||
.remote
|
||||
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
|
||||
.await;
|
||||
return host.remote.run(&settings, &project.root, &config, sink.clone(), Box::new(move |c| out(c))).await;
|
||||
}
|
||||
let mut config = config;
|
||||
let (root, settings) = settings_for(host, &project, &mut config)?;
|
||||
@@ -286,7 +380,6 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
ruby_reporter: ruby_reporter_dir(host, &config),
|
||||
};
|
||||
project.require_trust()?;
|
||||
let mut sink = project.sink.clone();
|
||||
if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) {
|
||||
let previous = sink;
|
||||
let root = project.root.clone();
|
||||
@@ -317,6 +410,9 @@ fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option<std::path::PathB
|
||||
|
||||
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
|
||||
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
plugins.run_operation_stopped(id);
|
||||
}
|
||||
let settings = match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
|
||||
@@ -218,12 +218,13 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| artifacts.first().map(|a| &a.id))
|
||||
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
|
||||
let mut plan = ide_build::web::build(
|
||||
&project.root,
|
||||
&model,
|
||||
id,
|
||||
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
|
||||
)?;
|
||||
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
|
||||
// "Build before launch" is skipped when the built WAR is already newer than the
|
||||
// module's sources, so a repeated run does not rebuild (IDEA's up-to-date check).
|
||||
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut plan = ide_build::web::build(&project.root, &model, id, &base)?;
|
||||
plan.env.extend(config.env.clone());
|
||||
let mut build = RunConfig {
|
||||
id: format!("tomcat-build:{}", config.id),
|
||||
|
||||
@@ -34,67 +34,12 @@ pub async fn start(
|
||||
// Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and
|
||||
// ignored names are never searched.
|
||||
query.exclude.extend(project.search_excludes());
|
||||
let roots = project.roots();
|
||||
let set = match query.scope {
|
||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||
_ => None,
|
||||
};
|
||||
let cancel = host.begin_request(request);
|
||||
let documents = project.documents.clone();
|
||||
let root = project.root.clone();
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
|
||||
// The whole project also covers the Project Structure content roots.
|
||||
let content_roots = match query.scope {
|
||||
ide_search::SearchScope::Project
|
||||
| ide_search::SearchScope::FileSet { .. }
|
||||
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let include_root = !workspace
|
||||
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let events = on_event.clone();
|
||||
let syntax = host.syntax();
|
||||
let summary = host
|
||||
.blocking(move || {
|
||||
let filter = |rel: &str| match set {
|
||||
Some(ProjectFileSet::Production) => !roots.is_test(rel),
|
||||
Some(ProjectFileSet::Test) => roots.is_test(rel),
|
||||
Some(ProjectFileSet::All) | None => true,
|
||||
};
|
||||
// Comments and string literals for the search context filter.
|
||||
let literals = |path: &Path, text: &str| {
|
||||
let language = languages.detect(path, text.lines().next());
|
||||
let found = syntax.literals(&language, text)?;
|
||||
Some(found.into_iter().map(literal_range).collect())
|
||||
};
|
||||
let mut total = SearchSummary::default();
|
||||
for dir in std::iter::once(&root).filter(|_| include_root).chain(&content_roots) {
|
||||
let s = ide_search::search_in_context(
|
||||
dir,
|
||||
&query,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| documents.search_text_of(p),
|
||||
&cancel,
|
||||
&|mut file| {
|
||||
if dir != &root {
|
||||
let name = dir.file_name().unwrap_or_default().to_string_lossy();
|
||||
file.rel_path = format!("{name}/{}", file.rel_path);
|
||||
}
|
||||
events(SearchEvent::File { file });
|
||||
},
|
||||
)?;
|
||||
total.files += s.files;
|
||||
total.matches += s.matches;
|
||||
total.elapsed_ms += s.elapsed_ms;
|
||||
total.limit_hit |= s.limit_hit;
|
||||
total.cancelled |= s.cancelled;
|
||||
if total.limit_hit || total.cancelled {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
search_project(&project, &syntax, &query, &cancel, &|file| events(SearchEvent::File { file }))
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
@@ -102,5 +47,69 @@ pub async fn start(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shared Find in Files engine: project excludes are added by the caller.
|
||||
pub(crate) fn search_project(
|
||||
project: &crate::project::Project,
|
||||
syntax: &ide_language::Syntax,
|
||||
query: &SearchQuery,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
emit: &(dyn Fn(FileMatches) + Sync),
|
||||
) -> IdeResult<SearchSummary> {
|
||||
let root = &project.root;
|
||||
let roots = project.roots();
|
||||
let set = match query.scope {
|
||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||
_ => None,
|
||||
};
|
||||
let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some_and(|w| w.is_container(root));
|
||||
let include_root = !workspace
|
||||
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
|
||||
let content_roots = match query.scope {
|
||||
ide_search::SearchScope::Project
|
||||
| ide_search::SearchScope::FileSet { .. }
|
||||
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let filter = |rel: &str| match set {
|
||||
Some(ProjectFileSet::Production) => !roots.is_test(rel),
|
||||
Some(ProjectFileSet::Test) => roots.is_test(rel),
|
||||
_ => true,
|
||||
};
|
||||
let literals = |path: &Path, text: &str| {
|
||||
let language = project.languages.detect(path, text.lines().next());
|
||||
Some(syntax.literals(&language, text)?.into_iter().map(literal_range).collect())
|
||||
};
|
||||
let mut total = SearchSummary::default();
|
||||
for dir in std::iter::once(root).filter(|_| include_root).chain(&content_roots) {
|
||||
let mut remaining = query.clone();
|
||||
let max = if query.max_matches == 0 { 10_000 } else { query.max_matches };
|
||||
remaining.max_matches = max.saturating_sub(total.matches).max(1);
|
||||
let summary = ide_search::search_in_context(
|
||||
dir,
|
||||
&remaining,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| project.documents.search_text_of(p),
|
||||
cancel,
|
||||
&|mut file| {
|
||||
if dir != root {
|
||||
let name = dir.file_name().unwrap_or_default().to_string_lossy();
|
||||
file.rel_path = format!("{name}/{}", file.rel_path);
|
||||
}
|
||||
emit(file);
|
||||
},
|
||||
)?;
|
||||
total.files += summary.files;
|
||||
total.matches += summary.matches;
|
||||
total.elapsed_ms += summary.elapsed_ms;
|
||||
total.limit_hit |= summary.limit_hit || total.matches >= max;
|
||||
total.cancelled |= summary.cancelled;
|
||||
if total.limit_hit || total.cancelled {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
@@ -24,8 +24,10 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
|
||||
.collect();
|
||||
// Godot is usually an app bundle / Flatpak outside the search path.
|
||||
let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p));
|
||||
let runtime = ide_sdk::apply::tool_install_environment(&root, &settings);
|
||||
let work_dir = (!root.as_os_str().is_empty()).then_some(root);
|
||||
Ok(Tools::new(&data)
|
||||
.with_runtime(runtime.commands, runtime.env)
|
||||
.with_custom_paths(custom)
|
||||
.with_found_paths(found.into_iter().collect())
|
||||
.with_work_dir(work_dir))
|
||||
|
||||
@@ -673,10 +673,11 @@ pub async fn vcs_rename_branch(
|
||||
repo: Option<PathBuf>,
|
||||
from: String,
|
||||
to: String,
|
||||
unset_upstream: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to, unset_upstream))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_merge(host: &Host, window: &str, repo: Option<PathBuf>, branch: String) -> IdeResult<()> {
|
||||
@@ -995,7 +996,17 @@ pub async fn vcs_rebase_with(
|
||||
|
||||
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.remotes())).await
|
||||
blocking(move || {
|
||||
let mut remotes = project.with_log_repo_at(repo.as_deref(), |r| r.remotes())?;
|
||||
for r in &mut remotes {
|
||||
let resolved = ide_ssh::resolve_ssh_remote_url(&r.url);
|
||||
if resolved != r.url {
|
||||
r.resolved_url = Some(resolved);
|
||||
}
|
||||
}
|
||||
Ok(remotes)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
|
||||
@@ -1379,7 +1390,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
|
||||
});
|
||||
d.add("vcs_rename_branch", |c| async move {
|
||||
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
|
||||
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
|
||||
d.add("vcs_rebase", |c| async move { vcs_rebase(&c.host, &c.window, c.arg("repo")?, c.arg("onto")?).await });
|
||||
|
||||
+1413
-35
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,205 @@
|
||||
//! One-shot OAuth redirects, always bound on the desktop's IPv4 loopback.
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::HostError;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{Ipv4Addr, TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(super) trait Service: Send + Sync {
|
||||
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError>;
|
||||
fn wait(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<String>, HostError>;
|
||||
fn close(&self, plugin: &str, id: u64);
|
||||
fn clear(&self, plugin: &str);
|
||||
fn forget_window(&self, window: &str);
|
||||
}
|
||||
struct Entry {
|
||||
window: String,
|
||||
path: String,
|
||||
listener: Mutex<Option<TcpListener>>,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct Loopbacks {
|
||||
next: AtomicU64,
|
||||
entries: Mutex<HashMap<(String, u64), Arc<Entry>>>,
|
||||
}
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
fn respond(stream: &mut TcpStream, ok: bool) {
|
||||
let body = if ok { "You can close this tab." } else { "Not found." };
|
||||
let status = if ok { "200 OK" } else { "404 Not Found" };
|
||||
let _ = write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
impl Loopbacks {
|
||||
fn remove_where(&self, predicate: impl Fn(&str, &Entry) -> bool) {
|
||||
self.entries.lock().retain(|(p, _), entry| {
|
||||
if !predicate(p, entry) {
|
||||
return true;
|
||||
}
|
||||
entry.cancel.cancel();
|
||||
entry.listener.lock().take();
|
||||
false
|
||||
});
|
||||
}
|
||||
}
|
||||
impl Service for Loopbacks {
|
||||
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError> {
|
||||
let path = path.trim_start_matches('/');
|
||||
if path.len() > 1024
|
||||
|| path.bytes().any(|b| !(b.is_ascii_alphanumeric() || b"/-._~".contains(&b)))
|
||||
|| path.split('/').any(|s| matches!(s, "." | ".."))
|
||||
{
|
||||
return Err(HostError::Invalid("loopback path must be a URL path without query or fragment".into()));
|
||||
}
|
||||
let mut entries = self.entries.lock();
|
||||
if entries.keys().filter(|(p, _)| p == plugin).count() >= 2 {
|
||||
return Err(HostError::Invalid("at most two open loopbacks per plugin".into()));
|
||||
}
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(failed)?;
|
||||
listener.set_nonblocking(true).map_err(failed)?;
|
||||
let uri = format!("http://127.0.0.1:{}/{path}", listener.local_addr().map_err(failed)?.port());
|
||||
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||
entries.insert(
|
||||
(plugin.into(), id),
|
||||
Arc::new(Entry {
|
||||
window: window.into(),
|
||||
path: format!("/{path}"),
|
||||
listener: Mutex::new(Some(listener)),
|
||||
cancel: CancellationToken::new(),
|
||||
}),
|
||||
);
|
||||
Ok((id, uri))
|
||||
}
|
||||
fn wait(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<String>, HostError> {
|
||||
let entry = self
|
||||
.entries
|
||||
.lock()
|
||||
.get(&(plugin.into(), id))
|
||||
.cloned()
|
||||
.ok_or_else(|| HostError::NotFound("loopback".into()))?;
|
||||
let listener =
|
||||
entry.listener.lock().take().ok_or_else(|| HostError::Invalid("loopback already awaited".into()))?;
|
||||
let deadline = Instant::now() + timeout;
|
||||
let result = (|| loop {
|
||||
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok((mut stream, address)) => {
|
||||
if !address.ip().is_loopback() {
|
||||
continue;
|
||||
}
|
||||
stream.set_nonblocking(true).map_err(failed)?;
|
||||
let mut head = Vec::new();
|
||||
loop {
|
||||
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut buf = [0; 1024];
|
||||
match stream.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
head.extend_from_slice(&buf[..n]);
|
||||
if head.windows(4).any(|w| w == b"\r\n\r\n") || head.len() > 8192 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(5))
|
||||
}
|
||||
Err(e) => return Err(failed(e)),
|
||||
}
|
||||
}
|
||||
let line = std::str::from_utf8(&head).unwrap_or_default().lines().next().unwrap_or_default();
|
||||
let target = line.strip_prefix("GET ").and_then(|s| s.split_once(' ')).map(|(target, _)| target);
|
||||
let query = target.and_then(|target| {
|
||||
let (path, query) = target.split_once('?').unwrap_or((target, ""));
|
||||
(path == entry.path && head.len() <= 8192).then(|| query.to_owned())
|
||||
});
|
||||
stream.set_nonblocking(false).map_err(failed)?;
|
||||
stream.set_write_timeout(Some(Duration::from_millis(100))).map_err(failed)?;
|
||||
respond(&mut stream, query.is_some());
|
||||
if query.is_some() {
|
||||
return Ok(query);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(e) => return Err(failed(e)),
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(None);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
})();
|
||||
self.close(plugin, id);
|
||||
result
|
||||
}
|
||||
fn close(&self, plugin: &str, id: u64) {
|
||||
let entry = self.entries.lock().remove(&(plugin.into(), id));
|
||||
if let Some(entry) = entry {
|
||||
entry.cancel.cancel();
|
||||
entry.listener.lock().take();
|
||||
}
|
||||
}
|
||||
fn clear(&self, plugin: &str) {
|
||||
self.remove_where(|p, _| p == plugin || p.starts_with("remote:") && p.ends_with(&format!(":{plugin}")));
|
||||
}
|
||||
fn forget_window(&self, window: &str) {
|
||||
self.remove_where(|_, entry| entry.window == window);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn one_shot_raw_query_limits_timeout_and_cleanup() {
|
||||
let service = Arc::new(Loopbacks::default());
|
||||
let (id, uri) = service.begin("a", "main", "oauth/callback").unwrap();
|
||||
assert!(uri.starts_with("http://127.0.0.1:"));
|
||||
let (other, _) = service.begin("a", "main", "").unwrap();
|
||||
assert!(service.begin("a", "main", "third").is_err());
|
||||
assert_eq!(service.wait("a", other, Duration::ZERO, &CancellationToken::new()).unwrap(), None);
|
||||
let host = uri.strip_prefix("http://").unwrap().split('/').next().unwrap().to_owned();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let mut stream = TcpStream::connect(host).unwrap();
|
||||
stream.write_all(b"GET /oauth/callback?code=a%2Bb&state=x HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap();
|
||||
let mut answer = String::new();
|
||||
stream.read_to_string(&mut answer).unwrap();
|
||||
assert!(answer.contains("You can close this tab"));
|
||||
});
|
||||
assert_eq!(
|
||||
service.wait("a", id, Duration::from_secs(2), &CancellationToken::new()).unwrap(),
|
||||
Some("code=a%2Bb&state=x".into())
|
||||
);
|
||||
worker.join().unwrap();
|
||||
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
|
||||
let (id, _) = service.begin("a", "main", "callback").unwrap();
|
||||
service.forget_window("main");
|
||||
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Database ownership stays on the project host; credentials never pass back to guests.
|
||||
use super::api::PluginApi;
|
||||
use super::{PluginHost, WindowRequest};
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult};
|
||||
use ide_db::{DataSource, DbDriver, DbProperty};
|
||||
use ide_plugin::{HostError, PluginDataSource};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct OwnedProject {
|
||||
root: PathBuf,
|
||||
sources: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
pub(super) fn settings(source: PluginDataSource) -> Result<DataSource, HostError> {
|
||||
let bad = || HostError::Invalid("invalid non-secret database source settings".into());
|
||||
if source.id.is_empty()
|
||||
|| source.id.len() > 128
|
||||
|| source.name.trim().is_empty()
|
||||
|| source.name.len() > 256
|
||||
|| source.id.chars().chain(source.name.chars()).any(char::is_control)
|
||||
|| source.options.len() > 128
|
||||
|| [&source.host, &source.database, &source.user, &source.url]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|v| v.len() > 16_384 || v.contains('\0'))
|
||||
{
|
||||
return Err(bad());
|
||||
}
|
||||
let driver = match source.driver.as_str() {
|
||||
"sqlite" => DbDriver::Sqlite,
|
||||
"postgres" => DbDriver::Postgres,
|
||||
"mysql" => DbDriver::Mysql,
|
||||
"sqlServer" => DbDriver::SqlServer,
|
||||
"clickhouse" => DbDriver::Clickhouse,
|
||||
"oracle" => DbDriver::Oracle,
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
let secret = |key: &str| {
|
||||
let key = key.to_ascii_lowercase();
|
||||
["password", "passwd", "pwd", "token", "secret", "credential"].iter().any(|s| key.contains(s))
|
||||
};
|
||||
if source.options.iter().any(|(k, v)| {
|
||||
k.is_empty() || k.len() > 256 || v.len() > 16_384 || k.contains('\0') || v.contains('\0') || secret(k)
|
||||
}) {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut out = DataSource {
|
||||
name: source.name,
|
||||
driver,
|
||||
host: source.host.unwrap_or_else(|| "localhost".into()),
|
||||
port: source.port,
|
||||
database: source.database.unwrap_or_default(),
|
||||
user: source.user.unwrap_or_default(),
|
||||
save_password: true,
|
||||
properties: source.options.into_iter().map(|(name, value)| DbProperty { name, value }).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
if driver == DbDriver::Sqlite {
|
||||
out.file = Some(out.database.clone().into());
|
||||
}
|
||||
if let Some(url) = source.url.filter(|u| !u.is_empty()) {
|
||||
if url.contains(';') {
|
||||
return Err(bad());
|
||||
}
|
||||
let parsed = ide_db::source::parse_url(&url).map_err(|_| bad())?;
|
||||
if parsed.password.is_some() || parsed.params.iter().any(|(k, _)| secret(k)) {
|
||||
return Err(bad());
|
||||
}
|
||||
// Case-insensitive URL credential keys must not bypass the parser's lowercase password key.
|
||||
if let Some((_, query)) = url.split_once('?') {
|
||||
for part in query.split('&') {
|
||||
let key = part.split('=').next().unwrap_or_default();
|
||||
// Parsing a synthetic URL decodes percent-encoded parameter names as well.
|
||||
let probe = ide_db::source::parse_url(&format!("postgres://host/db?{key}=x")).map_err(|_| bad())?;
|
||||
if probe.password.is_some() || probe.params.iter().any(|(k, _)| secret(k)) {
|
||||
return Err(bad());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.url_only = true;
|
||||
out.url = url;
|
||||
out.resolved().map_err(|_| bad())?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
pub(super) fn database_host(&self, root: &Path) -> Result<(Arc<crate::Host>, Arc<ide_db::DbManager>), HostError> {
|
||||
let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?;
|
||||
if !workspace.projects().iter().any(|r| r == root) {
|
||||
return Err(HostError::NotFound("project is not open".into()));
|
||||
}
|
||||
if !workspace.trusted(root) {
|
||||
return Err(HostError::Denied("project is not trusted".into()));
|
||||
}
|
||||
ide_plugin::resolve_new_in_root(root, ".openidea/data-sources.toml").map_err(HostError::Invalid)?;
|
||||
let host = workspace
|
||||
.database_host()
|
||||
.ok_or_else(|| HostError::Invalid("Database subsystem is unavailable on this host".into()))?;
|
||||
let db = host.projects().into_iter().find(|p| p.root == root).ok_or(HostError::Cancelled)?.db.clone();
|
||||
Ok((host, db))
|
||||
}
|
||||
pub(super) fn remember_database_project(&self, root: &Path, id: &str) -> Result<(), HostError> {
|
||||
let file = self.shared.data_dir.join(".database-projects").join(format!("{}.json", self.pkg.id()));
|
||||
let mut projects: Vec<OwnedProject> = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(e.to_string()))?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => vec![],
|
||||
Err(e) => return Err(HostError::Failed(e.to_string())),
|
||||
};
|
||||
if !projects.iter().any(|p| p.root == root) {
|
||||
projects.push(OwnedProject { root: root.into(), sources: Default::default() });
|
||||
}
|
||||
let project = projects.iter_mut().find(|p| p.root == root).unwrap();
|
||||
if project.sources.insert(id.into()) {
|
||||
std::fs::create_dir_all(file.parent().unwrap()).map_err(|e| HostError::Failed(e.to_string()))?;
|
||||
ide_vfs::write_atomic(&file, &serde_json::to_vec(&projects).unwrap()).map_err(error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn connect_database(
|
||||
&self,
|
||||
root: &Path,
|
||||
client: &str,
|
||||
id: &str,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError> {
|
||||
let (_, db) = self.database_host(root)?;
|
||||
if self.shared.project_of(client).as_deref() != Some(root) {
|
||||
return Err(HostError::Invalid("requires this project's invoking window".into()));
|
||||
}
|
||||
let source = db.plugin_source(self.pkg.id(), id).map_err(error)?;
|
||||
self.window_request(client, WindowRequest::DatabaseConnect { id: source.id }, cancel, timeout).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn changed(host: &crate::Host, root: &Path) {
|
||||
for project in host.projects().into_iter().filter(|p| p.root == root) {
|
||||
project.sink.emit(IdeEvent::DbSourcesChanged);
|
||||
}
|
||||
}
|
||||
impl PluginHost {
|
||||
pub(super) fn remove_database_sources(&self, plugin: &str) -> IdeResult<()> {
|
||||
let file = self.shared.data_dir.join(".database-projects").join(format!("{plugin}.json"));
|
||||
let projects: Vec<OwnedProject> = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string()))?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let host = self
|
||||
.shared
|
||||
.workspace()
|
||||
.and_then(|w| w.database_host())
|
||||
.ok_or_else(|| IdeError::unavailable("Database subsystem is unavailable on this host"))?;
|
||||
for project in projects {
|
||||
let root = project.root;
|
||||
let removed = ide_db::plugin::remove_all(&root, plugin)?;
|
||||
let store = ide_db::secrets::SecretStore::new(host.data_dir.join("db-passwords.json"), &root);
|
||||
let ids: std::collections::BTreeSet<_> = project
|
||||
.sources
|
||||
.iter()
|
||||
.map(|id| ide_db::plugin::source_id(plugin, id))
|
||||
.chain(removed.into_iter().map(|source| source.id))
|
||||
.collect();
|
||||
for id in ids {
|
||||
store.delete(&id);
|
||||
store.delete(&ide_db::secrets::ssh_key(&id));
|
||||
for project in host.projects().into_iter().filter(|p| p.root == root) {
|
||||
host.runtime.block_on(project.db.forget_plugin_source(&id));
|
||||
}
|
||||
}
|
||||
changed(&host, &root);
|
||||
}
|
||||
std::fs::remove_file(file)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn error(e: IdeError) -> HostError {
|
||||
use ide_core::error::ErrorKind;
|
||||
match e.kind {
|
||||
ErrorKind::NotFound => HostError::NotFound(e.message),
|
||||
ErrorKind::InvalidInput => HostError::Invalid(e.message),
|
||||
ErrorKind::PermissionDenied => HostError::Denied(e.message),
|
||||
_ => HostError::Failed(e.message),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn source() -> PluginDataSource {
|
||||
PluginDataSource { id: "local".into(), name: "Local".into(), driver: "postgres".into(), ..Default::default() }
|
||||
}
|
||||
#[test]
|
||||
fn rejects_secrets_without_echoing_them_and_documents_every_driver_id() {
|
||||
for driver in ["sqlite", "postgres", "mysql", "sqlServer", "clickhouse", "oracle"] {
|
||||
assert!(settings(PluginDataSource { driver: driver.into(), ..source() }).is_ok(), "{driver}");
|
||||
}
|
||||
for url in [
|
||||
"postgres://user:top-secret@host/db",
|
||||
"postgres://host/db?password=top-secret",
|
||||
"postgres://host/db?PASSWORD=top-secret",
|
||||
"postgres://host/db?to%6ben=top-secret",
|
||||
] {
|
||||
let error = settings(PluginDataSource { url: Some(url.into()), ..source() }).unwrap_err();
|
||||
assert!(!error.to_string().contains("top-secret"));
|
||||
}
|
||||
for key in ["password", "accessToken", "client_secret", "PWD"] {
|
||||
assert!(
|
||||
settings(PluginDataSource { options: vec![(key.into(), "top-secret".into())], ..source() }).is_err()
|
||||
);
|
||||
}
|
||||
let source = settings(PluginDataSource {
|
||||
url: Some("postgres://reader@host:5433/db?sslmode=require".into()),
|
||||
options: vec![("application_name".into(), "Example".into())],
|
||||
..source()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(source.resolved().unwrap().host, "host");
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,9 @@ impl Diagnostics {
|
||||
doc.cancel.cancel();
|
||||
doc.cancel = CancellationToken::new();
|
||||
let Some(host) = editor_plugin_host() else { return };
|
||||
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() {
|
||||
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty()
|
||||
&& !host.providers.any(ProviderKind::Injections)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Register only once per project incarnation; no project is retained by the host.
|
||||
@@ -113,6 +115,7 @@ impl Diagnostics {
|
||||
snapshot.text = current.text;
|
||||
drop(documents);
|
||||
|
||||
let injections = host.providers.injection_generations(&snapshot.language);
|
||||
let generations = host.providers.diagnostic_generations(&snapshot.language);
|
||||
let results = if snapshot.text.len() <= super::navigation::MAX_DOCUMENT_BYTES {
|
||||
host.provide_project_diagnostics(Some(&root), &snapshot, &cancel)
|
||||
@@ -124,6 +127,11 @@ impl Diagnostics {
|
||||
if state.closed || cancel.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
if injections != host.providers.injection_generations(&snapshot.language)
|
||||
|| injections.keys().any(|id| lifecycle_generations.get(id) != state.generations.get(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let matching: HashSet<_> = host
|
||||
.diagnostic_plugins(Some(&root), &snapshot.language)
|
||||
.into_iter()
|
||||
@@ -224,6 +232,13 @@ pub(super) type ProjectState = (Weak<Mutex<State>>, SharedSink);
|
||||
impl PluginHost {
|
||||
pub(super) fn clear_provider_diagnostics(&self, plugin: &str) {
|
||||
// Clone before taking a project's state lock (edits register in the other order).
|
||||
let injector = self.get(plugin).is_ok_and(|p| {
|
||||
p.manifest().declarations("providers").iter().any(|d| {
|
||||
d.get("kinds")
|
||||
.and_then(|v| v.as_array())
|
||||
.is_some_and(|v| v.iter().any(|k| k.as_str() == Some("injections")))
|
||||
})
|
||||
});
|
||||
let projects = self.diagnostic_projects.lock().clone();
|
||||
for (state, sink) in projects {
|
||||
if let Some(state) = state.upgrade() {
|
||||
@@ -231,7 +246,15 @@ impl PluginHost {
|
||||
// A completed answer can still be waiting for another provider.
|
||||
*state.generations.entry(plugin.to_owned()).or_default() += 1;
|
||||
for doc in state.documents.values_mut() {
|
||||
if doc.sources.remove(plugin) {
|
||||
if injector {
|
||||
for source in doc.sources.drain() {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: doc.snapshot.path.clone().into(),
|
||||
source: format!("plugin-provider:{source}"),
|
||||
items: vec![],
|
||||
});
|
||||
}
|
||||
} else if doc.sources.remove(plugin) {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: doc.snapshot.path.clone().into(),
|
||||
source: format!("plugin-provider:{plugin}"),
|
||||
|
||||
@@ -281,7 +281,7 @@ mod tests {
|
||||
#[test]
|
||||
fn validates_entire_rooted_batch_with_utf16_positions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let file = root.join("routes.rb");
|
||||
std::fs::write(&file, "😀x\n").unwrap();
|
||||
let good = endpoint(file.clone());
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Window-owned plugin operations. Instance reclamation does not end them.
|
||||
use super::api::{PluginApi, PromptEntry, Waiter};
|
||||
use super::{CommandAnswer, EventQueue, PluginEvent, PluginHost, WindowRequest};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_plugin::{ContentTab, ContentTabReply, HostError, WorkbenchEvent};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(super) struct Operation {
|
||||
pub plugin: String,
|
||||
pub client: String,
|
||||
pub kind: OperationKind,
|
||||
pub root: Option<std::path::PathBuf>,
|
||||
pub started: bool,
|
||||
pub closed: bool,
|
||||
pub run: Option<ide_core::RunId>,
|
||||
pub terminal: Option<(std::sync::Weak<crate::Host>, ide_core::TerminalId)>,
|
||||
}
|
||||
|
||||
pub(super) enum OperationKind {
|
||||
Run { config: String, configuration: String, program: String },
|
||||
Terminal { key: String, command: ide_plugin::ProcessCommand },
|
||||
}
|
||||
|
||||
pub(super) fn label(value: &str, max: usize) -> Result<(), HostError> {
|
||||
if value.is_empty() || value.encode_utf16().count() > max || value.chars().any(char::is_control) {
|
||||
return Err(HostError::Invalid(format!("expected 1-{max} characters without controls")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn body(value: &str) -> Result<(), HostError> {
|
||||
if value.encode_utf16().count() > 1 << 20 || value.contains('\0') {
|
||||
return Err(HostError::Invalid("content too large or contains NUL".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn validate_tab(tab: &ContentTab) -> Result<(), HostError> {
|
||||
label(&tab.key, 128)?;
|
||||
label(&tab.title, 256)?;
|
||||
body(&tab.content)?;
|
||||
if let Some(name) = &tab.file_name {
|
||||
label(name, 256)?;
|
||||
if name.contains(['/', '\\']) {
|
||||
return Err(HostError::Invalid("file-name cannot contain separators".into()));
|
||||
}
|
||||
}
|
||||
if let Some(language) = &tab.language {
|
||||
label(language, 64)?;
|
||||
}
|
||||
if tab.actions.len() > 16 || tab.actions.iter().filter(|a| a.save).count() > 1 {
|
||||
return Err(HostError::Invalid("at most 16 actions and one Save action".into()));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for action in &tab.actions {
|
||||
label(&action.id, 64)?;
|
||||
label(&action.label, 128)?;
|
||||
if !ids.insert(&action.id) {
|
||||
return Err(HostError::Invalid("duplicate action id".into()));
|
||||
}
|
||||
if let Some(icon) = &action.icon {
|
||||
label(icon, 128)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
|
||||
if let Some(content) = &reply.content {
|
||||
body(content)?;
|
||||
}
|
||||
if reply.status.as_ref().is_some_and(|s| s.len() > 512) || reply.error.as_ref().is_some_and(|s| s.len() > 1024) {
|
||||
return Err(HostError::Invalid("content status or error too long".into()));
|
||||
}
|
||||
if let Some(diff) = &reply.diff {
|
||||
body(&diff.left)?;
|
||||
body(&diff.right)?;
|
||||
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
|
||||
label(value, 128)?;
|
||||
}
|
||||
if let Some(title) = &diff.title {
|
||||
label(title, 256)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
pub(super) fn new_operation(&self, client: &str, kind: OperationKind) -> Result<String, HostError> {
|
||||
let closed = self.shared.closed_clients.lock();
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if closed.contains(client) || blocked.contains(self.pkg.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let mut operations = self.shared.operations.lock();
|
||||
if operations.values().filter(|o| o.plugin == self.pkg.id()).count() >= 32 {
|
||||
return Err(HostError::Invalid("at most 32 active operations".into()));
|
||||
}
|
||||
let id = format!("plugin-op-{}", self.shared.next_prompt.fetch_add(1, Ordering::Relaxed));
|
||||
operations.insert(
|
||||
id.clone(),
|
||||
Operation {
|
||||
plugin: self.pkg.id().into(),
|
||||
client: client.into(),
|
||||
kind,
|
||||
root: self.shared.project_of(client),
|
||||
started: false,
|
||||
closed: false,
|
||||
terminal: None,
|
||||
run: None,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
pub(super) fn window_request(
|
||||
&self,
|
||||
client: &str,
|
||||
request: WindowRequest,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<String>, HostError> {
|
||||
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||
{
|
||||
let closed = self.shared.closed_clients.lock();
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if closed.contains(client) || blocked.contains(self.pkg.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared.prompts.lock().insert(
|
||||
id,
|
||||
PromptEntry { plugin: self.pkg.id().into(), client: client.into(), answer: Waiter::Command(tx) },
|
||||
);
|
||||
}
|
||||
self.shared.sink.emit(
|
||||
Some(client),
|
||||
PluginEvent::WindowRequest { plugin_id: self.pkg.id().into(), request_id: id.to_string(), request },
|
||||
);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(20)) {
|
||||
Ok(CommandAnswer::Value(value)) => return Ok(value),
|
||||
Ok(CommandAnswer::NotFound) => return Err(HostError::NotFound("window service unavailable".into())),
|
||||
Ok(CommandAnswer::Failed(error)) => return Err(HostError::Failed(error)),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if !cancel.is_cancelled() && Instant::now() < deadline => {}
|
||||
Err(_) => {
|
||||
self.shared.prompts.lock().remove(&id);
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// The launcher alone may finish its operation; duplicate and foreign reports are ignored.
|
||||
pub fn finish_operation(&self, client: &str, id: &str, status: Option<i32>, stopped: bool) -> bool {
|
||||
let operation = {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
if !operations.get(id).is_some_and(|op| op.client == client) {
|
||||
return false;
|
||||
}
|
||||
operations.remove(id).unwrap()
|
||||
};
|
||||
let event = match operation.kind {
|
||||
OperationKind::Run { config, .. } => {
|
||||
WorkbenchEvent::RunExited(ide_plugin::RunExited { id: id.into(), config, status, killed: stopped })
|
||||
}
|
||||
OperationKind::Terminal { key, .. } => WorkbenchEvent::TerminalExited(ide_plugin::TerminalExited {
|
||||
id: id.into(),
|
||||
key,
|
||||
status,
|
||||
closed: stopped,
|
||||
}),
|
||||
};
|
||||
self.post_owned_event(&operation.plugin, client, event);
|
||||
true
|
||||
}
|
||||
/// Content actions bypass subscriptions, just like completions of owned operations.
|
||||
pub fn content_tab_action(
|
||||
&self,
|
||||
client: &str,
|
||||
plugin: &str,
|
||||
action: ide_plugin::ContentTabAction,
|
||||
) -> IdeResult<()> {
|
||||
let tabs = self.shared.content_tabs.lock();
|
||||
let tab = tabs
|
||||
.get(&(plugin.into(), client.into(), action.key.clone()))
|
||||
.ok_or_else(|| IdeError::not_found("unknown content tab"))?;
|
||||
if !tab.actions.iter().any(|a| a.id == action.action) {
|
||||
return Err(IdeError::invalid("unknown content action"));
|
||||
}
|
||||
body(&action.content).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
label(&action.request_id, 128).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
drop(tabs);
|
||||
let mut pending = self.shared.content_requests.lock();
|
||||
pending.retain(|_, (_, created)| created.elapsed() < Duration::from_secs(15));
|
||||
if pending.keys().filter(|(p, w, _)| p == plugin && w == client).count() >= 32 {
|
||||
return Err(IdeError::invalid("too many pending content actions"));
|
||||
}
|
||||
pending.insert((plugin.into(), client.into(), action.request_id.clone()), (action.key.clone(), Instant::now()));
|
||||
drop(pending);
|
||||
self.post_owned_event(plugin, client, WorkbenchEvent::ContentTabAction(action));
|
||||
Ok(())
|
||||
}
|
||||
fn post_owned_event(&self, plugin: &str, client: &str, event: WorkbenchEvent) {
|
||||
if self.shared.client_closed(client) {
|
||||
return;
|
||||
}
|
||||
self.queues
|
||||
.lock()
|
||||
.entry((plugin.into(), Some(client.into())))
|
||||
.or_insert_with(|| EventQueue { since: Instant::now(), events: Vec::new(), files_source: None })
|
||||
.events
|
||||
.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic configurations opt in with marker files, just like static detectors.
|
||||
pub(super) fn configurations_declaration(
|
||||
pkg: &ide_extension::Package,
|
||||
value: toml::Value,
|
||||
) -> Result<ide_extension::UiDecl, String> {
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
files: Vec<String>,
|
||||
}
|
||||
pkg.manifest.needs_api("contributes.runConfigurations", 4)?;
|
||||
let decl: Decl = ide_extension::decode("runConfigurations", value)?;
|
||||
if pkg.manifest.wasm.is_none() || decl.files.is_empty() || decl.files.len() > 16 {
|
||||
return Err("runConfigurations needs code and 1-16 marker files".into());
|
||||
}
|
||||
for file in &decl.files {
|
||||
ide_extension::check_relative(std::path::Path::new(file), "files")?;
|
||||
}
|
||||
Ok(ide_extension::UiDecl::new(
|
||||
serde_json::to_string(&decl.files).unwrap(),
|
||||
"Run configurations",
|
||||
serde_json::to_value(decl).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Refreshes configuration exports on the same listing path as static detectors.
|
||||
pub fn refresh_run_configurations(&self, root: &std::path::Path) {
|
||||
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
|
||||
return;
|
||||
}
|
||||
let owners: std::collections::BTreeSet<_> = self
|
||||
.ui
|
||||
.items("runConfigurations", None)
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
d.decl["files"]
|
||||
.as_array()
|
||||
.is_some_and(|files| files.iter().filter_map(|f| f.as_str()).any(|f| root.join(f).is_file()))
|
||||
})
|
||||
.map(|d| d.plugin_id)
|
||||
.collect();
|
||||
self.shared.run_configurations.write().retain(|(p, r), _| r != root || owners.contains(p));
|
||||
for plugin in owners {
|
||||
let project = root.to_path_buf();
|
||||
let invocation = ide_plugin::Invocation { workspace_root: Some(project.clone()), ..Default::default() };
|
||||
let result = self.code_call(&plugin, invocation, "run-configurations", |rt, ctx| {
|
||||
rt.run_configurations(
|
||||
ctx.workspace_root.unwrap().to_string_lossy().into_owned(),
|
||||
Duration::from_secs(2),
|
||||
)
|
||||
});
|
||||
let Ok(items) = result else { continue };
|
||||
if let Err(error) = validate_configurations(root, &items) {
|
||||
tracing::warn!(%plugin, %error, "invalid run configurations");
|
||||
continue;
|
||||
}
|
||||
self.shared.run_configurations.write().insert((plugin, project), items);
|
||||
}
|
||||
}
|
||||
/// Resolves only an owned plugin test configuration in the client's trusted project.
|
||||
fn test_configuration(
|
||||
&self,
|
||||
client: &str,
|
||||
config: &str,
|
||||
) -> IdeResult<Option<(std::path::PathBuf, String, String)>> {
|
||||
let root = self.shared.project_of(client).ok_or_else(|| IdeError::not_found("no project"))?;
|
||||
if !self.shared.workspace().is_some_and(|w| w.trusted(&root)) {
|
||||
return Err(IdeError::invalid("project is not trusted"));
|
||||
}
|
||||
let Some(rest) = config.strip_prefix(ide_run::EXTERNAL_PREFIX) else { return Ok(None) };
|
||||
let Some((owner, id)) = rest.split_once(':') else { return Ok(None) };
|
||||
let configuration = self
|
||||
.shared
|
||||
.workspace()
|
||||
.ok_or_else(IdeError::cancelled)?
|
||||
.run_configuration(client, owner, id)
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if !configuration.is_test || configuration.extras.params.get("framework").map(String::as_str) != Some("plugin")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some((root, owner.to_owned(), id.to_owned())))
|
||||
}
|
||||
|
||||
/// Derives replacement argv for a user action, under the command budget.
|
||||
pub fn select_tests(&self, client: &str, config: &str, tests: Vec<Vec<String>>) -> IdeResult<Option<Vec<String>>> {
|
||||
if tests.is_empty()
|
||||
|| tests.len() > 10000
|
||||
|| tests.iter().any(|p| p.is_empty() || p.len() > 128)
|
||||
|| serde_json::to_vec(&tests).is_ok_and(|b| b.len() > 4 << 20)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
|
||||
let plugin = self.get(&owner)?;
|
||||
let (actor, activated) = self.ensure_active(&plugin, None)?;
|
||||
if activated {
|
||||
self.changed();
|
||||
}
|
||||
let invocation = ide_plugin::Invocation { workspace_root: Some(root), ..Default::default() };
|
||||
// A user action, not the edit path: allow the command budget (a cold instance may be compiling).
|
||||
let timeout = self.limits.timeout;
|
||||
let started = Instant::now();
|
||||
let result = actor.call(Some(timeout), move |rt| rt.select_tests(id, tests, invocation, timeout));
|
||||
let args = self.finish_provider_call(&plugin, &actor, started, result).flatten();
|
||||
Ok(args.filter(|args| args.len() <= 10000 && args.iter().map(String::len).sum::<usize>() <= 4 << 20))
|
||||
}
|
||||
|
||||
/// Parses a bounded batch under the editor-provider soft/hard budgets.
|
||||
pub fn parse_test_results(
|
||||
&self,
|
||||
client: &str,
|
||||
config: &str,
|
||||
chunk: String,
|
||||
) -> IdeResult<Option<Vec<ide_plugin::PluginTestEvent>>> {
|
||||
if chunk.len() > 64 << 10 {
|
||||
return Err(IdeError::invalid("test output batch too large"));
|
||||
}
|
||||
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
|
||||
let plugin = self.get(&owner)?;
|
||||
let (actor, activated) = self.ensure_active(&plugin, None)?;
|
||||
if activated {
|
||||
self.changed();
|
||||
}
|
||||
let invocation = ide_plugin::Invocation { workspace_root: Some(root.clone()), ..Default::default() };
|
||||
let (id, hard) = (id.to_owned(), self.limits.edit_hard);
|
||||
let started = Instant::now();
|
||||
let result =
|
||||
actor.call(Some(self.limits.edit_soft), move |rt| rt.parse_test_results(id, chunk, invocation, hard));
|
||||
let mut events = self.finish_provider_call(&plugin, &actor, started, result);
|
||||
if let Some(events) = &mut events {
|
||||
if serde_json::to_vec(&*events).is_ok_and(|b| b.len() > 4 << 20) {
|
||||
return Ok(None);
|
||||
}
|
||||
for event in events {
|
||||
let location = match event {
|
||||
ide_plugin::PluginTestEvent::SuiteStarted(v) | ide_plugin::PluginTestEvent::TestStarted(v) => {
|
||||
v.location.as_mut()
|
||||
}
|
||||
ide_plugin::PluginTestEvent::Failed(v) => v.location.as_mut(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(location) = location {
|
||||
let Ok(path) = ide_plugin::resolve_new_in_root(&root, &location.path.to_string_lossy()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if location.range.end < location.range.start {
|
||||
return Ok(None);
|
||||
}
|
||||
location.path = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_configurations(
|
||||
root: &std::path::Path,
|
||||
items: &[ide_plugin::PluginRunConfiguration],
|
||||
) -> Result<(), HostError> {
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
if items.len() > 1000 || serde_json::to_vec(items).is_ok_and(|b| b.len() > 4 << 20) {
|
||||
return Err(HostError::Invalid("too many run configurations".into()));
|
||||
}
|
||||
for c in items {
|
||||
ide_extension::validate_item_id(&c.id, "run configuration id").map_err(HostError::Invalid)?;
|
||||
label(&c.name, 128)?;
|
||||
if !ids.insert(&c.id)
|
||||
|| c.program.is_empty()
|
||||
|| c.program.len() > 256
|
||||
|| c.program.contains(char::is_whitespace)
|
||||
|| c.args.len() > 256
|
||||
|| c.args.iter().any(|a| a.len() > 4096 || a.contains('\0'))
|
||||
{
|
||||
return Err(HostError::Invalid("invalid run configuration".into()));
|
||||
}
|
||||
if let Some(cwd) = &c.cwd {
|
||||
ide_plugin::resolve_new_in_root(root, cwd).map_err(HostError::Invalid)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Resolves and rechecks the pin immediately before starting a keyed terminal's PTY.
|
||||
pub fn spawn_terminal_session(
|
||||
self: &std::sync::Arc<Self>,
|
||||
host: &std::sync::Arc<crate::Host>,
|
||||
client: &str,
|
||||
operation: &str,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
output: crate::ops::Emit<String>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
let (plugin, command) = {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
let op = operations
|
||||
.get_mut(operation)
|
||||
.filter(|op| op.client == client)
|
||||
.ok_or_else(|| IdeError::not_found("unknown terminal operation"))?;
|
||||
if op.started || op.closed || op.root != self.shared.project_of(client) {
|
||||
return Err(IdeError::invalid("terminal operation already started or project changed"));
|
||||
}
|
||||
let OperationKind::Terminal { command, .. } = &op.kind else {
|
||||
return Err(IdeError::invalid("not a terminal operation"));
|
||||
};
|
||||
op.started = true;
|
||||
(op.plugin.clone(), command.clone())
|
||||
};
|
||||
let options = super::web::WebPtyCommand {
|
||||
program: command.program,
|
||||
args: command.args,
|
||||
env: command.env.into_iter().collect(),
|
||||
cols,
|
||||
rows,
|
||||
};
|
||||
let weak = Arc::downgrade(self);
|
||||
let (id, window) = (operation.to_owned(), client.to_owned());
|
||||
let completion = Arc::new(move |status| {
|
||||
if let Some(plugins) = weak.upgrade() {
|
||||
let closed = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
|
||||
plugins.finish_operation(&window, &id, status, closed);
|
||||
}
|
||||
});
|
||||
let result = self.spawn_plugin_pty(host, &plugin, client, output, Some(completion), || {
|
||||
self.pty_options(&plugin, client, options)
|
||||
});
|
||||
if let Ok(info) = &result {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
if let Some(op) = operations.get_mut(operation) {
|
||||
op.terminal = Some((Arc::downgrade(host), info.id));
|
||||
if op.closed {
|
||||
let _ = host.terminals.kill(info.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.is_err()
|
||||
&& let Some(op) = self.shared.operations.lock().get_mut(operation)
|
||||
{
|
||||
op.started = false;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Closing a tab marks the operation before killing its PTY. The real exit emits the event.
|
||||
pub fn close_terminal_session(&self, client: &str, id: &str) -> IdeResult<()> {
|
||||
let terminal = {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
let Some(op) = operations
|
||||
.get_mut(id)
|
||||
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Terminal { .. }))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
op.closed = true;
|
||||
op.terminal.clone()
|
||||
};
|
||||
if let Some((host, id)) = terminal
|
||||
&& let Some(host) = host.upgrade()
|
||||
{
|
||||
host.terminals.kill(id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Rechecks the owned configuration's program before Debug and every IDE rerun.
|
||||
pub fn prepare_debug_run(&self, client: &str, mut config: ide_run::RunConfig) -> IdeResult<ide_run::RunConfig> {
|
||||
let Some((plugin, id)) = config.id.strip_prefix(ide_run::EXTERNAL_PREFIX).and_then(|s| s.split_once(':'))
|
||||
else {
|
||||
return Err(IdeError::invalid("not a plugin configuration"));
|
||||
};
|
||||
let workspace = self.shared.workspace().ok_or_else(IdeError::cancelled)?;
|
||||
let owned = workspace.run_configuration(client, plugin, id).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if owned.debug_adapter.is_none() {
|
||||
return Err(IdeError::invalid("this configuration has no debugger"));
|
||||
}
|
||||
if config.extras.target.is_none() {
|
||||
config.extras.target = owned.extras.target.clone();
|
||||
}
|
||||
if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
|
||||
return Err(IdeError::invalid("debugging this target is not supported"));
|
||||
}
|
||||
config.command = self
|
||||
.program_for_project(plugin, &owned.command, self.shared.project_of(client).as_deref())?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
config.debug_adapter = owned.debug_adapter;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal launch metadata; it is ignored after the original operation finishes.
|
||||
pub(super) const RUN_OPERATION: &str = "plugin.operation";
|
||||
impl PluginHost {
|
||||
pub(crate) fn run_operation_sink(
|
||||
self: &Arc<Self>,
|
||||
client: &str,
|
||||
config: &mut ide_run::RunConfig,
|
||||
previous: Arc<dyn ide_core::EventSink>,
|
||||
) -> IdeResult<Arc<dyn ide_core::EventSink>> {
|
||||
let Some(id) = config.extras.params.get(RUN_OPERATION).cloned() else { return Ok(previous) };
|
||||
let origin = self.shared.operations.lock().get(&id).and_then(|op| {
|
||||
if op.client != client {
|
||||
return None;
|
||||
}
|
||||
let OperationKind::Run { configuration, program, .. } = &op.kind else { return None };
|
||||
(configuration == &config.id).then(|| (op.plugin.clone(), op.root.clone(), program.clone()))
|
||||
});
|
||||
let Some((plugin, root, program)) = origin else { return Ok(previous) };
|
||||
let binary = self.program_for_project(&plugin, &program, root.as_deref())?;
|
||||
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
|
||||
program
|
||||
} else {
|
||||
binary.to_string_lossy().into_owned()
|
||||
};
|
||||
config.detected = false;
|
||||
let (weak, client) = (Arc::downgrade(self), client.to_owned());
|
||||
Ok(Arc::new(move |event: ide_core::IdeEvent| {
|
||||
if let ide_core::IdeEvent::RunExited { code, .. } = &event
|
||||
&& let Some(plugins) = weak.upgrade()
|
||||
{
|
||||
let stopped = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
|
||||
plugins.finish_operation(&client, &id, *code, stopped);
|
||||
}
|
||||
previous.emit(event);
|
||||
}))
|
||||
}
|
||||
pub(crate) fn run_operation_started(&self, client: &str, operation: &str, run: ide_core::RunId) {
|
||||
if let Some(op) = self
|
||||
.shared
|
||||
.operations
|
||||
.lock()
|
||||
.get_mut(operation)
|
||||
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Run { .. }))
|
||||
{
|
||||
op.run = Some(run);
|
||||
}
|
||||
}
|
||||
pub(crate) fn run_operation_stopped(&self, run: ide_core::RunId) {
|
||||
for op in self.shared.operations.lock().values_mut().filter(|op| op.run == Some(run)) {
|
||||
op.closed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,7 @@ pub async fn plugin_navigation_index(
|
||||
&project.root,
|
||||
crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope },
|
||||
Invocation {
|
||||
command_chain: Vec::new(),
|
||||
client: Some(client.clone()),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
editor: None,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Embedded documents preserve the host's UTF-16 coordinates.
|
||||
use super::{PluginHost, ProviderKind};
|
||||
use ide_core::{Pos, Range};
|
||||
use ide_plugin::{ProviderDocument, language_features::Injection};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Allowed output ranges for one virtual document; the host document is unrestricted.
|
||||
pub(super) struct Bounds(Option<Vec<Range>>);
|
||||
impl Bounds {
|
||||
pub fn unrestricted() -> Self {
|
||||
Self(None)
|
||||
}
|
||||
pub fn contains(&self, range: Range) -> bool {
|
||||
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start <= range.start && range.end <= r.end))
|
||||
}
|
||||
pub fn lines(&self, start: u32, end: u32) -> bool {
|
||||
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start.line <= start && end <= r.end.line))
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub fn provide_injections(&self, doc: &ProviderDocument) -> Vec<Injection> {
|
||||
if !self.providers.any(ProviderKind::Injections) {
|
||||
return vec![];
|
||||
}
|
||||
if doc.text.len() > super::navigation::MAX_DOCUMENT_BYTES {
|
||||
return Vec::new();
|
||||
}
|
||||
let text = ide_text::Document::new(&doc.text);
|
||||
let d = Arc::new(doc.clone());
|
||||
let mut items: Vec<_> = self
|
||||
.document_calls(ProviderKind::Injections, doc, move |rt, t| rt.provide_injections(&d, t))
|
||||
.into_iter()
|
||||
.flat_map(|(_, v)| v)
|
||||
.filter(|i| {
|
||||
!i.range.is_empty()
|
||||
&& super::language_features::valid_range_in(&text, i.range)
|
||||
&& i.language != doc.language
|
||||
&& !i.language.is_empty()
|
||||
&& i.language.len() <= 64
|
||||
&& i.language.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c))
|
||||
})
|
||||
.collect();
|
||||
items.sort_by_key(|i| i.range);
|
||||
let mut end = Pos::default();
|
||||
items.retain(|i| {
|
||||
if i.range.start < end {
|
||||
return false;
|
||||
}
|
||||
end = i.range.end;
|
||||
true
|
||||
});
|
||||
items.truncate(1000);
|
||||
items
|
||||
}
|
||||
|
||||
pub(super) fn feature_documents(&self, doc: &ProviderDocument, at: Option<Pos>) -> Vec<(ProviderDocument, Bounds)> {
|
||||
let injections = self.provide_injections(doc);
|
||||
let mut languages = Vec::new();
|
||||
for i in &injections {
|
||||
if at.is_some_and(|p| p < i.range.start || p >= i.range.end) || languages.contains(&i.language) {
|
||||
continue;
|
||||
}
|
||||
languages.push(i.language.clone());
|
||||
if languages.len() == 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut docs = Vec::new();
|
||||
for language in languages {
|
||||
let ranges = injections.iter().filter(|i| i.language == language).map(|i| i.range).collect();
|
||||
let text = virtual_text(doc, &injections, &language);
|
||||
docs.push((ProviderDocument { language, text, ..doc.clone() }, Bounds(Some(ranges))));
|
||||
}
|
||||
docs.push((doc.clone(), Bounds(None)));
|
||||
docs
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn virtual_text(doc: &ProviderDocument, ranges: &[Injection], language: &str) -> String {
|
||||
let ranges: Vec<_> = ranges.iter().filter(|i| i.language == language).map(|i| i.range).collect();
|
||||
let mut index = 0;
|
||||
let mut at = Pos::default();
|
||||
let mut text = String::with_capacity(doc.text.len());
|
||||
for c in doc.text.chars() {
|
||||
while index < ranges.len() && ranges[index].end <= at {
|
||||
index += 1;
|
||||
}
|
||||
let inside = ranges.get(index).is_some_and(|r| r.start <= at && at < r.end);
|
||||
if inside || c == '\n' || c == '\r' {
|
||||
text.push(c);
|
||||
} else {
|
||||
for _ in 0..c.len_utf16() {
|
||||
text.push(' ');
|
||||
}
|
||||
}
|
||||
if c == '\n' {
|
||||
at.line += 1;
|
||||
at.col = 0;
|
||||
} else {
|
||||
at.col += c.len_utf16() as u32;
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Shared by local and remote projects; all plugin work runs on the host.
|
||||
static LSP_PROJECTS: parking_lot::Mutex<Vec<std::sync::Weak<ide_lsp::LspManager>>> =
|
||||
parking_lot::Mutex::new(Vec::new());
|
||||
|
||||
pub(super) fn providers_changed() {
|
||||
let projects = {
|
||||
let mut projects = LSP_PROJECTS.lock();
|
||||
projects.retain(|p| p.strong_count() > 0);
|
||||
projects.iter().filter_map(std::sync::Weak::upgrade).collect::<Vec<_>>()
|
||||
};
|
||||
for project in projects {
|
||||
project.injection_providers_changed();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn configure_lsp(lsp: &Arc<ide_lsp::LspManager>) {
|
||||
LSP_PROJECTS.lock().push(Arc::downgrade(lsp));
|
||||
lsp.set_injection_provider(Arc::new(|path, language, version, text| {
|
||||
let Some(plugins) = super::editor_plugin_host() else { return Vec::new() };
|
||||
plugins
|
||||
.provide_injections(&ProviderDocument {
|
||||
path: path.to_path_buf(),
|
||||
language: language.0.clone(),
|
||||
version: version.into(),
|
||||
text: text.to_owned(),
|
||||
})
|
||||
.into_iter()
|
||||
.map(|i| ide_lsp::InjectedRange { range: i.range, language: ide_core::LanguageId::new(i.language) })
|
||||
.collect()
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn virtual_documents_preserve_utf16_columns_and_newlines() {
|
||||
let d = ProviderDocument {
|
||||
path: "/p/a".into(),
|
||||
language: "host".into(),
|
||||
version: 7,
|
||||
text: "😀 sql\n outside\n sql".into(),
|
||||
};
|
||||
let ranges = vec![
|
||||
Injection { range: Range::new(Pos::new(0, 3), Pos::new(0, 6)), language: "sql".into() },
|
||||
Injection { range: Range::new(Pos::new(2, 1), Pos::new(2, 4)), language: "sql".into() },
|
||||
];
|
||||
let text = virtual_text(&d, &ranges, "sql");
|
||||
assert_eq!(text, " sql\n \n sql");
|
||||
assert_eq!(text.encode_utf16().count(), d.text.encode_utf16().count());
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,25 @@ pub fn read_source(path: &Path) -> IdeResult<IntellijThemeSource> {
|
||||
/// The theme plugins of the JetBrains IDEs (and Android Studio) of this user,
|
||||
/// the themes their IDEs are set to first.
|
||||
pub fn installed_themes() -> Vec<InstalledIntellijTheme> {
|
||||
find_installed(&latest_product_versions(local_ide_dirs()))
|
||||
}
|
||||
|
||||
/// The settings folder of a JetBrains IDE (or Android Studio) of this user.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstalledIdeSettings {
|
||||
/// The IDE and its version: `IntelliJ IDEA 2025.2`.
|
||||
pub ide: String,
|
||||
/// What Import Settings reads.
|
||||
pub path: IpcPath,
|
||||
}
|
||||
|
||||
/// The settings folders of the JetBrains IDEs of this user, most recently used first.
|
||||
pub fn installed_ide_settings() -> Vec<InstalledIdeSettings> {
|
||||
settings_folders(&local_ide_dirs())
|
||||
}
|
||||
|
||||
fn local_ide_dirs() -> Vec<IdeDir> {
|
||||
let Some(home) = dirs::home_dir() else { return Vec::new() };
|
||||
let layout = if cfg!(target_os = "macos") {
|
||||
Layout::Mac
|
||||
@@ -86,7 +105,65 @@ pub fn installed_themes() -> Vec<InstalledIntellijTheme> {
|
||||
} else {
|
||||
Layout::Xdg
|
||||
};
|
||||
find_installed(&ide_dirs(layout, &home, |key| std::env::var_os(key)))
|
||||
ide_dirs(layout, &home, |key| std::env::var_os(key))
|
||||
}
|
||||
|
||||
fn settings_folders(ides: &[IdeDir]) -> Vec<InstalledIdeSettings> {
|
||||
let mut used: Vec<_> =
|
||||
ides.iter().filter_map(|ide| settings_modified(&ide.config).map(|time| (ide, time))).collect();
|
||||
used.sort_by(|(a, at), (b, bt)| {
|
||||
bt.cmp(at).then_with(|| a.label.cmp(&b.label)).then_with(|| a.config.cmp(&b.config))
|
||||
});
|
||||
used.into_iter()
|
||||
.map(|(ide, _)| InstalledIdeSettings { ide: ide.label.clone(), path: ide.config.clone().into() })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn settings_modified(config: &Path) -> Option<std::time::SystemTime> {
|
||||
let options = config.join("options");
|
||||
// Match the folder reader: never count links or walk below the settings directories.
|
||||
if !config.symlink_metadata().ok()?.is_dir() || !options.symlink_metadata().ok()?.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let mut modified = None;
|
||||
let mut customised = false;
|
||||
for entry in std::fs::read_dir(&options).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if !entry.file_type().is_ok_and(|t| t.is_file())
|
||||
|| !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("xml"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
modified = modified.max(meta.modified().ok());
|
||||
// Startup bookkeeping can exist before the user has changed any settings.
|
||||
let name = entry.file_name();
|
||||
customised |= meta.len() > 0
|
||||
&& !matches!(
|
||||
name.to_str(),
|
||||
Some(
|
||||
"other.xml"
|
||||
| "nonRoamable.xml"
|
||||
| "early-access-registry.xml"
|
||||
| "applicationInfo.xml"
|
||||
| "updates.xml"
|
||||
| "usage.statistics.xml"
|
||||
)
|
||||
);
|
||||
}
|
||||
customised |= ["keymaps", "codestyles", "colors", "templates", "inspection"].iter().any(|sub| {
|
||||
let dir = config.join(sub);
|
||||
dir.symlink_metadata().is_ok_and(|m| m.is_dir())
|
||||
&& std::fs::read_dir(dir).into_iter().flatten().flatten().any(|entry| {
|
||||
entry.file_type().is_ok_and(|t| t.is_file())
|
||||
&& entry.metadata().is_ok_and(|m| m.len() > 0)
|
||||
&& entry
|
||||
.path()
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("xml") || e.eq_ignore_ascii_case("icls"))
|
||||
})
|
||||
});
|
||||
customised.then(|| modified.or_else(|| options.metadata().ok()?.modified().ok()).unwrap_or(std::time::UNIX_EPOCH))
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> Result<IntellijThemeSource, String> {
|
||||
@@ -422,8 +499,7 @@ struct IdeDir {
|
||||
plugins: PathBuf,
|
||||
}
|
||||
|
||||
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first, one
|
||||
/// per product: its newest version's (an upgrade leaves the older ones behind).
|
||||
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest version first.
|
||||
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
|
||||
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let (config_root, data_root) = match layout {
|
||||
@@ -463,11 +539,16 @@ fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>)
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
|
||||
let mut products = std::collections::HashSet::new();
|
||||
out.retain(|ide| products.insert(ide.product.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
/// Theme discovery only uses the newest version of each product.
|
||||
fn latest_product_versions(mut ides: Vec<IdeDir>) -> Vec<IdeDir> {
|
||||
let mut products = std::collections::HashSet::new();
|
||||
ides.retain(|ide| products.insert(ide.product.clone()));
|
||||
ides
|
||||
}
|
||||
|
||||
/// `IntelliJIdea2025.2` → (`IntelliJIdea`, (2025, 2), `2025.2`).
|
||||
fn product_dir(name: &str) -> Option<(&str, (u32, u32), &str)> {
|
||||
let at = name.find(|c: char| c.is_ascii_digit())?;
|
||||
@@ -787,15 +868,16 @@ mod tests {
|
||||
};
|
||||
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
|
||||
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
|
||||
// An upgrade leaves the older version's folder behind: only the newest of a product counts.
|
||||
// Settings import keeps every version; theme discovery uses only the newest.
|
||||
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2024.3"));
|
||||
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10"));
|
||||
let dirs = ide_dirs(Layout::Mac, home, |_| None);
|
||||
assert_eq!(dirs.len(), 1);
|
||||
assert_eq!(dirs.len(), 3);
|
||||
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.10");
|
||||
assert_eq!(latest_product_versions(dirs).len(), 1);
|
||||
std::fs::remove_dir(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10")).unwrap();
|
||||
let dirs = ide_dirs(Layout::Mac, home, |_| None);
|
||||
assert_eq!(dirs.len(), 1);
|
||||
assert_eq!(dirs.len(), 2);
|
||||
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
|
||||
assert_eq!((dirs[0].config.clone(), dirs[0].plugins.clone()), (mac.clone(), mac.join("plugins")));
|
||||
|
||||
@@ -881,4 +963,107 @@ mod tests {
|
||||
assert_eq!(found, [theme("shallow-id", "Shallow", false, true), theme("deep-sea-id", "Deep Sea", true, false)]);
|
||||
assert!(find_installed(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_newer_versions_do_not_hide_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
for (layout, root) in [
|
||||
(Layout::Mac, "Library/Application Support"),
|
||||
(Layout::Windows, "AppData/Roaming"),
|
||||
(Layout::Xdg, ".config"),
|
||||
] {
|
||||
let root = tmp.path().join(root).join("JetBrains");
|
||||
let used = root.join("IntelliJIdea2024.3");
|
||||
let fresh = root.join("IntelliJIdea2025.2");
|
||||
write(&used.join("options/editor.xml"), b"<application><component name=\"EditorSettings\"/></application>");
|
||||
std::fs::create_dir_all(&fresh).unwrap();
|
||||
let expected = vec![InstalledIdeSettings { ide: "IntelliJ IDEA 2024.3".into(), path: used.into() }];
|
||||
assert_eq!(settings_folders(&ide_dirs(layout, tmp.path(), |_| None)), expected);
|
||||
write(&fresh.join("options/other.xml"), b"<application/>");
|
||||
write(&fresh.join("options/early-access-registry.xml"), b"<application/>");
|
||||
assert_eq!(settings_folders(&ide_dirs(layout, tmp.path(), |_| None)), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_candidates_keep_used_versions_and_sort_by_options_modification() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("Library/Application Support/JetBrains");
|
||||
let put = |product: &str, file: &str, seconds| {
|
||||
let path = root.join(product).join(file);
|
||||
write(&path, b"<application/>");
|
||||
std::fs::File::open(path)
|
||||
.unwrap()
|
||||
.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds))
|
||||
.unwrap();
|
||||
};
|
||||
put("IntelliJIdea2025.2", "options/editor.xml", 100);
|
||||
put("IntelliJIdea2024.3", "options/editor.xml", 200);
|
||||
put("IntelliJIdea2024.3", "options/editor-font.xml", 400);
|
||||
put("PyCharm2025.1", "options/editor.xml", 300);
|
||||
// Directory times and unrelated files do not change options-file ordering.
|
||||
write(&root.join("IntelliJIdea2025.2/options/README.txt"), b"recent");
|
||||
let dirs = ide_dirs(Layout::Mac, tmp.path(), |_| None);
|
||||
let found = settings_folders(&dirs);
|
||||
assert_eq!(
|
||||
found.iter().map(|s| s.ide.as_str()).collect::<Vec<_>>(),
|
||||
["IntelliJ IDEA 2024.3", "PyCharm 2025.1", "IntelliJ IDEA 2025.2"]
|
||||
);
|
||||
assert_eq!(found[0].path, root.join("IntelliJIdea2024.3"));
|
||||
assert_eq!(latest_product_versions(dirs).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_empty_and_startup_only_settings_but_keeps_custom_schemes() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path();
|
||||
assert!(settings_modified(config).is_none());
|
||||
std::fs::create_dir(config.join("options")).unwrap();
|
||||
assert!(settings_modified(config).is_none());
|
||||
for file in ["other", "nonRoamable", "early-access-registry", "applicationInfo", "updates", "usage.statistics"]
|
||||
{
|
||||
write(
|
||||
&config.join(format!("options/{file}.xml")),
|
||||
b"<application><component name=\"Startup\"/></application>",
|
||||
);
|
||||
}
|
||||
write(&config.join("options/editor.xml"), b"");
|
||||
write(&config.join("options/nested/editor.xml"), b"<application/>");
|
||||
for sub in ["keymaps", "codestyles", "colors", "templates", "inspection"] {
|
||||
std::fs::create_dir(config.join(sub)).unwrap();
|
||||
write(&config.join(sub).join("README.txt"), b"not settings");
|
||||
}
|
||||
assert!(settings_modified(config).is_none());
|
||||
for (sub, file) in [
|
||||
("keymaps", "custom.xml"),
|
||||
("codestyles", "custom.xml"),
|
||||
("colors", "custom.icls"),
|
||||
("templates", "custom.xml"),
|
||||
("inspection", "custom.xml"),
|
||||
] {
|
||||
let path = config.join(sub).join(file);
|
||||
write(&path, b"<custom/>");
|
||||
assert!(settings_modified(config).is_some(), "{sub} is a user setting");
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_the_settings_folders_of_ides_that_have_been_run() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ide = |label: &str, product: &str, version: (u32, u32)| {
|
||||
let config = tmp.path().join(format!("{product}{}.{}", version.0, version.1));
|
||||
IdeDir { label: label.into(), product: product.into(), version, config: config.clone(), plugins: config }
|
||||
};
|
||||
let used = ide("IntelliJ IDEA 2025.2", "IntelliJIdea", (2025, 2));
|
||||
let fresh = ide("PyCharm 2025.1", "PyCharm", (2025, 1));
|
||||
let used_config = used.config.clone();
|
||||
write(&used_config.join("options/editor.xml"), b"<application/>");
|
||||
std::fs::create_dir_all(&fresh.config).unwrap();
|
||||
assert_eq!(
|
||||
settings_folders(&[used, fresh]),
|
||||
[InstalledIdeSettings { ide: "IntelliJ IDEA 2025.2".into(), path: used_config.into() }]
|
||||
);
|
||||
assert!(settings_folders(&[]).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Plugin-owned local byte streams, closed on resource drop and plugin shutdown.
|
||||
use super::api::net_runtime;
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::HostError;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
trait Stream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Stream for T {}
|
||||
struct Connection {
|
||||
stream: tokio::sync::Mutex<Box<dyn Stream>>,
|
||||
closed: CancellationToken,
|
||||
}
|
||||
impl Drop for Connection {
|
||||
fn drop(&mut self) {
|
||||
self.closed.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Connections {
|
||||
next: AtomicU64,
|
||||
entries: Mutex<HashMap<(String, u64), Arc<Connection>>>,
|
||||
}
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
impl Connections {
|
||||
pub fn connect(
|
||||
&self,
|
||||
plugin: &str,
|
||||
name: &str,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<u64, HostError> {
|
||||
let check = |entries: &HashMap<(String, u64), Arc<Connection>>| {
|
||||
if entries.keys().filter(|(p, _)| p == plugin).count() >= 8 {
|
||||
Err(HostError::Invalid("at most eight IPC connections per plugin".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
check(&self.entries.lock())?;
|
||||
let stream: Box<dyn Stream> = net_runtime().block_on(async {
|
||||
let connect = async {
|
||||
#[cfg(unix)]
|
||||
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::UnixStream::connect(name).await.map_err(failed)?)) }
|
||||
#[cfg(windows)]
|
||||
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::windows::named_pipe::ClientOptions::new().open(name).map_err(failed)?)) }
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{ let _ = name; Err(HostError::Failed("local IPC unavailable".into())) }
|
||||
};
|
||||
tokio::select! {
|
||||
r = tokio::time::timeout(timeout, connect) => r.map_err(|_| HostError::Failed("IPC connection timed out".into()))?,
|
||||
() = cancel.cancelled() => Err(HostError::Cancelled),
|
||||
}
|
||||
})?;
|
||||
let mut entries = self.entries.lock();
|
||||
check(&entries)?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||
entries.insert(
|
||||
(plugin.into(), id),
|
||||
Arc::new(Connection { stream: tokio::sync::Mutex::new(stream), closed: CancellationToken::new() }),
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
fn get(&self, plugin: &str, id: u64) -> Result<Arc<Connection>, HostError> {
|
||||
self.entries
|
||||
.lock()
|
||||
.get(&(plugin.into(), id))
|
||||
.cloned()
|
||||
.ok_or_else(|| HostError::NotFound("IPC connection".into()))
|
||||
}
|
||||
pub fn write(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
bytes: Vec<u8>,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError> {
|
||||
let entry = self.get(plugin, id)?;
|
||||
net_runtime().block_on(async {
|
||||
tokio::select! {
|
||||
r = tokio::time::timeout(timeout, async { entry.stream.lock().await.write_all(&bytes).await.map_err(failed) }) => r.map_err(|_| HostError::Failed("IPC write timed out".into()))?,
|
||||
() = cancel.cancelled() => Err(HostError::Cancelled),
|
||||
() = entry.closed.cancelled() => Err(HostError::Cancelled),
|
||||
}
|
||||
})
|
||||
}
|
||||
pub fn read(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
max: usize,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Vec<u8>, HostError> {
|
||||
let entry = self.get(plugin, id)?;
|
||||
net_runtime().block_on(async {
|
||||
let read = async {
|
||||
let mut bytes = vec![0; max.min(1 << 20)];
|
||||
let count = entry.stream.lock().await.read(&mut bytes).await.map_err(failed)?;
|
||||
bytes.truncate(count);
|
||||
Ok(bytes)
|
||||
};
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => Err(HostError::Cancelled),
|
||||
() = entry.closed.cancelled() => Err(HostError::Cancelled),
|
||||
r = read => r,
|
||||
() = tokio::time::sleep(timeout) => Ok(Vec::new()),
|
||||
}
|
||||
})
|
||||
}
|
||||
pub fn close(&self, plugin: &str, id: u64) {
|
||||
if let Some(entry) = self.entries.lock().remove(&(plugin.into(), id)) {
|
||||
entry.closed.cancel();
|
||||
}
|
||||
}
|
||||
pub fn clear(&self, plugin: &str) {
|
||||
self.entries.lock().retain(|(p, _), entry| {
|
||||
if p != plugin {
|
||||
return true;
|
||||
}
|
||||
entry.closed.cancel();
|
||||
false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::net::UnixListener;
|
||||
#[test]
|
||||
fn echo_limit_and_shutdown() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("echo.sock");
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut bytes = [0; 4];
|
||||
stream.read_exact(&mut bytes).unwrap();
|
||||
stream.write_all(&bytes).unwrap();
|
||||
// Accept the remaining seven connections before shutdown.
|
||||
for _ in 1..8 {
|
||||
listener.accept().unwrap();
|
||||
}
|
||||
});
|
||||
let pool = Connections::default();
|
||||
let token = CancellationToken::new();
|
||||
let timeout = Duration::from_secs(2);
|
||||
let id = pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
|
||||
pool.write("a", id, b"echo".to_vec(), &token, timeout).unwrap();
|
||||
assert_eq!(pool.read("a", id, 4, timeout, &token).unwrap(), b"echo");
|
||||
for _ in 1..8 {
|
||||
pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
|
||||
}
|
||||
assert!(matches!(pool.connect("a", path.to_str().unwrap(), &token, timeout), Err(HostError::Invalid(_))));
|
||||
assert!(pool.read("b", id, 4, timeout, &token).is_err());
|
||||
pool.clear("a");
|
||||
assert!(pool.read("a", id, 4, timeout, &token).is_err());
|
||||
worker.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! API 1.4 language features. Results are bounded and checked against the snapshot.
|
||||
use super::{PluginHost, ProviderKind};
|
||||
use ide_core::{IdeResult, Pos, Range};
|
||||
use ide_plugin::{ProviderDocument, language_features::*};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn valid_range(doc: &ProviderDocument, range: Range) -> bool {
|
||||
valid_range_in(&ide_text::Document::new(&doc.text), range)
|
||||
}
|
||||
pub(super) fn valid_range_in(text: &ide_text::Document, range: Range) -> bool {
|
||||
if range.start > range.end {
|
||||
return false;
|
||||
}
|
||||
[range.start, range.end].iter().all(|p| text.offset_to_pos(text.pos_to_offset(*p)) == *p)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub fn language_format(
|
||||
&self,
|
||||
id: &str,
|
||||
doc: &ProviderDocument,
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<ide_core::TextEdit>> {
|
||||
let candidates = if let Some(r) = range {
|
||||
self.feature_documents(doc, Some(r.start))
|
||||
} else {
|
||||
vec![(doc.clone(), super::injections::Bounds::unrestricted())]
|
||||
};
|
||||
let (target, bounds) = candidates
|
||||
.into_iter()
|
||||
.find(|(d, b)| {
|
||||
range.is_none_or(|r| b.contains(r))
|
||||
&& self
|
||||
.provider_plugins(ProviderKind::Formatting, &d.language)
|
||||
.iter()
|
||||
.any(|p| p.id() == id && p.dev_root.as_ref().is_none_or(|r| d.path.starts_with(r)))
|
||||
})
|
||||
.ok_or_else(|| ide_core::IdeError::not_found("The selected plugin formatter is unavailable"))?;
|
||||
let text = ide_text::Document::new(&target.text);
|
||||
let d = Arc::new(target);
|
||||
let edits = self.code_call(id, ide_plugin::Invocation::default(), "formatting", move |rt, _| {
|
||||
rt.language_format(&d, range, options.clone(), std::time::Duration::from_secs(5))
|
||||
})?;
|
||||
if edits.iter().any(|e| !valid_range_in(&text, e.range) || !bounds.contains(e.range)) {
|
||||
return Err(ide_core::IdeError::invalid("Plugin formatter returned an invalid range"));
|
||||
}
|
||||
let mut ranges: Vec<_> = edits.iter().map(|e| e.range).collect();
|
||||
ranges.sort();
|
||||
if ranges.windows(2).any(|p| p[0].end > p[1].start) {
|
||||
return Err(ide_core::IdeError::invalid("Plugin formatter returned overlapping edits"));
|
||||
}
|
||||
Ok(edits)
|
||||
}
|
||||
pub fn language_signature_help(&self, doc: &ProviderDocument, at: Pos) -> Option<SignatureHelp> {
|
||||
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::SignatureHelp, &snapshot, move |rt, t| {
|
||||
rt.language_signature_help(&d, at, t)
|
||||
});
|
||||
for (_, v) in results {
|
||||
if let Some(v) = v.filter(|h| !h.signatures.is_empty()) {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub fn language_document_highlights(&self, doc: &ProviderDocument, at: Pos) -> Vec<Highlight> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::DocumentHighlights, &snapshot, move |rt, t| {
|
||||
rt.language_document_highlights(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_document_links(&self, doc: &ProviderDocument) -> Vec<DocumentLink> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self
|
||||
.document_calls(ProviderKind::DocumentLinks, &snapshot, move |rt, t| rt.language_document_links(&d, t));
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_code_lenses(&self, doc: &ProviderDocument) -> Vec<(String, CodeLens)> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results =
|
||||
self.document_calls(ProviderKind::CodeLenses, &snapshot, move |rt, t| rt.language_code_lenses(&d, t));
|
||||
for (p, items) in results {
|
||||
let commands: std::collections::HashSet<_> =
|
||||
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
|
||||
for i in items {
|
||||
if valid_range_in(&text, i.range) && bounds.contains(i.range) && commands.contains(&i.command) {
|
||||
out.push((p.id().to_owned(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_selection_ranges(&self, doc: &ProviderDocument, at: Pos) -> Vec<Range> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::SelectionRanges, &snapshot, move |rt, t| {
|
||||
rt.language_selection_ranges(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if valid_range_in(&text, i) && i.start <= at && at <= i.end && bounds.contains(i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.start.cmp(&a.start).then(a.end.cmp(&b.end)));
|
||||
out.dedup();
|
||||
let mut end = None;
|
||||
out.retain(|r| {
|
||||
let ok = end.is_none_or(|prev: Range| r.start <= prev.start && prev.end <= r.end);
|
||||
if ok {
|
||||
end = Some(*r);
|
||||
}
|
||||
ok
|
||||
});
|
||||
out
|
||||
}
|
||||
pub fn language_folding(&self, doc: &ProviderDocument) -> Vec<FoldRange> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let lines = d.text.split('\n').count() as u32;
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results =
|
||||
self.document_calls(ProviderKind::Folding, &snapshot, move |rt, t| rt.language_folding(&d, t));
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if i.start_line < i.end_line && i.end_line < lines && bounds.lines(i.start_line, i.end_line) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_colors(&self, doc: &ProviderDocument) -> Vec<(String, ColorInfo)> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::Colors, &snapshot, move |rt, t| rt.language_colors(&d, t));
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
if valid_range_in(&text, i.range)
|
||||
&& bounds.contains(i.range)
|
||||
&& [i.red, i.green, i.blue, i.alpha].iter().all(|c| c.is_finite() && (0.0..=1.0).contains(c))
|
||||
{
|
||||
out.push((p.id().to_owned(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_color_presentations(&self, id: &str, doc: &ProviderDocument, color: ColorInfo) -> Vec<String> {
|
||||
for (d, bounds) in self.feature_documents(doc, Some(color.range.start)) {
|
||||
if !bounds.contains(color.range)
|
||||
|| !self.provider_plugins(ProviderKind::Colors, &d.language).iter().any(|p| p.id() == id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let color = color.clone();
|
||||
let result = self.document_calls_for(ProviderKind::Colors, &snapshot, Some(id), move |rt, t| {
|
||||
rt.language_color_presentations(&d, color.clone(), t)
|
||||
});
|
||||
if let Some((_, items)) = result.into_iter().find(|(p, _)| p.id() == id) {
|
||||
return items.into_iter().take(100).collect();
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
pub fn language_document_symbols(&self, doc: &ProviderDocument) -> Vec<DocumentSymbol> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::DocumentSymbols, &snapshot, move |rt, t| {
|
||||
rt.language_document_symbols(&d, t)
|
||||
});
|
||||
for (_, items) in results {
|
||||
let mut remap = std::collections::HashMap::new();
|
||||
for (index, mut i) in items.into_iter().enumerate() {
|
||||
if !valid_range_in(&text, i.range)
|
||||
|| !valid_range_in(&text, i.selection_range)
|
||||
|| i.selection_range.start < i.range.start
|
||||
|| i.selection_range.end > i.range.end
|
||||
|| !bounds.contains(i.range)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
i.parent = i.parent.and_then(|p| remap.get(&(p as usize)).copied());
|
||||
remap.insert(index, out.len() as u32);
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_implementation(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
|
||||
let mut out = Vec::new();
|
||||
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::Implementation, &snapshot, move |rt, t| {
|
||||
rt.language_implementation(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_type_definition(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
|
||||
let mut out = Vec::new();
|
||||
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::TypeDefinition, &snapshot, move |rt, t| {
|
||||
rt.language_type_definition(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_gutter_actions(&self, doc: &ProviderDocument) -> Vec<(String, GutterAction)> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let lines = d.text.split('\n').count() as u32;
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self
|
||||
.document_calls(ProviderKind::GutterActions, &snapshot, move |rt, t| rt.language_gutter_actions(&d, t));
|
||||
for (p, items) in results {
|
||||
let commands: std::collections::HashSet<_> =
|
||||
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
|
||||
for i in items {
|
||||
if i.line < lines && bounds.lines(i.line, i.line) && commands.contains(&i.command) {
|
||||
out.push((p.id().to_owned(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub(crate) fn run_language_command(
|
||||
&self,
|
||||
plugin: &str,
|
||||
command: &str,
|
||||
argument: Option<&str>,
|
||||
invocation: ide_plugin::Invocation,
|
||||
) -> IdeResult<()> {
|
||||
if !self.ui.items(super::COMMANDS, Some(plugin)).iter().any(|c| c.key == command) {
|
||||
return Err(ide_core::IdeError::not_found("The plugin did not declare this command"));
|
||||
}
|
||||
self.run_command(plugin, command, argument, invocation).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn signature_help(h: SignatureHelp) -> ide_lsp::types::SignatureHelp {
|
||||
let signatures: Vec<_> = h
|
||||
.signatures
|
||||
.into_iter()
|
||||
.take(100)
|
||||
.map(|s| {
|
||||
let len = s.label.encode_utf16().count() as u32;
|
||||
let parameters = s
|
||||
.parameters
|
||||
.into_iter()
|
||||
.take(100)
|
||||
.map(|p| match p.label {
|
||||
ParameterLabel::Offsets((a, b)) => (a.min(len), b.max(a).min(len)),
|
||||
ParameterLabel::Text(t) => s
|
||||
.label
|
||||
.find(&t)
|
||||
.map(|a| {
|
||||
let start = s.label[..a].encode_utf16().count() as u32;
|
||||
(start, start + t.encode_utf16().count() as u32)
|
||||
})
|
||||
.unwrap_or((0, 0)),
|
||||
})
|
||||
.collect();
|
||||
ide_lsp::types::Signature {
|
||||
label: s.label,
|
||||
documentation: s.documentation.map(|value| ide_lsp::types::Markup { kind: "markdown".into(), value }),
|
||||
parameters,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let active_signature = h.active_signature.min(signatures.len().saturating_sub(1) as u32);
|
||||
let active_parameter =
|
||||
h.active_parameter
|
||||
.min(signatures.get(active_signature as usize).map(|s| s.parameters.len().saturating_sub(1)).unwrap_or(0)
|
||||
as u32);
|
||||
ide_lsp::types::SignatureHelp { signatures, active_signature, active_parameter }
|
||||
}
|
||||
|
||||
pub(crate) fn outline(items: Vec<DocumentSymbol>) -> Vec<ide_core::OutlineItem> {
|
||||
// A parent must precede its child, ruling out cycles and unbounded recursion.
|
||||
let mut depths = vec![0usize; items.len()];
|
||||
let mut parents = vec![None; items.len()];
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
if let Some(parent) = item.parent.map(|p| p as usize).filter(|p| {
|
||||
*p < idx
|
||||
&& depths[*p] < 63
|
||||
&& items[*p].range.start <= item.range.start
|
||||
&& item.range.end <= items[*p].range.end
|
||||
}) {
|
||||
parents[idx] = Some(parent);
|
||||
depths[idx] = depths[parent] + 1;
|
||||
}
|
||||
}
|
||||
let mut nodes: Vec<_> = items
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
Some(ide_core::OutlineItem {
|
||||
name: i.name,
|
||||
detail: Some(i.detail).filter(|s| !s.is_empty()),
|
||||
kind: serde_json::from_value(serde_json::Value::String(symbol_kind_name(&i.kind)))
|
||||
.unwrap_or(ide_core::SymbolKind::Unknown),
|
||||
range: i.range,
|
||||
selection_range: i.selection_range,
|
||||
children: vec![],
|
||||
visibility: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut roots = Vec::new();
|
||||
for idx in (0..nodes.len()).rev() {
|
||||
let mut node = nodes[idx].take().expect("once");
|
||||
node.children.reverse();
|
||||
if let Some(parent) = parents[idx] {
|
||||
nodes[parent].as_mut().expect("parent precedes child").children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
roots.reverse();
|
||||
roots
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FormatterChoice {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub languages: Vec<String>,
|
||||
}
|
||||
impl PluginHost {
|
||||
pub fn formatter_choices(&self) -> Vec<FormatterChoice> {
|
||||
let mut out = std::collections::BTreeMap::<String, FormatterChoice>::new();
|
||||
for (id, languages) in self.providers.formatter_declarations() {
|
||||
let Ok(p) = self.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(p.meta.lock().status, super::PluginStatus::Active | super::PluginStatus::Inactive) {
|
||||
continue;
|
||||
}
|
||||
out.entry(id.clone())
|
||||
.or_insert_with(|| FormatterChoice { id, name: p.pkg.manifest.name.clone(), languages: vec![] })
|
||||
.languages
|
||||
.extend(languages);
|
||||
}
|
||||
out.into_values()
|
||||
.map(|mut c| {
|
||||
c.languages.sort();
|
||||
c.languages.dedup();
|
||||
c
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn symbol_kind_name(kind: &str) -> String {
|
||||
let mut parts = kind.split('-');
|
||||
let mut out = parts.next().unwrap_or_default().to_owned();
|
||||
for part in parts {
|
||||
let mut chars = part.chars();
|
||||
if let Some(c) = chars.next() {
|
||||
out.extend(c.to_uppercase());
|
||||
out.extend(chars);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn outline_uses_parent_order_containment_and_string_kinds() {
|
||||
let range = Range::new(Pos::new(0, 0), Pos::new(0, 10));
|
||||
let item = |name: &str, parent| DocumentSymbol {
|
||||
name: name.into(),
|
||||
detail: String::new(),
|
||||
kind: "enum-member".into(),
|
||||
range,
|
||||
selection_range: range,
|
||||
parent,
|
||||
};
|
||||
let out =
|
||||
outline(vec![item("root", None), item("child", Some(0)), item("future", Some(3)), item("other", Some(2))]);
|
||||
assert_eq!(out[0].children[0].kind, ide_core::SymbolKind::EnumMember);
|
||||
assert_eq!(out[1].name, "future");
|
||||
assert_eq!(out[1].children[0].name, "other");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Validated MCP declarations and dispatch through the ordinary plugin actor.
|
||||
use super::{PluginHost, PluginStatus};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_extension::{Package, UiDecl, decode};
|
||||
use ide_plugin::Invocation;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub(super) const POINT: &str = "mcpTools";
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct Declaration {
|
||||
name: String,
|
||||
description: String,
|
||||
input_schema: std::path::PathBuf,
|
||||
read_only: bool,
|
||||
destructive: bool,
|
||||
}
|
||||
fn tool_name(plugin: &str, name: &str) -> String {
|
||||
let plugin: String = plugin
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' { c } else { '_' })
|
||||
.collect();
|
||||
format!("{plugin}__{name}")
|
||||
}
|
||||
pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result<UiDecl, String> {
|
||||
pkg.manifest.needs_api("contributes.mcpTools", 4)?;
|
||||
if pkg.manifest.wasm.is_none() {
|
||||
return Err("mcpTools requires wasm".into());
|
||||
}
|
||||
let d: Declaration = decode(POINT, value)?;
|
||||
if d.name.is_empty()
|
||||
|| d.name.len() > 40
|
||||
|| !d.name.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
|
||||
|| tool_name(pkg.id(), &d.name).len() > 64
|
||||
|| d.description.trim().is_empty()
|
||||
|| d.description.len() > 4096
|
||||
{
|
||||
return Err(
|
||||
"mcpTools: name must match [a-z0-9_]{1,40}, namespaced name at most 64 bytes, description 1–4096 bytes"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let text = pkg.read_text(&d.input_schema)?;
|
||||
if text.len() > 256 << 10 {
|
||||
return Err("mcpTools: schema exceeds 256 KiB".into());
|
||||
}
|
||||
let schema: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("mcpTools: {e}"))?;
|
||||
if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
|
||||
return Err("mcpTools: inputSchema must describe an object".into());
|
||||
}
|
||||
// No HTTP or filesystem resolver features: schemas must be self-contained.
|
||||
jsonschema::validator_for(&schema).map_err(|e| format!("mcpTools: invalid JSON Schema: {e}"))?;
|
||||
let mut out = UiDecl::new(
|
||||
d.name.clone(),
|
||||
d.description.clone(),
|
||||
serde_json::json!({
|
||||
"name": d.name, "description": d.description, "inputSchema": schema, "readOnly": d.read_only, "destructive": d.destructive,
|
||||
}),
|
||||
);
|
||||
out.files.push(d.input_schema);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Live declarations only, refreshed on every list/call. Ambiguous normalized names fail closed.
|
||||
pub fn mcp_tools(&self, root: &std::path::Path) -> Vec<(String, String, ide_mcp::Tool)> {
|
||||
let mut tools = vec![];
|
||||
for item in self.ui.items(POINT, None) {
|
||||
let Ok(plugin) = self.get(&item.plugin_id) else { continue };
|
||||
if !matches!(plugin.status(), PluginStatus::Active | PluginStatus::Inactive)
|
||||
|| plugin.dev_root.as_deref().is_some_and(|r| r != root)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let d = &item.decl;
|
||||
let Some(name) = d["name"].as_str() else { continue };
|
||||
tools.push((
|
||||
plugin.id().to_owned(),
|
||||
name.to_owned(),
|
||||
ide_mcp::Tool {
|
||||
name: tool_name(plugin.id(), name),
|
||||
description: format!(
|
||||
"{} (plugin: {} [{}])",
|
||||
d["description"].as_str().unwrap_or_default(),
|
||||
plugin.manifest().name,
|
||||
plugin.id()
|
||||
),
|
||||
input_schema: d["inputSchema"].clone(),
|
||||
output_schema: None,
|
||||
read_only: d["readOnly"].as_bool().unwrap_or(false),
|
||||
destructive: d["destructive"].as_bool().unwrap_or(true),
|
||||
},
|
||||
));
|
||||
}
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
for (_, _, tool) in &tools {
|
||||
*counts.entry(tool.name.clone()).or_insert(0) += 1;
|
||||
}
|
||||
tools.retain(|(_, _, tool)| counts[&tool.name] == 1);
|
||||
tools
|
||||
}
|
||||
pub fn call_mcp_tool(
|
||||
&self,
|
||||
plugin: &str,
|
||||
tool: &str,
|
||||
arguments: &str,
|
||||
invocation: Invocation,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
if arguments.len() > 1 << 20 {
|
||||
return Err(IdeError::invalid("MCP arguments exceed 1 MiB"));
|
||||
}
|
||||
let root =
|
||||
invocation.workspace_root.as_ref().ok_or_else(|| IdeError::invalid("MCP requires a project window"))?;
|
||||
let (_, _, declared) = self
|
||||
.mcp_tools(std::path::Path::new(root))
|
||||
.into_iter()
|
||||
.find(|(p, n, _)| p == plugin && n == tool)
|
||||
.ok_or_else(|| IdeError::not_found("plugin MCP tool is no longer available"))?;
|
||||
let args: serde_json::Value = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let validator =
|
||||
jsonschema::validator_for(&declared.input_schema).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if !validator.is_valid(&args) {
|
||||
return Err(IdeError::invalid("MCP arguments do not match inputSchema"));
|
||||
}
|
||||
let (tool, arguments) = (tool.to_owned(), arguments.to_owned());
|
||||
self.code_call_cancellable(plugin, invocation, "calling the MCP tool", Some(cancel), move |rt, inv| {
|
||||
rt.mcp_call(&tool, &arguments, inv)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn validates_names_schema_files_versions_and_schema_keywords_at_install() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = |api: &str| {
|
||||
Package::new(
|
||||
ide_extension::Manifest::parse(&format!(
|
||||
"id='test.tools'\nname='Tools'\nversion='1.0.0'\nengines.openidea='{api}'\nwasm='plugin.wasm'"
|
||||
))
|
||||
.unwrap(),
|
||||
tmp.path().into(),
|
||||
)
|
||||
};
|
||||
let decl = |name: &str, path: &str| {
|
||||
toml::from_str::<toml::Value>(&format!(
|
||||
"name='{name}'\ndescription='Tool'\ninputSchema='{path}'\nreadOnly=true\ndestructive=false"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
std::fs::write(
|
||||
tmp.path().join("args.json"),
|
||||
r##"{"type":"object","$defs":{"text":{"type":"string"}},"properties":{"text":{"$ref":"#/$defs/text"}}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(declaration(&pkg("^1.4"), decl("echo", "args.json")).unwrap().files.len(), 1);
|
||||
assert!(declaration(&pkg("^1.3"), decl("echo", "args.json")).is_err());
|
||||
for name in ["", "Bad", "bad-name", &"x".repeat(41)] {
|
||||
assert!(declaration(&pkg("^1.4"), decl(name, "args.json")).is_err());
|
||||
}
|
||||
assert!(declaration(&pkg("^1.4"), decl("echo", "../args.json")).is_err());
|
||||
for schema in [
|
||||
r#"{"type":"nonsense"}"#,
|
||||
r#"{"type":"object","required":42}"#,
|
||||
r#"{"type":"object","properties":{"x":{"$ref":"file:///private/secret"}}}"#,
|
||||
r#"{"type":"object","properties":{"x":{"$ref":"https://example.invalid/schema"}}}"#,
|
||||
] {
|
||||
std::fs::write(tmp.path().join("args.json"), schema).unwrap();
|
||||
assert!(declaration(&pkg("^1.4"), decl("echo", "args.json")).is_err(), "{schema}");
|
||||
}
|
||||
assert_eq!(tool_name("a.b-c", "echo"), "a_b_c__echo");
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,13 @@
|
||||
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
|
||||
|
||||
mod api;
|
||||
mod auth;
|
||||
pub mod commit;
|
||||
mod database;
|
||||
pub(crate) mod diagnostics;
|
||||
pub mod endpoints;
|
||||
mod event_paths;
|
||||
mod execution;
|
||||
mod files_changed;
|
||||
mod hover;
|
||||
mod icon_extensions;
|
||||
@@ -27,17 +30,23 @@ mod icon_patterns;
|
||||
mod icon_settings;
|
||||
mod icons;
|
||||
pub mod index_ops;
|
||||
pub(crate) mod injections;
|
||||
mod inlay_hints;
|
||||
mod install;
|
||||
pub mod intellij;
|
||||
mod ipc;
|
||||
pub mod language_features;
|
||||
mod market;
|
||||
mod mcp;
|
||||
pub mod navigation;
|
||||
mod points;
|
||||
mod providers;
|
||||
pub mod remote;
|
||||
pub mod remote_host;
|
||||
mod run_detectors;
|
||||
pub mod run_targets;
|
||||
mod semantic_tokens;
|
||||
mod snippets;
|
||||
mod theme;
|
||||
mod types;
|
||||
pub mod ui_points;
|
||||
@@ -134,6 +143,8 @@ struct Meta {
|
||||
retry_at: Option<Instant>,
|
||||
/// The component exports `syntax-provider` (known once activated).
|
||||
has_syntax: bool,
|
||||
/// Known before activation can publish target registrations.
|
||||
has_run_target_validation: bool,
|
||||
/// Next `on-schedule` activation.
|
||||
next_schedule: Option<Instant>,
|
||||
/// The trusted publisher of a signed installed package.
|
||||
@@ -155,6 +166,7 @@ impl Meta {
|
||||
failures: 0,
|
||||
retry_at: None,
|
||||
has_syntax: false,
|
||||
has_run_target_validation: false,
|
||||
publisher: None,
|
||||
next_schedule: None,
|
||||
}
|
||||
@@ -410,6 +422,11 @@ struct PendingUpdate {
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Current decoration snapshots for windows opening after a plugin published them.
|
||||
pub fn decoration_sets(&self) -> std::collections::BTreeMap<String, Vec<DecorationSet>> {
|
||||
self.shared.decoration_sets()
|
||||
}
|
||||
|
||||
/// Creates the host; call [`load_all`](Self::load_all) to load installed plugins.
|
||||
pub fn new(plugins_dir: PathBuf, sink: Arc<dyn PluginEventSink>, limits: Limits) -> IdeResult<Self> {
|
||||
std::fs::create_dir_all(&plugins_dir)?;
|
||||
@@ -653,6 +670,7 @@ impl PluginHost {
|
||||
|
||||
/// Unloads the project plugins of `root` (the project became untrusted or closed).
|
||||
pub fn restrict_project(&self, root: &Path) {
|
||||
self.shared.withdraw_run_targets(None, Some(root));
|
||||
let _guard = self.lifecycle.lock();
|
||||
let ids: Vec<_> = self
|
||||
.plugins
|
||||
@@ -1080,7 +1098,9 @@ impl PluginHost {
|
||||
ledger.set(&subject, &cap, granted);
|
||||
if granted
|
||||
&& let Capability::Process(program) = &cap
|
||||
&& let Some(bin) = self.resolve_binary(program)
|
||||
&& let Some(bin) = ledger
|
||||
.program_choice(&subject, &cap)
|
||||
.map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok())
|
||||
{
|
||||
ledger.set_binary(&subject, &cap, bin);
|
||||
}
|
||||
@@ -1097,14 +1117,74 @@ impl PluginHost {
|
||||
/// The program a `process:` grant allows, checked against the file
|
||||
/// approved for it. Errors ask the user to confirm again.
|
||||
pub fn program_for(&self, id: &str, program: &str) -> IdeResult<PathBuf> {
|
||||
self.program_for_project(id, program, None)
|
||||
}
|
||||
|
||||
/// Resolves the approved executable for the invoking project, verifying its pinned content.
|
||||
pub fn program_for_project(&self, id: &str, program: &str, root: Option<&Path>) -> IdeResult<PathBuf> {
|
||||
let plugin = self.get(id)?;
|
||||
let cap = Capability::Process(program.to_owned());
|
||||
self.ledger
|
||||
.lock()
|
||||
.check_binary(&plugin.subject(), &cap)
|
||||
.check_binary_in_project(&plugin.subject(), &Capability::Process(program.into()), root)
|
||||
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))
|
||||
}
|
||||
|
||||
/// Records a user-approved executable choice; removing it revalidates the fallback or revokes the grant.
|
||||
pub fn choose_program(
|
||||
&self,
|
||||
id: &str,
|
||||
capability: &str,
|
||||
root: Option<&Path>,
|
||||
path: Option<&Path>,
|
||||
) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
self.check_not_uninstalling(&plugin)?;
|
||||
let cap: Capability = capability.parse().map_err(IdeError::invalid)?;
|
||||
let Capability::Process(program) = &cap else {
|
||||
return Err(IdeError::invalid("expected a process capability"));
|
||||
};
|
||||
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
|
||||
return Err(IdeError::invalid("undeclared process capability"));
|
||||
}
|
||||
let binary = if let Some(path) = path {
|
||||
Some(ledger::approve_program(path).map_err(IdeError::invalid)?)
|
||||
} else if root.is_none() {
|
||||
self.resolve_binary(program)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
{
|
||||
let mut ledger = self.ledger.lock();
|
||||
let subject = plugin.subject();
|
||||
if ledger.get(&subject).is_none() {
|
||||
return Err(IdeError::invalid("confirm this plugin's permissions first"));
|
||||
}
|
||||
if let Some(root) = root {
|
||||
ledger.set_project_binary(&subject, &cap, root, binary);
|
||||
if path.is_some() {
|
||||
ledger.set(&subject, &cap, true);
|
||||
} else if ledger.check_binary(&subject, &cap).is_err() {
|
||||
ledger.set(&subject, &cap, false);
|
||||
}
|
||||
} else if let Some(binary) = binary {
|
||||
ledger.set_program_choice(&subject, &cap, path);
|
||||
ledger.set(&subject, &cap, true);
|
||||
ledger.set_binary(&subject, &cap, binary);
|
||||
} else {
|
||||
ledger.set_program_choice(&subject, &cap, None);
|
||||
ledger.set(&subject, &cap, false);
|
||||
}
|
||||
self.save_ledger(&ledger);
|
||||
}
|
||||
self.stop(&plugin);
|
||||
if !self.state.lock().disabled.contains(id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
self.changed();
|
||||
self.info_of(id)
|
||||
}
|
||||
|
||||
fn check_uninstall_pending(&self, id: &str) -> IdeResult<()> {
|
||||
if install::uninstall_pending(&self.plugins_dir, id)? {
|
||||
return Err(IdeError::unavailable(std::io::Error::from(std::io::ErrorKind::ResourceBusy).to_string()));
|
||||
@@ -1151,6 +1231,7 @@ impl PluginHost {
|
||||
}
|
||||
self.stop(&plugin);
|
||||
let cleanup = || -> std::io::Result<()> {
|
||||
self.remove_database_sources(id).map_err(|e| std::io::Error::other(e.message))?;
|
||||
install::check_cleanup_parent(&self.plugins_dir, &self.shared.data_dir.join(id))?;
|
||||
install::check_cleanup_parent(&self.plugins_dir, plugin.dir())?;
|
||||
install::check_not_symlink(&self.shared.data_dir.join(id))?;
|
||||
@@ -1343,6 +1424,31 @@ impl PluginHost {
|
||||
what: &str,
|
||||
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> IdeResult<R> {
|
||||
self.code_call_cancellable(id, invocation, what, None, f)
|
||||
}
|
||||
|
||||
fn code_call_cancellable<R: Send + 'static>(
|
||||
&self,
|
||||
id: &str,
|
||||
invocation: Invocation,
|
||||
what: &str,
|
||||
cancel: Option<&ide_core::CancellationToken>,
|
||||
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> IdeResult<R> {
|
||||
self.code_call_with_budget(id, invocation, what, cancel, None, f)
|
||||
}
|
||||
|
||||
fn code_call_with_budget<R: Send + 'static>(
|
||||
&self,
|
||||
id: &str,
|
||||
invocation: Invocation,
|
||||
what: &str,
|
||||
cancel: Option<&ide_core::CancellationToken>,
|
||||
budget: Option<Duration>,
|
||||
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> IdeResult<R> {
|
||||
let deadline = budget.map(|budget| Instant::now() + budget);
|
||||
let fallback = ide_core::CancellationToken::new();
|
||||
let plugin = self.get(id)?;
|
||||
let client = invocation.client.clone();
|
||||
{
|
||||
@@ -1383,7 +1489,29 @@ impl PluginHost {
|
||||
}
|
||||
let started = Instant::now();
|
||||
let (call, inv) = (f.clone(), invocation.clone());
|
||||
let result = actor.call(None, move |rt| call(rt, inv));
|
||||
let result = match (deadline, cancel) {
|
||||
(Some(deadline), cancel) => actor.call_bounded(
|
||||
cancel.unwrap_or(&fallback),
|
||||
Duration::from_millis(50),
|
||||
deadline.saturating_duration_since(Instant::now()),
|
||||
move |rt| call(rt, inv),
|
||||
),
|
||||
(None, Some(cancel)) => {
|
||||
actor.call_cancellable(cancel, Duration::from_millis(50), move |rt| call(rt, inv))
|
||||
}
|
||||
(None, None) => actor.call(None, move |rt| call(rt, inv)),
|
||||
};
|
||||
if deadline.is_some() && matches!(result, Err(ActorError::SoftTimeout)) {
|
||||
self.reap(&plugin, &actor, client.as_deref());
|
||||
return Err(IdeError::new(
|
||||
ide_core::error::ErrorKind::TimedOut,
|
||||
format!("plugin {id:?} timed out while {what}"),
|
||||
));
|
||||
}
|
||||
if matches!(result, Err(ActorError::Cancelled)) {
|
||||
self.reap(&plugin, &actor, client.as_deref());
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if !matches!(result, Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled)) {
|
||||
let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_))));
|
||||
// Cancelling or a missing grant is not the plugin failing.
|
||||
@@ -1457,7 +1585,12 @@ impl PluginHost {
|
||||
|
||||
/// Plugins' `runDetectors`, for [`ide_run::set_external_detection`].
|
||||
pub fn run_detection(&self) -> Arc<dyn ide_run::ExternalDetection> {
|
||||
Arc::new(run_detectors::PluginRunDetection { store: self.run_detectors.clone(), pins: self.pins.clone() })
|
||||
Arc::new(run_detectors::PluginRunDetection {
|
||||
store: self.run_detectors.clone(),
|
||||
dynamic: self.shared.run_configurations.clone(),
|
||||
pins: self.pins.clone(),
|
||||
project_pins: self.shared.project_pins.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The syntax of plugin languages, for [`ide_language::set_external_syntax`].
|
||||
@@ -1513,6 +1646,10 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
/// Plugin context keys in effect for window `client`: global keys overlaid with its own.
|
||||
pub fn status_snapshot(&self, client: &str) -> Vec<PluginStatusItem> {
|
||||
self.shared.status_snapshot(client)
|
||||
}
|
||||
|
||||
pub fn context_keys(&self, client: Option<&str>) -> Vec<PluginContextKey> {
|
||||
self.shared.context_keys(client)
|
||||
}
|
||||
@@ -1543,6 +1680,10 @@ impl PluginHost {
|
||||
|
||||
/// A window answered a plugin's `CommitRequested`; false when the call no
|
||||
/// longer waits.
|
||||
pub fn answer_command(&self, client: &str, request: &str, answer: CommandAnswer) -> bool {
|
||||
request.parse().is_ok_and(|id| self.shared.answer_command(client, id, answer))
|
||||
}
|
||||
|
||||
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
|
||||
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
|
||||
}
|
||||
@@ -1632,6 +1773,30 @@ impl PluginHost {
|
||||
|
||||
/// Queues workbench events from window `client` for the plugins that want
|
||||
/// them; [`flush_events`](Self::flush_events) delivers them in batches.
|
||||
pub fn post_window_events(&self, client: &str, events: Vec<WorkbenchEvent>, editor: Option<PluginEditor>) {
|
||||
let mut editors = self.shared.event_editors.write();
|
||||
if let Some(editor) = editor {
|
||||
editors.insert(client.into(), editor.context());
|
||||
} else {
|
||||
editors.remove(client);
|
||||
}
|
||||
drop(editors);
|
||||
self.post_events(
|
||||
Some(client),
|
||||
events
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
!matches!(
|
||||
e,
|
||||
WorkbenchEvent::RunExited(_)
|
||||
| WorkbenchEvent::TerminalExited(_)
|
||||
| WorkbenchEvent::ContentTabAction(_)
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn post_events(&self, client: Option<&str>, events: Vec<WorkbenchEvent>) {
|
||||
self.post_events_from(client, events, None);
|
||||
}
|
||||
@@ -1661,7 +1826,13 @@ impl PluginHost {
|
||||
.as_ref()
|
||||
.is_none_or(|root| client.and_then(|c| self.shared.project_of(c)).as_ref() == Some(root))
|
||||
})
|
||||
.cloned()
|
||||
.filter_map(|e| match e {
|
||||
WorkbenchEvent::SettingsChanged(_) => {
|
||||
let keys = self.shared.changed_settings(&p.pkg, client);
|
||||
(!keys.is_empty()).then_some(WorkbenchEvent::SettingsChanged(keys))
|
||||
}
|
||||
_ => Some(e.clone()),
|
||||
})
|
||||
.collect();
|
||||
let wanted = Self::filter_path_events(&self.shared, &p, client, &wanted, &subs);
|
||||
if wanted.is_empty() {
|
||||
@@ -1682,7 +1853,15 @@ impl PluginHost {
|
||||
let excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS);
|
||||
let mut remaining = excess;
|
||||
queue.events.retain(|event| {
|
||||
if remaining > 0 && !matches!(event, WorkbenchEvent::FilesChanged(_)) {
|
||||
if remaining > 0
|
||||
&& !matches!(
|
||||
event,
|
||||
WorkbenchEvent::FilesChanged(_)
|
||||
| WorkbenchEvent::RunExited(_)
|
||||
| WorkbenchEvent::TerminalExited(_)
|
||||
| WorkbenchEvent::ContentTabAction(_)
|
||||
)
|
||||
{
|
||||
remaining -= 1;
|
||||
false
|
||||
} else {
|
||||
@@ -1809,9 +1988,10 @@ impl PluginHost {
|
||||
let invocation = Invocation {
|
||||
workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)),
|
||||
client: client.clone(),
|
||||
editor: None,
|
||||
editor: client.as_deref().and_then(|c| self.shared.event_editors.read().get(c).cloned()),
|
||||
selected_paths: Vec::new(),
|
||||
global_settings: false,
|
||||
command_chain: Vec::new(),
|
||||
};
|
||||
let kinds: BTreeSet<EventKind> = queue.events.iter().map(WorkbenchEvent::kind).collect();
|
||||
let filesystem = kinds.contains(&EventKind::FilesChanged);
|
||||
@@ -1823,6 +2003,9 @@ impl PluginHost {
|
||||
if batches.running.as_ref() == Some(&kinds)
|
||||
&& !kinds.contains(&EventKind::Scheduled)
|
||||
&& !kinds.contains(&EventKind::FilesChanged)
|
||||
&& !kinds.iter().any(|k| {
|
||||
matches!(k, EventKind::RunExited | EventKind::TerminalExited | EventKind::ContentTabAction)
|
||||
})
|
||||
{
|
||||
actor.cancel_soft();
|
||||
}
|
||||
@@ -2372,6 +2555,7 @@ impl PluginHost {
|
||||
return Err(IdeError::internal(format!("plugin {:?} {e}", plugin.id())));
|
||||
}
|
||||
};
|
||||
plugin.meta.lock().has_run_target_validation = rt.has_run_target_validation();
|
||||
let crash = match rt.activate() {
|
||||
Ok(_) => None,
|
||||
Err(CallError::Trap(e)) => Some(format!("activation {e}")),
|
||||
@@ -2599,8 +2783,21 @@ impl PluginHost {
|
||||
/// pick up a change on their next lookup.
|
||||
fn refresh_pins(&self, ledger: &Ledger) {
|
||||
let mut pins = HashMap::new();
|
||||
let mut project_pins = HashMap::new();
|
||||
for plugin in self.plugins.read().values() {
|
||||
let subject = plugin.subject();
|
||||
if let Some(grant) = ledger.get(&subject) {
|
||||
for (root, binaries) in &grant.project_binaries {
|
||||
for (cap, bin) in binaries {
|
||||
if grant.granted.contains(cap) {
|
||||
project_pins.insert(
|
||||
(plugin.id().to_owned(), cap.clone(), root.clone()),
|
||||
ProgramPin { path: bin.path.clone(), hash: bin.hash.clone() },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let declared =
|
||||
plugin.manifest().capabilities.all().cloned().chain(plugin.meta.lock().declared_extra.clone());
|
||||
for cap in declared.filter(|c| matches!(c, Capability::Process(_))) {
|
||||
@@ -2612,6 +2809,7 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
}
|
||||
*self.shared.project_pins.write() = project_pins;
|
||||
let mut current = self.pins.write();
|
||||
if *current != pins {
|
||||
*current = pins;
|
||||
@@ -2665,12 +2863,26 @@ impl PluginHost {
|
||||
|
||||
fn record_consent(&self, request: &Request<'_>, consent: &Consent) {
|
||||
let accepted: Vec<Capability> = request.optional.iter().filter(|c| consent.accepts(c)).cloned().collect();
|
||||
let choices: BTreeMap<_, _> = {
|
||||
let ledger = self.ledger.lock();
|
||||
request
|
||||
.required
|
||||
.iter()
|
||||
.chain(&accepted)
|
||||
.filter_map(|cap| {
|
||||
ledger.program_choice(&request.subject, cap).map(|p| (cap.to_string(), p.to_path_buf()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let binaries: BTreeMap<String, BinaryGrant> = request
|
||||
.required
|
||||
.iter()
|
||||
.chain(&accepted)
|
||||
.filter_map(|c| match c {
|
||||
Capability::Process(program) => self.resolve_binary(program).map(|b| (c.to_string(), b)),
|
||||
Capability::Process(program) => choices
|
||||
.get(&c.to_string())
|
||||
.map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok())
|
||||
.map(|b| (c.to_string(), b)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
pub const MAX_DOCUMENT_BYTES: usize = 2 << 20;
|
||||
pub const MAX_DOCUMENT_BYTES: usize = ide_lsp::MAX_INJECTED_DOCUMENT_BYTES;
|
||||
const MAX_FILES: usize = 20_000;
|
||||
const MAX_EDGES: usize = 4096;
|
||||
|
||||
@@ -197,6 +197,7 @@ impl PluginHost {
|
||||
/// navigation_at. Call off the UI thread, in small cancellable batches.
|
||||
pub fn index_navigation(&self, root: &Path, snapshot: Snapshot<'_>, invocation: Invocation) -> IdeResult<()> {
|
||||
let Snapshot { path, version, text, symbols, scope } = snapshot;
|
||||
let path = dunce::simplified(path);
|
||||
if !(self.project_policy.read())(root) {
|
||||
return Err(IdeError::unavailable("project is not trusted"));
|
||||
}
|
||||
@@ -452,6 +453,7 @@ impl PluginHost {
|
||||
return vec![];
|
||||
};
|
||||
let mut result = vec![];
|
||||
let mut icons = HashMap::new();
|
||||
for path in paths {
|
||||
for (id, graph) in graphs {
|
||||
let Some(plugin) = plugins.get(id) else { continue };
|
||||
@@ -462,6 +464,7 @@ impl PluginHost {
|
||||
if version_of(path).is_some_and(|v| file.version != Some(v)) {
|
||||
continue;
|
||||
}
|
||||
let prefixes = icons.entry(id).or_insert_with(|| marker_icons(plugin));
|
||||
let mut markers = vec![];
|
||||
for anchor in &file.edges.markers {
|
||||
let candidates = graph.targets(&anchor.key);
|
||||
@@ -481,7 +484,15 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
if !targets.is_empty() {
|
||||
markers.push(NavigationResult { range: anchor.range, targets });
|
||||
markers.push(IndexedMarker {
|
||||
range: anchor.range,
|
||||
targets,
|
||||
plugin_id: id.clone(),
|
||||
icon: prefixes
|
||||
.iter()
|
||||
.find(|(prefix, _)| anchor.key.starts_with(prefix.as_str()))
|
||||
.map(|(_, icon)| icon.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
if !markers.is_empty() {
|
||||
@@ -516,6 +527,23 @@ pub(crate) fn snapshot_hash(text: &str) -> blake3::Hash {
|
||||
struct Decl {
|
||||
extensions: Vec<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct MarkerIconPrefixes {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
markers: Vec<String>,
|
||||
}
|
||||
fn marker_icons(p: &super::LoadedPlugin) -> Vec<(String, String)> {
|
||||
let mut prefixes: Vec<_> = p
|
||||
.manifest()
|
||||
.declarations(super::ui_points::MARKER_ICONS)
|
||||
.into_iter()
|
||||
.filter_map(|d| decode::<MarkerIconPrefixes>(super::ui_points::MARKER_ICONS, d).ok())
|
||||
.flat_map(|d| d.markers.into_iter().map(move |prefix| (prefix, d.id.clone())))
|
||||
.collect();
|
||||
prefixes.sort_by_key(|(prefix, _)| std::cmp::Reverse(prefix.len()));
|
||||
prefixes
|
||||
}
|
||||
fn declaration(p: &super::LoadedPlugin) -> Option<Decl> {
|
||||
p.manifest()
|
||||
.declarations("documentIndexers")
|
||||
|
||||
@@ -20,15 +20,23 @@ pub struct CommandDecl {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
/// Enablement, evaluated by the client against context keys (`editorLang == rust`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub when: Option<String>,
|
||||
}
|
||||
|
||||
fn command(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
fn command(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let c: CommandDecl = decode(COMMANDS, decl)?;
|
||||
validate_item_id(&c.id, "command id")?;
|
||||
if let Some(icon) = &c.icon {
|
||||
pkg.manifest.needs_api("contributes.commands.icon", 4)?;
|
||||
if icon.is_empty() || icon.len() > 256 {
|
||||
return Err("command icon: expected 1–256 bytes".into());
|
||||
}
|
||||
}
|
||||
if c.title.trim().is_empty() {
|
||||
return Err(format!("command {:?} has an empty title", c.id));
|
||||
}
|
||||
@@ -40,7 +48,14 @@ fn command(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
}
|
||||
let c = CommandDecl { category: c.category.filter(|x| !x.trim().is_empty()), ..c };
|
||||
let json = serde_json::to_value(&c).map_err(|e| e.to_string())?;
|
||||
Ok(UiDecl::new(c.id.clone(), c.title, json))
|
||||
let mut out = UiDecl::new(c.id.clone(), c.title, json);
|
||||
if let Some(icon) = c.icon.filter(|s| s.ends_with(".svg")) {
|
||||
let path = std::path::PathBuf::from(icon);
|
||||
let svg = super::icons::read_icon(pkg, &path, super::icons::MAX_SVG)?;
|
||||
out.files.push(path);
|
||||
out.assets.push((format!("commandIcon/{}", c.id), svg));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Registers every first-party extension point.
|
||||
@@ -60,6 +75,13 @@ pub fn register_builtin_points(
|
||||
registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::endpoints::POINT, ui.clone(), super::endpoints::declaration)))?;
|
||||
registry.register(Arc::new(UiPoint::new(
|
||||
"runConfigurations",
|
||||
ui.clone(),
|
||||
super::execution::configurations_declaration,
|
||||
)))?;
|
||||
registry.register(Arc::new(UiPoint::new("snippets", ui.clone(), super::snippets::declaration)))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::mcp::POINT, ui.clone(), super::mcp::declaration)))?;
|
||||
use super::ui_points as p;
|
||||
registry.register(Arc::new(UiPoint::new(p::MENUS, ui.clone(), p::menu)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::KEYMAPS, ui.clone(), p::keymap)))?;
|
||||
|
||||
@@ -42,6 +42,19 @@ pub enum ProviderKind {
|
||||
Hover,
|
||||
InlayHints,
|
||||
Diagnostics,
|
||||
Formatting,
|
||||
SignatureHelp,
|
||||
DocumentHighlights,
|
||||
DocumentLinks,
|
||||
CodeLenses,
|
||||
SelectionRanges,
|
||||
Folding,
|
||||
Colors,
|
||||
DocumentSymbols,
|
||||
Implementation,
|
||||
TypeDefinition,
|
||||
GutterActions,
|
||||
Injections,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -63,16 +76,29 @@ pub struct Providers {
|
||||
|
||||
impl Providers {
|
||||
fn add(self: &Arc<Self>, plugin: String, decl: Decl) -> Disposable {
|
||||
let injections = decl.kinds.contains(&ProviderKind::Injections);
|
||||
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
|
||||
self.items.write().push((seq, plugin, decl.kinds, decl.languages));
|
||||
if injections {
|
||||
self.injections_changed();
|
||||
}
|
||||
let weak = Arc::downgrade(self);
|
||||
Disposable::new(move || {
|
||||
if let Some(this) = weak.upgrade() {
|
||||
this.items.write().retain(|(s, ..)| *s != seq);
|
||||
if injections {
|
||||
this.injections_changed();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn injections_changed(self: &Arc<Self>) {
|
||||
if editor_plugin_host().is_some_and(|host| Arc::ptr_eq(&host.providers, self)) {
|
||||
super::injections::providers_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugins providing `kind` for `language`, in declaration order.
|
||||
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
|
||||
self.matching::<Vec<_>>(kind, language).into_iter().map(|(plugin, _)| plugin).collect()
|
||||
@@ -94,9 +120,30 @@ impl Providers {
|
||||
}
|
||||
|
||||
pub(super) fn diagnostic_generations(&self, language: &str) -> HashMap<String, u64> {
|
||||
self.matching(ProviderKind::Diagnostics, language)
|
||||
if self.any(ProviderKind::Injections) {
|
||||
let mut out = HashMap::new();
|
||||
for (seq, plugin, kinds, _) in self.items.read().iter() {
|
||||
if kinds.contains(&ProviderKind::Diagnostics) {
|
||||
out.entry(plugin.clone()).or_insert(*seq);
|
||||
}
|
||||
}
|
||||
out
|
||||
} else {
|
||||
self.matching(ProviderKind::Diagnostics, language)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn injection_generations(&self, language: &str) -> HashMap<String, u64> {
|
||||
self.matching(ProviderKind::Injections, language)
|
||||
}
|
||||
pub(super) fn formatter_declarations(&self) -> Vec<(String, Vec<String>)> {
|
||||
self.items
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, _, k, _)| k.contains(&ProviderKind::Formatting))
|
||||
.map(|(_, p, _, l)| (p.clone(), l.clone()))
|
||||
.collect()
|
||||
}
|
||||
/// Whether any plugin provides `kind` (for any language).
|
||||
pub fn any(&self, kind: ProviderKind) -> bool {
|
||||
self.items.read().iter().any(|(_, _, kinds, _)| kinds.contains(&kind))
|
||||
@@ -142,6 +189,26 @@ impl ExtensionPoint for ProvidersPoint {
|
||||
if d.kinds.contains(&ProviderKind::InlayHints) {
|
||||
pkg.manifest.needs_api("contributes.providers: inlay-hints", 3)?;
|
||||
}
|
||||
if d.kinds.iter().any(|k| {
|
||||
matches!(
|
||||
k,
|
||||
ProviderKind::Formatting
|
||||
| ProviderKind::SignatureHelp
|
||||
| ProviderKind::DocumentHighlights
|
||||
| ProviderKind::DocumentLinks
|
||||
| ProviderKind::CodeLenses
|
||||
| ProviderKind::SelectionRanges
|
||||
| ProviderKind::Folding
|
||||
| ProviderKind::Colors
|
||||
| ProviderKind::DocumentSymbols
|
||||
| ProviderKind::Implementation
|
||||
| ProviderKind::TypeDefinition
|
||||
| ProviderKind::GutterActions
|
||||
| ProviderKind::Injections
|
||||
)
|
||||
}) {
|
||||
pkg.manifest.needs_api("contributes.providers: language features and injections", 4)?;
|
||||
}
|
||||
for l in &d.languages {
|
||||
if l != "*"
|
||||
&& (l.is_empty()
|
||||
@@ -206,12 +273,13 @@ static EDITOR_PROVIDERS: RwLock<Option<Weak<PluginHost>>> = RwLock::new(None);
|
||||
/// definition and inline completion (`None` removes them).
|
||||
pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
|
||||
*EDITOR_PROVIDERS.write() = host.map(Arc::downgrade);
|
||||
super::injections::providers_changed();
|
||||
}
|
||||
|
||||
/// The plugin host whose providers take part, when any declares `kind`.
|
||||
pub(crate) fn editor_providers(kind: ProviderKind) -> Option<Arc<PluginHost>> {
|
||||
let host = editor_plugin_host()?;
|
||||
host.providers.any(kind).then_some(host)
|
||||
(host.providers.any(kind) || host.providers.any(ProviderKind::Injections)).then_some(host)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
|
||||
@@ -236,15 +304,46 @@ impl PluginHost {
|
||||
/// declares `kind` for `language`, in plugin order, leaving out those that
|
||||
/// failed or were late. Each plugin runs on its own thread, so the calls
|
||||
/// run at the same time: the wait is the slowest plugin's, not the sum.
|
||||
fn edit_calls<R: Send + 'static>(
|
||||
pub(super) fn edit_calls<R: Send + 'static>(
|
||||
&self,
|
||||
kind: ProviderKind,
|
||||
language: &str,
|
||||
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> Vec<(Arc<LoadedPlugin>, R)> {
|
||||
self.edit_calls_plugins(self.provider_plugins(kind, language), f)
|
||||
}
|
||||
|
||||
pub(super) fn document_calls<R: Send + 'static>(
|
||||
&self,
|
||||
kind: ProviderKind,
|
||||
doc: &ProviderDocument,
|
||||
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> Vec<(Arc<LoadedPlugin>, R)> {
|
||||
self.document_calls_for(kind, doc, None, f)
|
||||
}
|
||||
pub(super) fn document_calls_for<R: Send + 'static>(
|
||||
&self,
|
||||
kind: ProviderKind,
|
||||
doc: &ProviderDocument,
|
||||
owner: Option<&str>,
|
||||
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> Vec<(Arc<LoadedPlugin>, R)> {
|
||||
let plugins = self
|
||||
.provider_plugins(kind, &doc.language)
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)) && owner.is_none_or(|id| p.id() == id)
|
||||
})
|
||||
.collect();
|
||||
self.edit_calls_plugins(plugins, f)
|
||||
}
|
||||
fn edit_calls_plugins<R: Send + 'static>(
|
||||
&self,
|
||||
plugins: Vec<Arc<LoadedPlugin>>,
|
||||
f: impl FnOnce(&mut Runtime, Duration) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> Vec<(Arc<LoadedPlugin>, R)> {
|
||||
let (soft, hard) = (self.limits.edit_soft, self.limits.edit_hard);
|
||||
let queued: Vec<_> = self
|
||||
.provider_plugins(kind, language)
|
||||
let queued: Vec<_> = plugins
|
||||
.into_iter()
|
||||
.filter_map(|plugin| {
|
||||
let (actor, activated) = self.ensure_active(&plugin, None).ok()?;
|
||||
@@ -303,13 +402,24 @@ impl PluginHost {
|
||||
at: ide_core::Pos,
|
||||
trigger: Option<&str>,
|
||||
) -> Vec<(String, CompletionItem)> {
|
||||
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
|
||||
self.edit_calls(ProviderKind::Completion, &doc.language, move |rt, timeout| {
|
||||
rt.provide_completion(&d, at, t.as_deref(), timeout)
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i)))
|
||||
.collect()
|
||||
self.feature_documents(doc, Some(at))
|
||||
.into_iter()
|
||||
.flat_map(|(doc, bounds)| {
|
||||
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
|
||||
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
|
||||
rt.provide_completion(&d, at, t.as_deref(), timeout)
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|(p, items)| items.into_iter().map(move |i| (p.id().to_owned(), i)))
|
||||
.filter_map(move |(p, mut i)| {
|
||||
if i.replace.is_some_and(|r| !bounds.contains(r)) {
|
||||
return None;
|
||||
}
|
||||
i.additional_edits.retain(|e| bounds.contains(e.range));
|
||||
Some((p, i))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn provide_code_actions(
|
||||
@@ -391,10 +501,20 @@ impl PluginHost {
|
||||
project: Option<&std::path::Path>,
|
||||
language: &str,
|
||||
) -> Vec<Arc<LoadedPlugin>> {
|
||||
self.provider_plugins(ProviderKind::Diagnostics, language)
|
||||
.into_iter()
|
||||
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
|
||||
.collect()
|
||||
let plugins = if self.providers.any(ProviderKind::Injections) {
|
||||
self.providers
|
||||
.diagnostic_generations(language)
|
||||
.keys()
|
||||
.filter_map(|id| self.get(id).ok())
|
||||
.filter(|p| {
|
||||
let m = p.meta.lock();
|
||||
matches!(m.status, PluginStatus::Active | PluginStatus::Inactive) && m.pending.is_empty()
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
self.provider_plugins(ProviderKind::Diagnostics, language)
|
||||
};
|
||||
plugins.into_iter().filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project).collect()
|
||||
}
|
||||
|
||||
pub(super) fn provide_project_diagnostics(
|
||||
@@ -402,11 +522,35 @@ impl PluginHost {
|
||||
project: Option<&std::path::Path>,
|
||||
doc: &ProviderDocument,
|
||||
cancel: &CancellationToken,
|
||||
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
|
||||
let mut out = std::collections::BTreeMap::<String, Vec<ide_core::Diagnostic>>::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
for (id, items) in self.provide_document_diagnostics(project, &d, cancel) {
|
||||
out.entry(id).or_default().extend(items.into_iter().filter(|i| bounds.contains(i.range)));
|
||||
}
|
||||
}
|
||||
out.into_iter()
|
||||
.map(|(id, mut items)| {
|
||||
items.truncate(5000);
|
||||
(id, items)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn provide_document_diagnostics(
|
||||
&self,
|
||||
project: Option<&std::path::Path>,
|
||||
doc: &ProviderDocument,
|
||||
cancel: &CancellationToken,
|
||||
) -> Vec<(String, Vec<ide_core::Diagnostic>)> {
|
||||
std::thread::scope(|scope| {
|
||||
let calls: Vec<_> = self
|
||||
.diagnostic_plugins(project, &doc.language)
|
||||
.provider_plugins(ProviderKind::Diagnostics, &doc.language)
|
||||
.into_iter()
|
||||
.filter(|p| p.dev_root.is_none() || p.dev_root.as_deref() == project)
|
||||
.map(|p| {
|
||||
scope.spawn(move || {
|
||||
if cancel.is_cancelled() {
|
||||
@@ -432,10 +576,15 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
pub fn provide_hover(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<PluginHover> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::Hover, &doc.language, move |rt, timeout| rt.provide_hover(&d, at, timeout))
|
||||
self.feature_documents(doc, Some(at))
|
||||
.into_iter()
|
||||
.filter_map(|(_, hover)| hover)
|
||||
.flat_map(|(doc, bounds)| {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.document_calls(ProviderKind::Hover, &doc, move |rt, timeout| rt.provide_hover(&d, at, timeout))
|
||||
.into_iter()
|
||||
.filter_map(|(_, h)| h)
|
||||
.filter(move |h| h.range.is_none_or(|r| bounds.contains(r)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
//! keychain and are asked for with a `plugin_secret` request
|
||||
//! ([`SecretRequest`]) when a request header needs one (rule 4).
|
||||
|
||||
use super::{
|
||||
Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, PluginStatusItem, SecretStore,
|
||||
};
|
||||
use super::{Consent, HostWorkspace, PluginEditor, PluginEvent, PluginHost, PluginSource, SecretStore};
|
||||
use crate::rpc::Dispatcher;
|
||||
use base64::Engine as _;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
@@ -45,6 +43,32 @@ pub const SYNC_PACKAGES: &str = "plugin_sync_packages";
|
||||
pub const EVENT_NOTIFICATION: &str = "plugin";
|
||||
/// Server → client request ([`SecretRequest`] → `Option<String>`).
|
||||
pub const SECRET_REQUEST: &str = "plugin_secret";
|
||||
/// Mutations of declared secrets, kept on the desktop.
|
||||
pub const SECRET_WRITE: &str = "plugin_secret_write";
|
||||
/// OAuth listeners bound on the desktop, never the project server.
|
||||
pub const AUTH_REQUEST: &str = "plugin_auth";
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct SecretWrite {
|
||||
pub plugin: String,
|
||||
pub name: String,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
/// A remote plugin's operation on a desktop OAuth listener.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AuthRequest {
|
||||
pub plugin: String,
|
||||
pub operation: AuthOperation,
|
||||
}
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub enum AuthOperation {
|
||||
Begin { path: String },
|
||||
Wait { id: u64, timeout_ms: u32 },
|
||||
Close { id: u64 },
|
||||
Clear,
|
||||
}
|
||||
|
||||
/// Under the server's data directory.
|
||||
pub const PLUGINS_DIR: &str = "remote-plugins";
|
||||
const INCOMING_DIR: &str = "remote-plugins.incoming";
|
||||
@@ -61,6 +85,13 @@ pub struct RemotePackage {
|
||||
pub digest: String,
|
||||
/// Capabilities the user granted on the desktop.
|
||||
pub granted: Vec<String>,
|
||||
/// Disabled packages remain installed so their persistent project data survives.
|
||||
#[serde(default = "enabled_by_default")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
fn enabled_by_default() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// One file of a package (`path` relative to the package, `data` base64).
|
||||
@@ -107,8 +138,18 @@ pub fn package_digest(dir: &Path, files: &[PathBuf]) -> IdeResult<String> {
|
||||
pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
PluginEvent::Notification { .. }
|
||||
PluginEvent::RunTargetsChanged
|
||||
| PluginEvent::WindowRequest { .. }
|
||||
| PluginEvent::ContentTabReply { .. }
|
||||
| PluginEvent::DecorationsChanged { .. }
|
||||
| PluginEvent::EditorOpen { .. }
|
||||
| PluginEvent::EditorRanges { .. }
|
||||
| PluginEvent::Notification { .. }
|
||||
| PluginEvent::StatusItemChanged { .. }
|
||||
| PluginEvent::WindowStatusItemChanged { .. }
|
||||
| PluginEvent::ExecuteCommand { .. }
|
||||
| PluginEvent::OutputChanged { .. }
|
||||
| PluginEvent::RevealOutput { .. }
|
||||
| PluginEvent::ContextChanged { .. }
|
||||
| PluginEvent::TaskStarted { .. }
|
||||
| PluginEvent::TaskProgress { .. }
|
||||
@@ -144,12 +185,16 @@ impl PluginHost {
|
||||
plugins
|
||||
.iter()
|
||||
.filter(|p| p.dev_root.is_none() && !p.invalid && p.manifest().wasm.is_some())
|
||||
.filter(|p| matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive))
|
||||
.filter_map(|p| {
|
||||
let files = self.package_files(p).ok()?;
|
||||
let digest = package_digest(p.dir(), &files).ok()?;
|
||||
let granted = p.meta.lock().granted.iter().map(ToString::to_string).collect();
|
||||
Some(RemotePackage { id: p.id().to_owned(), digest, granted })
|
||||
Some(RemotePackage {
|
||||
id: p.id().to_owned(),
|
||||
digest,
|
||||
granted,
|
||||
enabled: matches!(p.status(), super::PluginStatus::Active | super::PluginStatus::Inactive),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -218,8 +263,19 @@ impl SecretStore for ClientSecrets {
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> {
|
||||
Err("secrets are stored on the desktop".into())
|
||||
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> {
|
||||
let peer = self.peer.get().ok_or("desktop disconnected")?.clone();
|
||||
let request = SecretWrite { plugin: plugin.into(), name: name.into(), value: value.map(str::to_owned) };
|
||||
let ask = async {
|
||||
tokio::time::timeout(SECRET_TIMEOUT, peer.request::<_, Result<(), String>>(SECRET_WRITE, request, None))
|
||||
.await
|
||||
};
|
||||
let answer = if tokio::runtime::Handle::try_current().is_ok() {
|
||||
tokio::task::block_in_place(|| self.runtime.block_on(ask))
|
||||
} else {
|
||||
self.runtime.block_on(ask)
|
||||
};
|
||||
answer.map_err(|_| "desktop timed out".to_owned())?.map_err(|e| e.to_string())?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,8 +292,10 @@ impl RemotePlugins {
|
||||
}
|
||||
};
|
||||
let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?);
|
||||
projects.set_plugin_host(&host);
|
||||
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects)))));
|
||||
host.set_secret_store(Arc::new(ClientSecrets { peer, runtime: projects.runtime.clone() }));
|
||||
host.set_secret_store(Arc::new(ClientSecrets { peer: peer.clone(), runtime: projects.runtime.clone() }));
|
||||
*host.shared.loopbacks.write() = Arc::new(ClientLoopbacks { peer, runtime: projects.runtime.clone() });
|
||||
let trust = Arc::downgrade(projects);
|
||||
host.set_project_policy(Arc::new(move |root| {
|
||||
trust.upgrade().is_some_and(|h| h.trust.trusted(root).unwrap_or(false))
|
||||
@@ -285,6 +343,14 @@ impl RemotePlugins {
|
||||
report.failed.push(format!("{}: invalid plugin id", pkg.id));
|
||||
continue;
|
||||
}
|
||||
if !pkg.enabled {
|
||||
if self.host.info_of(&pkg.id).is_ok()
|
||||
&& let Err(e) = self.host.set_enabled(&pkg.id, false)
|
||||
{
|
||||
report.failed.push(format!("{}: {e}", pkg.id));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The desktop asked the user, change of signer included.
|
||||
let consent = Consent { optional: pkg.granted.clone(), signer_change: true };
|
||||
if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) {
|
||||
@@ -320,6 +386,9 @@ impl RemotePlugins {
|
||||
if have != want || info.status == super::PluginStatus::NeedsConsent {
|
||||
self.host.grant(&pkg.id, consent)?;
|
||||
}
|
||||
if info.status == super::PluginStatus::Disabled {
|
||||
self.host.set_enabled(&pkg.id, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -380,6 +449,7 @@ impl RemotePlugins {
|
||||
let paths: Option<Vec<PathBuf>> = c.arg("paths")?;
|
||||
let settings_scope: Option<SettingsScope> = c.arg("settingsScope")?;
|
||||
let invocation = Invocation {
|
||||
command_chain: c.arg::<Option<Vec<String>>>("commandChain")?.unwrap_or_default(),
|
||||
client: Some(c.window.clone()),
|
||||
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
|
||||
editor: editor.map(PluginEditor::context),
|
||||
@@ -398,10 +468,69 @@ impl RemotePlugins {
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_select_tests", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let host = alive(&h)?;
|
||||
let config = c.arg::<String>("config")?;
|
||||
let tests = c.arg("tests")?;
|
||||
blocking(move || host.select_tests(&c.window, &config, tests)).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_parse_test_results", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let host = alive(&h)?;
|
||||
let config = c.arg::<String>("config")?;
|
||||
let chunk = c.arg("chunk")?;
|
||||
blocking(move || host.parse_test_results(&c.window, &config, chunk)).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_terminal_session_close", move |c| {
|
||||
let h = h.clone();
|
||||
async move { alive(&h)?.close_terminal_session(&c.window, &c.arg::<String>("operation")?) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_terminal_session_spawn", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let plugins = alive(&h)?;
|
||||
let operation: String = c.arg("operation")?;
|
||||
let cols = c.arg("cols")?;
|
||||
let rows = c.arg("rows")?;
|
||||
let output = c.channel("onOutput")?;
|
||||
blocking(move || plugins.spawn_terminal_session(&c.host, &c.window, &operation, cols, rows, output))
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_prepare_debug_run", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let plugins = alive(&h)?;
|
||||
let config = c.arg("config")?;
|
||||
blocking(move || plugins.prepare_debug_run(&c.window, config)).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_finish_operation", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
Ok(alive(&h)?.finish_operation(&c.window, &c.arg::<String>("id")?, c.arg("status")?, c.arg("stopped")?))
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_content_tab_action", move |c| {
|
||||
let h = h.clone();
|
||||
async move { alive(&h)?.content_tab_action(&c.window, &c.arg::<String>("pluginId")?, c.arg("action")?) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_post_events", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
alive(&h)?.post_events(Some(&c.window), c.arg("events")?);
|
||||
alive(&h)?.post_window_events(&c.window, c.arg("events")?, c.arg("editor")?);
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
@@ -411,15 +540,9 @@ impl RemotePlugins {
|
||||
async move { Ok(alive(&h)?.event_kinds().into_iter().collect::<Vec<_>>()) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_status_items", move |_| {
|
||||
d.add("plugin_status_items", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
Ok(alive(&h)?
|
||||
.status_items()
|
||||
.into_iter()
|
||||
.map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item })
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
async move { Ok(alive(&h)?.status_snapshot(&c.window)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_context_keys", move |c| {
|
||||
@@ -437,6 +560,43 @@ impl RemotePlugins {
|
||||
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_choose_program", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let host = alive(&h)?;
|
||||
let id: String = c.arg("pluginId")?;
|
||||
let cap: String = c.arg("capability")?;
|
||||
let scope: SettingsScope = c.arg("scope")?;
|
||||
let path: Option<PathBuf> = c.arg("path")?;
|
||||
let root = match scope {
|
||||
SettingsScope::Global => None,
|
||||
SettingsScope::Project => Some(c.host.project(&c.window)?.root.clone()),
|
||||
};
|
||||
blocking(move || host.choose_program(&id, &cap, root.as_deref(), path.as_deref())).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_program", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let host = alive(&h)?;
|
||||
let id: String = c.arg("pluginId")?;
|
||||
let program: String = c.arg("program")?;
|
||||
let scope: SettingsScope = c.arg("scope")?;
|
||||
let root = match scope {
|
||||
SettingsScope::Global => None,
|
||||
SettingsScope::Project => Some(c.host.project(&c.window)?.root.clone()),
|
||||
};
|
||||
blocking(move || host.program_for_project(&id, &program, root.as_deref()).map(ide_core::IpcPath::from))
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_command", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.answer_command(&c.window, &c.arg::<String>("requestId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_commit", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
|
||||
@@ -456,6 +616,27 @@ impl RemotePlugins {
|
||||
.await
|
||||
}
|
||||
});
|
||||
d.add("mcp_set_open_editors", |c| async move {
|
||||
let paths: Vec<ide_core::IpcPath> = c.arg("paths")?;
|
||||
c.host.project(&c.window)?;
|
||||
c.host.mcp_reports.set_open_editors(&c.window, paths.into_iter().map(PathBuf::from).collect());
|
||||
Ok(())
|
||||
});
|
||||
d.add("mcp_set_focused_editor", |c| async move {
|
||||
c.host.project(&c.window)?;
|
||||
c.host.mcp_reports.set_focused_editor(&c.window, c.arg("editor")?, c.arg("focused")?);
|
||||
Ok(())
|
||||
});
|
||||
d.add("mcp_report_problems", |c| async move {
|
||||
c.host.project(&c.window)?;
|
||||
c.host.mcp_reports.report_problems(&c.window, c.arg("reset")?, c.arg("files")?);
|
||||
Ok(())
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_decoration_sets", move |_c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.decoration_sets()) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_set_active_file", move |c| {
|
||||
let h = h.clone();
|
||||
@@ -577,6 +758,21 @@ impl RemotePlugins {
|
||||
let h = h.clone();
|
||||
async move { super::index_ops::plugin_endpoints(&c.host, alive(&h)?, &c.window, c.arg("pluginId")?).await }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_run_targets", move |c| {
|
||||
let h = h.clone();
|
||||
async move { super::run_targets::list(&c.host, alive(&h)?.as_ref(), &c.window) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_run_target", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let plugins = alive(&h)?;
|
||||
let id: String = c.arg("id")?;
|
||||
let use_target = c.arg("useTarget")?;
|
||||
blocking(move || super::run_targets::answer(&c.host, &plugins, &c.window, &id, use_target)).await
|
||||
}
|
||||
});
|
||||
d.add("plugin_line_markers", |c| async move {
|
||||
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
|
||||
});
|
||||
@@ -621,6 +817,123 @@ fn safe_relative(path: &str) -> IdeResult<PathBuf> {
|
||||
Ok(p.to_path_buf())
|
||||
}
|
||||
|
||||
struct ClientLoopbacks {
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
}
|
||||
impl ClientLoopbacks {
|
||||
fn request(
|
||||
&self,
|
||||
plugin: &str,
|
||||
operation: AuthOperation,
|
||||
cancel: Option<ide_core::CancellationToken>,
|
||||
) -> Result<serde_json::Value, ide_plugin::HostError> {
|
||||
let peer = self.peer.get().ok_or(ide_plugin::HostError::Cancelled)?.clone();
|
||||
let timeout = match &operation {
|
||||
AuthOperation::Wait { timeout_ms, .. } => Duration::from_millis(u64::from(*timeout_ms)) + SECRET_TIMEOUT,
|
||||
_ => SECRET_TIMEOUT,
|
||||
};
|
||||
let request = AuthRequest { plugin: plugin.into(), operation };
|
||||
let ask = async {
|
||||
tokio::time::timeout(
|
||||
timeout,
|
||||
peer.request::<_, Result<serde_json::Value, ide_plugin::HostError>>(AUTH_REQUEST, request, cancel),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ide_plugin::HostError::Failed("desktop OAuth request timed out".into()))?
|
||||
.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))?
|
||||
};
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
tokio::task::block_in_place(|| self.runtime.block_on(ask))
|
||||
} else {
|
||||
self.runtime.block_on(ask)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl super::auth::Service for ClientLoopbacks {
|
||||
fn begin(&self, plugin: &str, _window: &str, path: &str) -> Result<(u64, String), ide_plugin::HostError> {
|
||||
serde_json::from_value(self.request(plugin, AuthOperation::Begin { path: path.into() }, None)?)
|
||||
.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
|
||||
}
|
||||
fn wait(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
timeout: Duration,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> Result<Option<String>, ide_plugin::HostError> {
|
||||
let result = self.request(
|
||||
plugin,
|
||||
AuthOperation::Wait { id, timeout_ms: timeout.as_millis().min(u128::from(u32::MAX)) as u32 },
|
||||
Some(cancel.clone()),
|
||||
);
|
||||
if cancel.is_cancelled() {
|
||||
self.close(plugin, id);
|
||||
return Err(ide_plugin::HostError::Cancelled);
|
||||
}
|
||||
serde_json::from_value(result?).map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
|
||||
}
|
||||
fn close(&self, plugin: &str, id: u64) {
|
||||
let _ = self.request(plugin, AuthOperation::Close { id }, None);
|
||||
}
|
||||
fn clear(&self, plugin: &str) {
|
||||
let _ = self.request(plugin, AuthOperation::Clear, None);
|
||||
}
|
||||
fn forget_window(&self, _: &str) {}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// A remote instance may mutate only declared secrets with a current grant.
|
||||
pub fn remote_secret_write(&self, request: SecretWrite) -> Result<(), String> {
|
||||
let plugin = self.get(&request.plugin).map_err(|e| e.message)?;
|
||||
if !plugin.meta.lock().granted.iter().any(|g| g.covers(&ide_extension::Capability::Secrets)) {
|
||||
return Err("secrets capability not granted".into());
|
||||
}
|
||||
if !self.ui.items(super::providers::SECRETS_POINT, Some(plugin.id())).iter().any(|s| s.key == request.name) {
|
||||
return Err("undeclared secret".into());
|
||||
}
|
||||
if request.value.as_ref().is_some_and(|v| v.len() > 64 << 10) {
|
||||
return Err("secret value too long".into());
|
||||
}
|
||||
self.shared.secrets.read().set(plugin.id(), &request.name, request.value.as_deref())
|
||||
}
|
||||
/// Serves a connected remote instance using this connection's actual window.
|
||||
pub fn remote_auth(&self, window: &str, request: AuthRequest) -> Result<serde_json::Value, ide_plugin::HostError> {
|
||||
if matches!(&request.operation, AuthOperation::Begin { .. } | AuthOperation::Wait { .. }) {
|
||||
self.get(&request.plugin).map_err(|e| ide_plugin::HostError::NotFound(e.message))?;
|
||||
}
|
||||
let owner = format!("remote:{window}:{}", request.plugin);
|
||||
let service = self.shared.loopbacks.read().clone();
|
||||
let result = match request.operation {
|
||||
AuthOperation::Begin { path } => {
|
||||
if self.shared.client_closed(window) {
|
||||
return Err(ide_plugin::HostError::Cancelled);
|
||||
}
|
||||
serde_json::to_value(service.begin(&owner, window, &path)?)
|
||||
}
|
||||
AuthOperation::Wait { id, timeout_ms } => serde_json::to_value(service.wait(
|
||||
&owner,
|
||||
id,
|
||||
Duration::from_millis(u64::from(timeout_ms)),
|
||||
&ide_core::CancellationToken::new(),
|
||||
)?),
|
||||
AuthOperation::Close { id } => {
|
||||
service.close(&owner, id);
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
AuthOperation::Clear => {
|
||||
service.clear(&owner);
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
};
|
||||
result.map_err(|e| ide_plugin::HostError::Failed(e.to_string()))
|
||||
}
|
||||
/// A remote transport closed; abandon its browser listeners.
|
||||
pub fn forget_remote_loopbacks(&self, window: &str) {
|
||||
self.shared.loopbacks.read().forget_window(window);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct Detector {
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub test: bool,
|
||||
/// Optional output parser, including the API 1.4 plugin export.
|
||||
#[serde(default)]
|
||||
pub framework: Option<String>,
|
||||
}
|
||||
|
||||
/// Detectors of enabled plugins.
|
||||
@@ -85,6 +88,12 @@ impl ExtensionPoint for RunDetectorsPoint {
|
||||
|
||||
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
||||
let d: Detector = decode(POINT_ID, decl)?;
|
||||
if d.framework.is_some() {
|
||||
pkg.manifest.needs_api("runDetectors.framework", 4)?;
|
||||
}
|
||||
if d.framework.as_ref().is_some_and(|v| v.is_empty() || v.len() > 64) {
|
||||
return Err("invalid test framework".into());
|
||||
}
|
||||
validate_item_id(&d.id, "run detector id")?;
|
||||
if d.name.trim().is_empty() || d.name.len() > 128 {
|
||||
return Err(format!("run detector {:?}: `name` must be 1–128 bytes", d.id));
|
||||
@@ -110,48 +119,106 @@ impl ExtensionPoint for RunDetectorsPoint {
|
||||
}
|
||||
|
||||
type Pins = Arc<RwLock<HashMap<(String, String), ProgramPin>>>;
|
||||
pub(super) type DynamicConfigurations =
|
||||
Arc<RwLock<std::collections::BTreeMap<(String, PathBuf), Vec<ide_plugin::PluginRunConfiguration>>>>;
|
||||
|
||||
/// [`ExternalDetection`] over the detectors and the approved programs.
|
||||
pub struct PluginRunDetection {
|
||||
pub(crate) store: Arc<RunDetectors>,
|
||||
pub(crate) pins: Pins,
|
||||
pub(crate) dynamic: DynamicConfigurations,
|
||||
pub(crate) project_pins: super::api::ProjectPins,
|
||||
}
|
||||
|
||||
fn config_id(plugin: &str, detector: &str) -> String {
|
||||
pub(super) fn config_id(plugin: &str, detector: &str) -> String {
|
||||
format!("{EXTERNAL_PREFIX}{plugin}:{detector}")
|
||||
}
|
||||
|
||||
impl PluginRunDetection {
|
||||
fn pin(&self, plugin: &str, d: &Detector) -> Option<ProgramPin> {
|
||||
fn pin(&self, plugin: &str, d: &Detector, root: Option<&Path>) -> Option<ProgramPin> {
|
||||
let cap = Capability::Process(d.command.trim().to_owned());
|
||||
self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
|
||||
root.and_then(|root| self.project_pins.read().get(&(plugin.into(), cap.to_string(), root.into())).cloned())
|
||||
.or_else(|| self.pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned())
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalDetection for PluginRunDetection {
|
||||
fn detect(&self, root: &Path) -> Vec<RunConfig> {
|
||||
let items = self.store.items.read().clone();
|
||||
items
|
||||
let mut configs: Vec<_> = items
|
||||
.into_iter()
|
||||
.filter(|(_, plugin, d)| self.pin(plugin, d).is_some() && d.files.iter().any(|f| root.join(f).exists()))
|
||||
.filter(|(_, plugin, d)| {
|
||||
self.pin(plugin, d, Some(root)).is_some() && d.files.iter().any(|f| root.join(f).exists())
|
||||
})
|
||||
.map(|(_, plugin, d)| RunConfig {
|
||||
id: config_id(&plugin, &d.id),
|
||||
name: d.name.clone(),
|
||||
kind: "plugin".into(),
|
||||
command: d.command.trim().to_owned(),
|
||||
args: d.args.clone(),
|
||||
cwd: None,
|
||||
cwd: Some(root.to_path_buf().into()),
|
||||
env: Default::default(),
|
||||
detected: true,
|
||||
is_test: d.test,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
extras: ide_run::RunExtras {
|
||||
params: d.framework.clone().map(|f| [("framework".into(), f)].into()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
for ((plugin, project), entries) in self.dynamic.read().iter().filter(|((_, project), _)| project == root) {
|
||||
for c in entries {
|
||||
let cap = Capability::Process(c.program.clone());
|
||||
if !self.project_pins.read().contains_key(&(plugin.clone(), cap.to_string(), project.clone()))
|
||||
&& !self.pins.read().contains_key(&(plugin.clone(), cap.to_string()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut config = RunConfig {
|
||||
id: String::new(),
|
||||
name: c.name.clone(),
|
||||
kind: "plugin".into(),
|
||||
command: c.program.clone(),
|
||||
args: c.args.clone(),
|
||||
cwd: Some(root.to_path_buf().into()),
|
||||
env: Default::default(),
|
||||
detected: true,
|
||||
is_test: c.test,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
config.id = config_id(plugin, &c.id);
|
||||
config.cwd = Some(c.cwd.as_ref().map(|p| root.join(p)).unwrap_or_else(|| root.to_path_buf()).into());
|
||||
if let Some(framework) = &c.framework {
|
||||
config.extras.params.insert("framework".into(), framework.clone());
|
||||
}
|
||||
configs.push(config);
|
||||
}
|
||||
}
|
||||
configs
|
||||
}
|
||||
|
||||
fn launch(&self, config: &RunConfig) -> Result<PathBuf, String> {
|
||||
let dynamic = self.dynamic.read();
|
||||
if let Some(((plugin, root), c)) =
|
||||
dynamic.iter().flat_map(|(k, entries)| entries.iter().map(move |c| (k, c))).find(|((plugin, root), c)| {
|
||||
config_id(plugin, &c.id) == config.id && config.cwd.as_ref().is_some_and(|cwd| cwd.starts_with(root))
|
||||
})
|
||||
{
|
||||
let cap = Capability::Process(c.program.clone()).to_string();
|
||||
let pin = self
|
||||
.project_pins
|
||||
.read()
|
||||
.get(&(plugin.clone(), cap.clone(), root.clone()))
|
||||
.cloned()
|
||||
.or_else(|| self.pins.read().get(&(plugin.clone(), cap)).cloned())
|
||||
.ok_or_else(|| "run program is no longer granted".to_owned())?;
|
||||
return pin.verify().map(Path::to_path_buf);
|
||||
}
|
||||
drop(dynamic);
|
||||
let items = self.store.items.read();
|
||||
let (plugin, d) = items
|
||||
.iter()
|
||||
@@ -159,7 +226,7 @@ impl ExternalDetection for PluginRunDetection {
|
||||
.map(|(_, p, d)| (p.clone(), d.clone()))
|
||||
.ok_or_else(|| "the plugin that detected this configuration is no longer enabled".to_owned())?;
|
||||
drop(items);
|
||||
let pin = self.pin(&plugin, &d).ok_or_else(|| {
|
||||
let pin = self.pin(&plugin, &d, config.cwd.as_ref().map(|p| p.as_ref())).ok_or_else(|| {
|
||||
format!("allow process:{} for plugin {plugin} in Settings › Plugins to run this configuration", d.command)
|
||||
})?;
|
||||
pin.verify().map(Path::to_path_buf)
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Project-owned registrations, retained across idle instance reclamation.
|
||||
use super::api::{PluginApi, Shared};
|
||||
use super::{PluginEvent, PluginHost};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_plugin::{HostError, PluginRunTarget};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegisteredRunTarget {
|
||||
/// Opaque selector id, namespaced by the host.
|
||||
pub id: String,
|
||||
pub plugin_id: String,
|
||||
pub plugin_name: String,
|
||||
pub target: PluginRunTarget,
|
||||
pub prompt: bool,
|
||||
}
|
||||
|
||||
pub(super) type Targets = std::collections::BTreeMap<(PathBuf, String), RegisteredRunTarget>;
|
||||
fn selector(plugin: &str, id: &str) -> String {
|
||||
format!("plugin-target:{}:{plugin}{id}", plugin.len())
|
||||
}
|
||||
impl Shared {
|
||||
fn targets_changed(&self) {
|
||||
if let Some(host) = self.workspace().and_then(|w| w.database_host()) {
|
||||
for project in host.projects() {
|
||||
project.lsp.reconfigure_soon();
|
||||
}
|
||||
}
|
||||
self.sink.emit(None, PluginEvent::RunTargetsChanged);
|
||||
}
|
||||
|
||||
fn target_answer_file(&self, root: &Path, id: &str) -> PathBuf {
|
||||
let key = format!("{}\0{id}", root.display());
|
||||
self.data_dir.join(".run-target-answers").join(blake3::hash(key.as_bytes()).to_hex().as_str())
|
||||
}
|
||||
pub(super) fn withdraw_run_targets(&self, plugin: Option<&str>, root: Option<&Path>) {
|
||||
let mut targets = self.run_targets.lock();
|
||||
let before = targets.len();
|
||||
targets.retain(|(r, _), t| !(plugin.is_none_or(|p| p == t.plugin_id) && root.is_none_or(|path| path == r)));
|
||||
if before != targets.len() {
|
||||
drop(targets);
|
||||
self.targets_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PluginApi {
|
||||
pub(super) fn register_target(&self, root: &Path, target: PluginRunTarget) -> Result<(), HostError> {
|
||||
ide_remote::plugin_target::validate(&target).map_err(|e| HostError::Invalid(e.to_string()))?;
|
||||
let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?;
|
||||
if !workspace.projects().iter().any(|r| r == root) {
|
||||
return Err(HostError::NotFound("project is not open".into()));
|
||||
}
|
||||
if !workspace.trusted(root) {
|
||||
return Err(HostError::Denied("project is not trusted".into()));
|
||||
}
|
||||
self.approved_program_in_project(&target.program, Some(root))?;
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if blocked.contains(self.pkg.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let id = selector(self.pkg.id(), &target.id);
|
||||
let mut targets = self.shared.run_targets.lock();
|
||||
if !targets.contains_key(&(root.into(), id.clone()))
|
||||
&& targets.iter().filter(|((r, _), t)| r == root && t.plugin_id == self.pkg.id()).count() >= 32
|
||||
{
|
||||
return Err(HostError::Invalid("at most 32 targets per plugin and project".into()));
|
||||
}
|
||||
let prompt = !self.shared.target_answer_file(root, &id).exists();
|
||||
targets.insert(
|
||||
(root.into(), id.clone()),
|
||||
RegisteredRunTarget {
|
||||
id,
|
||||
plugin_id: self.pkg.id().into(),
|
||||
plugin_name: self.pkg.manifest.name.clone(),
|
||||
target,
|
||||
prompt,
|
||||
},
|
||||
);
|
||||
drop(targets);
|
||||
drop(blocked);
|
||||
self.shared.targets_changed();
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn unregister_target(&self, root: &Path, id: &str) {
|
||||
if self.shared.run_targets.lock().remove(&(root.into(), selector(self.pkg.id(), id))).is_some() {
|
||||
self.shared.targets_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PluginHost {
|
||||
/// Drops registrations when their project closes or loses trust.
|
||||
pub fn withdraw_project_run_targets(&self, root: &Path) {
|
||||
self.shared.withdraw_run_targets(None, Some(root));
|
||||
}
|
||||
pub fn run_targets(&self, root: &Path) -> Vec<RegisteredRunTarget> {
|
||||
self.shared.run_targets.lock().iter().filter(|((r, _), _)| r == root).map(|(_, t)| t.clone()).collect()
|
||||
}
|
||||
pub fn prepare_run_target(
|
||||
&self,
|
||||
root: &Path,
|
||||
id: &str,
|
||||
config: &ide_run::RunConfig,
|
||||
) -> IdeResult<ide_run::RunConfig> {
|
||||
self.prepare_target(root, id, config, None)
|
||||
}
|
||||
|
||||
/// Prepares a user launch, including the optional plugin validation gate.
|
||||
pub fn prepare_run_target_launch(
|
||||
&self,
|
||||
root: &Path,
|
||||
id: &str,
|
||||
config: &ide_run::RunConfig,
|
||||
operation: ide_plugin::RunTargetOperation,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<ide_run::RunConfig> {
|
||||
self.prepare_target(root, id, config, Some((operation, cancel)))
|
||||
}
|
||||
|
||||
fn prepare_target(
|
||||
&self,
|
||||
root: &Path,
|
||||
id: &str,
|
||||
config: &ide_run::RunConfig,
|
||||
launch: Option<(ide_plugin::RunTargetOperation, &ide_core::CancellationToken)>,
|
||||
) -> IdeResult<ide_run::RunConfig> {
|
||||
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
|
||||
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
|
||||
}
|
||||
let target = self
|
||||
.shared
|
||||
.run_targets
|
||||
.lock()
|
||||
.get(&(root.into(), id.into()))
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
|
||||
let program = self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?;
|
||||
let wrapped = ide_remote::plugin_target::prepare(root, &target.target, &program, config)?;
|
||||
let validates = self.get(&target.plugin_id)?.meta.lock().has_run_target_validation;
|
||||
if let Some((operation, cancel)) = launch
|
||||
&& validates
|
||||
{
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let info = ide_plugin::RunTargetLaunch {
|
||||
operation,
|
||||
program: ide_run::wrap::program_name(&config.command),
|
||||
cwd: wrapped.cwd.as_ref().unwrap().to_string_lossy().into_owned(),
|
||||
};
|
||||
let project_root = root.to_path_buf();
|
||||
let invocation =
|
||||
ide_plugin::Invocation { workspace_root: Some(project_root.clone()), ..Default::default() };
|
||||
let (target_id, timeout) = (target.target.id.clone(), self.limits.timeout);
|
||||
let result = self.code_call_with_budget(
|
||||
&target.plugin_id,
|
||||
invocation,
|
||||
"validating the run target",
|
||||
Some(cancel),
|
||||
Some(timeout),
|
||||
move |rt, _| {
|
||||
rt.validate_run_target(
|
||||
project_root.to_string_lossy().into_owned(),
|
||||
target_id.clone(),
|
||||
info.clone(),
|
||||
timeout,
|
||||
)
|
||||
},
|
||||
);
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(message)) => return Err(IdeError::invalid(format!("{}: {message}", target.plugin_name))),
|
||||
Err(error) if error.kind == ide_core::error::ErrorKind::Cancelled => return Err(error),
|
||||
Err(error) => return Err(IdeError::unavailable(format!("{}: {error}", target.plugin_name))),
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?;
|
||||
}
|
||||
Ok(wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list(host: &crate::Host, plugins: &PluginHost, window: &str) -> IdeResult<Vec<RegisteredRunTarget>> {
|
||||
Ok(plugins.run_targets(&host.project(window)?.root))
|
||||
}
|
||||
|
||||
/// The first answer wins across the project's windows. Only Use changes settings.
|
||||
pub fn answer(
|
||||
host: &crate::Host,
|
||||
plugins: &PluginHost,
|
||||
window: &str,
|
||||
id: &str,
|
||||
use_target: bool,
|
||||
) -> IdeResult<ide_core::settings::Settings> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let mut targets = plugins.shared.run_targets.lock();
|
||||
let target = targets
|
||||
.get_mut(&(project.root.clone(), id.into()))
|
||||
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
|
||||
let file = plugins.shared.target_answer_file(&project.root, id);
|
||||
if file.exists() {
|
||||
target.prompt = false;
|
||||
return Ok(project.settings.effective());
|
||||
}
|
||||
if use_target {
|
||||
plugins.program_for_project(&target.plugin_id, &target.target.program, Some(&project.root))?;
|
||||
let scope = ide_workspace::SettingsScope::Project;
|
||||
for key in ["run.defaultTarget", "languages.php.runTarget", "languages.node.runTarget"] {
|
||||
project.settings.set(scope, key, serde_json::json!(id))?;
|
||||
for window in host.windows_for_root(&project.root) {
|
||||
if let Ok(open) = host.project(&window) {
|
||||
open.settings.reload();
|
||||
open.sink.emit(ide_core::IdeEvent::SettingsChanged { key: key.into() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(file.parent().unwrap())?;
|
||||
std::fs::write(file, if use_target { "use" } else { "not-now" })?;
|
||||
target.prompt = false;
|
||||
drop(targets);
|
||||
plugins.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
|
||||
Ok(project.settings.effective())
|
||||
}
|
||||
Loaded 100 of 1219 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user