mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 17:20:38 -10:00
Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f262e0cc0 | ||
|
|
4d39216b00 | ||
|
|
97ff542cdd | ||
|
|
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 |
No files matched your search
Generated
+69
-59
@@ -1522,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]]
|
||||
@@ -4180,7 +4180,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4204,7 +4204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4218,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4229,7 +4229,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
@@ -4245,7 +4245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
@@ -4259,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",
|
||||
@@ -4268,7 +4269,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4286,7 +4287,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4300,7 +4301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4323,7 +4324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"blake3",
|
||||
@@ -4353,7 +4354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4366,7 +4367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4386,7 +4387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4398,7 +4399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4411,7 +4412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4478,7 +4479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4496,7 +4497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4518,7 +4519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4535,7 +4536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4591,7 +4592,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4617,7 +4618,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-mcp"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dunce",
|
||||
@@ -4636,7 +4637,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4659,7 +4660,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4682,7 +4683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4695,7 +4696,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"globset",
|
||||
@@ -4714,7 +4715,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4738,7 +4739,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4746,6 +4747,7 @@ dependencies = [
|
||||
"ide-run",
|
||||
"ide-vfs",
|
||||
"ignore",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4757,7 +4759,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4777,7 +4779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4790,7 +4792,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4811,7 +4813,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4826,7 +4828,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4839,7 +4841,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4852,7 +4854,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4873,7 +4875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4894,7 +4896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4918,7 +4920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4934,7 +4936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5581,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",
|
||||
@@ -6363,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"
|
||||
@@ -6512,7 +6523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.39"
|
||||
version = "0.1.40"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -8629,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",
|
||||
@@ -8642,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",
|
||||
@@ -8665,7 +8674,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2 0.11.1",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
@@ -8678,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",
|
||||
@@ -8691,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",
|
||||
@@ -8703,7 +8710,7 @@ dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2 0.11.1",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
@@ -8716,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",
|
||||
@@ -8742,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",
|
||||
@@ -8777,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",
|
||||
@@ -9543,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",
|
||||
@@ -11309,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,7 +69,7 @@ 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
|
||||
|
||||
@@ -169,8 +169,10 @@ impl Shared {
|
||||
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);
|
||||
shared.emit(AgentEvent::Providers);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -253,6 +255,13 @@ impl Shared {
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -366,6 +366,35 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
|
||||
)
|
||||
}
|
||||
|
||||
#[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() {
|
||||
@@ -2146,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 {
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -23,4 +23,4 @@ tempfile.workspace = true
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_Registry", "Win32_System_SystemInformation", "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"] }
|
||||
@@ -42,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 {
|
||||
@@ -148,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 {
|
||||
|
||||
@@ -27,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;
|
||||
|
||||
@@ -104,6 +104,11 @@ 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.
|
||||
@@ -136,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]);
|
||||
@@ -153,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);
|
||||
@@ -193,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) })
|
||||
}
|
||||
@@ -209,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
|
||||
@@ -914,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());
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,18 @@ pub struct PluginRunTarget {
|
||||
pub args: Vec<String>,
|
||||
pub workdir_args: Vec<String>,
|
||||
pub container_root: Option<String>,
|
||||
pub commands: Vec<(String, Vec<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)]
|
||||
|
||||
@@ -277,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.
|
||||
@@ -292,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 {
|
||||
@@ -299,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,
|
||||
@@ -307,6 +314,7 @@ impl Default for GeneralSettings {
|
||||
power_save_mode: false,
|
||||
project_name: String::new(),
|
||||
update_source: "auto".into(),
|
||||
auto_check_updates: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -361,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 {
|
||||
@@ -409,6 +432,9 @@ pub struct AppearanceSettings {
|
||||
/// 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>,
|
||||
@@ -481,6 +507,7 @@ impl Default for AppearanceSettings {
|
||||
widescreen_tool_windows: false,
|
||||
show_main_toolbar: true,
|
||||
show_status_bar: true,
|
||||
navigation_bar: NavigationBarPosition::StatusBar,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
}
|
||||
@@ -518,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,
|
||||
@@ -562,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(),
|
||||
@@ -606,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.
|
||||
@@ -633,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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -654,6 +695,7 @@ pub struct LanguagesSettings {
|
||||
pub java: JavaSettings,
|
||||
pub ruby: RubySettings,
|
||||
pub markdown: MarkdownSettings,
|
||||
pub arduino: ArduinoSettings,
|
||||
}
|
||||
|
||||
impl Default for LanguagesSettings {
|
||||
@@ -675,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")]
|
||||
@@ -896,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.
|
||||
@@ -1179,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();
|
||||
@@ -1241,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();
|
||||
@@ -1477,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"));
|
||||
}
|
||||
}
|
||||
@@ -525,9 +525,10 @@ async fn spawn_adapter(
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
if spec.adapter == "rdbg" {
|
||||
ide_core::process::own_group(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)
|
||||
|
||||
@@ -1130,6 +1130,9 @@ 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
|
||||
@@ -1157,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
|
||||
|
||||
+155
-17
@@ -45,6 +45,13 @@ 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>,
|
||||
@@ -59,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).
|
||||
@@ -89,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,
|
||||
@@ -103,6 +116,8 @@ 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(),
|
||||
@@ -111,6 +126,8 @@ impl Session {
|
||||
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()),
|
||||
@@ -278,6 +295,24 @@ 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()
|
||||
@@ -291,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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,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)]
|
||||
@@ -327,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.
|
||||
@@ -349,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" {
|
||||
@@ -373,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;
|
||||
}
|
||||
@@ -669,6 +790,14 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
}
|
||||
}
|
||||
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}"))?;
|
||||
@@ -836,7 +965,16 @@ 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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -1876,3 +1963,55 @@ async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
|
||||
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);
|
||||
}
|
||||
@@ -92,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")]
|
||||
|
||||
@@ -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.4.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.
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -552,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())?,
|
||||
|
||||
@@ -12,13 +12,18 @@ pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult<Vec<Scope>>
|
||||
}
|
||||
|
||||
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: cache.join(crate::project::project_key(&root)),
|
||||
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,
|
||||
})
|
||||
@@ -36,10 +41,12 @@ 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 scope.root == root {
|
||||
if scopes.len() == 1 && scope.root == root {
|
||||
return model;
|
||||
}
|
||||
if let Some(maven) = &mut model.maven {
|
||||
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());
|
||||
@@ -73,7 +80,7 @@ pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
for g in &mut model.gradle {
|
||||
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);
|
||||
@@ -145,6 +152,30 @@ pub(super) fn update_files(root: &Path, scopes: &[Scope], model: &BuildModel, to
|
||||
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();
|
||||
|
||||
@@ -9,6 +9,32 @@ 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
|
||||
@@ -17,7 +43,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
.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).await;
|
||||
return target::start(host, window, config, owner, id, cancel).await;
|
||||
}
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
@@ -392,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
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(super) async fn start(
|
||||
mut config: LaunchConfig,
|
||||
owner: SessionOwner,
|
||||
id: String,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<ide_core::DebugSessionId> {
|
||||
let project = host.project(window)?;
|
||||
let plugins =
|
||||
@@ -33,6 +34,23 @@ pub(super) async fn start(
|
||||
{
|
||||
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!(
|
||||
@@ -48,6 +66,7 @@ pub(super) async fn start(
|
||||
.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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -197,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 }),
|
||||
@@ -584,6 +585,82 @@ pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> Ide
|
||||
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);
|
||||
@@ -822,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,
|
||||
@@ -1646,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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -24,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)?;
|
||||
@@ -145,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();
|
||||
@@ -202,8 +203,22 @@ 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).await;
|
||||
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);
|
||||
}
|
||||
@@ -215,7 +230,11 @@ async fn start_inner(
|
||||
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() {
|
||||
@@ -258,7 +277,23 @@ async fn start_inner(
|
||||
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 config = host.blocking(move || plugins.prepare_run_target(&root, &id, &config)).await?;
|
||||
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,
|
||||
|
||||
@@ -61,7 +61,7 @@ pub(crate) fn search_project(
|
||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||
_ => None,
|
||||
};
|
||||
let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some();
|
||||
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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -110,6 +110,10 @@ pub trait PluginWorkspace: Send + Sync {
|
||||
fn trusted(&self, root: &Path) -> bool;
|
||||
/// Root of the project open in `window`.
|
||||
fn project_of(&self, window: &str) -> Option<PathBuf>;
|
||||
/// Windows showing the open project at `root`, the most recently focused first.
|
||||
fn project_windows(&self, _root: &Path) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
|
||||
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
|
||||
-> Result<(), HostError>;
|
||||
@@ -1758,17 +1762,31 @@ impl HostApi for PluginApi {
|
||||
body: String,
|
||||
actions: Vec<ide_plugin::NotificationAction>,
|
||||
) -> Result<(), HostError> {
|
||||
if !actions.is_empty() {
|
||||
let commands = self.shared.ui.items("commands", Some(self.id()));
|
||||
if let Some(a) = actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
|
||||
return Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command)));
|
||||
}
|
||||
}
|
||||
check_actions(self, &actions)?;
|
||||
let plugin_id = self.id().to_owned();
|
||||
self.shared.sink.emit(client, PluginEvent::Notification { plugin_id, level, title, body, actions });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn notify_project(
|
||||
&self,
|
||||
root: &Path,
|
||||
level: NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<ide_plugin::NotificationAction>,
|
||||
) -> Result<(), HostError> {
|
||||
check_actions(self, &actions)?;
|
||||
let windows = self.shared.workspace().map(|w| w.project_windows(root)).unwrap_or_default();
|
||||
let window = windows
|
||||
.into_iter()
|
||||
.find(|w| !self.shared.client_closed(w))
|
||||
.ok_or_else(|| HostError::NotFound(format!("the project {} is not open", root.display())))?;
|
||||
let plugin_id = self.id().to_owned();
|
||||
self.shared.sink.emit(Some(&window), PluginEvent::Notification { plugin_id, level, title, body, actions });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
let client = from_window(client, "copy to the clipboard")?;
|
||||
self.shared.sink.emit(Some(client), PluginEvent::CopyToClipboard { plugin_id: self.id().to_owned(), text });
|
||||
@@ -2718,6 +2736,15 @@ fn not_writable(path: &Path, e: ide_core::error::IdeError) -> HostError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification buttons must name commands the plugin declares.
|
||||
fn check_actions(api: &PluginApi, actions: &[ide_plugin::NotificationAction]) -> Result<(), HostError> {
|
||||
let commands = if actions.is_empty() { Vec::new() } else { api.shared.ui.items("commands", Some(api.id())) };
|
||||
match actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
|
||||
Some(a) => Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`PluginWorkspace`] over the desktop host's open projects.
|
||||
pub struct HostWorkspace(pub std::sync::Weak<crate::Host>);
|
||||
|
||||
@@ -3016,6 +3043,13 @@ impl PluginWorkspace for HostWorkspace {
|
||||
self.0.upgrade()?.project(window).ok().map(|p| p.root.clone())
|
||||
}
|
||||
|
||||
fn project_windows(&self, root: &Path) -> Vec<String> {
|
||||
let Some(host) = self.0.upgrade() else { return Vec::new() };
|
||||
let mut windows = host.windows_for_root(root);
|
||||
host.mcp_reports.most_recently_focused_first(&mut windows);
|
||||
windows
|
||||
}
|
||||
|
||||
fn publish_diagnostics(
|
||||
&self,
|
||||
source: &str,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -143,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.
|
||||
@@ -164,6 +166,7 @@ impl Meta {
|
||||
failures: 0,
|
||||
retry_at: None,
|
||||
has_syntax: false,
|
||||
has_run_target_validation: false,
|
||||
publisher: None,
|
||||
next_schedule: None,
|
||||
}
|
||||
@@ -1432,6 +1435,20 @@ impl PluginHost {
|
||||
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();
|
||||
{
|
||||
@@ -1472,10 +1489,25 @@ impl PluginHost {
|
||||
}
|
||||
let started = Instant::now();
|
||||
let (call, inv) = (f.clone(), invocation.clone());
|
||||
let result = match cancel {
|
||||
Some(cancel) => actor.call_cancellable(cancel, Duration::from_millis(50), move |rt| call(rt, inv)),
|
||||
None => 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());
|
||||
@@ -2523,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}")),
|
||||
|
||||
@@ -101,6 +101,28 @@ impl PluginHost {
|
||||
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"));
|
||||
@@ -113,7 +135,50 @@ impl PluginHost {
|
||||
.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))?;
|
||||
ide_remote::plugin_target::prepare(root, &target.target, &program, config)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ impl Host {
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Reject a damaged manifest before replacing the currently open project.
|
||||
ide_workspace::workspace::Workspace::load(&root)?;
|
||||
let is_workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
// it, with its warm index, language servers and unsaved documents.
|
||||
if let Ok(current) = self.project(window)
|
||||
@@ -96,7 +96,7 @@ impl Host {
|
||||
self.wait_detached_closes();
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
// Before anything the project starts: its children get the project's environment.
|
||||
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
|
||||
let trusted = Arc::new(AtomicBool::new(self.trust.window_trusted(&root)?));
|
||||
let post_start =
|
||||
trusted.load(Ordering::Acquire).then(|| self.environment.project_opened(&root, &sink)).flatten();
|
||||
let settings = SettingsStore::load(&self.config_dir, Some(&root));
|
||||
@@ -137,6 +137,16 @@ impl Host {
|
||||
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
lsp.set_content_roots(extra_roots.clone());
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
let settings = settings.clone();
|
||||
// Workspace members have independent settings; target servers have their own path mappings.
|
||||
lsp.set_jdtls_shared_hook(Box::new(move || {
|
||||
let effective = settings.effective();
|
||||
!is_workspace
|
||||
&& effective.languages.java.jdtls_shared
|
||||
&& effective.language_server_targets.get("java").is_none_or(|target| target.target.is_empty())
|
||||
}));
|
||||
}
|
||||
{
|
||||
let settings = settings.clone();
|
||||
let root = root.clone();
|
||||
@@ -188,14 +198,15 @@ impl Host {
|
||||
.as_ref()
|
||||
.map(|p| SettingsStore::load(&config_dir, Some(p)).effective())
|
||||
.unwrap_or_else(|| settings.effective());
|
||||
let root = member.as_ref().unwrap_or(&root);
|
||||
let window_root = &root;
|
||||
let root = member.as_ref().unwrap_or(window_root);
|
||||
ide_sdk::apply::server_launch_overrides(cfg, &effective);
|
||||
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
|
||||
if cfg.name == "jdtls" {
|
||||
// A workspace member keeps its build model under its own key (Build Project).
|
||||
let cache = match &member {
|
||||
Some(member) => build_cache.join(crate::project::project_key(member)),
|
||||
None => build_cache.clone(),
|
||||
Some(member) if member != window_root => build_cache.join(crate::project::project_key(member)),
|
||||
_ => build_cache.clone(),
|
||||
};
|
||||
let profiles = ide_build::maven_profiles(&cache);
|
||||
ide_sdk::maven_profiles::configure_jdtls(cfg, root, &effective, &profiles, &cache);
|
||||
@@ -401,6 +412,7 @@ impl Host {
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
let (ignored, ignored_gone) = ignored_changes(batch);
|
||||
index.ignore_rules_changed(&batch.ignore_rules);
|
||||
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
|
||||
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
|
||||
phpstan.files_removed(batch.removed.iter());
|
||||
@@ -1214,17 +1226,32 @@ pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspa
|
||||
}
|
||||
}
|
||||
|
||||
/// A workspace uses its root only for state, never as indexed content.
|
||||
/// Only explicit workspace containers exclude their root from indexed content.
|
||||
fn index_roots(root: &Path, folders: Vec<PathBuf>) -> ide_index::IndexRoots {
|
||||
ide_index::IndexRoots { include_root: !root.join(ide_workspace::workspace::FILE).is_file(), folders }
|
||||
let container =
|
||||
ide_workspace::workspace::Workspace::load(root).ok().flatten().is_some_and(|w| w.is_container(root));
|
||||
ide_index::IndexRoots { include_root: !container, folders: folders.into_iter().filter(|p| p != root).collect() }
|
||||
}
|
||||
|
||||
/// Workspace members in manifest order, or extra Project Structure directories and the
|
||||
/// Gradle project folders outside the project as of the last sync (`build_cache`).
|
||||
fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
|
||||
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
|
||||
return workspace.projects;
|
||||
if workspace.is_container(root) {
|
||||
return workspace.projects;
|
||||
}
|
||||
let mut roots = workspace.projects.into_iter().filter(|p| !p.starts_with(root)).collect::<Vec<_>>();
|
||||
for path in extra_content_roots(settings, root, build_cache) {
|
||||
if !roots.contains(&path) {
|
||||
roots.push(path);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
extra_content_roots(settings, root, build_cache)
|
||||
}
|
||||
|
||||
fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim()));
|
||||
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
|
||||
@@ -1283,6 +1310,7 @@ fn content_root_watcher(
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
let (ignored, ignored_gone) = ignored_changes(batch);
|
||||
index.ignore_rules_changed(&batch.ignore_rules);
|
||||
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
|
||||
// A workspace member's PHP language level follows its own composer.json.
|
||||
if composer_changed(&root, changed.iter().chain(&batch.removed)) {
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const EOF_GRACE: Duration = Duration::from_secs(5);
|
||||
const EOF_GRACE: Duration = Duration::from_secs(10);
|
||||
|
||||
/// `<host> <port>` from the arguments after `--connect`.
|
||||
pub fn parse_args(args: &[String]) -> Result<(String, u16), String> {
|
||||
@@ -25,7 +25,7 @@ pub fn parse_args(args: &[String]) -> Result<(String, u16), String> {
|
||||
|
||||
/// Connects to `host:port`, copies `input` to it and its replies to `output`, and returns when
|
||||
/// the target is done (the copy of `input` may still be blocked: the caller exits). Fails only
|
||||
/// when the connection can't be opened. An input EOF allows five idle seconds for a final reply.
|
||||
/// when the connection can't be opened. An input EOF allows ten idle seconds for a final reply.
|
||||
pub fn relay(host: &str, port: u16, mut input: impl Read + Send + 'static, mut output: impl Write) -> io::Result<()> {
|
||||
let mut from_target = connect(host, port)?;
|
||||
from_target.set_read_timeout(Some(Duration::from_millis(100)))?;
|
||||
@@ -140,11 +140,11 @@ mod tests {
|
||||
let mut request = Vec::new();
|
||||
socket.read_to_end(&mut request).unwrap();
|
||||
// Remain open after seeing EOF, like an idle keep-alive server.
|
||||
wait.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
wait.recv_timeout(Duration::from_secs(15)).unwrap();
|
||||
});
|
||||
let start = Instant::now();
|
||||
relay("127.0.0.1", port, io::empty(), io::sink()).unwrap();
|
||||
assert!(start.elapsed() < Duration::from_secs(8));
|
||||
assert!(start.elapsed() < Duration::from_secs(13));
|
||||
done.send(()).unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ impl Dispatcher {
|
||||
.add("run_system_env", |_| async move { Ok(ide_run::system_env()) })
|
||||
.add("run_start", |c| async move {
|
||||
let on_output = c.channel("onOutput")?;
|
||||
ops::run::start(&c.host, &c.window, c.arg("config")?, on_output).await
|
||||
ops::run::start_request(&c.host, &c.window, c.arg("config")?, on_output, c.arg("request")?).await
|
||||
})
|
||||
.add("run_stop", |c| async move { ops::run::stop(&c.host, &c.window, c.arg("id")?) })
|
||||
.add("run_stdin", |c| async move { ops::run::stdin(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
|
||||
|
||||
@@ -56,6 +56,22 @@ impl TrustStore {
|
||||
|| self.inherited.lock().contains(&root)))
|
||||
}
|
||||
|
||||
pub fn window_trusted(&self, root: &Path) -> IdeResult<bool> {
|
||||
if !self.trusted(root)? {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)?
|
||||
&& !workspace.is_container(root)
|
||||
{
|
||||
for member in workspace.projects.iter().filter(|p| *p != root && p.exists()) {
|
||||
if !self.trusted(member)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Trusts `path` for this process without recording a decision (see [`TRUSTED_ROOT_ENV`]).
|
||||
pub fn inherit(&self, path: &Path) -> IdeResult<()> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
@@ -134,7 +150,7 @@ impl crate::Host {
|
||||
|
||||
pub async fn refresh_project_trust(&self) -> IdeResult<()> {
|
||||
for project in self.projects().into_iter().filter(|p| !p.light) {
|
||||
let trusted = self.trust.trusted(&project.root).unwrap_or(false);
|
||||
let trusted = self.trust.window_trusted(&project.root).unwrap_or(false);
|
||||
let previous = project.trusted.swap(trusted, std::sync::atomic::Ordering::AcqRel);
|
||||
if previous == trusted {
|
||||
continue;
|
||||
@@ -221,13 +237,17 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "app_cli_uninstall"
|
||||
| "app_log_location"
|
||||
| "app_check_update"
|
||||
| "app_startup_update_check"
|
||||
| "app_update_window_ready"
|
||||
| "app_update_status"
|
||||
| "app_update_announcement"
|
||||
| "app_install_update"
|
||||
| "caches_schedule_invalidation"
|
||||
| "clear_caches"
|
||||
| "clipboard_file_list"
|
||||
| "clipboard_has_files"
|
||||
| "clipboard_image"
|
||||
| "clipboard_set_file_list"
|
||||
| "clipboard_write_text"
|
||||
| "jdk_download_vendors"
|
||||
| "jdk_download_releases"
|
||||
| "jdk_download_location"
|
||||
@@ -271,6 +291,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "deploy_coverage"
|
||||
| "deploy_test_connection"
|
||||
| "find_files"
|
||||
| "find_files_named"
|
||||
| "find_dirs"
|
||||
| "find_symbols"
|
||||
| "mark_recent"
|
||||
@@ -434,3 +455,11 @@ impl TrustStore {
|
||||
Ok(TrustStatus { decided: trusted || state.restricted.contains(&root), root: root.into(), trusted })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn restricted_mode_allows_clipboard_write_text() {
|
||||
assert!(super::restricted_command_allowed("clipboard_write_text"));
|
||||
}
|
||||
}
|
||||
@@ -352,3 +352,203 @@ async fn plugin_run_targets_register_prompt_route_revalidate_and_withdraw() {
|
||||
assert!(plugins.run_targets(&f.root).is_empty());
|
||||
plugins.shutdown();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn run_target_api15_overrides_and_frozen_api14_registration() {
|
||||
use ide_host::plugins::{Consent, PluginHost};
|
||||
use ide_plugin::{Invocation, Limits, PluginEngine};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let f = Harness::new();
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
let plugins = Arc::new(
|
||||
PluginHost::new(f._tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(),
|
||||
);
|
||||
plugins.set_workspace(Some(Arc::new(f.workspace())));
|
||||
f.host.set_plugin_host(&plugins);
|
||||
let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures");
|
||||
let engine = PluginEngine::new().unwrap();
|
||||
let legacy = engine.compile(&fixtures.join("run-target-api14/dist/plugin.wasm")).unwrap();
|
||||
assert_eq!(engine.check_component(&legacy).unwrap().unwrap().to_string(), "1.4.0");
|
||||
let tool = f._tmp.path().join("wrapper");
|
||||
std::fs::write(&tool, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let invocation =
|
||||
|| Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
|
||||
let config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({
|
||||
"id":"composer", "name":"Composer", "kind":"shell", "command":"composer", "args":["install"],
|
||||
"env":{}, "detected":false, "isTest":false,
|
||||
}))
|
||||
.unwrap();
|
||||
for (plugin, command) in [("run-target-api14", "target"), ("execution", "target-overrides")] {
|
||||
plugins.install_from_dir(&fixtures.join(plugin), &Consent::all()).unwrap();
|
||||
assert!(plugins.run_command(plugin, command, Some("empty"), invocation()).is_err());
|
||||
plugins.choose_program(plugin, "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
|
||||
let cases = if plugin == "execution" {
|
||||
vec![
|
||||
("empty", vec!["composer", "install"]),
|
||||
("own", vec!["composer", "--working-dir=/container", "install"]),
|
||||
("inherit", vec!["composer", "--cwd=/container", "install"]),
|
||||
]
|
||||
} else {
|
||||
vec![("inherit", vec!["composer", "--cwd=/container", "install"])]
|
||||
};
|
||||
for (arg, expected) in cases {
|
||||
plugins.run_command(plugin, command, Some(arg), invocation()).unwrap();
|
||||
let target = plugins.run_targets(&f.root).into_iter().find(|t| t.plugin_id == plugin).unwrap();
|
||||
let wrapped = plugins
|
||||
.prepare_run_target_launch(
|
||||
&f.root,
|
||||
&target.id,
|
||||
&config,
|
||||
ide_plugin::RunTargetOperation::Run,
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(wrapped.args, expected);
|
||||
assert_eq!(PathBuf::from(wrapped.command), tool);
|
||||
assert_eq!(wrapped.cwd.unwrap(), f.root);
|
||||
let php = ide_run::RunConfig { command: "php".into(), args: vec!["x.php".into()], ..config.clone() };
|
||||
assert_eq!(
|
||||
plugins.prepare_run_target(&f.root, &target.id, &php).unwrap().args,
|
||||
["wrapped", "--cwd=/container", "php", "x.php"]
|
||||
);
|
||||
}
|
||||
if plugin == "execution" {
|
||||
assert!(plugins.run_command(plugin, command, Some("invalid"), invocation()).is_err());
|
||||
// A failed replacement retains the previous valid registration.
|
||||
assert_eq!(plugins.run_targets(&f.root).iter().filter(|t| t.plugin_id == plugin).count(), 1);
|
||||
}
|
||||
plugins.set_capability(plugin, "process:execution-tool", false).unwrap();
|
||||
assert!(plugins.run_targets(&f.root).iter().all(|t| t.plugin_id != plugin));
|
||||
assert!(plugins.run_command(plugin, command, None, invocation()).is_err());
|
||||
plugins.choose_program(plugin, "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
|
||||
f.host.set_project_trust(&f.root, false, false).await.unwrap();
|
||||
assert!(plugins.run_command(plugin, command, None, invocation()).unwrap_err().to_string().contains("trusted"));
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
}
|
||||
plugins.shutdown();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn run_target_validation_gates_launches_and_preserves_selection() {
|
||||
use ide_host::plugins::{Consent, PluginHost};
|
||||
use ide_plugin::{Invocation, Limits, RunTargetOperation};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::time::Duration;
|
||||
let f = Harness::new();
|
||||
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
||||
let plugins = Arc::new(
|
||||
PluginHost::new(
|
||||
f._tmp.path().join("plugins"),
|
||||
Arc::new(|_: Option<&str>, _| {}),
|
||||
Limits {
|
||||
timeout: Duration::from_millis(200),
|
||||
idle_timeout: Some(Duration::from_millis(20)),
|
||||
restart_backoff: Duration::ZERO,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
plugins.set_workspace(Some(Arc::new(f.workspace())));
|
||||
f.host.set_plugin_host(&plugins);
|
||||
let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
|
||||
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
|
||||
let tool = f._tmp.path().join("wrapper");
|
||||
std::fs::write(&tool, "#!/bin/sh\ntouch spawned\n").unwrap();
|
||||
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
|
||||
let invocation =
|
||||
|| Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
|
||||
let config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({
|
||||
"id":"composer", "name":"Composer", "kind":"shell", "command":"/tools/composer", "args":["install"],
|
||||
"env":{"SECRET":"not sent"}, "detected":false, "isTest":false,
|
||||
}))
|
||||
.unwrap();
|
||||
for id in ["allow", "refuse", "long-refusal", "timeout", "trap"] {
|
||||
plugins.run_command("execution", "target", Some(id), invocation()).unwrap();
|
||||
}
|
||||
let targets = plugins.run_targets(&f.root);
|
||||
let id = |name: &str| targets.iter().find(|t| t.target.id == name).unwrap().id.clone();
|
||||
for operation in [RunTargetOperation::Run, RunTargetOperation::Test, RunTargetOperation::Debug] {
|
||||
plugins.prepare_run_target_launch(&f.root, &id("allow"), &config, operation, &Default::default()).unwrap();
|
||||
let snapshot = plugins.run_command("execution", "validation", None, invocation()).unwrap().unwrap();
|
||||
assert_eq!(snapshot, format!("{}|allow|{operation:?}|composer|{}", f.root.display(), f.root.display()));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
plugins.tick();
|
||||
assert_eq!(plugins.info_of("execution").unwrap().status, ide_host::plugins::PluginStatus::Inactive);
|
||||
plugins
|
||||
.prepare_run_target_launch(&f.root, &id("allow"), &config, RunTargetOperation::Run, &Default::default())
|
||||
.unwrap();
|
||||
assert!(plugins.info_of("execution").unwrap().stats.reclaims > 0);
|
||||
// Non-launch consumers (LSP and document reads) do not invoke validation.
|
||||
assert!(plugins.prepare_run_target(&f.root, &id("refuse"), &config).is_ok());
|
||||
let selected = id("refuse");
|
||||
ide_host::plugins::run_targets::answer(&f.host, &plugins, "main", &selected, true).unwrap();
|
||||
for is_test in [false, true] {
|
||||
let mut config = config.clone();
|
||||
config.is_test = is_test;
|
||||
config.extras.target = Some(selected.clone());
|
||||
let error = ide_host::ops::run::start(&f.host, "main", config, Arc::new(|_| {})).await.unwrap_err();
|
||||
assert!(error.message.contains("Plugin execution tests: Project runtime is stopped"), "{error}");
|
||||
assert!(f.host.processes.list().is_empty());
|
||||
assert!(!f.root.join("spawned").exists());
|
||||
assert_eq!(f.host.project("main").unwrap().settings.effective().run.default_target, selected);
|
||||
assert_eq!(plugins.run_targets(&f.root).len(), targets.len());
|
||||
}
|
||||
for adapter in ["php-debug", "debugpy"] {
|
||||
let launch = ide_dap::LaunchConfig {
|
||||
name: "Refused debug".into(),
|
||||
adapter: adapter.into(),
|
||||
request: "launch".into(),
|
||||
program: Some("app.php".into()),
|
||||
args: Vec::new(),
|
||||
cwd: Some(f.root.clone().into()),
|
||||
env: Default::default(),
|
||||
extra_json: Some(serde_json::json!({"__runTarget":selected, "__runTargetProgram":"php"}).to_string()),
|
||||
};
|
||||
let error = ide_host::ops::debug::debug_start(&f.host, "main", launch).await.unwrap_err();
|
||||
assert!(error.message.contains("Project runtime is stopped"), "{adapter}: {error}");
|
||||
assert!(f.host.debugger.sessions(None).is_empty());
|
||||
}
|
||||
let error = plugins
|
||||
.prepare_run_target_launch(&f.root, &id("long-refusal"), &config, RunTargetOperation::Run, &Default::default())
|
||||
.unwrap_err();
|
||||
assert!(error.message.len() < 4200);
|
||||
let request = ide_core::RequestId(2408);
|
||||
let mut cfg = config.clone();
|
||||
cfg.extras.target = Some(id("timeout"));
|
||||
let worker = f.host.clone();
|
||||
let pending = tokio::spawn(async move {
|
||||
ide_host::ops::run::start_request(&worker, "main", cfg, Arc::new(|_| {}), Some(request)).await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
f.host.cancel_request(request);
|
||||
assert_eq!(pending.await.unwrap().unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
|
||||
for name in ["timeout", "trap"] {
|
||||
let started = std::time::Instant::now();
|
||||
let error = plugins
|
||||
.prepare_run_target_launch(&f.root, &id(name), &config, RunTargetOperation::Run, &Default::default())
|
||||
.unwrap_err();
|
||||
assert!(error.message.contains("Plugin execution tests"), "{error}");
|
||||
if name == "timeout" {
|
||||
assert!(error.message.contains("timed out"), "{error}");
|
||||
assert!(started.elapsed() < Duration::from_secs(2), "background tasks must not extend validation");
|
||||
}
|
||||
assert!(f.host.processes.list().is_empty());
|
||||
}
|
||||
// A cancelled launch never enters the wrapper.
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
plugins
|
||||
.prepare_run_target_launch(&f.root, &id("allow"), &config, RunTargetOperation::Run, &cancel)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
plugins.shutdown();
|
||||
}
|
||||
@@ -126,6 +126,10 @@ impl PluginWorkspace for TestWorkspace {
|
||||
fn trusted(&self, _root: &Path) -> bool {
|
||||
self.trusted
|
||||
}
|
||||
fn project_windows(&self, root: &Path) -> Vec<String> {
|
||||
// `main` shows the fixture project; other open roots have no window left.
|
||||
if root == self.root { vec!["closing".into(), "main".into()] } else { Vec::new() }
|
||||
}
|
||||
fn project_of(&self, _window: &str) -> Option<PathBuf> {
|
||||
if let Some(left) = self.project_queries_left.lock().as_mut() {
|
||||
if *left == 0 {
|
||||
@@ -3690,31 +3694,6 @@ fn listing_while_projects_change_does_not_deadlock() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Excalidraw: a web custom editor beside the text editor, with its fonts,
|
||||
/// file icon, template and language — no code.
|
||||
#[test]
|
||||
fn official_excalidraw_installs_and_serves_its_page() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = with_stand_in_page(&official("excalidraw"), tmp.path());
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&pkg);
|
||||
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
|
||||
let page = f.host.web_page("openidea.excalidraw", "canvas").unwrap();
|
||||
let (rev, entry) = page.path.strip_prefix("openidea.excalidraw/").unwrap().split_once('/').unwrap();
|
||||
assert_eq!(entry, "web/index.html");
|
||||
let html = f.host.web_file("openidea.excalidraw", &format!("{rev}/{entry}")).unwrap();
|
||||
let html = String::from_utf8(html.bytes).unwrap();
|
||||
// Relative URLs only: the page is served from the package.
|
||||
let script = html.split("src=\"").nth(1).unwrap().split('"').next().unwrap();
|
||||
assert!(script.starts_with("./assets/"), "{script}");
|
||||
assert!(f.host.web_file("openidea.excalidraw", &format!("{rev}/web/{}", &script[2..])).is_ok());
|
||||
let font = f.host.web_file("openidea.excalidraw", &format!("{rev}/web/fonts/Virgil/Virgil-Regular.woff2")).unwrap();
|
||||
assert_eq!(font.mime, "font/woff2");
|
||||
assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "preview");
|
||||
assert!(f.host.languages().iter().any(|l| l.id.as_str() == "excalidraw"));
|
||||
assert_eq!(f.host.assets("openidea.excalidraw", "fileIcon/excalidraw/").unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// A copy of the official plugin at `src` in `dir`, with a stand-in for its
|
||||
/// built page (`web/` is built by plugins/build.sh, not checked in; build.sh
|
||||
/// checks what the build produces): an entry loading a script and a font by
|
||||
@@ -3756,7 +3735,18 @@ fn official_graphviz_turns_its_pages_reports_into_problems() {
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "split");
|
||||
assert!(f.host.languages().iter().any(|l| l.id.as_str() == "dot"));
|
||||
assert!(f.host.web_page("openidea.graphviz", "preview").is_ok());
|
||||
assert_eq!(f.host.assets("openidea.graphviz", "fileIcon/graphviz/").unwrap().len(), 2);
|
||||
let page = f.host.web_page("openidea.graphviz", "preview").unwrap();
|
||||
let (rev, entry) = page.path.strip_prefix("openidea.graphviz/").unwrap().split_once('/').unwrap();
|
||||
assert_eq!(entry, "web/index.html");
|
||||
let html = f.host.web_file("openidea.graphviz", &format!("{rev}/{entry}")).unwrap();
|
||||
let html = String::from_utf8(html.bytes).unwrap();
|
||||
// Relative URLs only: the page is served from the package.
|
||||
let script = html.split("src=\"").nth(1).unwrap().split('"').next().unwrap();
|
||||
assert!(script.starts_with("./assets/"), "{script}");
|
||||
assert!(f.host.web_file("openidea.graphviz", &format!("{rev}/web/{}", &script[2..])).is_ok());
|
||||
let font = f.host.web_file("openidea.graphviz", &format!("{rev}/web/fonts/Virgil/Virgil-Regular.woff2")).unwrap();
|
||||
assert_eq!(font.mime, "font/woff2");
|
||||
|
||||
std::fs::write(f.project.join("flow.dot"), "digraph {\n a -> \n}\n").unwrap();
|
||||
let editor = f.workspace.open("flow.dot", &[]);
|
||||
@@ -4801,6 +4791,40 @@ fn notification_actions_keep_commands_and_window_scope() {
|
||||
assert_eq!(f.status("jobs"), PluginStatus::Active, "a refused notification leaves the plugin running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_notifications_go_to_a_window_of_that_project_only() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("jobs"));
|
||||
f.host.set_capability("jobs", "project.read", false).unwrap();
|
||||
f.host.forget_client("closing");
|
||||
let other = f.project.parent().unwrap().join("other");
|
||||
std::fs::create_dir(&other).unwrap();
|
||||
f.workspace.other_roots.lock().push(other.clone());
|
||||
f.take_events();
|
||||
let notifications = |f: &Fixture| {
|
||||
f.take_events()
|
||||
.into_iter()
|
||||
.filter_map(|(client, event)| match event {
|
||||
PluginEvent::Notification { title, actions, .. } => Some((client, title, actions)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// A scheduled call has no invoking window; a closing window is skipped.
|
||||
let path = f.project.join("notes.txt").to_string_lossy().into_owned();
|
||||
f.host.run_command("jobs", "notify-project", Some(&path), Invocation::default()).unwrap();
|
||||
let sent = notifications(&f);
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!((sent[0].0.as_deref(), sent[0].1.as_str()), (Some("main"), "Project update"));
|
||||
assert_eq!((sent[0].2[0].command.as_str(), sent[0].2[0].argument.as_deref()), ("context", Some(path.as_str())));
|
||||
// A project without a window fails without falling back to another one, even when invoked from one.
|
||||
let path = other.join("notes.txt").to_string_lossy().into_owned();
|
||||
let error = f.host.run_command("jobs", "notify-project", Some(&path), f.inv()).unwrap_err().message;
|
||||
assert!(error.contains("not open"), "{error}");
|
||||
assert!(notifications(&f).is_empty());
|
||||
assert_eq!(f.status("jobs"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capability() {
|
||||
use ide_plugin::Prompt;
|
||||
|
||||
@@ -548,7 +548,7 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
|
||||
let bin = tmp.path().join("bin");
|
||||
std::fs::create_dir(&bin).unwrap();
|
||||
let executable = bin.join("execution-tool");
|
||||
std::fs::write(&executable, "#!/bin/sh\nif [ \"$1\" = wrapped ]; then printf '<%s>\\n' \"$@\" > \"$PWD/wrapped.txt\"; exit; fi\nread line\nprintf '%s\\n' \"$line\"\n").unwrap();
|
||||
std::fs::write(&executable, "#!/bin/sh\nif [ \"$1\" = wrapped ]; then printf '<%s>\\n' \"$@\" > \"$PWD/wrapped.txt\"; exit; fi\nif [ \"$1\" = composer ]; then printf '<%s>\\n' \"$@\" > \"$PWD/composer.txt\"; exit; fi\nread line\nprintf '%s\\n' \"$line\"\n").unwrap();
|
||||
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path_var = {
|
||||
let bin = bin.clone();
|
||||
@@ -599,9 +599,17 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId":"execution", "commandId":"target-overrides", "arg":"empty", "editor":null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let targets = invoke(&peer, "plugin_run_targets", json!({})).await.unwrap();
|
||||
assert_eq!(targets.as_array().unwrap().len(), 1);
|
||||
assert_eq!(targets[0]["pluginId"], "execution");
|
||||
assert_eq!(targets[0]["target"]["commands"][0]["workdirArgs"], json!([]));
|
||||
assert!(desktop.run_targets(&project).is_empty());
|
||||
let id = targets[0]["id"].as_str().unwrap();
|
||||
let settings = invoke(&peer, "plugin_answer_run_target", json!({"id":id, "useTarget":false})).await.unwrap();
|
||||
@@ -614,6 +622,10 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
|
||||
"<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<literal ; $(touch bad)>\n"
|
||||
);
|
||||
assert!(!project.join("bad").exists());
|
||||
let composer = json!({"id":"composer", "name":"Composer", "kind":"shell", "command":"composer", "args":["install"], "env":{}, "detected":false, "isTest":false, "target":id});
|
||||
invoke(&peer, "run_start", json!({"config":composer, "onOutput":{"$channel":39}})).await.unwrap();
|
||||
until("remote composer override", 5, || project.join("composer.txt").exists()).await;
|
||||
assert_eq!(std::fs::read_to_string(project.join("composer.txt")).unwrap(), "<composer>\n<install>\n");
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
@@ -622,6 +634,28 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(invoke(&peer, "plugin_run_targets", json!({})).await.unwrap(), json!([]));
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId":"execution", "commandId":"target", "arg":"refuse", "editor":null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let targets = invoke(&peer, "plugin_run_targets", json!({})).await.unwrap();
|
||||
let refused = targets[0]["id"].as_str().unwrap();
|
||||
std::fs::remove_file(project.join("wrapped.txt")).unwrap();
|
||||
let config = json!({"id":"refused", "name":"Refused", "kind":"shell", "command":"php", "args":[], "env":{}, "detected":false, "isTest":false, "target":refused});
|
||||
let error = invoke(&peer, "run_start", json!({"config":config, "onOutput":{"$channel":40}})).await.unwrap_err();
|
||||
assert!(error.to_string().contains("Project runtime is stopped"), "{error}");
|
||||
assert!(!project.join("wrapped.txt").exists());
|
||||
assert!(desktop.run_targets(&project).is_empty());
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
json!({"pluginId":"execution", "commandId":"untarget", "arg":"refuse", "editor":null}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
client.plugin_events.lock().clear();
|
||||
client.events.lock().clear();
|
||||
let runner = peer.clone();
|
||||
|
||||
@@ -38,13 +38,17 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"app_cli_uninstall",
|
||||
"app_log_location",
|
||||
"app_check_update",
|
||||
"app_startup_update_check",
|
||||
"app_update_window_ready",
|
||||
"app_update_status",
|
||||
"app_update_announcement",
|
||||
"app_install_update",
|
||||
"caches_schedule_invalidation",
|
||||
"clear_caches",
|
||||
"clipboard_file_list",
|
||||
"clipboard_has_files",
|
||||
"clipboard_image",
|
||||
"clipboard_set_file_list",
|
||||
"clipboard_write_text",
|
||||
"jdk_download_vendors",
|
||||
"jdk_download_releases",
|
||||
"jdk_download_location",
|
||||
@@ -448,3 +452,33 @@ async fn revocation_still_applies_when_a_project_folder_was_removed() {
|
||||
assert!(!project.is_trusted());
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_member_trust_is_required_after_restart_and_revocation() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (config, data) = (tmp.path().join("config"), tmp.path().join("data"));
|
||||
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();
|
||||
}
|
||||
ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap();
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
trust.decide(&root, true, false).unwrap();
|
||||
trust.inherit(&other).unwrap();
|
||||
assert!(trust.window_trusted(&root).unwrap());
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
assert!(trust.status(&root).unwrap().trusted);
|
||||
assert!(!trust.window_trusted(&root).unwrap());
|
||||
assert!(!trust.status(&other).unwrap().decided);
|
||||
trust.decide(&root, true, false).unwrap();
|
||||
assert!(!trust.window_trusted(&root).unwrap(), "trusting only the root does not recover the window");
|
||||
trust.decide(&other, true, false).unwrap();
|
||||
assert!(trust.window_trusted(&root).unwrap());
|
||||
trust.decide(&other, false, false).unwrap();
|
||||
assert!(trust.status(&other).unwrap().decided);
|
||||
assert!(!trust.window_trusted(&root).unwrap());
|
||||
trust.decide(&other, true, false).unwrap();
|
||||
assert!(trust.window_trusted(&root).unwrap());
|
||||
}
|
||||
@@ -197,6 +197,8 @@ async fn code_actions_skip_a_command_no_server_provides_after_their_edit() {
|
||||
let info = ops::doc::open(&host, "main", file.clone()).await.unwrap();
|
||||
// Optimize Imports as vtsls offers it: the edit, then a command its client runs itself.
|
||||
let action = |edit: bool| CodeAction {
|
||||
diagnostics: Vec::new(),
|
||||
original_diagnostics: None,
|
||||
title: "Organize Imports".into(),
|
||||
kind: Some("source.organizeImports".into()),
|
||||
is_preferred: false,
|
||||
|
||||
@@ -32,11 +32,15 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
|
||||
});
|
||||
std::fs::write(root.join("internal-needle.txt"), "needle").unwrap();
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
for member in &roots {
|
||||
host.trust.decide(member, true, false).unwrap();
|
||||
}
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert_eq!(ops::project::info(&project).name, "Full stack");
|
||||
assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), root.as_path());
|
||||
ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
|
||||
assert_eq!(project.content_roots(), roots);
|
||||
assert!(!root.join(".openidea/.gitignore").exists());
|
||||
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
||||
assert!(project.index.find_files("internal-needle", 100).is_empty());
|
||||
assert!(project.index.find_files("workspace.json", 100).is_empty());
|
||||
@@ -98,20 +102,6 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
|
||||
std::fs::write(invalid.join(ide_workspace::workspace::FILE), "broken").unwrap();
|
||||
assert!(host.open_project("main", &invalid).is_err());
|
||||
assert!(Arc::ptr_eq(&host.project("main").unwrap(), &project));
|
||||
host.open_project("main", &roots[0]).unwrap();
|
||||
let automatic = ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
|
||||
assert_eq!(host.project("main").unwrap().root, roots[0]);
|
||||
let automatic = host.open_project("main", &automatic).unwrap();
|
||||
assert_eq!(automatic.content_roots(), roots);
|
||||
assert_eq!(ops::project::info(&automatic).name, "frontend");
|
||||
assert!(automatic.root.starts_with(base.join("data/workspaces")));
|
||||
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
|
||||
assert!(automatic.index.find_files("workspace.json", 100).is_empty());
|
||||
let updated = ops::project::save_workspace(&host, "main", "frontend", vec![roots[1].clone()]).unwrap();
|
||||
assert_eq!(updated.workspace.unwrap().projects, vec![roots[1].clone()]);
|
||||
assert_eq!(automatic.root, updated.root.as_ref());
|
||||
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
|
||||
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
@@ -206,3 +196,133 @@ async fn build_tool_availability_follows_workspace_members_and_trust() {
|
||||
assert_eq!(model.gradle.len(), 1);
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn attach_and_detach_preserve_the_primary_project_session() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let roots = vec![base.join("primary"), base.join("other")];
|
||||
for root in &roots {
|
||||
std::fs::create_dir(root).unwrap();
|
||||
std::fs::write(root.join("same.txt"), "needle").unwrap();
|
||||
std::fs::write(root.join("package.json"), r#"{"scripts":{"test":"echo test"}}"#).unwrap();
|
||||
}
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&roots[0], true, false).unwrap();
|
||||
let project = host.open_project("main", &roots[0]).unwrap();
|
||||
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
||||
let configs = ops::run::configs(&host, "main").unwrap();
|
||||
assert!(!configs.is_empty());
|
||||
let cache = project.build_cache_dir();
|
||||
let doc = ops::doc::open(&host, "main", roots[0].join("same.txt")).await.unwrap();
|
||||
ops::doc::edit(
|
||||
&host,
|
||||
"main",
|
||||
doc.id,
|
||||
doc.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "unsaved ".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ops::project::attach_workspace(&host, "main", &roots[1]).is_err());
|
||||
host.trust.decide(&roots[1], false, false).unwrap();
|
||||
assert!(ops::project::attach_workspace(&host, "main", &roots[1]).is_err());
|
||||
assert!(!roots[0].join(ide_workspace::workspace::FILE).exists());
|
||||
let existing_index = &project.index as *const _;
|
||||
host.trust.decide(&roots[1], true, false).unwrap();
|
||||
assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), roots[0]);
|
||||
assert!(Arc::ptr_eq(&host.project("main").unwrap(), &project));
|
||||
assert_eq!(&host.project("main").unwrap().index as *const _, existing_index);
|
||||
assert_eq!(project.root, roots[0]);
|
||||
assert_eq!(project.build_cache_dir(), cache);
|
||||
assert!(!base.join("data/workspaces").exists());
|
||||
assert_eq!(Workspace::load(&roots[0]).unwrap().unwrap().projects, roots);
|
||||
assert!(
|
||||
std::fs::read_to_string(roots[0].join(".openidea/.gitignore")).unwrap().lines().any(|l| l == "/workspace.json")
|
||||
);
|
||||
assert_eq!(project.content_roots(), vec![roots[1].clone()]);
|
||||
let attached = ops::project::info(&project);
|
||||
assert_eq!(attached.name, "primary");
|
||||
assert_eq!(attached.display_name, "primary, other");
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
"main",
|
||||
ide_workspace::SettingsScope::Project,
|
||||
"general.projectName",
|
||||
&serde_json::to_string(&attached.name).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ops::project::info(&project).display_name, "primary, other");
|
||||
let restricted = base.join("restricted");
|
||||
std::fs::create_dir(&restricted).unwrap();
|
||||
host.trust.decide(&restricted, false, false).unwrap();
|
||||
assert!(
|
||||
ops::project::save_workspace(&host, "main", "Primary", vec![roots[0].clone(), roots[1].clone(), restricted])
|
||||
.is_err()
|
||||
);
|
||||
let all = ops::run::configs(&host, "main").unwrap();
|
||||
assert_eq!(all.len(), configs.len() * 2);
|
||||
for config in &configs {
|
||||
assert_eq!(all.iter().filter(|c| c.id == config.id).count(), 1);
|
||||
}
|
||||
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
||||
assert_eq!(project.index.find_files("same.txt", 100).len(), 2);
|
||||
let hits = Arc::new(Mutex::new(Vec::new()));
|
||||
let out = hits.clone();
|
||||
let query = serde_json::from_value(serde_json::json!({"pattern":"needle", "isRegex":false,"caseSensitive":false,"wholeWord":false,"include":[],"exclude":[],"scope":{"kind":"project"},"maxMatches":100})).unwrap();
|
||||
ops::search::start(
|
||||
&host,
|
||||
"main",
|
||||
RequestId::next(),
|
||||
query,
|
||||
Arc::new(move |event| {
|
||||
if let ops::search::SearchEvent::File { file } = event {
|
||||
out.lock().unwrap().push(file);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(hits.lock().unwrap().len(), 2);
|
||||
assert!(ops::project::save_workspace(&host, "main", "Primary", vec![roots[1].clone()]).is_err());
|
||||
let updated = ops::project::save_workspace(&host, "main", "Primary", vec![roots[0].clone()]).unwrap();
|
||||
assert!(updated.workspace.is_none());
|
||||
assert_eq!(updated.name, "primary");
|
||||
assert_eq!(updated.root.as_ref(), roots[0]);
|
||||
assert!(!roots[0].join(ide_workspace::workspace::FILE).exists());
|
||||
assert!(project.content_roots().is_empty());
|
||||
assert_eq!(
|
||||
ops::run::configs(&host, "main").unwrap().iter().map(|c| &c.id).collect::<Vec<_>>(),
|
||||
configs.iter().map(|c| &c.id).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(project.documents.text_of(&roots[0].join("same.txt")).unwrap().starts_with("unsaved"));
|
||||
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
||||
assert_eq!(project.index.find_files("same.txt", 100).len(), 1);
|
||||
let nested = roots[0].join("nested");
|
||||
std::fs::create_dir(&nested).unwrap();
|
||||
std::fs::write(nested.join("same.txt"), "needle").unwrap();
|
||||
host.trust.decide(&nested, true, false).unwrap();
|
||||
Workspace::attach(&nested, "Nested", vec![nested.clone()]).unwrap();
|
||||
ops::project::attach_workspace(&host, "main", &nested).unwrap();
|
||||
assert!(project.content_roots().is_empty());
|
||||
let container = base.join("container");
|
||||
Workspace::save(&container, "Container", vec![], true).unwrap();
|
||||
host.trust.decide(&container, true, false).unwrap();
|
||||
assert!(ops::project::attach_workspace(&host, "main", &container).is_err());
|
||||
host.close_project("main");
|
||||
let reopened = host.open_project("main", &roots[0]).unwrap();
|
||||
assert_eq!(ops::project::info(&reopened).name, "primary");
|
||||
assert_eq!(ops::project::info(&reopened).display_name, "primary, nested");
|
||||
host.set_project_trust(&nested, false, false).await.unwrap();
|
||||
assert!(!reopened.is_trusted());
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -138,6 +138,10 @@ impl PathFilter {
|
||||
&self.config.0 == extra_roots && self.config.1 == excludes
|
||||
}
|
||||
|
||||
pub(crate) fn reload(&self) -> Self {
|
||||
Self::new(&self.root, &self.config.0, &self.config.1)
|
||||
}
|
||||
|
||||
pub(crate) fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
@@ -234,6 +234,15 @@ impl ProjectIndex {
|
||||
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
|
||||
}
|
||||
|
||||
/// All indexed files with this exact basename, for resolving printed source locations.
|
||||
/// Unlike interactive fuzzy search, this must not truncate candidates before package matching.
|
||||
pub fn files_named(&self, name: &str) -> Vec<PathBuf> {
|
||||
let data = self.shared.data.read();
|
||||
let mut files: Vec<_> = data.files.iter().flatten().filter(|file| file.name() == name).collect();
|
||||
files.sort_by(|a, b| a.rel.len().cmp(&b.rel.len()).then_with(|| a.rel.cmp(&b.rel)));
|
||||
files.into_iter().map(|file| file.path.clone()).collect()
|
||||
}
|
||||
|
||||
/// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to
|
||||
/// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is
|
||||
/// indexed yet (first scan without a cache).
|
||||
@@ -323,6 +332,28 @@ impl ProjectIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes ignore matchers and indexed entries in the affected subtrees.
|
||||
pub fn ignore_rules_changed(&self, dirs: &[PathBuf]) {
|
||||
let filter = self.shared.filter();
|
||||
let mut affected = Vec::new();
|
||||
for dir in dirs {
|
||||
let path = if dir.is_absolute() { dir.clone() } else { self.root.join(dir) };
|
||||
let path = dunce::canonicalize(&path).unwrap_or(path);
|
||||
for root in filter.roots() {
|
||||
if path.starts_with(root) {
|
||||
affected.push(path.clone());
|
||||
break;
|
||||
}
|
||||
if root.starts_with(&path) {
|
||||
affected.push(root.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !affected.is_empty() {
|
||||
self.shared.enqueue(Job::IgnoreRules(affected));
|
||||
}
|
||||
}
|
||||
|
||||
/// Boost for "recent files" ranking.
|
||||
/// Records a recently opened file; a virtual document's URI (library
|
||||
/// class) is kept verbatim, like IDEA lists library classes in Recent Files.
|
||||
|
||||
@@ -57,6 +57,27 @@ fn files(idx: &ProjectIndex, q: &str) -> Vec<String> {
|
||||
idx.find_files(q, 20).into_iter().map(|m| m.rel_path).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_file_names_keep_all_source_candidates() {
|
||||
let f = Fixture::new(&[
|
||||
("src/OtherOrders.kt", "package other"),
|
||||
("src/Orders.kt.bak", "backup"),
|
||||
("target/Orders.kt", "excluded"),
|
||||
("src/orders.kt", "different case"),
|
||||
]);
|
||||
for i in 0..65 {
|
||||
f.write(&format!("module{i}/Orders.kt"), &format!("package module{i}"));
|
||||
}
|
||||
let source = f.write("service/src/Orders.kt", "package com.example.orders");
|
||||
let idx = f.open();
|
||||
let candidates = idx.files_named("Orders.kt");
|
||||
assert_eq!(candidates.len(), 66);
|
||||
assert!(candidates.contains(&source));
|
||||
assert!(candidates.iter().all(|path| path.file_name().unwrap() == "Orders.kt"));
|
||||
assert_eq!(candidates, idx.files_named("Orders.kt"));
|
||||
assert!(idx.files_named("Missing.kt").is_empty());
|
||||
}
|
||||
|
||||
fn symbols(idx: &ProjectIndex, q: &str, filter: SymbolFilter) -> Vec<String> {
|
||||
idx.find_symbols(q, filter, 20).into_iter().map(|m| m.name).collect()
|
||||
}
|
||||
@@ -729,3 +750,66 @@ fn persisted_symbols_do_not_load_through_escaping_symlink_ancestors() {
|
||||
assert!(symbols(&idx, "old_symbol", SymbolFilter::All).is_empty());
|
||||
assert_eq!(initial.lock()[0], 0, "unsafe cached symbols must never be published");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_changes_refresh_only_the_affected_index_subtree() {
|
||||
for (rule, subtree) in [(".git/info/exclude", ""), ("sub/.gitignore", "sub"), ("sub/.ignore", "sub")] {
|
||||
let fx = Fixture::new(&[
|
||||
(".git/HEAD", "ref: refs/heads/main\n"),
|
||||
("sub/generated/file.ts", "export function generated() {}\n"),
|
||||
("sub/local.ts", "export function localOnly() {}\n"),
|
||||
("other/keep.ts", "export function keep() {}\n"),
|
||||
]);
|
||||
fx.write(rule, "");
|
||||
let idx = fx.open();
|
||||
assert_eq!(files(&idx, "file.ts"), ["sub/generated/file.ts"]);
|
||||
fx.write(rule, "generated/\nlocal.ts\n");
|
||||
let dirs = [fx.root.join(subtree)];
|
||||
idx.ignore_rules_changed(&dirs);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(files(&idx, "file.ts").is_empty(), "{rule}: ignored directory remained indexed");
|
||||
assert_eq!(files(&idx, "local.ts"), ["sub/local.ts"]);
|
||||
assert!(symbols(&idx, "localOnly", SymbolFilter::All).is_empty());
|
||||
assert_eq!(files(&idx, "keep.ts"), ["other/keep.ts"]);
|
||||
|
||||
fx.write(rule, "");
|
||||
idx.ignore_rules_changed(&dirs);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert_eq!(files(&idx, "file.ts"), ["sub/generated/file.ts"]);
|
||||
assert_eq!(symbols(&idx, "localOnly", SymbolFilter::All), ["localOnly"]);
|
||||
// The cached root matcher must also follow the rules for later watcher updates.
|
||||
let fresh = fx.write("sub/generated/new.ts", "export function fresh() {}\n");
|
||||
idx.update(&[fresh], &[]);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert_eq!(files(&idx, "new.ts"), ["sub/generated/new.ts"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_rule_changes_refresh_members_of_an_unindexed_container() {
|
||||
let fx = Fixture::new(&[
|
||||
(".git/HEAD", "ref: refs/heads/main\n"),
|
||||
(".git/info/exclude", ""),
|
||||
("member/generated/item.txt", "x"),
|
||||
]);
|
||||
let idx = ProjectIndex::open(
|
||||
&fx.root,
|
||||
&fx.cache,
|
||||
IndexRoots { include_root: false, folders: vec![fx.root.join("member")] },
|
||||
vec![],
|
||||
Arc::new(LanguageRegistry::load(None).unwrap()),
|
||||
Arc::new(Syntax::new()),
|
||||
Arc::new(NullSink),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert_eq!(files(&idx, "item.txt"), ["member/generated/item.txt"]);
|
||||
fx.write(".git/info/exclude", "generated/\n");
|
||||
idx.ignore_rules_changed(std::slice::from_ref(&fx.root));
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(files(&idx, "item.txt").is_empty());
|
||||
fx.write(".git/info/exclude", "");
|
||||
idx.ignore_rules_changed(std::slice::from_ref(&fx.root));
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert_eq!(files(&idx, "item.txt"), ["member/generated/item.txt"]);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ pub(crate) enum Job {
|
||||
SaveRecent,
|
||||
/// The roots or excludes changed: scan again (unchanged files are kept).
|
||||
Rescan,
|
||||
IgnoreRules(Vec<PathBuf>),
|
||||
/// File types changed: re-parse the indexed files whose language under the
|
||||
/// previous registry differs from the current one.
|
||||
Reparse(std::sync::Arc<LanguageRegistry>),
|
||||
@@ -212,6 +213,10 @@ impl Shared {
|
||||
}
|
||||
|
||||
fn full_scan(&self) {
|
||||
self.scan(None);
|
||||
}
|
||||
|
||||
fn scan(&self, dirs: Option<&[PathBuf]>) {
|
||||
let started = Instant::now();
|
||||
self.indexing.store(true, Ordering::SeqCst);
|
||||
self.emit_state();
|
||||
@@ -227,7 +232,11 @@ impl Shared {
|
||||
|
||||
let filter = self.filter();
|
||||
let mut found = Vec::new();
|
||||
for root in filter.roots() {
|
||||
let roots: Vec<&Path> = match dirs {
|
||||
Some(dirs) => dirs.iter().map(PathBuf::as_path).collect(),
|
||||
None => filter.roots().collect(),
|
||||
};
|
||||
for root in roots {
|
||||
found.extend(filter.walk(root, &self.cancelled));
|
||||
}
|
||||
if self.is_cancelled() {
|
||||
@@ -237,7 +246,12 @@ impl Shared {
|
||||
let (todo, stale): (Vec<&Found>, Vec<PathBuf>) = {
|
||||
let d = self.data.read();
|
||||
let todo = found.iter().filter(|f| d.get(&f.path).is_none_or(|e| e.stale_for(f))).collect();
|
||||
let stale = d.paths().filter(|p| !seen.contains(p.as_path())).cloned().collect();
|
||||
let stale = d
|
||||
.paths()
|
||||
.filter(|p| dirs.is_none_or(|dirs| dirs.iter().any(|dir| p.starts_with(dir))))
|
||||
.filter(|p| !seen.contains(p.as_path()))
|
||||
.cloned()
|
||||
.collect();
|
||||
(todo, stale)
|
||||
};
|
||||
if !stale.is_empty() {
|
||||
@@ -381,6 +395,7 @@ impl Shared {
|
||||
let mut removed: Vec<PathBuf> = Vec::new();
|
||||
let mut save_recent = false;
|
||||
let mut rescan = false;
|
||||
let mut rule_dirs = Vec::new();
|
||||
let mut reparse: Vec<std::sync::Arc<LanguageRegistry>> = Vec::new();
|
||||
for job in jobs {
|
||||
match job {
|
||||
@@ -388,9 +403,29 @@ impl Shared {
|
||||
Job::Removed(p) => removed.extend(p),
|
||||
Job::SaveRecent => save_recent = true,
|
||||
Job::Rescan => rescan = true,
|
||||
Job::IgnoreRules(dirs) => rule_dirs.extend(dirs),
|
||||
Job::Reparse(before) => reparse.push(before),
|
||||
}
|
||||
}
|
||||
if !rule_dirs.is_empty() {
|
||||
{
|
||||
let mut filter = self.filter.write();
|
||||
*filter = std::sync::Arc::new(filter.reload());
|
||||
}
|
||||
rule_dirs.sort();
|
||||
rule_dirs.dedup();
|
||||
let mut roots: Vec<PathBuf> = Vec::new();
|
||||
for dir in rule_dirs {
|
||||
if !roots.iter().any(|root| dir.starts_with(root)) {
|
||||
roots.push(dir);
|
||||
}
|
||||
}
|
||||
if !rescan {
|
||||
self.scan(Some(&roots));
|
||||
changed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
|
||||
removed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
|
||||
}
|
||||
}
|
||||
if rescan {
|
||||
// The scan also picks up whatever `changed` / `removed` reported.
|
||||
self.full_scan();
|
||||
|
||||
@@ -14,14 +14,14 @@ mod markers;
|
||||
pub mod plugin_point;
|
||||
mod registry;
|
||||
mod syntax;
|
||||
pub use markers::marked_root;
|
||||
pub use markers::{arduino_sketch_root, is_arduino_sketch_dir, marked_root};
|
||||
pub use plugin_point::PluginLanguages;
|
||||
pub use registry::{FileTypes, LanguageRegistry};
|
||||
pub use syntax::{
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
|
||||
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
|
||||
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference,
|
||||
WebReferenceKind, merge_visibility, set_external_syntax,
|
||||
WebReferenceKind, merge_visibility, set_external_syntax, shift_pos, shift_range,
|
||||
};
|
||||
|
||||
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
|
||||
@@ -332,3 +332,15 @@ pub struct SymbolTag {
|
||||
pub container: Option<String>,
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
/// One inline TS/JS `<script>` block of a Vue SFC / HTML document.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ScriptRegion {
|
||||
/// Document-absolute UTF-8 byte offset of `text`, for tree-sitter parsing.
|
||||
/// Convert to UTF-16 positions before exposing ranges through IPC.
|
||||
pub base: usize,
|
||||
/// The language the block's `lang` attribute picks.
|
||||
pub language: LanguageId,
|
||||
/// The script text.
|
||||
pub text: String,
|
||||
}
|
||||
@@ -19,6 +19,27 @@ pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String])
|
||||
best
|
||||
}
|
||||
|
||||
/// `dir` contains at least one `.ino` regular file.
|
||||
pub fn is_arduino_sketch_dir(dir: &Path) -> bool {
|
||||
std::fs::read_dir(dir).is_ok_and(|entries| {
|
||||
entries.flatten().any(|entry| {
|
||||
entry.path().extension().is_some_and(|ext| ext.eq_ignore_ascii_case("ino"))
|
||||
&& entry.file_type().is_ok_and(|kind| kind.is_file())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Nearest enclosing directory of `path` (including `path` when it is already a
|
||||
/// directory) within `project` that contains a `.ino` file.
|
||||
pub fn arduino_sketch_root(project: &Path, path: &Path) -> Option<PathBuf> {
|
||||
let start = usize::from(!path.is_dir());
|
||||
path.ancestors()
|
||||
.skip(start)
|
||||
.take_while(|dir| dir.starts_with(project))
|
||||
.find(|dir| is_arduino_sketch_dir(dir))
|
||||
.map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// `dir` contains `marker`: a relative path, a file name pattern with one
|
||||
/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it
|
||||
/// must contain (`pyproject.toml#tool.ruff`).
|
||||
|
||||
@@ -70,6 +70,7 @@ const BUILTIN: &[(&str, &str)] = &[
|
||||
("proto", include_str!("../../../languages/proto.toml")),
|
||||
("python", include_str!("../../../languages/python.toml")),
|
||||
("r", include_str!("../../../languages/r.toml")),
|
||||
("restructuredtext", include_str!("../../../languages/restructuredtext.toml")),
|
||||
("ruby", include_str!("../../../languages/ruby.toml")),
|
||||
("rust", include_str!("../../../languages/rust.toml")),
|
||||
("sass", include_str!("../../../languages/sass.toml")),
|
||||
@@ -562,6 +563,7 @@ mod tests {
|
||||
"R",
|
||||
"RPM Changes",
|
||||
"RPM Spec",
|
||||
"reStructuredText",
|
||||
"Ruby",
|
||||
"SAS",
|
||||
"Scala",
|
||||
@@ -628,6 +630,12 @@ mod tests {
|
||||
let asciidoc = reg.get(&LanguageId::new("asciidoc")).unwrap();
|
||||
assert_eq!(asciidoc.ui_mode.as_deref(), Some("AsciiDoc"));
|
||||
assert_eq!(asciidoc.line_comment.as_deref(), Some("//"));
|
||||
for path in ["guide.rst", "README.RST"] {
|
||||
assert_eq!(detect(®, path), "restructuredtext");
|
||||
}
|
||||
let rst = reg.get(&LanguageId::new("restructuredtext")).unwrap();
|
||||
assert_eq!(rst.ui_mode.as_deref(), Some("reStructuredText"));
|
||||
assert_eq!(rst.line_comment.as_deref(), Some(".."));
|
||||
assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python");
|
||||
assert_eq!(detect(®, "README"), "plaintext");
|
||||
assert_eq!(detect(®, "unknown.zzz"), "plaintext");
|
||||
@@ -999,11 +1007,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn astro_server_reads_the_project_typescript() {
|
||||
fn astro_server_leaves_typescript_sdk_resolution_to_the_launcher() {
|
||||
let reg = reg();
|
||||
let ls = ®.get(&LanguageId::new("astro")).unwrap().language_servers[0];
|
||||
let tsdk = ls.initialization_options.as_ref().and_then(|o| o.get("typescript")?.get("tsdk")?.as_str());
|
||||
assert_eq!(tsdk, Some("node_modules/typescript/lib"));
|
||||
assert_eq!(tsdk, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -195,9 +195,18 @@ impl SyntaxCache {
|
||||
|| {
|
||||
self.with_tree(doc, lang, version, text, |g, tree, text| {
|
||||
let query = g.outline_query()?;
|
||||
Some(outline::outline(g, query, tree, text))
|
||||
// HTML documents answer from the cached tree, with nested
|
||||
// <script> declarations.
|
||||
Some(outline::outline_nested(g, query, tree, text, &|rl, rt| self.syntax.outline(rl, rt)))
|
||||
})
|
||||
// Web containers (Vue SFC / Svelte / JSP) have no grammar of their
|
||||
// own, hence no cached tree: the syntax layer answers with a fresh
|
||||
// HTML parse, which the memo covers per version.
|
||||
// shortcut: no incremental re-parse for containers, upgrade when
|
||||
// their trees join the cache.
|
||||
.or_else(|| self.syntax.web_outline(lang, text))
|
||||
.or_else(|| super::external::outline(lang, text))
|
||||
.or_else(|| self.syntax.builtin_outline(lang, text))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -256,8 +265,11 @@ impl SyntaxCache {
|
||||
let Some(result) = compute() else { return Vec::new() };
|
||||
if let Some(e) = &entry {
|
||||
let mut e = e.lock();
|
||||
// Not if the document moved on (or its tree was dropped) meanwhile.
|
||||
if e.version == version && e.tree.is_some() {
|
||||
// Containers without their own grammar memoize their web outline too.
|
||||
// Never store an answer after the document moves on.
|
||||
if e.version == version
|
||||
&& (e.tree.is_some() || (e.lang == *lang && self.syntax.web_grammar_name(lang) == Some("html")))
|
||||
{
|
||||
*slot(e.memo.at(version, lang)) = Some(Arc::new(result.clone()));
|
||||
}
|
||||
}
|
||||
@@ -474,6 +486,35 @@ mod tests {
|
||||
assert!(cache.outline(id, &LanguageId::new("plaintext"), 1, &text).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_outline_memo_tracks_versions_without_a_cached_tree() {
|
||||
for language in ["html", "vue", "svelte", "jsp"] {
|
||||
let cache = cache();
|
||||
let id = DocId::next();
|
||||
let lang = LanguageId::new(language);
|
||||
let mut doc = Document::new("<script setup lang=ts>const first = 1;</script>");
|
||||
cache.open(id, &lang, 0);
|
||||
let first = cache.outline(id, &lang, 0, &doc.text());
|
||||
let hits = cache.stats().hits;
|
||||
assert_eq!(cache.outline(id, &lang, 0, &doc.text()), first);
|
||||
assert_eq!(cache.stats().hits, hits + 1, "{language}: memo hit");
|
||||
|
||||
let edits = edit(&mut doc, 0, 0, "<script>const second = 2;</script>\n");
|
||||
cache.edit(id, doc.version(), &edits);
|
||||
let second = cache.outline(id, &lang, doc.version(), &doc.text());
|
||||
assert_ne!(second, first);
|
||||
assert_eq!(second, cache.syntax().outline(&lang, &doc.text()));
|
||||
// An older query must not replace the current version's memo.
|
||||
cache.outline(id, &lang, 0, "<script>const old = 0;</script>");
|
||||
let hits = cache.stats().hits;
|
||||
assert_eq!(cache.outline(id, &lang, doc.version(), &doc.text()), second);
|
||||
assert_eq!(cache.stats().hits, hits + 1);
|
||||
cache.clear();
|
||||
assert!(cache.entries.lock().get(&id).unwrap().lock().memo.outline.is_none());
|
||||
cache.close(id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_mismatch_and_lifecycle() {
|
||||
let cache = cache();
|
||||
|
||||
@@ -96,6 +96,19 @@ pub(crate) fn byte_at(text: &str, pos: Pos) -> Option<usize> {
|
||||
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
|
||||
}
|
||||
|
||||
/// Shifts a position from a document suffix that starts at `base` into document
|
||||
/// coordinates: the suffix's first line continues `base`'s line, later lines
|
||||
/// add the base line. Used to map inline `<script>` region answers (Vue SFC /
|
||||
/// HTML) back onto the whole document; positions are 0-based lines / UTF-16 cols.
|
||||
pub fn shift_pos(pos: Pos, base: Pos) -> Pos {
|
||||
if pos.line == 0 { Pos::new(base.line, base.col + pos.col) } else { Pos::new(base.line + pos.line, pos.col) }
|
||||
}
|
||||
|
||||
/// [`shift_pos`] for both ends of a range.
|
||||
pub fn shift_range(range: Range, base: Pos) -> Range {
|
||||
Range::new(shift_pos(range.start, base), shift_pos(range.end, base))
|
||||
}
|
||||
|
||||
pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize {
|
||||
let mut units = 0;
|
||||
for (i, c) in s.char_indices() {
|
||||
@@ -160,6 +173,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifts_suffix_positions_into_document_coordinates() {
|
||||
let base = Pos::new(3, 7);
|
||||
assert_eq!(shift_pos(Pos::new(0, 2), base), Pos::new(3, 9));
|
||||
assert_eq!(shift_pos(Pos::new(0, 0), base), base);
|
||||
assert_eq!(shift_pos(Pos::new(2, 1), base), Pos::new(5, 1));
|
||||
assert_eq!(
|
||||
shift_range(Range::new(Pos::new(0, 1), Pos::new(1, 4)), base),
|
||||
Range::new(Pos::new(3, 8), Pos::new(4, 4))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_minified_line_is_linear() {
|
||||
// 200k symbols-worth of positions on one 4 MB line.
|
||||
|
||||
@@ -9,16 +9,19 @@ mod highlight;
|
||||
pub(crate) mod lines;
|
||||
mod literals;
|
||||
mod outline;
|
||||
mod rst;
|
||||
mod selection;
|
||||
mod sfc;
|
||||
mod web;
|
||||
|
||||
use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag};
|
||||
use crate::{FoldRange, LanguageConfig, LanguageRegistry, ScriptRegion, SymbolTag};
|
||||
pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats};
|
||||
pub use configuration::ConfigurationKey;
|
||||
pub use external::{ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken, set_external_syntax};
|
||||
use grammars::{SPECS, Spec};
|
||||
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
|
||||
use ide_core::{LanguageId, OutlineItem, Pos, Range};
|
||||
pub use lines::{shift_pos, shift_range};
|
||||
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
|
||||
pub use outline::merge_visibility;
|
||||
use std::cell::RefCell;
|
||||
@@ -165,13 +168,39 @@ impl Syntax {
|
||||
}
|
||||
|
||||
pub fn outline(&self, lang: &LanguageId, text: &str) -> Vec<OutlineItem> {
|
||||
// HTML containers (Vue SFC / Svelte / JSP have no grammar of their own;
|
||||
// HTML itself parses alike): HTML outline with each inline `<script>`'s
|
||||
// TS/JS declarations nested under its entry, in document coordinates.
|
||||
if let Some(items) = self.web_outline(lang, text) {
|
||||
return items;
|
||||
}
|
||||
self.analyze(lang, text, |g, tree, text| {
|
||||
let query = g.outline_query()?;
|
||||
Some(outline::outline(g, query, tree, text))
|
||||
})
|
||||
.or_else(|| self.builtin_outline(lang, text))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Outline for languages without a grammar that have a built-in parser.
|
||||
fn builtin_outline(&self, lang: &LanguageId, text: &str) -> Option<Vec<OutlineItem>> {
|
||||
(lang.as_str() == "restructuredtext" && text.len() <= MAX_PARSE_BYTES).then(|| rst::outline(text))
|
||||
}
|
||||
|
||||
/// Outline of an HTML container document (html, vue, svelte, jsp): `None`
|
||||
/// for every other language, CSS containers included.
|
||||
pub(crate) fn web_outline(&self, lang: &LanguageId, text: &str) -> Option<Vec<OutlineItem>> {
|
||||
let g = self.web_grammar(lang)?;
|
||||
if g.spec.name != "html" || text.len() > MAX_PARSE_BYTES {
|
||||
return None;
|
||||
}
|
||||
let tree = parse(&g.language, text, None)?;
|
||||
let query = g.outline_query()?;
|
||||
Some(outline::outline_nested(g, query, &tree, text, &|region_lang, region_text| {
|
||||
self.outline(region_lang, region_text)
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn folding(&self, lang: &LanguageId, text: &str) -> Vec<FoldRange> {
|
||||
let tokens = self.comment_tokens(lang);
|
||||
self.analyze(lang, text, |g, tree, text| Some(folding::folding(g.spec, g.kinds(), tree, text, tokens)))
|
||||
@@ -248,6 +277,19 @@ impl Syntax {
|
||||
self.with_web_tree(lang, text, |_, _, tree| web::anchors(tree, text, name)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Inline TS/JS `<script>` regions of a Vue SFC / HTML document, in
|
||||
/// document order. Empty for other languages or non-JavaScript blocks.
|
||||
pub fn script_regions(&self, lang: &LanguageId, text: &str) -> Vec<ScriptRegion> {
|
||||
self.with_web_tree(lang, text, |_g, _css, tree| sfc::script_regions(tree, text)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`Self::script_regions`] of an already parsed document (`ParsedTree`
|
||||
/// lends the tree and text) — the cache-aware way for other crates, which
|
||||
/// reuses a cached incremental HTML tree instead of parsing again.
|
||||
pub fn script_regions_of(tree: &Tree, text: &str) -> Vec<ScriptRegion> {
|
||||
sfc::script_regions(tree, text)
|
||||
}
|
||||
|
||||
/// Highlighting from the grammar's own query; `None` without one.
|
||||
pub fn highlights(&self, lang: &LanguageId, text: &str) -> Option<SyntaxHighlights> {
|
||||
self.analyze(lang, text, |g, tree, text| {
|
||||
@@ -280,6 +322,13 @@ impl Syntax {
|
||||
self.grammar(lang).map(|g| g.spec.name)
|
||||
}
|
||||
|
||||
/// Name of the grammar web features parse `lang` with (html for Vue SFC /
|
||||
/// Svelte / JSP, css for SCSS / Less); `None` when `lang` has its own
|
||||
/// grammar or no web features. Lets other crates recognize containers.
|
||||
pub fn web_grammar_name(&self, lang: &LanguageId) -> Option<&'static str> {
|
||||
self.web_grammar(lang).map(|g| g.spec.name)
|
||||
}
|
||||
|
||||
/// Grammar name and tree-sitter language for `lang`, for engines that parse
|
||||
/// on their own (structural search patterns).
|
||||
pub fn ts_language(&self, lang: &LanguageId) -> Option<(&'static str, Language)> {
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
//! macro event operator key heading impl extension.
|
||||
|
||||
use super::Grammar;
|
||||
use super::lines::LineIndex;
|
||||
use super::lines::{LineIndex, shift_range};
|
||||
use crate::SymbolTag;
|
||||
use ide_core::{OutlineItem, SymbolKind, Visibility};
|
||||
use ide_core::{LanguageId, OutlineItem, Pos, SymbolKind, Visibility};
|
||||
use std::collections::HashMap;
|
||||
use tree_sitter::{Node, Query, QueryCursor, StreamingIterator, Tree};
|
||||
|
||||
@@ -296,6 +296,69 @@ pub(crate) fn outline(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) ->
|
||||
collect(g, q, tree, text).into_iter().map(convert).collect()
|
||||
}
|
||||
|
||||
/// [`outline`], plus, for the HTML grammar, each inline `<script>` region's
|
||||
/// TS/JS declarations nested under its `script` entry and shifted to document
|
||||
/// coordinates (Vue SFC / HTML documents). `block` outlines one region's
|
||||
/// language, answering in region-local coordinates.
|
||||
pub(crate) fn outline_nested(
|
||||
g: &Grammar,
|
||||
q: &OutlineQuery,
|
||||
tree: &Tree,
|
||||
text: &str,
|
||||
block: &impl Fn(&LanguageId, &str) -> Vec<OutlineItem>,
|
||||
) -> Vec<OutlineItem> {
|
||||
let mut items = outline(g, q, tree, text);
|
||||
if g.spec.name != "html" {
|
||||
return items;
|
||||
}
|
||||
let lines = LineIndex::new(text);
|
||||
for region in super::sfc::script_regions(tree, text) {
|
||||
let mut decls = block(®ion.language, ®ion.text);
|
||||
if decls.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let base = lines.pos(region.base);
|
||||
shift_outline(&mut decls, base);
|
||||
if !attach_script(&mut items, base, &mut Some(decls)) {
|
||||
// The region and the entry come from the same `script_element` node,
|
||||
// so this means the outline query no longer matches script elements.
|
||||
tracing::debug!("outline: no script entry for the <script> region at byte {}", region.base);
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Recursively shifts region-local positions in `items` to document coordinates.
|
||||
fn shift_outline(items: &mut [OutlineItem], base: Pos) {
|
||||
for item in items {
|
||||
item.range = shift_range(item.range, base);
|
||||
item.selection_range = shift_range(item.selection_range, base);
|
||||
shift_outline(&mut item.children, base);
|
||||
}
|
||||
}
|
||||
|
||||
/// Nests `decls` under the innermost `script` entry covering `base`. The
|
||||
/// region and the entry both come from the same `script_element` node, so the
|
||||
/// entry always exists; a miss means the outline query no longer matches it.
|
||||
fn attach_script(items: &mut [OutlineItem], base: Pos, decls: &mut Option<Vec<OutlineItem>>) -> bool {
|
||||
let covers = |range: &ide_core::Range| {
|
||||
(range.start.line, range.start.col) <= (base.line, base.col)
|
||||
&& (base.line, base.col) < (range.end.line, range.end.col)
|
||||
};
|
||||
for item in items.iter_mut() {
|
||||
if item.name.eq_ignore_ascii_case("script") && covers(&item.range) {
|
||||
item.children.extend(decls.take().unwrap_or_default());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for item in items.iter_mut() {
|
||||
if attach_script(&mut item.children, base, decls) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn tags(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) -> Vec<SymbolTag> {
|
||||
fn walk(items: Vec<Built>, container: Option<&str>, out: &mut Vec<SymbolTag>) {
|
||||
for b in items {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! reStructuredText section titles for the outline. No bundled grammar nests
|
||||
//! sections (tree-sitter-rst emits them flat), so levels follow docutils: each
|
||||
//! adornment style (character, with or without overline) takes the next level
|
||||
//! the first time it appears.
|
||||
|
||||
use super::lines::LineIndex;
|
||||
use ide_core::{OutlineItem, SymbolKind};
|
||||
|
||||
struct Line<'a> {
|
||||
start: usize,
|
||||
text: &'a str,
|
||||
}
|
||||
|
||||
struct Heading {
|
||||
level: usize,
|
||||
start: usize,
|
||||
end: usize,
|
||||
name_start: usize,
|
||||
name_end: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn outline(text: &str) -> Vec<OutlineItem> {
|
||||
let lines: Vec<Line> = text
|
||||
.split_inclusive('\n')
|
||||
.scan(0, |at, raw| {
|
||||
let start = *at;
|
||||
*at += raw.len();
|
||||
Some(Line { start, text: raw.trim_end_matches(['\n', '\r']) })
|
||||
})
|
||||
.collect();
|
||||
let mut heads = headings(&lines);
|
||||
for k in 0..heads.len() {
|
||||
let end = heads[k + 1..].iter().find(|h| h.level <= heads[k].level).map_or(text.len(), |h| h.start);
|
||||
heads[k].end = heads[k].start + text[heads[k].start..end].trim_end().len();
|
||||
}
|
||||
let index = LineIndex::new(text);
|
||||
let mut i = 0;
|
||||
nest(&heads, &mut i, None, text, &index)
|
||||
}
|
||||
|
||||
fn headings(lines: &[Line]) -> Vec<Heading> {
|
||||
let mut styles: Vec<(char, bool)> = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
let mut push = |style: (char, bool), start: usize, title: &Line| {
|
||||
let level = styles.iter().position(|&s| s == style).unwrap_or_else(|| {
|
||||
styles.push(style);
|
||||
styles.len() - 1
|
||||
});
|
||||
let name = title.text.trim();
|
||||
let name_start = title.start + (title.text.len() - title.text.trim_start().len());
|
||||
out.push(Heading { level, start, end: 0, name_start, name_end: name_start + name.len() });
|
||||
};
|
||||
// A title starts a new block: it follows a blank line, the start, or another title.
|
||||
let mut at_block_start = true;
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let line = &lines[i];
|
||||
if line.text.trim().is_empty() {
|
||||
at_block_start = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if at_block_start {
|
||||
if let Some(over) = adornment(line.text) {
|
||||
if let [title, under, ..] = &lines[i + 1..]
|
||||
&& !title.text.trim().is_empty()
|
||||
&& adornment(title.text).is_none()
|
||||
&& adornment(under.text) == Some(over)
|
||||
&& fits(title.text.trim_end(), over.1)
|
||||
{
|
||||
push((over.0, true), line.start, title);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
} else if !line.text.starts_with([' ', '\t'])
|
||||
&& !starts_body_element(line.text)
|
||||
&& let Some(under) = lines.get(i + 1).and_then(|l| adornment(l.text))
|
||||
&& fits(line.text.trim_end(), under.1)
|
||||
{
|
||||
push((under.0, false), line.start, line);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Indented lines (literal blocks after `::`, directive bodies, quotes)
|
||||
// never hold titles: they are skipped here because adornments and
|
||||
// underlined titles must start in column 0.
|
||||
at_block_start = false;
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `(character, length)` of a line made of one repeated ASCII punctuation character.
|
||||
fn adornment(line: &str) -> Option<(char, usize)> {
|
||||
let line = line.trim_end();
|
||||
let c = line.chars().next().filter(char::is_ascii_punctuation)?;
|
||||
line.chars().all(|x| x == c).then_some((c, line.len()))
|
||||
}
|
||||
|
||||
/// docutils accepts a short adornment (with a warning) unless it is shorter
|
||||
/// than 4 characters, which reads as ordinary text instead.
|
||||
// ponytail: counts chars, not display columns; wide CJK titles get a looser check.
|
||||
fn fits(title: &str, adornment_len: usize) -> bool {
|
||||
adornment_len >= title.chars().count() || adornment_len >= 4
|
||||
}
|
||||
|
||||
/// Lines docutils parses as another body element before trying a title:
|
||||
/// explicit markup (`..`, `__`), bullets, field lists, doctests, line blocks.
|
||||
fn starts_body_element(line: &str) -> bool {
|
||||
let marker = |m: &str| line == m || line.strip_prefix(m).is_some_and(|r| r.starts_with(' '));
|
||||
["..", "__", "-", "*", "+", "•", ">>>", "|"].into_iter().any(marker)
|
||||
|| (line.starts_with(':') && line[1..].find(':').is_some_and(|k| k > 0 && marker(&line[..k + 2])))
|
||||
}
|
||||
|
||||
fn nest(heads: &[Heading], i: &mut usize, parent: Option<usize>, text: &str, index: &LineIndex) -> Vec<OutlineItem> {
|
||||
let mut items = Vec::new();
|
||||
while let Some(h) = heads.get(*i).filter(|h| parent.is_none_or(|p| h.level > p)) {
|
||||
*i += 1;
|
||||
items.push(OutlineItem {
|
||||
name: text[h.name_start..h.name_end].to_owned(),
|
||||
detail: None,
|
||||
kind: SymbolKind::Key,
|
||||
range: index.range(h.start, h.end),
|
||||
selection_range: index.range(h.name_start, h.name_end),
|
||||
children: nest(heads, i, Some(h.level), text, index),
|
||||
visibility: None,
|
||||
});
|
||||
}
|
||||
items
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Inline TS/JS `<script>` regions located with the HTML grammar's
|
||||
//! `script_element` nodes. The block's `lang` attribute selects the grammar.
|
||||
//!
|
||||
//! Blocks whose `type` attribute names anything but JavaScript are skipped
|
||||
//! (`application/json`, `text/template`, `importmap`, …), as are unknown
|
||||
//! `lang` values.
|
||||
|
||||
use super::web::walk;
|
||||
use crate::ScriptRegion;
|
||||
use ide_core::LanguageId;
|
||||
use tree_sitter::{Node, Tree};
|
||||
|
||||
/// The TS/JS grammar a `lang` attribute value names; `None` skips the block.
|
||||
fn lang_grammar(value: &str) -> Option<&'static str> {
|
||||
match value {
|
||||
"ts" | "typescript" => Some("typescript"),
|
||||
"tsx" => Some("tsx"),
|
||||
"" | "js" | "jsx" | "javascript" => Some("javascript"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when a `type` attribute value names JavaScript (or is absent).
|
||||
fn is_js_type(value: &str) -> bool {
|
||||
matches!(value, "" | "module" | "text/javascript" | "application/javascript")
|
||||
}
|
||||
|
||||
/// The `<script>` regions of a document parsed with the HTML grammar,
|
||||
/// in document order. `base` is each block's byte offset into the document.
|
||||
pub(crate) fn script_regions(tree: &Tree, text: &str) -> Vec<ScriptRegion> {
|
||||
let mut out = Vec::new();
|
||||
walk(tree.root_node(), &mut |n| {
|
||||
if n.kind() != "script_element" {
|
||||
return true;
|
||||
}
|
||||
let mut lang = "javascript";
|
||||
let mut skip = false;
|
||||
let mut c = n.walk();
|
||||
for ch in n.children(&mut c) {
|
||||
match ch.kind() {
|
||||
// The start tag precedes the raw text, so the attributes are
|
||||
// known before the region is taken.
|
||||
"start_tag" => {
|
||||
let mut c2 = ch.walk();
|
||||
for attr in ch.children(&mut c2) {
|
||||
if attr.kind() != "attribute" {
|
||||
continue;
|
||||
}
|
||||
let (name, value) = attribute(attr, text);
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"lang" => match lang_grammar(&value.to_ascii_lowercase()) {
|
||||
Some(g) => lang = g,
|
||||
None => skip = true,
|
||||
},
|
||||
"type" if !is_js_type(&value.trim().to_ascii_lowercase()) => skip = true,
|
||||
"src" => skip = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"raw_text" if !skip => {
|
||||
out.push(ScriptRegion {
|
||||
base: ch.start_byte(),
|
||||
language: LanguageId::new(lang),
|
||||
text: text[ch.start_byte()..ch.end_byte()].to_string(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false // the raw text is not markup
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// An attribute's `(name, value)`; a bare attribute (`setup`) has an empty value.
|
||||
fn attribute<'t>(attr: Node<'t>, text: &'t str) -> (&'t str, &'t str) {
|
||||
let mut c = attr.walk();
|
||||
let mut name = "";
|
||||
let mut value = "";
|
||||
for ch in attr.children(&mut c) {
|
||||
match ch.kind() {
|
||||
"attribute_name" => name = ch.utf8_text(text.as_bytes()).unwrap_or(""),
|
||||
"quoted_attribute_value" => {
|
||||
value = ch.named_child(0).and_then(|v| v.utf8_text(text.as_bytes()).ok()).unwrap_or("");
|
||||
}
|
||||
"attribute_value" => value = ch.utf8_text(text.as_bytes()).unwrap_or(""),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(name, value)
|
||||
}
|
||||
@@ -759,6 +759,7 @@ fn outline_html_css_xml() {
|
||||
Field body
|
||||
Field div
|
||||
Field script
|
||||
Variable a
|
||||
",
|
||||
);
|
||||
check_outline(
|
||||
@@ -1409,3 +1410,319 @@ fn spelling_regions_cover_declared_names_only() {
|
||||
.collect();
|
||||
assert_eq!(names, ["Bird", "fly", "text"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Script regions of Vue SFCs and HTML documents
|
||||
|
||||
#[test]
|
||||
fn script_regions_skip_typed_blocks() {
|
||||
let src = "<html><body><script type=\"module\">var x = 1;</script>\
|
||||
<script type=\"application/json\">{\"a\":1}</script>\
|
||||
<script type=\"importmap\">{}</script>\
|
||||
<script>var y = 2;</script></body></html>";
|
||||
let regions = syntax().script_regions(&lang("html"), src);
|
||||
assert_eq!(regions.len(), 2, "module and plain scripts; json/importmap skipped");
|
||||
assert_eq!(regions[0].language.as_str(), "javascript");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_pick_the_lang_attribute() {
|
||||
let src = "<script setup lang=\"tsx\">\nconst App = () => <div/>\n</script>\n";
|
||||
let regions = syntax().script_regions(&lang("vue"), src);
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert_eq!(regions[0].language.as_str(), "tsx");
|
||||
assert!(regions[0].text.contains("const App"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_empty_for_other_languages() {
|
||||
assert!(syntax().script_regions(&lang("rust"), "fn main() {}").is_empty());
|
||||
assert!(syntax().script_regions(&lang("vue"), "<template><div/></template>").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_classify_attributes() {
|
||||
for language in ["vue", "html"] {
|
||||
for (attributes, expected) in [
|
||||
("", Some("javascript")),
|
||||
("setup lang=ts", Some("typescript")),
|
||||
("lang='typescript'", Some("typescript")),
|
||||
("lang=tsx", Some("tsx")),
|
||||
("lang=js", Some("javascript")),
|
||||
("lang=jsx", Some("javascript")),
|
||||
("LANG=TS", Some("typescript")),
|
||||
("type=module", Some("javascript")),
|
||||
("TYPE=' text/JAVASCRIPT '", Some("javascript")),
|
||||
("type=application/javascript", Some("javascript")),
|
||||
("type=application/json", None),
|
||||
("type='application/ld+json'", None),
|
||||
("type=text/template", None),
|
||||
("type=importmap", None),
|
||||
("type=speculationrules", None),
|
||||
("lang=coffee", None),
|
||||
("lang=ts type=application/json", None),
|
||||
("src='./external.ts' lang=ts", None),
|
||||
("SRC=external.js", None),
|
||||
("src", None),
|
||||
] {
|
||||
let src = format!("<script {attributes}>const value = 1;</script>");
|
||||
let regions = syntax().script_regions(&lang(language), &src);
|
||||
assert_eq!(regions.len(), usize::from(expected.is_some()), "{language}: {attributes}");
|
||||
if let Some(expected) = expected {
|
||||
assert_eq!(regions[0].language.as_str(), expected, "{attributes}");
|
||||
assert_eq!(regions[0].text, "const value = 1;");
|
||||
assert_eq!(&src[regions[0].base..regions[0].base + regions[0].text.len()], regions[0].text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_preserve_document_offsets_and_line_endings() {
|
||||
use super::lines::LineIndex;
|
||||
|
||||
let src = "<template>中文😀</template>\r\n<script lang=ts>const 名 = '😀';\r\n</script>\r\n<script setup lang=jsx>const View = () => <div/>;</script>";
|
||||
let regions = syntax().script_regions(&lang("vue"), src);
|
||||
assert_eq!(regions.len(), 2);
|
||||
let idx = LineIndex::new(src);
|
||||
let doc = ide_text::Document::new(src);
|
||||
for (region, (body, pos)) in
|
||||
regions.iter().zip([("const 名 = '😀';\r\n", Pos::new(1, 16)), ("const View = () => <div/>;", Pos::new(3, 23))])
|
||||
{
|
||||
assert_eq!(region.base, src.find(body).unwrap());
|
||||
assert_eq!(region.text, body);
|
||||
assert_eq!(idx.pos(region.base), pos);
|
||||
assert_eq!(idx.pos(region.base), doc.byte_to_pos(src[..region.base].replace("\r\n", "\n").len()));
|
||||
assert_eq!(
|
||||
idx.pos(region.base + body.len()),
|
||||
doc.byte_to_pos(src[..region.base + body.len()].replace("\r\n", "\n").len())
|
||||
);
|
||||
if let Some(name) = body.find('名') {
|
||||
assert_eq!(
|
||||
idx.range(region.base + name, region.base + name + '名'.len_utf8()),
|
||||
Range::new(Pos::new(1, 22), Pos::new(1, 23))
|
||||
);
|
||||
}
|
||||
}
|
||||
let same_line = "<template>中文😀</template><script>let x = 1;</script>";
|
||||
let region = &syntax().script_regions(&lang("html"), same_line)[0];
|
||||
assert_eq!(LineIndex::new(same_line).pos(region.base), Pos::new(0, 33));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_tolerate_incomplete_markup() {
|
||||
for src in [
|
||||
"<script",
|
||||
"<script lang=",
|
||||
"<script lang='ts",
|
||||
"<script>const unfinished =",
|
||||
"<template><div><script lang=ts>const x = 1;</script>",
|
||||
"<script></script>",
|
||||
"<script src='missing.js'>ignored</script><script>const x = 1;",
|
||||
] {
|
||||
for region in syntax().script_regions(&lang("vue"), src) {
|
||||
assert_eq!(src.get(region.base..region.base + region.text.len()), Some(region.text.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_large_vue_document() {
|
||||
for count in [2_000, 4_000, 8_000] {
|
||||
let src =
|
||||
"<template><div>中文😀</div></template>\r\n<script setup lang=ts>const x = 1;</script>\r\n".repeat(count);
|
||||
let start = std::time::Instant::now();
|
||||
let regions = syntax().script_regions(&lang("vue"), &src);
|
||||
eprintln!("script regions: {} bytes, {count} blocks: {:?}", src.len(), start.elapsed());
|
||||
assert_eq!(regions.len(), count);
|
||||
assert!(regions.windows(2).all(|pair| pair[0].base < pair[1].base));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_regions_tolerate_deep_markup() {
|
||||
let src = "<div>".repeat(1_000) + "<script>const x = 1;</script>";
|
||||
let regions = syntax().script_regions(&lang("html"), &src);
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert_eq!(regions[0].base, src.find("const").unwrap());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outline with nested <script> declarations (Vue SFC / HTML)
|
||||
|
||||
#[test]
|
||||
fn html_outline_nests_script_declarations() {
|
||||
let src = "<html><body>\n<script>\nfunction boot() {}\nconst app = 1;\n</script>\n\
|
||||
<script type=\"application/json\">{\"a\": 1}</script>\n</body></html>";
|
||||
check_outline(
|
||||
"html",
|
||||
src,
|
||||
"
|
||||
Field html
|
||||
Field body
|
||||
Field script
|
||||
Function boot
|
||||
Constant app
|
||||
Field script
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vue_outline_nests_script_declarations_without_a_grammar() {
|
||||
let src = "<template>\n <div class=\"app\"/>\n</template>\n\
|
||||
<script setup lang=\"ts\">\ninterface Props { size: number }\nfunction scale(n: number) {}\n</script>\n\
|
||||
<style scoped>\n.app { color: red }\n</style>";
|
||||
check_outline(
|
||||
"vue",
|
||||
src,
|
||||
"
|
||||
Field template
|
||||
Field script
|
||||
Interface Props
|
||||
Property size
|
||||
Function scale
|
||||
Field style
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_outline_positions_are_document_coordinates() {
|
||||
use super::lines::LineIndex;
|
||||
let src = "<template><!-- 😀 --></template>\r\n<script lang=\"ts\">\r\nconst answer = 42;\r\n</script>";
|
||||
let outline = syntax().outline(&lang("vue"), src);
|
||||
let decl = &outline.iter().find(|i| i.name == "script").unwrap().children[0];
|
||||
// Cross-check against the document's own byte -> UTF-16 conversion.
|
||||
let idx = LineIndex::new(src);
|
||||
let at = src.find("answer").unwrap();
|
||||
assert_eq!(decl.selection_range, idx.range(at, at + "answer".len()));
|
||||
// `@def.constant` captures the declarator (`answer = 42`), not the `const` keyword.
|
||||
assert_eq!(decl.range, idx.range(at, src.find("42;").unwrap() + 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_outline_matches_syntax_outline_for_web_documents() {
|
||||
use ide_core::DocId;
|
||||
use std::sync::Arc;
|
||||
let cache = SyntaxCache::new(Arc::new(Syntax::new()));
|
||||
let id = DocId::next();
|
||||
let src = "<template><div/></template>\n<script lang=\"ts\">\nconst app = 1;\n</script>\n";
|
||||
cache.open(id, &lang("vue"), 0);
|
||||
assert_eq!(cache.outline(id, &lang("vue"), 0, src), syntax().outline(&lang("vue"), src));
|
||||
cache.open(id, &lang("html"), 0);
|
||||
assert_eq!(cache.outline(id, &lang("html"), 0, src), syntax().outline(&lang("html"), src));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_outlines_compose_positions_across_multiple_blocks() {
|
||||
use super::lines::LineIndex;
|
||||
|
||||
let src = "<template>中文😀</template><script lang=ts>/* 中文😀 */ function first() { return 1; }</script>\r\n<script setup lang=ts>/* 中文😀 */ const second = 2;\r\nfunction third() {}\r\n</script>";
|
||||
let lines = LineIndex::new(src);
|
||||
for id in ["html", "vue", "svelte", "jsp"] {
|
||||
let items = syntax().outline(&lang(id), src);
|
||||
let scripts: Vec<_> = items.iter().filter(|item| item.name == "script").collect();
|
||||
assert_eq!(scripts.len(), 2, "{id}");
|
||||
assert_eq!(scripts[0].children.len(), 1);
|
||||
assert_eq!(scripts[1].children.len(), 2);
|
||||
for (item, name) in scripts.iter().flat_map(|s| &s.children).zip(["first", "second", "third"]) {
|
||||
let byte = src.find(name).unwrap();
|
||||
assert_eq!(item.name, name);
|
||||
assert_eq!(item.selection_range, lines.range(byte, byte + name.len()), "{id}: {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_outline_attaches_to_case_insensitive_html_tags() {
|
||||
check_outline("html", "<SCRIPT>function boot() {}</SCRIPT>", "Field SCRIPT\n Function boot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_restructuredtext_levels_follow_first_use_of_each_adornment() {
|
||||
let src = "\
|
||||
=========
|
||||
Project
|
||||
=========
|
||||
|
||||
Install
|
||||
~~~~~~~
|
||||
|
||||
From source
|
||||
-----------
|
||||
|
||||
Literal block::
|
||||
|
||||
Not a title
|
||||
-----------
|
||||
|
||||
Intro
|
||||
--
|
||||
|
||||
A paragraph
|
||||
with a line
|
||||
------------
|
||||
|
||||
.. comment
|
||||
-----------
|
||||
|
||||
Usage
|
||||
~~~~~
|
||||
|
||||
.. note::
|
||||
|
||||
Indented
|
||||
--------
|
||||
|
||||
Short underline, still a title
|
||||
------
|
||||
|
||||
Underline only
|
||||
==============
|
||||
";
|
||||
check_outline(
|
||||
"restructuredtext",
|
||||
src,
|
||||
"
|
||||
Key Project
|
||||
Key Install
|
||||
Key From source
|
||||
Key Usage
|
||||
Key Short underline, still a title
|
||||
Key Underline only
|
||||
",
|
||||
);
|
||||
let items = syntax().outline(&lang("restructuredtext"), src);
|
||||
let project = &items[0];
|
||||
assert_eq!(project.range.start, Pos::new(0, 0));
|
||||
assert_eq!((project.selection_range.start, project.selection_range.end), (Pos::new(1, 1), Pos::new(1, 8)));
|
||||
let install = &project.children[0];
|
||||
assert_eq!((install.range.start, install.range.end), (Pos::new(4, 0), Pos::new(23, 11)));
|
||||
assert!(syntax().outline(&lang("restructuredtext"), "Title\n\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_restructuredtext_unicode_crlf_and_cached_edits() {
|
||||
use ide_core::DocId;
|
||||
use std::sync::Arc;
|
||||
|
||||
let src = "=========\r\n 中文😀\r\n=========\r\n\r\n子章节\r\n------\r\n\r\nBody\r\n\r\nOther\r\n------\r\n";
|
||||
let id = DocId::next();
|
||||
let language = lang("restructuredtext");
|
||||
let cache = SyntaxCache::new(Arc::new(Syntax::new()));
|
||||
cache.open(id, &language, 0);
|
||||
let items = cache.outline(id, &language, 0, src);
|
||||
assert_eq!(items, syntax().outline(&language, src));
|
||||
assert_eq!(items[0].name, "中文😀");
|
||||
assert_eq!(items[0].selection_range, Range::new(Pos::new(1, 1), Pos::new(1, 5)));
|
||||
assert_eq!(items[0].children[0].name, "子章节");
|
||||
assert_eq!(items[0].children[0].selection_range, Range::new(Pos::new(4, 0), Pos::new(4, 3)));
|
||||
assert_eq!(items[0].children[0].range.end, Pos::new(7, 4));
|
||||
assert_eq!(items[0].children[1].name, "Other");
|
||||
let edited = src.replace("子章节", "新章节😀");
|
||||
let updated = cache.outline(id, &language, 1, &edited);
|
||||
assert_eq!(updated, syntax().outline(&language, &edited));
|
||||
assert_eq!(updated[0].children[0].selection_range.end, Pos::new(4, 5));
|
||||
assert_ne!(items, updated);
|
||||
}
|
||||
@@ -81,13 +81,20 @@ fn css_regions<'t>(grammar: &str, tree: &Tree, text: &'t str) -> Vec<(usize, &'t
|
||||
}
|
||||
|
||||
/// Pre-order walk; `f` returns whether to descend.
|
||||
fn walk<'t>(n: Node<'t>, f: &mut dyn FnMut(Node<'t>) -> bool) {
|
||||
if !f(n) {
|
||||
return;
|
||||
}
|
||||
let mut c = n.walk();
|
||||
for child in n.children(&mut c) {
|
||||
walk(child, f);
|
||||
pub(crate) fn walk<'t>(n: Node<'t>, f: &mut dyn FnMut(Node<'t>) -> bool) {
|
||||
let mut cursor = n.walk();
|
||||
loop {
|
||||
if f(cursor.node()) && cursor.goto_first_child() {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
if cursor.goto_next_sibling() {
|
||||
break;
|
||||
}
|
||||
if !cursor.goto_parent() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Arduino sketches need their own board context, including their C/C++ sources.
|
||||
|
||||
use crate::LspManager;
|
||||
use crate::server::Server;
|
||||
use ide_core::{LanguageId, Location};
|
||||
use serde_json::Value;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) const SERVER: &str = "arduino-language-server";
|
||||
|
||||
pub(crate) fn sketch_root(project: &Path, file: &Path) -> Option<PathBuf> {
|
||||
ide_language::arduino_sketch_root(project, file)
|
||||
}
|
||||
|
||||
pub(crate) fn configure_client_capabilities(capabilities: &mut Value) {
|
||||
// Arduino's proxy cannot decode the boolean full form, panics on inlayHint (#225),
|
||||
// and cannot handle clangd's semanticTokens refresh request (#125).
|
||||
for section in ["textDocument", "workspace"] {
|
||||
if let Some(section) = capabilities[section].as_object_mut() {
|
||||
section.remove("semanticTokens");
|
||||
section.remove("inlayHint");
|
||||
}
|
||||
}
|
||||
// ALS uses go.bug.st/lsp (LSP 3.14) which requires a `range` field on `lsp.TextEdit`.
|
||||
// Clangd returns `InsertReplaceEdit` (`insert` + `replace`) when `insertReplaceSupport` is advertised,
|
||||
// causing ALS to drop the connection with "json: undefined required field range into lsp.TextEdit".
|
||||
if let Some(completion) = capabilities.get_mut("textDocument").and_then(|t| t.get_mut("completion"))
|
||||
&& let Some(item) = completion.get_mut("completionItem").and_then(|c| c.as_object_mut())
|
||||
{
|
||||
item.remove("insertReplaceSupport");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_server_capabilities(capabilities: &mut Value) {
|
||||
// Arduino Language Server's request dispatcher advertises documentSymbol and
|
||||
// workspaceSymbol, but panics with "not implemented" / "unimplemented" when called.
|
||||
// Tree-sitter handles C++ outline/symbols in OpenIDEA without hitting ALS.
|
||||
if let Some(obj) = capabilities.as_object_mut() {
|
||||
obj.remove("documentSymbolProvider");
|
||||
obj.remove("workspaceSymbolProvider");
|
||||
}
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
pub(crate) fn arduino_servers(&self, language: &LanguageId, path: &Path) -> Option<Vec<Arc<Server>>> {
|
||||
if !matches!(language.as_str(), "c" | "cpp") {
|
||||
return None;
|
||||
}
|
||||
let configs = self.language_servers(&LanguageId::new("arduino"));
|
||||
let cfg = configs.iter().find(|cfg| cfg.name == SERVER)?;
|
||||
let project = self.project_root_for(path);
|
||||
if sketch_root(&project, path).is_some() {
|
||||
let server = self.server_for(cfg, &LanguageId::new("arduino"), path);
|
||||
server.add_language(language);
|
||||
let mut servers = vec![server];
|
||||
servers.extend(
|
||||
self.language_servers(language)
|
||||
.iter()
|
||||
.filter(|cfg| cfg.name != "clangd" && cfg.name != SERVER)
|
||||
.filter(|cfg| {
|
||||
!cfg.require_root_marker
|
||||
|| crate::launch::marked_root(&project, path, &cfg.root_markers).is_some()
|
||||
})
|
||||
.map(|cfg| self.server_for(cfg, language, path)),
|
||||
);
|
||||
return Some(servers);
|
||||
}
|
||||
if !path.starts_with(&project)
|
||||
&& let Some(server) = self.origin_server(&crate::uri::path_to_uri_string(path))
|
||||
&& server.name == SERVER
|
||||
{
|
||||
return Some(vec![server]);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn note_arduino_sources(&self, server: &Arc<Server>, locations: &[Location]) {
|
||||
if server.name != SERVER {
|
||||
return;
|
||||
}
|
||||
let mut origins = None;
|
||||
for location in locations {
|
||||
let path: &Path = &location.path;
|
||||
if location.uri.is_none() && !path.starts_with(self.project_root_for(path)) {
|
||||
origins
|
||||
.get_or_insert_with(|| self.virtual_origins.lock())
|
||||
.insert(crate::uri::path_to_uri_string(path), server.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bundled_server_allows_diagnostics() {
|
||||
let registry = ide_language::LanguageRegistry::load(None).unwrap();
|
||||
let language = registry.get(&LanguageId::new("arduino")).unwrap();
|
||||
let cfg = language.language_servers.iter().find(|cfg| cfg.name == SERVER).unwrap();
|
||||
assert!(crate::caps::Restriction::new(SERVER, &cfg.features, &cfg.code_action_kinds).diagnostics());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inspection_checks_the_sketch_server_and_root_for_all_sources() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let sketch = root.join("examples/blink");
|
||||
std::fs::create_dir_all(&sketch).unwrap();
|
||||
std::fs::write(sketch.join("blink.ino"), "").unwrap();
|
||||
let registry = Arc::new(ide_language::LanguageRegistry::load(None).unwrap());
|
||||
let manager = LspManager::new(root.to_path_buf(), registry, Arc::new(|_: ide_core::IdeEvent| {}));
|
||||
let expected_root = sketch.clone();
|
||||
manager.set_config_hook(Box::new(move |cfg, server_root| {
|
||||
cfg.command = std::env::current_exe().unwrap().to_string_lossy().into_owned();
|
||||
if cfg.name != SERVER || server_root != expected_root {
|
||||
cfg.launch_error = Some("unavailable".into());
|
||||
}
|
||||
}));
|
||||
for name in ["blink.ino", "helper.cpp", "helper.h"] {
|
||||
assert!(manager.serves_path(&sketch.join(name)), "{name}");
|
||||
}
|
||||
assert!(!manager.serves_path(&root.join("main.cpp")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_sketches_keep_separate_board_roots() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let inner = root.join("examples/blink");
|
||||
std::fs::create_dir_all(inner.join("src")).unwrap();
|
||||
std::fs::write(root.join("main.ino"), "").unwrap();
|
||||
std::fs::write(inner.join("blink.ino"), "").unwrap();
|
||||
assert_eq!(sketch_root(root, &inner.join("blink.ino")), Some(inner.clone()));
|
||||
assert_eq!(sketch_root(root, &inner.join("src/helper.cpp")), Some(inner));
|
||||
assert_eq!(sketch_root(root, &root.join("helper.h")), Some(root.to_path_buf()));
|
||||
assert_eq!(sketch_root(&root.join("examples"), &root.join("other.cpp")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_cpp_projects_and_ino_named_directories_are_not_sketches() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join("not-a-file.ino")).unwrap();
|
||||
assert_eq!(sketch_root(dir.path(), &dir.path().join("main.cpp")), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cpp_routing_is_scoped_to_sketches_and_respects_disabled_arduino() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let sketch = root.join("examples/blink");
|
||||
std::fs::create_dir_all(&sketch).unwrap();
|
||||
std::fs::write(sketch.join("blink.ino"), "").unwrap();
|
||||
let registry = Arc::new(ide_language::LanguageRegistry::load(None).unwrap());
|
||||
let manager = LspManager::new(root.to_path_buf(), registry, Arc::new(|_: ide_core::IdeEvent| {}));
|
||||
let cpp = LanguageId::new("cpp");
|
||||
let sketch_servers = manager.servers_for(&cpp, &sketch.join("helper.cpp"));
|
||||
assert_eq!(sketch_servers.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), [SERVER]);
|
||||
assert_eq!(sketch_servers[0].root, sketch);
|
||||
assert!(sketch_servers[0].languages().contains(&LanguageId::new("arduino")));
|
||||
assert_eq!(manager.servers_for(&cpp, &root.join("main.cpp"))[0].name, "clangd");
|
||||
manager.set_servers_hook(Box::new(
|
||||
|language, defined| {
|
||||
if language.as_str() == "arduino" { Vec::new() } else { defined.to_vec() }
|
||||
},
|
||||
));
|
||||
assert_eq!(manager.servers_for(&cpp, &sketch.join("helper.cpp"))[0].name, "clangd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_are_sanitized_for_arduino_proxy_quirks() {
|
||||
let mut client = serde_json::json!({
|
||||
"textDocument": {
|
||||
"semanticTokens": { "dynamicRegistration": false },
|
||||
"inlayHint": { "dynamicRegistration": false },
|
||||
"completion": {
|
||||
"completionItem": {
|
||||
"snippetSupport": true,
|
||||
"insertReplaceSupport": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"semanticTokens": { "refreshSupport": true },
|
||||
"inlayHint": { "refreshSupport": true }
|
||||
}
|
||||
});
|
||||
configure_client_capabilities(&mut client);
|
||||
assert!(client["textDocument"].get("semanticTokens").is_none());
|
||||
assert!(client["textDocument"].get("inlayHint").is_none());
|
||||
assert!(client["textDocument"]["completion"]["completionItem"].get("insertReplaceSupport").is_none());
|
||||
assert_eq!(client["textDocument"]["completion"]["completionItem"]["snippetSupport"], true);
|
||||
assert!(client["workspace"].get("semanticTokens").is_none());
|
||||
assert!(client["workspace"].get("inlayHint").is_none());
|
||||
|
||||
let mut server = serde_json::json!({
|
||||
"documentSymbolProvider": {},
|
||||
"workspaceSymbolProvider": {},
|
||||
"completionProvider": { "triggerCharacters": ["."] },
|
||||
"hoverProvider": true
|
||||
});
|
||||
sanitize_server_capabilities(&mut server);
|
||||
assert!(server.get("documentSymbolProvider").is_none());
|
||||
assert!(server.get("workspaceSymbolProvider").is_none());
|
||||
assert!(server.get("completionProvider").is_some());
|
||||
assert!(server.get("hoverProvider").is_some());
|
||||
}
|
||||
}
|
||||
@@ -301,6 +301,7 @@ pub fn completion_item(conv: &Conv, path: &Path, item: lsp::CompletionItem, serv
|
||||
let mut out = CompletionItem {
|
||||
label: item.label.clone(),
|
||||
label_detail: None,
|
||||
label_description: None,
|
||||
detail: None,
|
||||
kind: None,
|
||||
documentation: None,
|
||||
@@ -349,6 +350,9 @@ pub fn overlay_completion(conv: &Conv, path: &Path, out: &mut CompletionItem, it
|
||||
if let Some(d) = label_details.and_then(|d| d.detail) {
|
||||
out.label_detail = Some(d);
|
||||
}
|
||||
if let Some(d) = description.clone() {
|
||||
out.label_description = Some(d);
|
||||
}
|
||||
if let Some(d) = detail.or(description) {
|
||||
out.detail = Some(d);
|
||||
}
|
||||
@@ -425,10 +429,12 @@ pub fn lsp_completion_item(text: &DocText, enc: Encoding, item: &CompletionItem)
|
||||
});
|
||||
lsp::CompletionItem {
|
||||
label: item.label.clone(),
|
||||
label_details: item
|
||||
.label_detail
|
||||
.clone()
|
||||
.map(|detail| lsp::CompletionItemLabelDetails { detail: Some(detail), description: None }),
|
||||
label_details: (item.label_detail.is_some() || item.label_description.is_some()).then(|| {
|
||||
lsp::CompletionItemLabelDetails {
|
||||
detail: item.label_detail.clone(),
|
||||
description: item.label_description.clone(),
|
||||
}
|
||||
}),
|
||||
kind: item.kind.map(lsp_completion_kind),
|
||||
detail: item.detail.clone(),
|
||||
documentation: item.documentation.as_ref().map(lsp_documentation),
|
||||
@@ -562,10 +568,18 @@ pub fn lsp_command(c: &Command) -> lsp::Command {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code_action(conv: &Conv, a: lsp::CodeActionOrCommand, server: ServerId) -> CodeAction {
|
||||
pub fn code_action(
|
||||
conv: &Conv,
|
||||
path: &Path,
|
||||
a: lsp::CodeActionOrCommand,
|
||||
server: ServerId,
|
||||
server_name: &str,
|
||||
) -> CodeAction {
|
||||
match a {
|
||||
lsp::CodeActionOrCommand::Command(c) => CodeAction {
|
||||
title: c.title.clone(),
|
||||
diagnostics: Vec::new(),
|
||||
original_diagnostics: None,
|
||||
kind: None,
|
||||
is_preferred: false,
|
||||
disabled_reason: None,
|
||||
@@ -576,6 +590,13 @@ pub fn code_action(conv: &Conv, a: lsp::CodeActionOrCommand, server: ServerId) -
|
||||
},
|
||||
lsp::CodeActionOrCommand::CodeAction(a) => CodeAction {
|
||||
title: a.title,
|
||||
original_diagnostics: a.diagnostics.as_ref().and_then(|d| serde_json::to_string(d).ok()),
|
||||
diagnostics: a
|
||||
.diagnostics
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|d| diagnostic(conv, path, d, server_name))
|
||||
.collect(),
|
||||
kind: a.kind.map(|k| k.as_str().to_owned()),
|
||||
is_preferred: a.is_preferred.unwrap_or(false),
|
||||
disabled_reason: a.disabled.map(|d| d.reason),
|
||||
@@ -591,7 +612,7 @@ pub fn lsp_code_action(a: &CodeAction) -> lsp::CodeAction {
|
||||
lsp::CodeAction {
|
||||
title: a.title.clone(),
|
||||
kind: a.kind.clone().map(lsp::CodeActionKind::from),
|
||||
diagnostics: None,
|
||||
diagnostics: a.original_diagnostics.as_deref().and_then(|d| serde_json::from_str(d).ok()),
|
||||
edit: None,
|
||||
command: a.command.as_ref().map(lsp_command),
|
||||
is_preferred: a.is_preferred.then_some(true),
|
||||
@@ -1067,6 +1088,66 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn code_action_diagnostics_use_ide_coordinates_and_round_trip_for_resolve() {
|
||||
let path = Path::new("/go.mod");
|
||||
let conv = Conv::new(Encoding::Utf8, |_| Some(Arc::new(DocText::new("😀 dependency"))));
|
||||
let raw: lsp::Diagnostic = serde_json::from_value(json!({
|
||||
"range": { "start": { "line": 0, "character": 5 }, "end": { "line": 0, "character": 15 } },
|
||||
"message": "github.com/sergi/go-diff is not used in this module",
|
||||
"severity": 2,
|
||||
"code": 123,
|
||||
"source": "go mod tidy",
|
||||
"data": { "fix": "remove" }
|
||||
}))
|
||||
.unwrap();
|
||||
let incoming = lsp::CodeAction {
|
||||
title: "Remove dependency: github.com/sergi/go-diff".into(),
|
||||
diagnostics: Some(vec![raw.clone()]),
|
||||
..Default::default()
|
||||
};
|
||||
let action = code_action(&conv, path, lsp::CodeActionOrCommand::CodeAction(incoming), ServerId(1), "gopls");
|
||||
assert_eq!(action.diagnostics, vec![diagnostic(&conv, path, raw.clone(), "gopls")]);
|
||||
assert_eq!(action.diagnostics[0].range, Range::new(Pos::new(0, 3), Pos::new(0, 13)));
|
||||
assert_eq!(action.diagnostics[0].code.as_deref(), Some("123"));
|
||||
let action: CodeAction = serde_json::from_value(serde_json::to_value(action).unwrap()).unwrap();
|
||||
assert_eq!(lsp_code_action(&action).diagnostics, Some(vec![raw.clone()]));
|
||||
let resolved = merge_code_action(&conv, action, lsp::CodeAction::default());
|
||||
assert_eq!(lsp_code_action(&resolved).diagnostics, Some(vec![raw]));
|
||||
assert_eq!(resolved.diagnostics.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_actions_without_diagnostics_keep_them_absent_for_resolve() {
|
||||
let conv = Conv::utf16();
|
||||
for incoming in [
|
||||
lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { title: "Fix".into(), ..Default::default() }),
|
||||
lsp::CodeActionOrCommand::Command(lsp::Command {
|
||||
title: "Fix".into(),
|
||||
command: "fix".into(),
|
||||
arguments: None,
|
||||
}),
|
||||
] {
|
||||
let action = code_action(&conv, Path::new("/a"), incoming, ServerId(1), "srv");
|
||||
assert!(action.diagnostics.is_empty());
|
||||
assert!(lsp_code_action(&action).diagnostics.is_none());
|
||||
}
|
||||
let raw = lsp::Diagnostic::new_simple(lsp::Range::default(), "problem".into());
|
||||
let expected = diagnostic(&conv, Path::new("/a"), raw.clone(), "srv");
|
||||
let action = code_action(
|
||||
&conv,
|
||||
Path::new("/a"),
|
||||
lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction {
|
||||
diagnostics: Some(vec![raw]),
|
||||
..Default::default()
|
||||
}),
|
||||
ServerId(1),
|
||||
"srv",
|
||||
);
|
||||
assert_eq!(action.diagnostics, vec![expected]);
|
||||
assert_eq!(action.diagnostics[0].source.as_deref(), Some("srv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_rename_edits_keep_the_first_servers_ones() {
|
||||
let edit = |l: u32, c: u32, text: &str| TextEdit {
|
||||
@@ -1209,7 +1290,8 @@ mod tests {
|
||||
assert_eq!(a.insert_text, "println!($0)");
|
||||
assert!(a.is_snippet);
|
||||
assert_eq!(a.label_detail.as_deref(), Some("(…)"));
|
||||
assert_eq!(a.detail.as_deref(), Some("std"));
|
||||
assert_eq!(a.label_description.as_deref(), Some("std"));
|
||||
assert_eq!(a.detail.as_deref(), Some("std"), "no `detail` from the server: the description stands in");
|
||||
assert_eq!(a.kind, Some(CompletionKind::Function));
|
||||
assert_eq!(a.replace_range, Some(Range::new(p(0, 4), p(0, 6))));
|
||||
assert_eq!(a.replace_range_tab, Some(Range::new(p(0, 4), p(0, 9))));
|
||||
@@ -1225,6 +1307,9 @@ mod tests {
|
||||
let back = lsp_completion_item(&text, Encoding::Utf16, &a);
|
||||
assert_eq!(back.data, Some(json!({"k": 1})));
|
||||
assert!(matches!(back.text_edit, Some(lsp::CompletionTextEdit::InsertAndReplace(_))));
|
||||
let details = back.label_details.expect("label details survive the resolve round trip");
|
||||
assert_eq!(details.detail.as_deref(), Some("(…)"));
|
||||
assert_eq!(details.description.as_deref(), Some("std"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1255,6 +1340,10 @@ mod tests {
|
||||
let item = completion_item(&Conv::utf16(), Path::new("/Main.java"), items[0].clone(), ServerId(1));
|
||||
assert_eq!(item.replace_range, Some(Range::new(p(2, 19), p(2, end))));
|
||||
assert_eq!(item.replace_range_tab, None);
|
||||
// The list shows `print (Object obj)` and `void`; the long signature is only for the docs panel.
|
||||
assert_eq!(item.label_detail.as_deref(), Some("(Object obj)"));
|
||||
assert_eq!(item.label_description.as_deref(), Some("void"));
|
||||
assert_eq!(item.detail.as_deref(), Some("PrintStream.print(Object obj) : void"));
|
||||
assert_eq!(item.filter_text.as_deref(), Some("print(Object obj) : void"));
|
||||
assert_eq!(item.insert_text, "print(${0});");
|
||||
assert!(item.is_snippet);
|
||||
|
||||
@@ -268,6 +268,12 @@ impl Handler for ClientHandler {
|
||||
"window/showMessage" => self.show_message(&mgr, ¶ms),
|
||||
"window/logMessage" => {
|
||||
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
|
||||
if self.server.name == "jdtls"
|
||||
&& text.contains("Can't use Java ")
|
||||
&& text.contains(" to import Gradle project ")
|
||||
{
|
||||
self.show_message(&mgr, &json!({ "type": 1, "message": text.lines().next().unwrap_or(text) }));
|
||||
}
|
||||
match params.get("type").and_then(Value::as_u64) {
|
||||
Some(1) if self.server.name == "jdtls" && text.contains(crate::jdtls::reserved_names::IMPORT_FAILURE) => {
|
||||
tracing::warn!("[{}] {text}", self.server.name);
|
||||
|
||||
@@ -63,6 +63,17 @@ impl LspManager {
|
||||
/// run doesn't prompt to install every language it meets.
|
||||
pub fn serves_path(&self, path: &Path) -> bool {
|
||||
let lang = self.languages.detect(path, None);
|
||||
let arduino = if lang.as_str() == "arduino" {
|
||||
Some(self.servers_for(&lang, path))
|
||||
} else {
|
||||
self.arduino_servers(&lang, path)
|
||||
};
|
||||
if let Some(servers) = arduino {
|
||||
return servers.iter().any(|server| {
|
||||
let cfg = self.configured(server);
|
||||
cfg.launch_error.is_none() && self.launcher.available(&cfg, &server.root)
|
||||
});
|
||||
}
|
||||
self.language_servers(&lang).iter().any(|ls| {
|
||||
if ls.require_root_marker
|
||||
&& crate::launch::marked_root(&self.project_root, path, &ls.root_markers).is_none()
|
||||
|
||||
+48
-20
@@ -627,7 +627,11 @@ impl LspManager {
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root))
|
||||
.filter(|d| {
|
||||
d.language.as_str() == "java"
|
||||
&& d.path.is_absolute()
|
||||
&& d.servers.iter().any(|a| a.server.id == server.id)
|
||||
})
|
||||
.map(|d| crate::uri::path_to_uri_string(&d.path))
|
||||
.collect();
|
||||
if uris.is_empty() {
|
||||
@@ -652,26 +656,50 @@ impl LspManager {
|
||||
/// started if needed and waited for.
|
||||
async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc<Server>, Running)> {
|
||||
let java = LanguageId::new("java");
|
||||
let server = self
|
||||
.servers_for(&java, path)
|
||||
.into_iter()
|
||||
.find(|s| s.name == SERVER)
|
||||
.ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?;
|
||||
self.ensure_started(&server);
|
||||
match cancellable_wait(cancel, server.wait_ready(READY_TIMEOUT)).await? {
|
||||
Some(run) => Ok((server, run)),
|
||||
None => {
|
||||
let status = server.status();
|
||||
Err(IdeError::unavailable(match status {
|
||||
ide_core::event::ServerStatus::Missing => {
|
||||
"jdtls is not installed (Settings › Languages & Frameworks › Language Servers)".to_owned()
|
||||
}
|
||||
ide_core::event::ServerStatus::Misconfigured | ide_core::event::ServerStatus::Crashed => {
|
||||
server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned())
|
||||
}
|
||||
_ => format!("jdtls is not ready ({status:?})"),
|
||||
}))
|
||||
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let server = self
|
||||
.servers_for(&java, path)
|
||||
.into_iter()
|
||||
.find(|s| s.name == SERVER)
|
||||
.ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?;
|
||||
self.ensure_started(&server);
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if let Some(run) = cancellable_wait(cancel, server.wait_ready(remaining)).await? {
|
||||
return Ok((server, run));
|
||||
}
|
||||
let status = server.status();
|
||||
if !matches!(
|
||||
status,
|
||||
ide_core::event::ServerStatus::Missing
|
||||
| ide_core::event::ServerStatus::Misconfigured
|
||||
| ide_core::event::ServerStatus::Crashed
|
||||
) && tokio::time::Instant::now() < deadline
|
||||
&& let Ok(_restart) =
|
||||
cancellable_wait(cancel, tokio::time::timeout_at(deadline, self.reconfigure_lock.lock())).await?
|
||||
{
|
||||
// A restart publishes Down while reaping its old JVM. Its successor may
|
||||
// also have a new identity after changing the shared workspace.
|
||||
if let Some(current) = self.servers_for(&java, path).into_iter().find(|s| s.name == SERVER)
|
||||
&& current.root.is_dir()
|
||||
&& (current.id != server.id || current.is_starting() || current.running().is_some())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let status = server.status();
|
||||
return Err(IdeError::unavailable(match status {
|
||||
ide_core::event::ServerStatus::Missing => {
|
||||
"jdtls is not installed (Settings › Languages & Frameworks › Language Servers)".to_owned()
|
||||
}
|
||||
ide_core::event::ServerStatus::Misconfigured | ide_core::event::ServerStatus::Crashed => {
|
||||
server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned())
|
||||
}
|
||||
_ => format!("jdtls is not ready ({status:?})"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+315
-47
@@ -7,6 +7,7 @@
|
||||
//! never `lsp-types`, so the IPC surface stays stable.
|
||||
|
||||
mod angular;
|
||||
mod arduino;
|
||||
mod caps;
|
||||
mod clang_tidy;
|
||||
mod convert;
|
||||
@@ -175,6 +176,20 @@ impl DocSnap {
|
||||
/// Receives the folders outside the project that servers want watched.
|
||||
pub type WatchRootsFn = dyn Fn(Vec<PathBuf>) + Send + Sync;
|
||||
|
||||
pub type JdtlsSharedFn = dyn Fn() -> bool + Send + Sync;
|
||||
|
||||
#[derive(Default)]
|
||||
struct JdtlsWorkspace {
|
||||
initialized: bool,
|
||||
shared: bool,
|
||||
roots: Vec<PathBuf>,
|
||||
transitioning: bool,
|
||||
}
|
||||
|
||||
fn is_local_jdtls(config: &LanguageServerConfig) -> bool {
|
||||
config.name == "jdtls" && config.target_key.is_none() && config.target.is_none()
|
||||
}
|
||||
|
||||
pub struct LspManager {
|
||||
me: Weak<LspManager>,
|
||||
project_root: PathBuf,
|
||||
@@ -195,6 +210,9 @@ pub struct LspManager {
|
||||
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
|
||||
excludes_hook: RwLock<Option<Arc<external_project::ExcludesFn>>>,
|
||||
watch_roots_hook: RwLock<Option<Arc<WatchRootsFn>>>,
|
||||
jdtls_shared_hook: RwLock<Option<Arc<JdtlsSharedFn>>>,
|
||||
jdtls_workspace: Mutex<JdtlsWorkspace>,
|
||||
reconfigure_lock: tokio::sync::Mutex<()>,
|
||||
shutting_down: AtomicBool,
|
||||
execution_allowed: AtomicBool,
|
||||
java_usages_warned: AtomicBool,
|
||||
@@ -402,6 +420,9 @@ impl LspManager {
|
||||
candidates_hook: RwLock::new(None),
|
||||
excludes_hook: RwLock::new(None),
|
||||
watch_roots_hook: RwLock::new(None),
|
||||
jdtls_shared_hook: RwLock::new(None),
|
||||
jdtls_workspace: Mutex::new(Default::default()),
|
||||
reconfigure_lock: tokio::sync::Mutex::new(()),
|
||||
shutting_down: AtomicBool::new(false),
|
||||
execution_allowed: AtomicBool::new(true),
|
||||
java_usages_warned: AtomicBool::new(false),
|
||||
@@ -416,7 +437,56 @@ impl LspManager {
|
||||
// ---- server lookup & lifecycle ----
|
||||
|
||||
pub fn set_content_roots(&self, roots: Vec<PathBuf>) {
|
||||
*self.content_roots.write() = roots;
|
||||
let changed = {
|
||||
let mut current = self.content_roots.write();
|
||||
let changed = *current != roots;
|
||||
*current = roots;
|
||||
changed
|
||||
};
|
||||
if changed
|
||||
&& self.desired_jdtls_workspace().0
|
||||
&& let Some(me) = self.me.upgrade()
|
||||
{
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shares JDT LS within this manager; callers reconfigure after the hook's value changes.
|
||||
pub fn set_jdtls_shared_hook(&self, hook: Box<JdtlsSharedFn>) {
|
||||
*self.jdtls_shared_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
fn desired_jdtls_workspace(&self) -> (bool, Vec<PathBuf>) {
|
||||
let shared = self.jdtls_shared_hook.read().as_ref().is_some_and(|hook| hook());
|
||||
let mut roots = Vec::new();
|
||||
if shared {
|
||||
roots.push(self.project_root.clone());
|
||||
for root in self.content_roots.read().iter() {
|
||||
if root.is_absolute() && root.is_dir() && !roots.contains(root) {
|
||||
roots.push(root.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(shared, roots)
|
||||
}
|
||||
|
||||
fn jdtls_workspace_state(&self) -> parking_lot::MutexGuard<'_, JdtlsWorkspace> {
|
||||
let mut state = self.jdtls_workspace.lock();
|
||||
if !state.initialized {
|
||||
(state.shared, state.roots) = self.desired_jdtls_workspace();
|
||||
state.initialized = true;
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn server_workspace_roots(&self, server: &Server) -> Vec<PathBuf> {
|
||||
if is_local_jdtls(&server.config) {
|
||||
let state = self.jdtls_workspace_state();
|
||||
if state.shared {
|
||||
return state.roots.clone();
|
||||
}
|
||||
}
|
||||
vec![server.root.clone()]
|
||||
}
|
||||
|
||||
fn project_root_for(&self, path: &Path) -> PathBuf {
|
||||
@@ -431,16 +501,23 @@ impl LspManager {
|
||||
}
|
||||
|
||||
fn server_for(&self, cfg: &LanguageServerConfig, language: &LanguageId, path: &Path) -> Arc<Server> {
|
||||
let shared = is_local_jdtls(cfg).then(|| self.jdtls_workspace_state());
|
||||
let mut servers = self.servers.lock();
|
||||
let root = launch::find_root(&self.project_root_for(path), path, &cfg.root_markers).unwrap_or_else(|| {
|
||||
// Outside the project (library sources, …): reuse a running instance.
|
||||
servers
|
||||
.iter()
|
||||
.filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key)
|
||||
.min_by_key(|s| s.root != self.project_root)
|
||||
.map(|s| s.root.clone())
|
||||
.unwrap_or_else(|| self.project_root.clone())
|
||||
});
|
||||
let project = self.project_root_for(path);
|
||||
let sketch = (cfg.name == arduino::SERVER).then(|| arduino::sketch_root(&project, path)).flatten();
|
||||
let root = if shared.as_ref().is_some_and(|state| state.shared) {
|
||||
self.project_root.clone()
|
||||
} else {
|
||||
sketch.or_else(|| launch::find_root(&project, path, &cfg.root_markers)).unwrap_or_else(|| {
|
||||
// Outside the project (library sources, …): reuse a running instance.
|
||||
servers
|
||||
.iter()
|
||||
.filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key)
|
||||
.min_by_key(|s| s.root != self.project_root)
|
||||
.map(|s| s.root.clone())
|
||||
.unwrap_or_else(|| self.project_root.clone())
|
||||
})
|
||||
};
|
||||
if let Some(s) =
|
||||
servers.iter().find(|s| s.name == cfg.name && s.root == root && s.config.target_key == cfg.target_key)
|
||||
{
|
||||
@@ -456,6 +533,9 @@ impl LspManager {
|
||||
/// only while all servers listed before it are missing. One that requires a
|
||||
/// root marker serves only files below one, and stands in for no other.
|
||||
fn servers_for(&self, language: &LanguageId, path: &Path) -> Vec<Arc<Server>> {
|
||||
if let Some(servers) = self.arduino_servers(language, path) {
|
||||
return servers;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut available_before = false;
|
||||
for sc in &self.language_servers(language) {
|
||||
@@ -490,6 +570,7 @@ impl LspManager {
|
||||
/// to the open documents (and start them). Also attaches servers newly
|
||||
/// enabled in settings.
|
||||
fn attach_fallbacks(&self) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> = self
|
||||
.docs
|
||||
.lock()
|
||||
@@ -538,6 +619,27 @@ impl LspManager {
|
||||
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let workspace = is_local_jdtls(&server.config).then(|| self.jdtls_workspace_state());
|
||||
if let Some(state) = &workspace {
|
||||
if state.transitioning || !self.servers.lock().iter().any(|s| s.id == server.id) {
|
||||
return;
|
||||
}
|
||||
let (shared, roots) = self.desired_jdtls_workspace();
|
||||
if state.shared != shared || (shared && state.roots != roots) {
|
||||
if let Some(me) = self.me.upgrade() {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let startup = if is_local_jdtls(&server.config) {
|
||||
match server.startup_lock.clone().try_lock_owned() {
|
||||
Ok(guard) => Some(guard),
|
||||
Err(_) => return,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// `begin_start` only claims Idle servers, so a claimed server is never a
|
||||
// live one. Its root is the process's working directory: starting with
|
||||
// one deleted from disk fails (`os error 267` on Windows). Such a
|
||||
@@ -553,7 +655,10 @@ impl LspManager {
|
||||
return;
|
||||
}
|
||||
let server = server.clone();
|
||||
self.runtime.spawn(async move { me.run_instance(server, generation).await });
|
||||
self.runtime.spawn(async move {
|
||||
let _startup = startup;
|
||||
me.run_instance(server, generation).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_instance(self: Arc<Self>, server: Arc<Server>, generation: u64) {
|
||||
@@ -561,6 +666,7 @@ impl LspManager {
|
||||
let handler = Arc::new(handler::ClientHandler::new(self.me.clone(), server.clone(), generation));
|
||||
let me = self.clone();
|
||||
let tail = server.clone();
|
||||
let launch_cancel = cancel.clone();
|
||||
// The settings hook may touch the filesystem (e.g. scanning for JDKs); TCP
|
||||
// servers may take a while to start listening.
|
||||
let launched = tokio::task::spawn_blocking(move || {
|
||||
@@ -568,17 +674,17 @@ impl LspManager {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
let mut cfg = me.configured(&tail);
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || launch_cancel.is_cancelled() {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
tail.set_effective_config(generation, cfg.clone());
|
||||
let prepare = me.prepare_hook.read().clone();
|
||||
if cfg.target.is_none()
|
||||
&& !cancel.is_cancelled()
|
||||
&& !launch_cancel.is_cancelled()
|
||||
&& me.launcher.available(&cfg, &tail.root)
|
||||
&& let Some(prepare) = prepare
|
||||
{
|
||||
prepare(&cfg, &tail.root, &cancel);
|
||||
prepare(&cfg, &tail.root, &launch_cancel);
|
||||
cfg = me.configured(&tail);
|
||||
}
|
||||
tail.set_effective_config(generation, cfg.clone());
|
||||
@@ -588,7 +694,7 @@ impl LspManager {
|
||||
let missing = cfg.target.is_none() && !me.launcher.available(&cfg, &root);
|
||||
return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) });
|
||||
}
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || launch_cancel.is_cancelled() {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
me.launcher.launch_resolved(
|
||||
@@ -596,7 +702,7 @@ impl LspManager {
|
||||
&root,
|
||||
handler,
|
||||
Box::new(move |line| tail.push_stderr(generation, line)),
|
||||
&cancel,
|
||||
&launch_cancel,
|
||||
)
|
||||
})
|
||||
.await
|
||||
@@ -605,7 +711,7 @@ impl LspManager {
|
||||
let peer = match launched {
|
||||
Ok((peer, binary)) => {
|
||||
if !server.set_resolved_binary(generation, binary) {
|
||||
peer.kill();
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
return;
|
||||
}
|
||||
peer
|
||||
@@ -662,15 +768,24 @@ impl LspManager {
|
||||
init["initializationOptions"]["triggerFiles"] = json!([uri::path_to_uri_string(&path)]);
|
||||
}
|
||||
}
|
||||
let result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, None))
|
||||
.await
|
||||
.unwrap_or(Err(RpcError::Timeout));
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => {
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
return;
|
||||
}
|
||||
result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, Some(cancel.clone()))) => result.unwrap_or(Err(RpcError::Timeout)),
|
||||
};
|
||||
let caps = match result {
|
||||
Ok(result) => {
|
||||
if let Some(info) = result.get("serverInfo") {
|
||||
tracing::info!("{} initialized: {info}", server.name);
|
||||
}
|
||||
let mut caps = ServerCaps::parse(result.get("capabilities").cloned().unwrap_or_else(|| json!({})));
|
||||
let mut caps_val = result.get("capabilities").cloned().unwrap_or_else(|| json!({}));
|
||||
if server.name == arduino::SERVER {
|
||||
arduino::sanitize_server_capabilities(&mut caps_val);
|
||||
}
|
||||
let mut caps = ServerCaps::parse(caps_val);
|
||||
let cfg = server.effective_config();
|
||||
caps.restrict(caps::Restriction::new(&server.name, &cfg.features, &cfg.code_action_kinds));
|
||||
caps
|
||||
@@ -681,7 +796,7 @@ impl LspManager {
|
||||
// The server is exiting: let it, so the crash report has its exit code and last output.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), peer.closed()).await;
|
||||
}
|
||||
peer.kill();
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
// Treated like a crash so transient failures retry with backoff.
|
||||
self.spawn_supervisor(server, generation, peer);
|
||||
return;
|
||||
@@ -690,7 +805,7 @@ impl LspManager {
|
||||
let run =
|
||||
Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)), watchers: Default::default() };
|
||||
if !server.set_initializing(run.clone()) {
|
||||
peer.kill();
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
return;
|
||||
}
|
||||
if let Err(e) = peer.notify("initialized", json!({})) {
|
||||
@@ -709,24 +824,30 @@ impl LspManager {
|
||||
|
||||
// Publish readiness and (re)open attached documents atomically w.r.t. did_change.
|
||||
let mut reopened = Vec::new();
|
||||
{
|
||||
let ready = {
|
||||
let mut docs = self.docs.lock();
|
||||
if !server.mark_ready(generation, run.clone()) {
|
||||
drop(docs);
|
||||
peer.kill();
|
||||
return;
|
||||
}
|
||||
let open_close = run.caps.read().open_close;
|
||||
for (id, d) in docs.iter_mut() {
|
||||
let params = d.did_open_params();
|
||||
for a in d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation)) {
|
||||
if open_close && let Err(e) = peer.notify("textDocument/didOpen", params.clone()) {
|
||||
tracing::debug!("{}: didOpen: {e}", server.name);
|
||||
false
|
||||
} else {
|
||||
let open_close = run.caps.read().open_close;
|
||||
for (id, d) in docs.iter_mut() {
|
||||
let params = d.did_open_params();
|
||||
for a in
|
||||
d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation))
|
||||
{
|
||||
if open_close && let Err(e) = peer.notify("textDocument/didOpen", params.clone()) {
|
||||
tracing::debug!("{}: didOpen: {e}", server.name);
|
||||
}
|
||||
a.opened_gen = Some(generation);
|
||||
reopened.push(*id);
|
||||
}
|
||||
a.opened_gen = Some(generation);
|
||||
reopened.push(*id);
|
||||
}
|
||||
true
|
||||
}
|
||||
};
|
||||
if !ready {
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
return;
|
||||
}
|
||||
for doc in reopened {
|
||||
self.schedule_pull(doc, Some(&server), Duration::ZERO);
|
||||
@@ -767,7 +888,6 @@ impl LspManager {
|
||||
|
||||
fn initialize_params(&self, server: &Server) -> Value {
|
||||
let root_uri = uri::path_to_uri(&server.root);
|
||||
let name = server.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into());
|
||||
let cfg = server.effective_config();
|
||||
let mut init_options =
|
||||
cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or(Value::Null);
|
||||
@@ -776,11 +896,24 @@ impl LspManager {
|
||||
init_options = json!({});
|
||||
}
|
||||
init_options["settings"] = handler::all_settings(&cfg);
|
||||
let shared = is_local_jdtls(&server.config) && self.jdtls_workspace_state().shared;
|
||||
if shared {
|
||||
init_options["workspaceFolders"] = json!(
|
||||
self.server_workspace_roots(server)
|
||||
.iter()
|
||||
.map(|root| uri::path_to_uri_string(root))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
let triggers: Vec<_> = self
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root))
|
||||
.filter(|d| {
|
||||
d.language.as_str() == "java"
|
||||
&& d.path.is_absolute()
|
||||
&& d.servers.iter().any(|a| a.server.id == server.id)
|
||||
})
|
||||
.map(|d| uri::path_to_uri_string(&d.path))
|
||||
.collect();
|
||||
if !triggers.is_empty() && init_options.get("triggerFiles").is_none() {
|
||||
@@ -794,10 +927,13 @@ impl LspManager {
|
||||
"locale": "en",
|
||||
"rootPath": server.root.to_string_lossy(),
|
||||
"rootUri": root_uri,
|
||||
"workspaceFolders": [{ "uri": root_uri, "name": name }],
|
||||
"workspaceFolders": self.workspace_folders(server),
|
||||
"capabilities": caps::client_capabilities(),
|
||||
"trace": "off",
|
||||
});
|
||||
if server.name == arduino::SERVER {
|
||||
arduino::configure_client_capabilities(&mut params["capabilities"]);
|
||||
}
|
||||
if server.name == "gopls" {
|
||||
params["capabilities"]["experimental"]["interactiveResolve"] = json!({ "inputTypes": ["string"] });
|
||||
}
|
||||
@@ -811,8 +947,16 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_folders(&self, server: &Server) -> Value {
|
||||
let name = server.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into());
|
||||
json!([{ "uri": uri::path_to_uri(&server.root), "name": name }])
|
||||
json!(
|
||||
self.server_workspace_roots(server)
|
||||
.iter()
|
||||
.map(|root| {
|
||||
let name =
|
||||
root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into());
|
||||
json!({"uri": uri::path_to_uri(root), "name": name})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn sink(&self) -> &SharedSink {
|
||||
@@ -862,6 +1006,7 @@ impl LspManager {
|
||||
/// Opens a read-only virtual document (`jdt://…`) on the server that
|
||||
/// returned its URI (`didOpen` only: it is never changed or saved).
|
||||
pub fn did_open_virtual(&self, doc: DocId, uri: &str, language: &LanguageId, version: u32, text: &str) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
if self.docs.lock().contains_key(&doc) {
|
||||
self.close_injections(doc);
|
||||
self.close_document(doc);
|
||||
@@ -1428,6 +1573,7 @@ impl LspManager {
|
||||
let locs = convert::goto_response(&self.conv(t.enc()), r);
|
||||
let locs = library_sources(snap, pos, locs).await;
|
||||
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
|
||||
self.note_arduino_sources(&t.server, &locs);
|
||||
let locs = merge_locations(first, locs);
|
||||
if !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
@@ -1656,6 +1802,7 @@ impl LspManager {
|
||||
.collect();
|
||||
let locations: Vec<Location> = classified.iter().map(|r| r.location.clone()).collect();
|
||||
self.note_virtual(&t.server, locations.iter().map(|l| l.uri.as_deref()));
|
||||
self.note_arduino_sources(&t.server, &locations);
|
||||
Ok(References { locations, classified, incomplete: widened.incomplete, libraries_skipped })
|
||||
}
|
||||
.await;
|
||||
@@ -1723,6 +1870,7 @@ impl LspManager {
|
||||
locs.retain(|l| !declarations.contains(l));
|
||||
}
|
||||
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
|
||||
self.note_arduino_sources(&t.server, &locs);
|
||||
acc.locations.extend(locs);
|
||||
acc.incomplete = acc.incomplete.or(w.incomplete);
|
||||
}
|
||||
@@ -1926,7 +2074,8 @@ impl LspManager {
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let mut action = convert::code_action(&conv, a, t.server.id);
|
||||
let mut action =
|
||||
convert::code_action(&conv, &snap.path, a, t.server.id, &t.server.name);
|
||||
self.tag_injected_origin(doc, &mut action.data);
|
||||
self.tag_injected_command(doc, action.server, &mut action.command);
|
||||
action
|
||||
@@ -2450,15 +2599,22 @@ impl LspManager {
|
||||
.into_iter()
|
||||
.flat_map(|(paths, change)| paths.iter().map(move |p| (p, change)));
|
||||
let watchers = run.watchers.read();
|
||||
let roots = self.server_workspace_roots(&s);
|
||||
let changes: Vec<(&PathBuf, Change)> = if watchers.is_empty() {
|
||||
// Dart watches the filesystem itself and rejects this unregistered notification.
|
||||
if s.name == "dart" {
|
||||
continue;
|
||||
}
|
||||
let languages = s.languages();
|
||||
all.filter(|(p, change)| {
|
||||
p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)) && send(p, *change)
|
||||
roots.iter().any(|root| p.starts_with(root))
|
||||
&& languages.contains(&self.languages.detect(p, None))
|
||||
&& send(p, *change)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
all.chain(excluded_dirs.iter().map(|p| (p, Change::Created)))
|
||||
.filter(|(p, change)| watchers.wants(&s.root, p, *change) && send(p, *change))
|
||||
.filter(|(p, change)| roots.iter().any(|root| watchers.wants(root, p, *change)) && send(p, *change))
|
||||
.collect()
|
||||
};
|
||||
drop(watchers);
|
||||
@@ -2796,6 +2952,64 @@ impl LspManager {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// The binary archive behind a library document, never its displayed tab title.
|
||||
pub fn library_archive(uri: &str) -> Option<PathBuf> {
|
||||
if virtual_doc::scheme(uri) == "jar" {
|
||||
return library_source::parse(uri).map(|e| e.jar);
|
||||
}
|
||||
virtual_doc::description(uri).filter(|p| p.ends_with(".jar") && Path::new(p).is_absolute()).map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// Open an attached archive at the declaration under the decompiled editor's caret.
|
||||
pub async fn library_source_location(
|
||||
&self,
|
||||
uri: &str,
|
||||
sources: PathBuf,
|
||||
pos: Pos,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<Location>> {
|
||||
let previous = tokio::select! {
|
||||
result = self.virtual_document(uri) => result?,
|
||||
_ = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
let name = previous
|
||||
.text
|
||||
.lines()
|
||||
.nth(pos.line as usize)
|
||||
.and_then(|line| library_source::identifier_at(line, pos.col))
|
||||
.map(str::to_owned);
|
||||
let original = uri.to_owned();
|
||||
let archive = sources.clone();
|
||||
let found = tokio::task::spawn_blocking(move || {
|
||||
library_source::source_in_archive(&original, &archive).map(|(uri, text)| {
|
||||
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
|
||||
Location { path: uri.clone().into(), uri: Some(uri), range }
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if found.is_some() {
|
||||
if virtual_doc::scheme(uri) == "jdt" {
|
||||
let result = self
|
||||
.jdtls_command_cancellable(
|
||||
&self.project_root,
|
||||
"java.project.updateSourceAttachment",
|
||||
vec![json!({ "classFileUri": uri, "attributes": { "sourceAttachmentPath": sources } })],
|
||||
cancel,
|
||||
)
|
||||
.await?;
|
||||
if let Some(error) = result.get("errorMessage").and_then(Value::as_str).filter(|s| !s.is_empty()) {
|
||||
return Err(IdeError::unavailable(error));
|
||||
}
|
||||
}
|
||||
self.virtual_docs.lock().remove(uri);
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Contents of a virtual document, from the server that owns `uri`:
|
||||
/// `workspace/textDocumentContent` (LSP 3.18) when the server serves the
|
||||
/// scheme, jdtls' `java/classFileContents` for `jdt:` URIs. A `jar:` entry
|
||||
@@ -3036,7 +3250,8 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
if allowed && !previous {
|
||||
for server in self.servers.lock().iter() {
|
||||
let servers = self.servers.lock().clone();
|
||||
for server in &servers {
|
||||
server.reset_for_restart();
|
||||
self.ensure_started(server);
|
||||
}
|
||||
@@ -3053,11 +3268,13 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub async fn reconfigure(&self) {
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
if !self.execution_allowed.load(Ordering::Acquire) {
|
||||
let servers = self.servers.lock().clone();
|
||||
join_all(servers.iter().map(|s| self.stop_instance(s, true))).await;
|
||||
return;
|
||||
}
|
||||
self.reconfigure_jdtls_workspace().await;
|
||||
self.stop_unlisted().await;
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
@@ -3120,6 +3337,39 @@ impl LspManager {
|
||||
self.injection_providers_changed();
|
||||
}
|
||||
|
||||
async fn reconfigure_jdtls_workspace(&self) {
|
||||
let (shared, roots) = self.desired_jdtls_workspace();
|
||||
let retired = {
|
||||
// Opens attach atomically with root selection; no document can acquire an old
|
||||
// instance between removing the registry entries and detaching its documents.
|
||||
let _gate = self.injections.gate.lock();
|
||||
let mut state = self.jdtls_workspace_state();
|
||||
if state.shared == shared && (!shared || state.roots == roots) {
|
||||
return;
|
||||
}
|
||||
state.transitioning = true;
|
||||
state.shared = shared;
|
||||
state.roots = roots;
|
||||
let mut servers = self.servers.lock();
|
||||
let retired: Vec<_> = servers.iter().filter(|s| is_local_jdtls(&s.config)).cloned().collect();
|
||||
servers.retain(|s| !is_local_jdtls(&s.config));
|
||||
for doc in self.docs.lock().values_mut() {
|
||||
doc.servers.retain(|a| !is_local_jdtls(&a.server.config));
|
||||
}
|
||||
retired
|
||||
};
|
||||
for server in &retired {
|
||||
self.stop_instance(server, true).await;
|
||||
}
|
||||
self.forget(&retired);
|
||||
self.jdtls_workspace.lock().transitioning = false;
|
||||
self.attach_fallbacks();
|
||||
let pending: Vec<_> = self.servers.lock().iter().filter(|s| is_local_jdtls(&s.config)).cloned().collect();
|
||||
for server in pending {
|
||||
self.ensure_started(&server);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detaches fallbacks shadowed by enabled servers and instances whose target
|
||||
/// no longer matches the document's language. Stop them once unused.
|
||||
async fn detach_shadowed_servers(&self) {
|
||||
@@ -3137,6 +3387,7 @@ impl LspManager {
|
||||
.filter(|a| {
|
||||
!wanted.iter().any(|w| w.id == a.server.id)
|
||||
&& (configs.iter().any(|c| c.fallback && c.name == a.server.name)
|
||||
|| (a.server.name == "clangd" && wanted.iter().any(|s| s.name == arduino::SERVER))
|
||||
|| !configs
|
||||
.iter()
|
||||
.any(|c| c.name == a.server.name && c.target_key == a.server.config.target_key))
|
||||
@@ -3255,6 +3506,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub async fn restart(&self, server: ide_core::ServerId) -> IdeResult<()> {
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
let s = self
|
||||
.servers
|
||||
.lock()
|
||||
@@ -3282,7 +3534,12 @@ impl LspManager {
|
||||
|
||||
/// Gracefully stops the current instance (`shutdown` + `exit`, then kill).
|
||||
async fn stop_instance(&self, server: &Arc<Server>, final_stop: bool) {
|
||||
let Some(run) = server.begin_stop(&self.sink, final_stop) else { return };
|
||||
let Some(run) = server.begin_stop(&self.sink, final_stop) else {
|
||||
if is_local_jdtls(&server.config) {
|
||||
let _startup = server.startup_lock.lock().await;
|
||||
}
|
||||
return;
|
||||
};
|
||||
let peer = run.peer.clone();
|
||||
// A server we connected to without starting it (an open Godot editor) is not
|
||||
// ours to shut down: only the connection closes.
|
||||
@@ -3302,13 +3559,24 @@ impl LspManager {
|
||||
peer.kill();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), peer.closed()).await;
|
||||
}
|
||||
peer.kill();
|
||||
Self::kill_peer(server, &peer).await;
|
||||
if is_local_jdtls(&server.config) {
|
||||
let _startup = server.startup_lock.lock().await;
|
||||
}
|
||||
server.end_stop(run.generation, &self.sink);
|
||||
self.file_watch_roots_changed();
|
||||
}
|
||||
|
||||
async fn kill_peer(server: &Server, peer: &ide_jsonrpc::Peer) {
|
||||
peer.kill();
|
||||
if is_local_jdtls(&server.config) && peer.pid().is_some() {
|
||||
peer.exited().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown_all(&self) {
|
||||
self.shutting_down.store(true, Ordering::Release);
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
join_all(servers.iter().map(|s| self.stop_instance(s, true))).await;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,27 @@ pub(crate) fn class_source(uri: &str) -> Option<(String, String)> {
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, String)> {
|
||||
let jar = if uri.starts_with("jdt://contents/") {
|
||||
let path = uri.split('?').next()?.strip_prefix("jdt://contents/")?;
|
||||
let parts: Vec<_> = path.split('/').collect();
|
||||
let [_, package, file] = parts.as_slice() else { return None };
|
||||
let file = percent_decode_str(file).decode_utf8().ok()?;
|
||||
let name = file.strip_suffix(".class").or_else(|| file.strip_suffix(".java"))?;
|
||||
let package = percent_decode_str(package).decode_utf8().ok()?.replace('.', "/");
|
||||
JarEntry {
|
||||
prefix: "jar:file://".into(),
|
||||
jar: sources.to_owned(),
|
||||
entry: if package.is_empty() { format!("{name}.class") } else { format!("{package}/{name}.class") },
|
||||
}
|
||||
} else {
|
||||
parse(uri)?
|
||||
};
|
||||
let class = jar.entry.strip_suffix(".class")?;
|
||||
let (entry, text) = source_entry(sources, class)?;
|
||||
Some((jar.uri_of(sources, &entry), text))
|
||||
}
|
||||
|
||||
/// Sources jars of the artifact `jar`: `<name>-sources.jar` next to it (Maven
|
||||
/// repository), or in a sibling hash folder (Gradle `files-2.1/<g>/<a>/<v>/<hash>/`).
|
||||
fn sources_jars(jar: &Path) -> Vec<PathBuf> {
|
||||
@@ -139,7 +160,20 @@ pub(crate) fn declaration_range(text: &str, name: &str) -> Range {
|
||||
const KEYWORDS: [&str; 9] = ["fun", "class", "interface", "object", "val", "var", "typealias", "enum", "record"];
|
||||
let is_ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
|
||||
let mut fallback = None;
|
||||
let mut block_comment = false;
|
||||
for (line_no, line) in text.lines().enumerate() {
|
||||
let trimmed = line.trim_start();
|
||||
if block_comment {
|
||||
block_comment = !trimmed.contains("*/");
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("/*") {
|
||||
block_comment = !trimmed.contains("*/");
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("//") || trimmed.starts_with('*') {
|
||||
continue;
|
||||
}
|
||||
let mut from = 0;
|
||||
while let Some(i) = line[from..].find(name) {
|
||||
let start = from + i;
|
||||
@@ -161,8 +195,11 @@ pub(crate) fn declaration_range(text: &str, name: &str) -> Range {
|
||||
if words.iter().any(|w| KEYWORDS.contains(w)) {
|
||||
return range;
|
||||
}
|
||||
let java_member =
|
||||
after == Some('(') && !head.trim_end().ends_with('.') && !head.trim().is_empty() && !head.contains('=');
|
||||
let java_member = after == Some('(')
|
||||
&& !head.trim_end().ends_with('.')
|
||||
&& !head.trim().is_empty()
|
||||
&& !head.contains(['=', '('])
|
||||
&& !words.iter().any(|w| matches!(*w, "return" | "throw" | "new" | "yield" | "assert"));
|
||||
if java_member && fallback.is_none() {
|
||||
fallback = Some(range);
|
||||
}
|
||||
@@ -227,6 +264,24 @@ mod tests {
|
||||
assert_eq!(parse("jar:///no/entry.jar"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_selected_archives_for_jdt_and_jar_classes() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sources = tmp.path().join("library sources.zip");
|
||||
jar(&sources, &[("p/Lib.java", "package p; public class Lib { class Inner {} }")]);
|
||||
for uri in [
|
||||
"jdt://contents/lib-1.jar/p/Lib.class?=project/escaped-jar-path",
|
||||
"jdt://contents/lib-1.jar/p/Lib.java?=project/escaped-jar-path",
|
||||
"jar:file:///m2/g/lib/1/lib-1.jar!/p/Lib$Inner.class",
|
||||
] {
|
||||
let (uri, text) = source_in_archive(uri, &sources).unwrap();
|
||||
assert!(uri.ends_with("library%20sources.zip!/p/Lib.java"), "{uri}");
|
||||
assert!(text.contains("class Lib"));
|
||||
}
|
||||
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources).is_none());
|
||||
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_source_of_each_kind_of_class_in_maven_and_gradle_caches() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -285,6 +340,8 @@ mod tests {
|
||||
assert_eq!(at(java, "run"), (3, 49, 52), "the member, not the call in its body");
|
||||
assert_eq!(at("val x = 1", "missing"), (0, 0, 0));
|
||||
assert_eq!(at("// é\nfun ü() {}", "ü"), (1, 4, 5), "UTF-16 columns");
|
||||
let java = "class Lib {\n boolean caller(String s) { return isBlank(s); }\n /**\n * Changed signature from isBlank(String) to isBlank(CharSequence).\n */\n public static boolean isBlank(CharSequence cs) { return false; }\n}\n";
|
||||
assert_eq!(at(java, "isBlank"), (5, 26, 33), "the declaration, not a call or Javadoc signature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -52,6 +52,7 @@ pub enum Phase {
|
||||
struct State {
|
||||
generation: u64,
|
||||
conn: Conn,
|
||||
restarting: bool,
|
||||
events: VecDeque<IdeEvent>,
|
||||
publishing: bool,
|
||||
}
|
||||
@@ -119,6 +120,8 @@ pub struct Server {
|
||||
phase_notify: Notify,
|
||||
transient: Mutex<HashMap<PathBuf, Transient>>,
|
||||
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
/// JDT retirement waits for launch and initialization cleanup, even before `Ready`.
|
||||
pub startup_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
|
||||
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
|
||||
pub external_diagnostics: std::sync::atomic::AtomicBool,
|
||||
@@ -138,7 +141,13 @@ impl Server {
|
||||
effective: Mutex::new(None),
|
||||
resolved_binary: RwLock::new(None),
|
||||
languages: Mutex::new(vec![language]),
|
||||
state: Mutex::new(State { generation: 0, conn: Conn::Idle, events: VecDeque::new(), publishing: false }),
|
||||
state: Mutex::new(State {
|
||||
generation: 0,
|
||||
conn: Conn::Idle,
|
||||
restarting: false,
|
||||
events: VecDeque::new(),
|
||||
publishing: false,
|
||||
}),
|
||||
initializing: Mutex::new(None),
|
||||
start_cancel: Mutex::new(CancellationToken::new()),
|
||||
phase: watch::channel(Phase::Down).0,
|
||||
@@ -158,6 +167,7 @@ impl Server {
|
||||
phase_notify: Notify::new(),
|
||||
transient: Mutex::new(HashMap::new()),
|
||||
java_build_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
startup_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false),
|
||||
external_project: tokio::sync::Mutex::new(Default::default()),
|
||||
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
|
||||
@@ -214,6 +224,7 @@ impl Server {
|
||||
}
|
||||
st.generation += 1;
|
||||
st.conn = Conn::Starting;
|
||||
st.restarting = false;
|
||||
*self.start_cancel.lock() = CancellationToken::new();
|
||||
*self.started.lock() = Some(Instant::now());
|
||||
self.phase.send_replace(Phase::Starting);
|
||||
@@ -269,6 +280,10 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_starting(&self) -> bool {
|
||||
matches!(self.state.lock().conn, Conn::Starting)
|
||||
}
|
||||
|
||||
/// The instance told `initialized`, ready or not.
|
||||
pub fn instance(&self) -> Option<Running> {
|
||||
self.running().or_else(|| self.initializing.lock().clone())
|
||||
@@ -293,21 +308,43 @@ impl Server {
|
||||
self.running().filter(|r| r.generation == generation)
|
||||
}
|
||||
|
||||
/// Waits while the server is starting; returns the instance once ready.
|
||||
/// Waits through startup or a planned restart; returns the instance once ready.
|
||||
pub async fn wait_ready(&self, timeout: Duration) -> Option<Running> {
|
||||
let mut rx = self.phase.subscribe();
|
||||
let _ = tokio::time::timeout(timeout, rx.wait_for(|p| *p != Phase::Starting)).await;
|
||||
self.running()
|
||||
tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
{
|
||||
let st = self.state.lock();
|
||||
// Read both under the writer's lock: a restart can begin just after a wakeup.
|
||||
if *rx.borrow_and_update() != Phase::Starting {
|
||||
return match &st.conn {
|
||||
Conn::Ready(run) => Some(run.clone()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
if rx.changed().await.is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Ready → Stopping, returning the instance to shut down. An in-flight
|
||||
/// start is abandoned instead.
|
||||
pub fn begin_stop(&self, sink: &SharedSink, final_stop: bool) -> Option<Running> {
|
||||
let mut st = self.state.lock();
|
||||
if final_stop {
|
||||
st.restarting = false;
|
||||
}
|
||||
match std::mem::replace(&mut st.conn, Conn::Idle) {
|
||||
Conn::Ready(run) => {
|
||||
st.conn = Conn::Stopping;
|
||||
self.phase.send_replace(Phase::Down);
|
||||
st.restarting = !final_stop;
|
||||
self.phase.send_replace(if st.restarting { Phase::Starting } else { Phase::Down });
|
||||
Some(run)
|
||||
}
|
||||
Conn::Starting => {
|
||||
@@ -315,7 +352,8 @@ impl Server {
|
||||
self.start_cancel.lock().cancel();
|
||||
self.queue_cleanup(&mut st);
|
||||
self.queue_status(&mut st, ServerStatus::Stopped, None);
|
||||
self.phase.send_replace(Phase::Down);
|
||||
st.restarting = !final_stop;
|
||||
self.phase.send_replace(if st.restarting { Phase::Starting } else { Phase::Down });
|
||||
drop(st);
|
||||
self.publish(sink);
|
||||
None
|
||||
@@ -324,6 +362,7 @@ impl Server {
|
||||
st.conn = other;
|
||||
if final_stop {
|
||||
self.queue_status(&mut st, ServerStatus::Stopped, None);
|
||||
self.phase.send_replace(Phase::Down);
|
||||
}
|
||||
drop(st);
|
||||
self.publish(sink);
|
||||
@@ -345,7 +384,7 @@ impl Server {
|
||||
self.queue_cleanup(&mut st);
|
||||
self.queue_status(&mut st, ServerStatus::Stopped, None);
|
||||
st.conn = Conn::Idle;
|
||||
self.phase.send_replace(Phase::Down);
|
||||
self.phase.send_replace(if st.restarting { Phase::Starting } else { Phase::Down });
|
||||
drop(st);
|
||||
self.publish(sink);
|
||||
}
|
||||
@@ -899,6 +938,37 @@ mod tests {
|
||||
assert_eq!(s.status(), ServerStatus::Stopped);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn final_stop_cancels_pending_restarts_in_every_phase() {
|
||||
for phase in ["starting", "stopping", "between"] {
|
||||
let s = server();
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
let generation = s.begin_start().unwrap();
|
||||
if phase == "starting" {
|
||||
s.begin_stop(&sink, false);
|
||||
} else {
|
||||
let mut st = s.state.lock();
|
||||
st.conn = Conn::Stopping;
|
||||
st.restarting = true;
|
||||
}
|
||||
if phase == "between" {
|
||||
s.end_stop(generation, &sink);
|
||||
}
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(10), s.wait_ready(Duration::from_secs(30))).await.is_err()
|
||||
);
|
||||
s.begin_stop(&sink, true);
|
||||
s.end_stop(generation, &sink);
|
||||
assert_eq!(*s.phase.borrow(), Phase::Down);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(1), s.wait_ready(Duration::from_secs(30)))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_publication_can_restart_without_overwriting_the_new_phase() {
|
||||
for conn in [Conn::Missing, Conn::Failed] {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use ide_core::event::ServerStatus;
|
||||
use ide_core::settings::ExternalFormatter;
|
||||
use ide_core::{Location, Range, ServerId, SymbolKind, TextEdit, WorkspaceEdit};
|
||||
use ide_core::{Diagnostic, Location, Range, ServerId, SymbolKind, TextEdit, WorkspaceEdit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
@@ -49,7 +49,11 @@ pub struct CompletionItem {
|
||||
pub label: String,
|
||||
/// e.g. `(req: CreateOrderRequest)` shown right after the label.
|
||||
pub label_detail: Option<String>,
|
||||
/// e.g. return type, shown right-aligned.
|
||||
/// `labelDetails.description`: a return type or module path, shown right-aligned. Servers
|
||||
/// such as JDT.LS also send the whole signature as `detail`, which would repeat the label.
|
||||
pub label_description: Option<String>,
|
||||
/// The signature or type when the server sent no `label_description`; the documentation panel
|
||||
/// shows it either way.
|
||||
pub detail: Option<String>,
|
||||
pub kind: Option<CompletionKind>,
|
||||
pub documentation: Option<Markup>,
|
||||
@@ -193,6 +197,14 @@ pub struct InlineCompletionItem {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeAction {
|
||||
pub title: String,
|
||||
/// Diagnostics this action fixes, in IDE coordinates.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
/// Original server diagnostics, including encoding and data, for codeAction/resolve.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub original_diagnostics: Option<String>,
|
||||
/// LSP kind, e.g. `quickfix`, `refactor.extract`, `source.organizeImports`.
|
||||
pub kind: Option<String>,
|
||||
pub is_preferred: bool,
|
||||
|
||||
@@ -35,6 +35,8 @@ struct MockState {
|
||||
responder: Mutex<Option<Responder>>,
|
||||
/// Holds the handling of notifications of this method until the gate opens.
|
||||
held: Mutex<Option<(String, Arc<Gate>)>>,
|
||||
initialize_gate: Mutex<Option<Arc<Gate>>>,
|
||||
launch_gate: Mutex<Option<Arc<Gate>>>,
|
||||
peer: Mutex<Option<Peer>>,
|
||||
}
|
||||
|
||||
@@ -73,7 +75,9 @@ fn utf16_to_byte(text: &str, line: u64, col: u64) -> usize {
|
||||
text.len()
|
||||
}
|
||||
|
||||
struct MockHandler(Arc<MockState>);
|
||||
type MockConnection = Mutex<Option<Peer>>;
|
||||
|
||||
struct MockHandler(Arc<MockState>, std::sync::Weak<MockConnection>);
|
||||
|
||||
impl Handler for MockHandler {
|
||||
fn on_notification(&self, method: &str, params: Value) {
|
||||
@@ -118,8 +122,14 @@ impl Handler for MockHandler {
|
||||
s.docs.lock().remove(&uri);
|
||||
}
|
||||
"exit" => {
|
||||
if let Some(p) = s.peer.lock().take() {
|
||||
p.kill();
|
||||
if let Some(connection) = self.1.upgrade()
|
||||
&& let Some(peer) = connection.lock().take()
|
||||
{
|
||||
peer.kill();
|
||||
}
|
||||
let mut latest = s.peer.lock();
|
||||
if latest.as_ref().is_some_and(Peer::is_closed) {
|
||||
*latest = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -131,7 +141,13 @@ impl Handler for MockHandler {
|
||||
let s = &self.0;
|
||||
s.requests.lock().push((method.to_owned(), params.clone()));
|
||||
match method {
|
||||
"initialize" => Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } })),
|
||||
"initialize" => {
|
||||
let gate = s.initialize_gate.lock().take();
|
||||
if let Some(gate) = gate {
|
||||
gate.hold();
|
||||
}
|
||||
Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } }))
|
||||
}
|
||||
"shutdown" => Ok(Value::Null),
|
||||
_ => {
|
||||
if let Some(error) = s.errors.lock().get(method).cloned() {
|
||||
@@ -150,6 +166,7 @@ impl Handler for MockHandler {
|
||||
#[derive(Default)]
|
||||
struct MockLauncher {
|
||||
servers: Mutex<HashMap<String, Arc<MockState>>>,
|
||||
connections: Mutex<Vec<Arc<MockConnection>>>,
|
||||
}
|
||||
|
||||
impl MockLauncher {
|
||||
@@ -186,10 +203,18 @@ impl Launcher for MockLauncher {
|
||||
state.docs.lock().clear();
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
let server = Peer::connect(sr, sw, Arc::new(MockHandler(state.clone())));
|
||||
let connection = Arc::new(Mutex::new(None));
|
||||
let server = Peer::connect(sr, sw, Arc::new(MockHandler(state.clone(), Arc::downgrade(&connection))));
|
||||
*connection.lock() = Some(server.clone());
|
||||
self.connections.lock().push(connection);
|
||||
*state.peer.lock() = Some(server);
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
Ok(Peer::connect(cr, cw, handler))
|
||||
let client = Peer::connect(cr, cw, handler);
|
||||
let gate = state.launch_gate.lock().take();
|
||||
if let Some(gate) = gate {
|
||||
gate.hold();
|
||||
}
|
||||
Ok(client)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +420,45 @@ fn change(range: Range, text: &str) -> AppliedChange {
|
||||
AppliedChange { range, text: text.into() }
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn dart_file_watching_requires_an_explicit_registration() {
|
||||
for (language, extension, name, fallback) in
|
||||
[("dart", "dart", "dart", false), ("dart", "dart", "alternative-dart", true), ("java", "java", "jdtls", true)]
|
||||
{
|
||||
let h = harness_for(language, extension, &[(name, incremental_caps())], &[]);
|
||||
let (doc, _) = h.open(&format!("open.{extension}"), "x");
|
||||
h.wait_status(name, ServerStatus::Ready).await;
|
||||
let s = h.launcher.state(name);
|
||||
let path = h.root.join(format!("closed.{extension}"));
|
||||
let notify = || h.mgr.watched_files_changed(&[], std::slice::from_ref(&path), &[], &[]);
|
||||
notify();
|
||||
h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.unwrap();
|
||||
assert_eq!(s.notified("workspace/didChangeWatchedFiles").len(), usize::from(fallback), "{name}");
|
||||
s.peer()
|
||||
.request::<_, Value>(
|
||||
"client/registerCapability",
|
||||
json!({ "registrations": [{ "id": "watch", "method": "workspace/didChangeWatchedFiles", "registerOptions": { "watchers": [{ "globPattern": "**/*" }] } }] }),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
notify();
|
||||
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == usize::from(fallback) + 1).await;
|
||||
s.peer()
|
||||
.request::<_, Value>(
|
||||
"client/unregisterCapability",
|
||||
json!({ "unregisterations": [{ "id": "watch", "method": "workspace/didChangeWatchedFiles" }] }),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
notify();
|
||||
h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.unwrap();
|
||||
assert_eq!(s.notified("workspace/didChangeWatchedFiles").len(), usize::from(fallback) * 2 + 1, "{name}");
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tests
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -420,6 +484,451 @@ async fn linked_projects_initialize_separate_language_server_roots() {
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
fn java_siblings(h: &Harness) -> Vec<PathBuf> {
|
||||
["first", "second"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let root = h.root.join(name);
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::write(root.join("mock.toml"), "").unwrap();
|
||||
let path = root.join("Main.java");
|
||||
std::fs::write(&path, "class Main {}").unwrap();
|
||||
path
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_is_opt_in_and_keeps_module_requests_on_one_instance() {
|
||||
for shared in [false, true] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(move || shared));
|
||||
let paths = java_siblings(&h);
|
||||
std::thread::scope(|scope| {
|
||||
for path in &paths {
|
||||
scope.spawn(|| h.mgr.did_open(DocId::next(), path, &h.lang, 1, "class Main {}"));
|
||||
}
|
||||
});
|
||||
let state = h.launcher.state("jdtls");
|
||||
let count = if shared { 1 } else { 2 };
|
||||
eventually(|| state.requested("initialize").len() == count).await;
|
||||
assert_eq!(state.launches.load(Ordering::SeqCst), count);
|
||||
assert_eq!(h.mgr.servers().len(), count);
|
||||
for path in &paths {
|
||||
h.mgr.java_classpath(path.parent().unwrap(), false, &Default::default()).await.unwrap();
|
||||
}
|
||||
let requests = state.requested("workspace/executeCommand");
|
||||
let classpaths: Vec<_> = requests.iter().filter(|r| r["command"] == "java.project.getClasspaths").collect();
|
||||
assert_eq!(classpaths.len(), 2);
|
||||
for path in &paths {
|
||||
assert!(classpaths.iter().any(|r| r["arguments"][0] == uri_of(path.parent().unwrap())));
|
||||
}
|
||||
if shared {
|
||||
let init = &state.requested("initialize")[0];
|
||||
assert_eq!(init["rootUri"], uri_of(&h.root));
|
||||
assert_eq!(init["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
|
||||
}
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_toggle_retires_old_instances_and_reopens_every_document() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let shared = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let shared = shared.clone();
|
||||
move || shared.load(Ordering::SeqCst)
|
||||
}));
|
||||
let paths = java_siblings(&h);
|
||||
for path in &paths {
|
||||
h.mgr.did_open(DocId::next(), path, &h.lang, 1, "class Main {}");
|
||||
}
|
||||
let state = h.launcher.state("jdtls");
|
||||
eventually(|| state.requested("initialize").len() == 2).await;
|
||||
eventually(|| {
|
||||
paths
|
||||
.iter()
|
||||
.all(|path| state.notified("textDocument/didOpen").iter().any(|v| v["textDocument"]["uri"] == uri_of(path)))
|
||||
})
|
||||
.await;
|
||||
let old: Vec<_> = h.mgr.servers().into_iter().map(|s| s.id).collect();
|
||||
shared.store(true, Ordering::SeqCst);
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| state.launches.load(Ordering::SeqCst) == 3 && state.docs.lock().len() == 2).await;
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
for id in old {
|
||||
assert!(
|
||||
h.events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|event| matches!(event, IdeEvent::LanguageServerRemoved {server} if *server == id))
|
||||
);
|
||||
}
|
||||
shared.store(false, Ordering::SeqCst);
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| state.launches.load(Ordering::SeqCst) == 5).await;
|
||||
assert_eq!(h.mgr.servers().len(), 2);
|
||||
eventually(|| {
|
||||
paths.iter().all(|path| {
|
||||
state.notified("textDocument/didOpen").iter().filter(|v| v["textDocument"]["uri"] == uri_of(path)).count()
|
||||
== 3
|
||||
})
|
||||
})
|
||||
.await;
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_cancels_a_retiring_initialize_before_starting_its_replacement() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let shared = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let shared = shared.clone();
|
||||
move || shared.load(Ordering::SeqCst)
|
||||
}));
|
||||
let state = h.launcher.state("jdtls");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.initialize_gate.lock() = Some(gate.clone());
|
||||
let path = java_siblings(&h).remove(0);
|
||||
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
|
||||
eventually(|| gate.entered()).await;
|
||||
let old = state.peer();
|
||||
shared.store(true, Ordering::SeqCst);
|
||||
let switched = tokio::time::timeout(Duration::from_secs(2), h.mgr.reconfigure()).await;
|
||||
let closed_before_response = old.is_closed();
|
||||
gate.open();
|
||||
switched.expect("initialize cancellation must not wait for its response");
|
||||
eventually(|| state.requested("initialize").len() == 2).await;
|
||||
assert!(closed_before_response, "the old transport must close before its initialize response is released");
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
assert_eq!(h.mgr.servers()[0].root.as_ref(), h.root.as_path());
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn shared_java_waits_for_an_abandoned_launcher_before_replacing_the_workspace() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(|| true));
|
||||
let state = h.launcher.state("jdtls");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.launch_gate.lock() = Some(gate.clone());
|
||||
h.open("Main.java", "class Main {}");
|
||||
eventually(|| gate.entered()).await;
|
||||
let old = state.peer();
|
||||
let id = h.mgr.servers()[0].id;
|
||||
let external = tempfile::tempdir().unwrap();
|
||||
h.mgr.set_content_roots(vec![dunce::canonicalize(external.path()).unwrap()]);
|
||||
eventually(|| h.mgr.servers().iter().all(|s| s.id != id)).await;
|
||||
h.open("Another.java", "class Another {}");
|
||||
let mut transition = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.reconfigure().await }
|
||||
});
|
||||
let pending = tokio::time::timeout(Duration::from_millis(100), &mut transition).await.is_err();
|
||||
let launches = state.launches.load(Ordering::SeqCst);
|
||||
let old_alive = !old.is_closed();
|
||||
gate.open();
|
||||
assert!(pending, "retirement must wait for the launcher to return");
|
||||
assert_eq!(launches, 1, "the retiring launcher still owns its peer");
|
||||
assert!(old_alive);
|
||||
transition.await.unwrap();
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| state.launches.load(Ordering::SeqCst) == 2 && state.docs.lock().len() == 2).await;
|
||||
assert!(old.is_closed());
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_classpath_waits_for_a_config_restart_that_is_reaping_startup() {
|
||||
for (shared, failed) in [(false, false), (true, false), (false, true)] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let changed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let changed = changed.clone();
|
||||
move || shared && changed.load(Ordering::SeqCst)
|
||||
}));
|
||||
h.mgr.set_config_hook(Box::new({
|
||||
let changed = changed.clone();
|
||||
move |cfg, _| {
|
||||
let changed = changed.load(Ordering::SeqCst);
|
||||
cfg.env.insert("MOCK_CONFIG_REVISION".into(), changed.to_string());
|
||||
if failed && changed {
|
||||
cfg.launch_error = Some("restart configuration error".into());
|
||||
}
|
||||
}
|
||||
}));
|
||||
let state = h.launcher.state("jdtls");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.launch_gate.lock() = Some(gate.clone());
|
||||
let path = java_siblings(&h).remove(0);
|
||||
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
|
||||
eventually(|| gate.entered()).await;
|
||||
let old = h.mgr.servers()[0].id;
|
||||
changed.store(true, Ordering::SeqCst);
|
||||
let restarting = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.reconfigure().await }
|
||||
});
|
||||
h.wait_status("jdtls", ServerStatus::Stopped).await;
|
||||
let mut query = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.java_classpath(path.parent().unwrap(), false, &Default::default()).await }
|
||||
});
|
||||
let early = tokio::time::timeout(Duration::from_millis(100), &mut query).await;
|
||||
gate.open();
|
||||
restarting.await.unwrap();
|
||||
let result = match early {
|
||||
Ok(result) => result.unwrap(),
|
||||
Err(_) => query.await.unwrap(),
|
||||
};
|
||||
if failed {
|
||||
assert!(result.unwrap_err().message.contains("restart configuration error"));
|
||||
} else {
|
||||
assert!(result.is_ok(), "a configuration restart is transient: {result:?}");
|
||||
assert_eq!(state.launches.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(h.mgr.servers()[0].id != old, shared);
|
||||
}
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_classpath_restart_barrier_wait_remains_cancellable() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let changed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_config_hook(Box::new({
|
||||
let changed = changed.clone();
|
||||
move |cfg, _| {
|
||||
cfg.env.insert("MOCK_CONFIG_REVISION".into(), changed.load(Ordering::SeqCst).to_string());
|
||||
}
|
||||
}));
|
||||
let state = h.launcher.state("jdtls");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.launch_gate.lock() = Some(gate.clone());
|
||||
h.open("Main.java", "class Main {}");
|
||||
eventually(|| gate.entered()).await;
|
||||
changed.store(true, Ordering::SeqCst);
|
||||
let restarting = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.reconfigure().await }
|
||||
});
|
||||
h.wait_status("jdtls", ServerStatus::Stopped).await;
|
||||
let cancel = CancellationToken::new();
|
||||
let mut query = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
let root = h.root.clone();
|
||||
let cancel = cancel.clone();
|
||||
async move { manager.java_classpath(&root, false, &cancel).await }
|
||||
});
|
||||
let early = tokio::time::timeout(Duration::from_millis(100), &mut query).await;
|
||||
let waiting = early.is_err();
|
||||
cancel.cancel();
|
||||
let result = match early {
|
||||
Ok(result) => Ok(result),
|
||||
Err(_) => tokio::time::timeout(Duration::from_secs(1), query).await,
|
||||
};
|
||||
gate.open();
|
||||
restarting.await.unwrap();
|
||||
assert!(waiting, "the query must wait behind the restart barrier");
|
||||
assert_eq!(result.unwrap().unwrap().unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_classpath_does_not_retry_permanently_unavailable_servers() {
|
||||
for missing in [false, true] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
if missing {
|
||||
h.launcher.state("jdtls").missing.store(true, Ordering::SeqCst);
|
||||
} else {
|
||||
h.mgr.set_config_hook(Box::new(|cfg, _| {
|
||||
cfg.launch_error = Some("permanent mock configuration error".into())
|
||||
}));
|
||||
}
|
||||
let result =
|
||||
tokio::time::timeout(Duration::from_secs(1), h.mgr.java_classpath(&h.root, false, &Default::default()))
|
||||
.await
|
||||
.expect("permanent failures must not incur the Java readiness timeout");
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.message.contains(if missing { "not installed" } else { "permanent mock configuration error" }));
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn shared_java_external_roots_reach_initialization_queries_and_watches() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(|| true));
|
||||
let external = tempfile::tempdir().unwrap();
|
||||
let external = dunce::canonicalize(external.path()).unwrap();
|
||||
h.mgr.set_content_roots(vec![external.clone(), external.clone(), h.root.clone()]);
|
||||
let file = external.join("External.java");
|
||||
std::fs::write(&file, "class External {}").unwrap();
|
||||
h.mgr.did_open(DocId::next(), &file, &h.lang, 1, "class External {}");
|
||||
let state = h.launcher.state("jdtls");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let init = &state.requested("initialize")[0];
|
||||
assert_eq!(init["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root), uri_of(&external)]));
|
||||
assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
|
||||
let folders: Value = state.peer().request("workspace/workspaceFolders", Value::Null, None).await.unwrap();
|
||||
assert_eq!(folders, init["workspaceFolders"]);
|
||||
state.peer().request::<_, Value>("client/registerCapability", json!({"registrations":[{
|
||||
"id":"external-java", "method":"workspace/didChangeWatchedFiles", "registerOptions":{"watchers":[{"globPattern":"**/*.java"}]}
|
||||
}]}), None).await.unwrap();
|
||||
let other = external.join("Other.java");
|
||||
h.mgr.watched_files_changed(std::slice::from_ref(&other), &[], &[], &[]);
|
||||
eventually(|| {
|
||||
state.notified("workspace/didChangeWatchedFiles").iter().any(|v| v["changes"][0]["uri"] == uri_of(&other))
|
||||
})
|
||||
.await;
|
||||
h.mgr.set_content_roots(vec![]);
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| state.requested("initialize").len() == 2).await;
|
||||
assert_eq!(state.requested("initialize")[1]["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn trusting_an_existing_java_instance_restarts_it_without_locking_the_registry() {
|
||||
for shared in [false, true] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(move || shared));
|
||||
h.open("Main.java", "class Main {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
h.mgr.set_execution_allowed(false);
|
||||
h.mgr.reconfigure().await;
|
||||
h.mgr.set_execution_allowed(true);
|
||||
eventually(|| state.launches.load(Ordering::SeqCst) == 2 && state.docs.lock().len() == 1).await;
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_preserves_other_servers_and_marker_scoped_fallbacks() {
|
||||
for fallback in [false, true] {
|
||||
let h = harness_for(
|
||||
"java",
|
||||
"java",
|
||||
&[("jdtls", incremental_caps()), ("other", incremental_caps())],
|
||||
if fallback { &["other"] } else { &[] },
|
||||
);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(|| true));
|
||||
h.launcher.state("jdtls").missing.store(fallback, Ordering::SeqCst);
|
||||
for path in java_siblings(&h) {
|
||||
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
|
||||
}
|
||||
eventually(|| h.launcher.state("other").requested("initialize").len() == 2).await;
|
||||
let servers = h.mgr.servers();
|
||||
assert_eq!(servers.iter().filter(|s| s.name == "jdtls").count(), 1);
|
||||
assert_eq!(servers.iter().filter(|s| s.name == "other").count(), 2);
|
||||
assert_eq!(h.launcher.state("jdtls").launches.load(Ordering::SeqCst), usize::from(!fallback));
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_keeps_target_instances_separate_and_preserves_them_on_toggle() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let shared = Arc::new(std::sync::atomic::AtomicBool::new(true));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let shared = shared.clone();
|
||||
move || shared.load(Ordering::SeqCst)
|
||||
}));
|
||||
h.mgr.set_servers_hook(Box::new(|_, configs| {
|
||||
let mut target = configs[0].clone();
|
||||
target.target_key = Some("container".into());
|
||||
target
|
||||
.initialization_options
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.as_table_mut()
|
||||
.unwrap()
|
||||
.insert("selectedTarget".into(), toml::Value::Boolean(true));
|
||||
vec![configs[0].clone(), target]
|
||||
}));
|
||||
h.open("Main.java", "class Main {}");
|
||||
for path in java_siblings(&h) {
|
||||
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
|
||||
}
|
||||
let state = h.launcher.state("jdtls");
|
||||
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 4).await;
|
||||
let initial = state.requested("initialize");
|
||||
assert_eq!(initial.len(), 4);
|
||||
let targets: Vec<_> = initial.iter().filter(|v| v["initializationOptions"]["selectedTarget"] == true).collect();
|
||||
assert_eq!(targets.len(), 3, "target roots retain their marker-based identity");
|
||||
assert!(targets.iter().all(|v| v["initializationOptions"].get("workspaceFolders").is_none()));
|
||||
let local = initial.iter().find(|v| v["initializationOptions"]["selectedTarget"] != true).unwrap();
|
||||
assert_eq!(local["rootUri"], uri_of(&h.root));
|
||||
assert_eq!(local["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
|
||||
let old: HashSet<_> = h.mgr.servers().into_iter().map(|s| s.id).collect();
|
||||
shared.store(false, Ordering::SeqCst);
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 6).await;
|
||||
assert_eq!(
|
||||
h.mgr.servers().iter().filter(|s| old.contains(&s.id)).count(),
|
||||
3,
|
||||
"local sharing does not retire targets"
|
||||
);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn virtual_java_documents_opened_during_a_toggle_attach_to_the_successor() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let shared = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let shared = shared.clone();
|
||||
move || shared.load(Ordering::SeqCst)
|
||||
}));
|
||||
let (doc, _) = h.open("Main.java", "class Main {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
let uris: Vec<_> = (0..16).map(|n| format!("jdt://contents/project/Virtual{n}.class")).collect();
|
||||
state.respond(
|
||||
"textDocument/definition",
|
||||
json!(
|
||||
uris.iter()
|
||||
.map(|uri| json!({"uri":uri,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}}}))
|
||||
.collect::<Vec<_>>()
|
||||
),
|
||||
);
|
||||
h.mgr.goto(doc, Pos::default(), GotoKind::Definition).await.unwrap();
|
||||
shared.store(true, Ordering::SeqCst);
|
||||
let toggle = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.reconfigure().await }
|
||||
});
|
||||
let mut opened = Vec::new();
|
||||
for uri in uris {
|
||||
let manager = h.mgr.clone();
|
||||
let language = h.lang.clone();
|
||||
let doc = DocId::next();
|
||||
opened.push((
|
||||
doc,
|
||||
std::thread::spawn(move || manager.did_open_virtual(doc, &uri, &language, 0, "class Virtual {}")),
|
||||
));
|
||||
}
|
||||
let docs: Vec<_> = opened
|
||||
.into_iter()
|
||||
.map(|(doc, thread)| {
|
||||
thread.join().unwrap();
|
||||
doc
|
||||
})
|
||||
.collect();
|
||||
toggle.await.unwrap();
|
||||
eventually(|| state.docs.lock().len() == docs.len() + 1).await;
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
for doc in docs {
|
||||
h.mgr.hover(doc, Pos::default(), Default::default()).await.unwrap();
|
||||
}
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn files_changed_on_disk_reach_the_servers_of_their_language() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
@@ -792,7 +1301,7 @@ async fn completion_mapping_merge_and_resolve() {
|
||||
"isIncomplete": false,
|
||||
"itemDefaults": { "data": { "from": "defaults" } },
|
||||
"items": [{
|
||||
"label": "bar", "kind": 2, "detail": "fn() -> u8", "labelDetails": { "detail": "()" },
|
||||
"label": "bar", "kind": 2, "detail": "fn() -> u8", "labelDetails": { "detail": "()", "description": "u8" },
|
||||
"textEdit": { "newText": "bar()$0", "insert": {"start":{"line":0,"character":12},"end":{"line":0,"character":14}},
|
||||
"replace": {"start":{"line":0,"character":12},"end":{"line":0,"character":15}} },
|
||||
"insertTextFormat": 2, "sortText": "0001"
|
||||
@@ -813,6 +1322,7 @@ async fn completion_mapping_merge_and_resolve() {
|
||||
assert_eq!(bar.insert_text, "bar()$0");
|
||||
assert!(bar.is_snippet);
|
||||
assert_eq!(bar.label_detail.as_deref(), Some("()"));
|
||||
assert_eq!(bar.label_description.as_deref(), Some("u8"));
|
||||
assert_eq!(bar.replace_range, Some(rng(0, 12, 0, 14)));
|
||||
assert_eq!(bar.replace_range_tab, Some(rng(0, 12, 0, 15)));
|
||||
assert_eq!(bar.data, Some(json!({ "from": "defaults" })));
|
||||
@@ -828,9 +1338,13 @@ async fn completion_mapping_merge_and_resolve() {
|
||||
assert_eq!(resolved.documentation.as_ref().unwrap().value, "**docs**");
|
||||
assert_eq!(resolved.additional_edits[0].new_text, "use foo;\n");
|
||||
assert_eq!(resolved.insert_text, "bar()$0", "fields absent from the resolve reply are kept");
|
||||
assert_eq!(resolved.label_detail.as_deref(), Some("()"));
|
||||
assert_eq!(resolved.label_description.as_deref(), Some("u8"));
|
||||
assert_eq!(resolved.detail.as_deref(), Some("fn() -> u8"));
|
||||
assert_eq!(resolved.replace_range_tab, Some(rng(0, 12, 0, 15)));
|
||||
let sent = &a.requested("completionItem/resolve")[0];
|
||||
assert_eq!(sent["data"], json!({ "from": "defaults" }));
|
||||
assert_eq!(sent["labelDetails"], json!({ "detail": "()", "description": "u8" }));
|
||||
assert_eq!(sent["textEdit"]["replace"]["end"]["character"], 15);
|
||||
|
||||
// Server b has no resolve support: the item comes back unchanged.
|
||||
@@ -935,7 +1449,7 @@ async fn diagnostics_become_events_and_feed_code_actions() {
|
||||
s.respond(
|
||||
"textDocument/codeAction",
|
||||
json!([
|
||||
{ "title": "Remove", "kind": "quickfix", "isPreferred": true, "data": { "id": 7 } },
|
||||
{ "title": "Remove", "kind": "quickfix", "isPreferred": true, "data": { "id": 7 }, "diagnostics": [diag(0)] },
|
||||
{ "title": "Run", "command": "mock.run", "arguments": [1] }
|
||||
]),
|
||||
);
|
||||
@@ -950,6 +1464,9 @@ async fn diagnostics_become_events_and_feed_code_actions() {
|
||||
assert_eq!(actions.len(), 2);
|
||||
assert_eq!(actions[0].kind.as_deref(), Some("quickfix"));
|
||||
assert!(actions[0].is_preferred);
|
||||
assert_eq!(actions[0].diagnostics[0].range, rng(0, 4, 0, 10));
|
||||
assert_eq!(actions[0].diagnostics[0].message, "unused variable");
|
||||
assert!(actions[1].diagnostics.is_empty());
|
||||
assert_eq!(actions[1].command.as_ref().unwrap().command, "mock.run");
|
||||
let req = &s.requested("textDocument/codeAction")[0];
|
||||
assert_eq!(req["context"]["only"], json!(["quickfix"]));
|
||||
@@ -959,6 +1476,8 @@ async fn diagnostics_become_events_and_feed_code_actions() {
|
||||
|
||||
let resolved = h.mgr.resolve_code_action(doc, actions[0].clone()).await.unwrap();
|
||||
assert_eq!(s.requested("codeAction/resolve")[0]["data"], json!({ "id": 7 }));
|
||||
assert_eq!(s.requested("codeAction/resolve")[0]["diagnostics"], json!([diag(0)]));
|
||||
assert_eq!(resolved.diagnostics, actions[0].diagnostics);
|
||||
let edit = resolved.edit.unwrap();
|
||||
assert!(
|
||||
matches!(&edit.files[0], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].range == rng(0, 0, 1, 0))
|
||||
@@ -1877,6 +2396,34 @@ async fn java_source_paths_reach_initialization_live_configuration_and_builds()
|
||||
assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_build_waits_for_a_reconfigured_server_to_restart() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("App.java", "class App {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
state.respond("java/buildWorkspace", json!(1));
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.held.lock() = Some(("exit".into(), gate.clone()));
|
||||
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.args.push("--new-configuration".into())));
|
||||
let reconfigure = tokio::spawn({
|
||||
let mgr = h.mgr.clone();
|
||||
async move { mgr.reconfigure().await }
|
||||
});
|
||||
eventually(|| gate.entered()).await;
|
||||
let mut build = tokio::spawn({
|
||||
let (mgr, root) = (h.mgr.clone(), h.root.clone());
|
||||
async move { mgr.java_build(&root, false, &Default::default()).await }
|
||||
});
|
||||
let early = tokio::time::timeout(Duration::from_millis(100), &mut build).await;
|
||||
gate.open();
|
||||
reconfigure.await.unwrap();
|
||||
assert!(early.is_err(), "a planned restart must not fail a pending build: {early:?}");
|
||||
assert_eq!(build.await.unwrap().unwrap(), ide_lsp::jdtls::JavaBuildStatus::Succeeded);
|
||||
assert_eq!(state.launches.load(Ordering::SeqCst), 2);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_build_asks_for_a_full_build_when_rebuilding() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
@@ -2730,7 +3277,7 @@ async fn noop_handler_is_usable_for_raw_peers() {
|
||||
let (a, b) = tokio::io::duplex(1024);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
let (br, bw) = tokio::io::split(b);
|
||||
let _server = Peer::connect(br, bw, Arc::new(MockHandler(Arc::default())));
|
||||
let _server = Peer::connect(br, bw, Arc::new(MockHandler(Arc::default(), std::sync::Weak::new())));
|
||||
let client = Peer::connect(ar, aw, Arc::new(NoopHandler));
|
||||
let v: Value = client.request("shutdown", (), None).await.unwrap();
|
||||
assert_eq!(v, Value::Null);
|
||||
@@ -5136,6 +5683,39 @@ async fn jdtls_import_failure_names_reserved_device_files_on_windows() {
|
||||
let _ = std::fs::remove_file(&nul);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn gradle_incompatible_jdk_log_is_surfaced_once_across_message_channels() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
h.open("A.java", "class A {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let failure = "Can't use Java 27.0.1.0.3 and Gradle 9.7.1 to import Gradle project kotlin-gradle.";
|
||||
for _ in 0..2 {
|
||||
s.peer()
|
||||
.notify(
|
||||
"window/logMessage",
|
||||
json!({ "type": 1, "message": format!("{failure}\njava.lang.Exception: details stay in the log") }),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { body: Some(b), .. } if b == failure)))
|
||||
.await;
|
||||
s.peer().notify("window/showMessage", json!({ "type": 1, "message": failure })).unwrap();
|
||||
s.peer().notify("window/logMessage", json!({ "type": 1, "message": "unrelated internal error" })).unwrap();
|
||||
s.peer().notify("window/showMessage", json!({ "type": 2, "message": "after" })).unwrap();
|
||||
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { body: Some(b), .. } if b == "after")))
|
||||
.await;
|
||||
let events = h.events.lock();
|
||||
let messages: Vec<_> = events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Notify { body: Some(body), .. } => Some(body.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(messages, vec![failure, "after"]);
|
||||
}
|
||||
|
||||
/// Pull Members Up / Push Members Down: the extension's answers, refusals and edits.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_hierarchy_refactorings_use_the_extension_commands() {
|
||||
|
||||
@@ -63,6 +63,104 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires OPENIDEA_TEST_JDTLS and OPENIDEA_TEST_GRADLE_HOME, with JDK 21+"]
|
||||
async fn shared_jdtls_imports_independent_gradle_builds_and_external_root() {
|
||||
use serde_json::json;
|
||||
|
||||
let jdtls = std::env::var("OPENIDEA_TEST_JDTLS").expect("set OPENIDEA_TEST_JDTLS");
|
||||
let gradle_home = std::env::var("OPENIDEA_TEST_GRADLE_HOME").expect("set OPENIDEA_TEST_GRADLE_HOME");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let temp_root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let root = temp_root.join("workspace");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let external = temp_root.join("external");
|
||||
let mut files = Vec::new();
|
||||
for (name, build) in [("alpha", root.join("alpha")), ("beta", root.join("beta")), ("external", external.clone())] {
|
||||
let source_dir = build.join("app/src/main/java/demo");
|
||||
std::fs::create_dir_all(&source_dir).unwrap();
|
||||
std::fs::write(build.join("settings.gradle"), format!("rootProject.name = '{name}'\ninclude 'app'\n")).unwrap();
|
||||
std::fs::write(build.join("build.gradle"), "").unwrap();
|
||||
std::fs::write(build.join("app/build.gradle"), "plugins { id 'java' }\n").unwrap();
|
||||
let source = format!(
|
||||
"package demo; public class Main {{ public static void main(String[] args) {{ {name}Marker(); }} static void {name}Marker() {{}} }}\n"
|
||||
);
|
||||
let path = source_dir.join("Main.java");
|
||||
std::fs::write(&path, &source).unwrap();
|
||||
files.push((name, path, source));
|
||||
}
|
||||
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
|
||||
java.language_servers.retain(|s| s.name == "jdtls");
|
||||
let server = &mut java.language_servers[0];
|
||||
server.command = jdtls;
|
||||
server.env.insert("GRADLE_USER_HOME".into(), dir.path().join("gradle-user-home").to_string_lossy().into_owned());
|
||||
server.args.extend(["-data".into(), dir.path().join("jdtls-data").to_string_lossy().into_owned()]);
|
||||
let options = server.initialization_options.as_mut().unwrap().as_table_mut().unwrap();
|
||||
let settings = options.get_mut("settings").unwrap().as_table_mut().unwrap();
|
||||
let java_settings = settings.get_mut("java").unwrap().as_table_mut().unwrap();
|
||||
let imports = java_settings.get_mut("import").unwrap().as_table_mut().unwrap();
|
||||
let gradle = imports.get_mut("gradle").unwrap().as_table_mut().unwrap();
|
||||
gradle.insert("home".into(), toml::Value::String(gradle_home));
|
||||
gradle.insert("wrapper".into(), toml::Value::try_from(json!({"enabled": false})).unwrap());
|
||||
if let Ok(home) = std::env::var("OPENIDEA_TEST_GRADLE_JAVA_HOME") {
|
||||
gradle.insert("java".into(), toml::Value::try_from(json!({"home": home})).unwrap());
|
||||
}
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java]));
|
||||
mgr.set_jdtls_shared_hook(Box::new(|| true));
|
||||
mgr.set_content_roots(vec![external]);
|
||||
for (_, path, source) in &files {
|
||||
mgr.did_open(DocId::next(), path, &LanguageId::new("java"), 1, source);
|
||||
}
|
||||
retry("shared jdtls ready", Duration::from_secs(60), || {
|
||||
let status = last_status(&events, "jdtls");
|
||||
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, message)) = &status {
|
||||
panic!("shared jdtls failed: {message:?}");
|
||||
}
|
||||
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
|
||||
})
|
||||
.await;
|
||||
for (name, path, _) in &files {
|
||||
let cp = retry(&format!("{name} Gradle classpath"), Duration::from_secs(180), || async {
|
||||
let cp =
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.java_classpath(path, false, &Default::default()))
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
cp.class_paths.iter().any(|p| p.contains(&format!("/{name}/app/"))).then_some(cp)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
cp.class_paths
|
||||
.iter()
|
||||
.all(|p| !files.iter().any(|(other, _, _)| other != name && p.contains(&format!("/{other}/app/")))),
|
||||
"classpath leaked between builds: {cp:?}"
|
||||
);
|
||||
let launch = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
mgr.java_launch(path, false, false, false, false, &Default::default()),
|
||||
)
|
||||
.await
|
||||
.expect("launch resolution timed out")
|
||||
.expect("launch resolution failed");
|
||||
assert_eq!(launch.class_paths, cp.class_paths, "launch resolved a different build for {name}");
|
||||
}
|
||||
let servers = mgr.servers().into_iter().filter(|s| s.name == "jdtls").collect::<Vec<_>>();
|
||||
assert_eq!(servers.len(), 1, "expected one JDT LS: {servers:?}");
|
||||
assert!(servers[0].pid.is_some(), "JDT LS process is not running");
|
||||
for (_, path, _) in [&files[0], &files[2]] {
|
||||
let bad = path.parent().unwrap().join("Broken.java");
|
||||
let bad_source = "package demo; class Broken { MissingType x; }";
|
||||
std::fs::write(&bad, bad_source).unwrap();
|
||||
mgr.did_open(DocId::next(), &bad, &LanguageId::new("java"), 1, bad_source);
|
||||
retry("diagnostics from shared JDT LS", Duration::from_secs(120), || {
|
||||
let found = events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { path, items, .. } if path.0 == bad && items.iter().any(|d| d.message.contains("MissingType"))));
|
||||
async move { found.then_some(()) }
|
||||
}).await;
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires kmp-lsp on PATH (or OPENIDEA_TEST_KMP_LSP)"]
|
||||
async fn kmp_lsp_java_end_to_end() {
|
||||
|
||||
@@ -196,7 +196,7 @@ impl Actor {
|
||||
grace: Duration,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
) -> Result<R, ActorError> {
|
||||
self.call_cancellable_before(cancel, grace, None, f)
|
||||
self.call_cancellable_before(cancel, grace, None, false, f)
|
||||
}
|
||||
|
||||
/// A cancellable editor call: queued work expires after `soft`, and a
|
||||
@@ -209,7 +209,18 @@ impl Actor {
|
||||
soft: Duration,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
) -> Result<R, ActorError> {
|
||||
self.call_cancellable_before(cancel, grace, Some(Instant::now() + soft), f)
|
||||
self.call_cancellable_before(cancel, grace, Some(Instant::now() + soft), false, f)
|
||||
}
|
||||
|
||||
/// A launch gate's deadline also interrupts running host calls and background tasks.
|
||||
pub fn call_bounded<R: Send + 'static>(
|
||||
&self,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
grace: Duration,
|
||||
timeout: Duration,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
) -> Result<R, ActorError> {
|
||||
self.call_cancellable_before(cancel, grace, Some(Instant::now() + timeout), true, f)
|
||||
}
|
||||
|
||||
fn call_cancellable_before<R: Send + 'static>(
|
||||
@@ -217,6 +228,7 @@ impl Actor {
|
||||
cancel: &ide_core::CancellationToken,
|
||||
grace: Duration,
|
||||
deadline: Option<Instant>,
|
||||
hard_deadline: bool,
|
||||
f: impl FnOnce(&mut Runtime) -> Result<R, CallError> + Send + 'static,
|
||||
) -> Result<R, ActorError> {
|
||||
let (reply, result) = mpsc::sync_channel(1);
|
||||
@@ -260,7 +272,7 @@ impl Actor {
|
||||
match result.recv_timeout(Duration::from_millis(5)) {
|
||||
Ok(outcome) if cancelled_at.is_some() => {
|
||||
drop(outcome);
|
||||
return Err(ActorError::Cancelled);
|
||||
return Err(if cancel.is_cancelled() { ActorError::Cancelled } else { ActorError::SoftTimeout });
|
||||
}
|
||||
Ok(outcome) => return outcome,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
@@ -268,19 +280,21 @@ impl Actor {
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
if !cancel.is_cancelled() {
|
||||
let timed_out = hard_deadline && deadline.is_some_and(|t| Instant::now() >= t);
|
||||
if !cancel.is_cancelled() && !timed_out {
|
||||
if deadline.is_some_and(|t| Instant::now() >= t) && !*running.lock().unwrap_or_else(|e| e.into_inner())
|
||||
{
|
||||
return Err(ActorError::SoftTimeout);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let failure = || if cancel.is_cancelled() { ActorError::Cancelled } else { ActorError::SoftTimeout };
|
||||
match cancelled_at {
|
||||
None => {
|
||||
cancelled_at = Some(Instant::now());
|
||||
let running = running.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !*running {
|
||||
return Err(ActorError::Cancelled);
|
||||
return Err(failure());
|
||||
}
|
||||
self.cancel.cancel_soft();
|
||||
}
|
||||
@@ -290,7 +304,7 @@ impl Actor {
|
||||
if *running {
|
||||
self.cancel();
|
||||
}
|
||||
return Err(ActorError::Cancelled);
|
||||
return Err(failure());
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
|
||||
@@ -151,3 +151,19 @@ pub enum PluginTestEvent {
|
||||
Output(TestOutput),
|
||||
Other { kind: String, data: String },
|
||||
}
|
||||
|
||||
/// Operation about to launch through a plugin-owned run target (API 1.5).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RunTargetOperation {
|
||||
Run,
|
||||
Debug,
|
||||
Test,
|
||||
}
|
||||
|
||||
/// Launch metadata available to a target validator; arguments and environment are omitted.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RunTargetLaunch {
|
||||
pub operation: RunTargetOperation,
|
||||
pub program: String,
|
||||
pub cwd: String,
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use crate::types::{
|
||||
};
|
||||
use crate::wit_host::injections_bindings::exports::openidea::plugin::injections as wit_inj;
|
||||
use crate::wit_host::language_features_bindings::exports::openidea::plugin::language_features as wit_lf;
|
||||
use crate::wit_host::run_target_validation_bindings::exports::openidea::plugin::run_target_validation as wit_rtv;
|
||||
use crate::wit_host::{
|
||||
bindings, commit_checks_bindings, details_bindings, endpoints_bindings, file_actions_bindings, hover_bindings,
|
||||
indexing_bindings, inlay_bindings, providers_bindings, semantic_bindings, syntax_bindings, views_bindings,
|
||||
@@ -54,7 +55,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
|
||||
/// The API version of the world above, `major.minor` of [`host_api`].
|
||||
pub const API_VERSION: &str = "1.4";
|
||||
pub const API_VERSION: &str = "1.5";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
@@ -259,6 +260,19 @@ pub trait HostApi: Send + Sync {
|
||||
body: String,
|
||||
actions: Vec<crate::NotificationAction>,
|
||||
) -> Result<(), HostError>;
|
||||
/// [`HostApi::notify`] in the most recently focused window of the open project at
|
||||
/// `root`; not-found (and nothing shown) once it has closed.
|
||||
fn notify_project(
|
||||
&self,
|
||||
root: &Path,
|
||||
level: NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<crate::NotificationAction>,
|
||||
) -> Result<(), HostError> {
|
||||
let _ = (root, level, title, body, actions);
|
||||
Err(HostError::NotFound("project notifications unavailable".into()))
|
||||
}
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
let _ = (client, text);
|
||||
Ok(())
|
||||
@@ -912,6 +926,7 @@ pub struct Runtime {
|
||||
|
||||
/// What the component exports: the `plugin` world, plus optional interfaces.
|
||||
struct Exports {
|
||||
run_target_validation: Option<wit_rtv::Guest>,
|
||||
language_features: Option<wit_lf::Guest>,
|
||||
injections: Option<wit_inj::Guest>,
|
||||
plugin: bindings::Plugin,
|
||||
@@ -1049,6 +1064,7 @@ impl Runtime {
|
||||
let endpoint_indices = wit_endpoints::GuestIndices::new(&pre).ok();
|
||||
let configs_indices = wit_configs::GuestIndices::new(&pre).ok();
|
||||
let tests_indices = wit_tests::GuestIndices::new(&pre).ok();
|
||||
let validation_indices = wit_rtv::GuestIndices::new(&pre).ok();
|
||||
let lf_indices = wit_lf::GuestIndices::new(&pre).ok();
|
||||
let inj_indices = wit_inj::GuestIndices::new(&pre).ok();
|
||||
let mcp_indices =
|
||||
@@ -1060,6 +1076,8 @@ impl Runtime {
|
||||
let test_results = tests_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let language_features = lf_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let injections = inj_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let run_target_validation =
|
||||
validation_indices.map(|i| i.load(&mut store, &instance)).transpose().map_err(api_hint)?;
|
||||
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
|
||||
let syntax = match syntax_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
@@ -1112,6 +1130,7 @@ impl Runtime {
|
||||
Ok(Self {
|
||||
store,
|
||||
exports: Exports {
|
||||
run_target_validation,
|
||||
language_features,
|
||||
injections,
|
||||
plugin,
|
||||
@@ -1136,6 +1155,37 @@ impl Runtime {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_run_target_validation(&self) -> bool {
|
||||
self.exports.run_target_validation.is_some()
|
||||
}
|
||||
|
||||
/// Missing optional validators allow the launch without a guest call.
|
||||
pub fn validate_run_target(
|
||||
&mut self,
|
||||
root: String,
|
||||
target: String,
|
||||
launch: crate::RunTargetLaunch,
|
||||
timeout: Duration,
|
||||
) -> Result<Result<(), String>, CallError> {
|
||||
if self.exports.run_target_validation.is_none() {
|
||||
return Ok(Ok(()));
|
||||
}
|
||||
let invocation = Invocation { workspace_root: Some(root.clone().into()), ..Default::default() };
|
||||
let launch = wit_rtv::LaunchInfo {
|
||||
operation: match launch.operation {
|
||||
crate::RunTargetOperation::Run => wit_rtv::Operation::Run,
|
||||
crate::RunTargetOperation::Debug => wit_rtv::Operation::Debug,
|
||||
crate::RunTargetOperation::Test => wit_rtv::Operation::Test,
|
||||
},
|
||||
program: launch.program,
|
||||
cwd: launch.cwd,
|
||||
};
|
||||
self.call(invocation, timeout, |e, store| {
|
||||
e.run_target_validation.as_ref().unwrap().call_validate(store, &root, &target, &launch)
|
||||
})
|
||||
.map(|r| r.map_err(|message| clip(message, 4096)))
|
||||
}
|
||||
|
||||
/// Complete configurations from the optional API 1.4 export.
|
||||
pub fn run_configurations(
|
||||
&mut self,
|
||||
|
||||
@@ -362,6 +362,25 @@ impl StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks `ui.notify` buttons; the host checks that their commands are declared.
|
||||
fn notification_actions(actions: Vec<ui::NotificationAction>) -> Result<Vec<crate::NotificationAction>, wit::Error> {
|
||||
const MAX_ACTIONS: usize = 3;
|
||||
if actions.len() > MAX_ACTIONS {
|
||||
return Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions")));
|
||||
}
|
||||
let mut checked = Vec::with_capacity(actions.len());
|
||||
for a in actions {
|
||||
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
|
||||
return Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label)));
|
||||
}
|
||||
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
|
||||
return Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into()));
|
||||
}
|
||||
checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument });
|
||||
}
|
||||
Ok(checked)
|
||||
}
|
||||
|
||||
impl ui::Host for StoreData {
|
||||
fn notify(
|
||||
&mut self,
|
||||
@@ -370,22 +389,29 @@ impl ui::Host for StoreData {
|
||||
body: String,
|
||||
actions: Vec<ui::NotificationAction>,
|
||||
) -> Fallible<()> {
|
||||
const MAX_ACTIONS: usize = 3;
|
||||
if actions.len() > MAX_ACTIONS {
|
||||
return Ok(Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions"))));
|
||||
}
|
||||
let mut checked = Vec::with_capacity(actions.len());
|
||||
for a in actions {
|
||||
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
|
||||
return Ok(Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label))));
|
||||
}
|
||||
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
|
||||
return Ok(Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into())));
|
||||
}
|
||||
checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument });
|
||||
}
|
||||
let actions = match notification_actions(actions) {
|
||||
Ok(actions) => actions,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
|
||||
Ok(self.host.notify(self.client(), notification_level(level), title, body, checked).map_err(to_wit))
|
||||
Ok(self.host.notify(self.client(), notification_level(level), title, body, actions).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn notify_project(
|
||||
&mut self,
|
||||
target: Resource<ProjectRes>,
|
||||
level: wit::NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<ui::NotificationAction>,
|
||||
) -> Fallible<()> {
|
||||
let actions = match notification_actions(actions) {
|
||||
Ok(actions) => actions,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let root = self.table.get(&target)?.root.clone();
|
||||
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
|
||||
Ok(self.host.notify_project(&root, notification_level(level), title, body, actions).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn show_notification(
|
||||
@@ -1852,31 +1878,72 @@ impl From<bindings::openidea::plugin::views::ContentTabReply> for crate::Content
|
||||
}
|
||||
}
|
||||
|
||||
impl StoreData {
|
||||
fn register_plugin_run_target(
|
||||
&mut self,
|
||||
project: Resource<ProjectRes>,
|
||||
target: crate::PluginRunTarget,
|
||||
) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Process(target.program.clone())) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
Ok(self.host.register_run_target(&root, target).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl bindings::openidea::plugin::run_targets::Host for StoreData {
|
||||
fn register(
|
||||
&mut self,
|
||||
project: Resource<ProjectRes>,
|
||||
target: bindings::openidea::plugin::run_targets::RunTarget,
|
||||
) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Process(target.program.clone())) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
Ok(self
|
||||
.host
|
||||
.register_run_target(
|
||||
&root,
|
||||
crate::PluginRunTarget {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
program: target.program,
|
||||
args: target.args,
|
||||
workdir_args: target.workdir_args,
|
||||
container_root: target.container_root,
|
||||
commands: target.commands,
|
||||
},
|
||||
)
|
||||
.map_err(to_wit))
|
||||
self.register_plugin_run_target(
|
||||
project,
|
||||
crate::PluginRunTarget {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
program: target.program,
|
||||
args: target.args,
|
||||
workdir_args: target.workdir_args,
|
||||
container_root: target.container_root,
|
||||
commands: target
|
||||
.commands
|
||||
.into_iter()
|
||||
.map(|(name, args)| ide_core::remote_settings::PluginCommandOverride {
|
||||
name,
|
||||
args,
|
||||
workdir_args: None,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
)
|
||||
}
|
||||
fn register_with_overrides(
|
||||
&mut self,
|
||||
project: Resource<ProjectRes>,
|
||||
target: bindings::openidea::plugin::run_targets::RunTargetWithOverrides,
|
||||
) -> Fallible<()> {
|
||||
self.register_plugin_run_target(
|
||||
project,
|
||||
crate::PluginRunTarget {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
program: target.program,
|
||||
args: target.args,
|
||||
workdir_args: target.workdir_args,
|
||||
container_root: target.container_root,
|
||||
commands: target
|
||||
.commands
|
||||
.into_iter()
|
||||
.map(|c| ide_core::remote_settings::PluginCommandOverride {
|
||||
name: c.name,
|
||||
args: c.args,
|
||||
workdir_args: c.workdir_args,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
)
|
||||
}
|
||||
fn unregister(&mut self, project: Resource<ProjectRes>, id: String) -> wasmtime::Result<()> {
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
@@ -2268,3 +2335,8 @@ pub(crate) mod mcp_bindings {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// Optional API 1.5 run-target launch validation.
|
||||
pub(crate) mod run_target_validation_bindings {
|
||||
wasmtime::component::bindgen!({ world: "run-target-validator", path: "../../wit" });
|
||||
}
|
||||
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
|
||||
|
||||
#[test]
|
||||
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
|
||||
let host = v("1.4.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.3.7", "1.4.0", "1.0.0-rc.1"] {
|
||||
let host = v("1.5.0");
|
||||
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.0.0-rc.1"] {
|
||||
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
|
||||
}
|
||||
let newer = check_api(&v("1.5.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.5") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
let newer = check_api(&v("1.6.0"), &host).unwrap_err();
|
||||
assert!(newer.contains("1.6") && newer.contains("update OpenIDEA"), "{newer}");
|
||||
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
|
||||
let older = check_api(&v("0.2.0"), &host).unwrap_err();
|
||||
assert!(older.contains("rebuild it with the 1.4 SDK"), "{older}");
|
||||
assert!(older.contains("rebuild it with the 1.5 SDK"), "{older}");
|
||||
// 0.x: every minor is its own API.
|
||||
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
|
||||
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
4647fa9aec9732e3dd824efccd61b1c6b9c36645a3586f402f6a7a47419db61f
|
||||
023542d758cd359aa4d1282b95ddd2edca9fcf746862700705dd637b3e524571
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
6787b195df1057176ea9fcf11d0fcaf8cd347fc182fab94fe3a229063deed831
|
||||
ef4cedb7ab9caa7a5a597fa8bd302e4e10607989c575af1ca0c237f2d8bb06c8
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c2d17076337b1225ef8b0323b335a3b03bf022485088957c520e0cb797d7df7d
|
||||
c215812502ccbb85b08efb9da234e6e9709deb84a492b41e771c61d4c38a05fb
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
415a4cd17c573aeeb1b45d4b1af55834eab5e686c4f624495323e4fb63c3ac71
|
||||
52efdb99877cded6fc5357d80f722975e314610b9d2bc91979b4b4cb05d8cdb8
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
5f502b7650acd5d07bffba8016680248b5ceac0926013916ff351562e3c6d831
|
||||
0fb78e5c48f267e529ceb719ceb1e5aca82f2288071e9a0cc14809c27e7cd15a
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
19e8d460269999aeac7b4021bfedb803ad0d68c24ce1c22f058070fb406a64c3
|
||||
5a4c7a37b5ff83d62dcc3da616da3712113fa6257e834f51705a4f7e417e519a
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
63809abb0f8a4f40b0c0f465062cc8ba0ebfd03d41a108ff19866e6565eda931
|
||||
e5598d719f6e66790858758718842e4474747c6e14eb3e1c29c4584b26279ef6
|
||||
+9
-1
@@ -1,7 +1,7 @@
|
||||
id = "execution"
|
||||
name = "Plugin execution tests"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.4"
|
||||
engines.openidea = "^1.5"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*"]
|
||||
[capabilities]
|
||||
@@ -50,3 +50,11 @@ title = "target"
|
||||
[[contributes.commands]]
|
||||
id = "untarget"
|
||||
title = "untarget"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "target-overrides"
|
||||
title = "target-overrides"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "validation"
|
||||
title = "validation"
|
||||
+49
-1
@@ -20,6 +20,32 @@ impl Plugin for Execution {
|
||||
)?;
|
||||
Ok(None)
|
||||
}
|
||||
"target-overrides" => {
|
||||
let project = ctx.project().ok_or_else(|| Error::Invalid("no project".into()))?;
|
||||
let workdir_args = match arg {
|
||||
Some("empty") => Some(vec![]),
|
||||
Some("own") => Some(vec!["--working-dir={cwd}".into()]),
|
||||
Some("invalid") => Some(vec!["bad\0arg".into()]),
|
||||
_ => None,
|
||||
};
|
||||
host::register_run_target_with_overrides(
|
||||
&project,
|
||||
&PluginRunTargetWithOverrides {
|
||||
id: "wrapper".into(),
|
||||
name: "Execution target".into(),
|
||||
program: "execution-tool".into(),
|
||||
args: vec!["wrapped".into()],
|
||||
workdir_args: vec!["--cwd={cwd}".into()],
|
||||
container_root: Some("/container".into()),
|
||||
commands: vec![PluginCommandOverride {
|
||||
name: "composer".into(),
|
||||
args: vec!["composer".into()],
|
||||
workdir_args,
|
||||
}],
|
||||
},
|
||||
)?;
|
||||
Ok(None)
|
||||
}
|
||||
"untarget" => {
|
||||
host::unregister_run_target(&ctx.project().unwrap(), arg.unwrap_or("wrapper"));
|
||||
Ok(None)
|
||||
@@ -53,6 +79,7 @@ impl Plugin for Execution {
|
||||
Ok(None)
|
||||
}
|
||||
"events" => host::storage::get("events"),
|
||||
"validation" => host::storage::get("validation"),
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
@@ -121,4 +148,25 @@ impl TestResults for Execution {
|
||||
]
|
||||
}
|
||||
}
|
||||
register_plugin!(Execution, run_configurations, test_results);
|
||||
impl RunTargetValidation for Execution {
|
||||
fn validate(&mut self, project: &str, target: &str, launch: &RunTargetLaunch) -> Result<(), String> {
|
||||
host::storage::set(
|
||||
"validation",
|
||||
&format!("{project}|{target}|{:?}|{}|{}", launch.operation, launch.program, launch.cwd),
|
||||
)
|
||||
.unwrap();
|
||||
match target {
|
||||
"refuse" => Err("Project runtime is stopped".into()),
|
||||
"long-refusal" => Err("x".repeat(10000)),
|
||||
"trap" => panic!("validator failed"),
|
||||
"timeout" => {
|
||||
let _task = host::begin_task("Validation", true);
|
||||
loop {
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
register_plugin!(Execution, run_configurations, test_results, run_target_validation);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
99c968b98d8f1d895c428b4048451275bd322e7070aa0a020737097efea17eb1
|
||||
18a37beb12a0a3da68345314f7bfbb9521e8819702ef50a1c81f29bc3112c997
|
||||
Loaded 100 of 748 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user