mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
123
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe68d57aa2 | ||
|
|
d14a067ee1 | ||
|
|
b3b4c835dd | ||
|
|
041e72106b | ||
|
|
aa2c24c648 | ||
|
|
c03085340d | ||
|
|
3a87c697b2 | ||
|
|
87bf89d688 | ||
|
|
f6d7facd82 | ||
|
|
ec5193f827 | ||
|
|
9bb44cbeb3 | ||
|
|
8d38aa4a9c | ||
|
|
683f8d8826 | ||
|
|
f19eacc336 | ||
|
|
d3e63d8ff0 | ||
|
|
5c0a5239fa | ||
|
|
f8d6f9df77 | ||
|
|
0c54266d03 | ||
|
|
1716f3e062 | ||
|
|
482241ebb9 | ||
|
|
7c72411dd7 | ||
|
|
376501a5fe | ||
|
|
21925b7f4b | ||
|
|
1e898367a5 | ||
|
|
7a58d9f253 | ||
|
|
ad80e903e3 | ||
|
|
9847fd2c01 | ||
|
|
fa26ba842c | ||
|
|
16033c481b | ||
|
|
2c3eabb786 | ||
|
|
c938d712b5 | ||
|
|
7cf4f7948d | ||
|
|
b281447da8 | ||
|
|
1e913c2334 | ||
|
|
52cb592692 | ||
|
|
bbaf2c89a0 | ||
|
|
6456f0e404 | ||
|
|
c3d569d3e3 | ||
|
|
0458424f9c | ||
|
|
4395975664 | ||
|
|
0bf597c2fd | ||
|
|
95e79116d1 | ||
|
|
cde74394d7 | ||
|
|
9ca65b4df6 | ||
|
|
2841f97145 | ||
|
|
0be97c4323 | ||
|
|
0411a5b08a | ||
|
|
f48cd85d0b | ||
|
|
9e35cfc064 | ||
|
|
0a71f6be88 | ||
|
|
b3c3150489 | ||
|
|
1d937d52e5 | ||
|
|
5f57ce962e | ||
|
|
95b45c095d | ||
|
|
9dc736de09 | ||
|
|
369c63979d | ||
|
|
347e3525d2 | ||
|
|
5280c9e584 | ||
|
|
67cad01ba7 | ||
|
|
ad92b1577f | ||
|
|
06109aae95 | ||
|
|
b2d1188049 | ||
|
|
5329ddc469 | ||
|
|
2faa64fb68 | ||
|
|
d6f6525ca5 | ||
|
|
2c9a6470ff | ||
|
|
cf51b57924 | ||
|
|
e7740e4032 | ||
|
|
bdadecdf91 | ||
|
|
178fc9eaba | ||
|
|
17e3cd2b39 | ||
|
|
4796265b5c | ||
|
|
1e9cf1c447 | ||
|
|
7610fc3f80 | ||
|
|
a2405c083a | ||
|
|
ee2585e7e9 | ||
|
|
999eb86408 | ||
|
|
8f85f35ebe | ||
|
|
1e8551ad0b | ||
|
|
27ed0515e8 | ||
|
|
ac360b6143 | ||
|
|
4418f49c35 | ||
|
|
c5cb217c7c | ||
|
|
a553dafa7a | ||
|
|
cbdc177bbc | ||
|
|
c0f8557f05 | ||
|
|
d4b354732c | ||
|
|
53bebe4836 | ||
|
|
290abd303e | ||
|
|
bde19959b9 | ||
|
|
92cbdd0cb8 | ||
|
|
8fe09eba7f | ||
|
|
9d670e6262 | ||
|
|
504d8592d2 | ||
|
|
4f1feebbd0 | ||
|
|
4a620122e8 | ||
|
|
f9a7c68897 | ||
|
|
28baa4a57a | ||
|
|
a5fb30e04f | ||
|
|
f2b8db548f | ||
|
|
89475f4fed | ||
|
|
2bc5e8d0c8 | ||
|
|
79e4233c56 | ||
|
|
df6ed5ccba | ||
|
|
f592ff5b05 | ||
|
|
c0c9553550 | ||
|
|
059fd8a69b | ||
|
|
696377acc2 | ||
|
|
52834088fe | ||
|
|
c3d29dc842 | ||
|
|
8dc1ee062f | ||
|
|
fcc36d67e7 | ||
|
|
00108a669e | ||
|
|
c08b0f543e | ||
|
|
7f9dda50b6 | ||
|
|
98c8c55b9e | ||
|
|
8fabf054ef | ||
|
|
a3cd99db1f | ||
|
|
0660f3c020 | ||
|
|
2754cf327f | ||
|
|
986b0ade58 | ||
|
|
7aad01b035 | ||
|
|
2613e61ebf |
No files matched your search
@@ -70,6 +70,12 @@ jobs:
|
||||
- name: Linux x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: Linux arm64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- name: Windows arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
@@ -196,11 +202,16 @@ jobs:
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
const kinds = [
|
||||
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
|
||||
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
|
||||
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
|
||||
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
|
||||
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
|
||||
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
|
||||
];
|
||||
|
||||
Generated
+48
-35
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4061,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4075,7 +4075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4090,7 +4090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4099,7 +4099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4115,7 +4115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4129,7 +4129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4147,7 +4147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4169,7 +4169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4183,18 +4183,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4208,7 +4209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4253,7 +4254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4271,7 +4272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4293,7 +4294,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4309,7 +4310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4364,14 +4365,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-text",
|
||||
"ide-vfs",
|
||||
"ignore",
|
||||
"lsp-types",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
@@ -4386,7 +4390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4409,7 +4413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4427,7 +4431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4440,7 +4444,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4457,9 +4461,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"ide-build",
|
||||
"ide-core",
|
||||
"libc",
|
||||
"parking_lot",
|
||||
@@ -4478,7 +4484,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4497,7 +4503,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4517,7 +4523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4530,7 +4536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4549,7 +4555,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4564,7 +4570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4577,7 +4583,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4589,7 +4595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4610,7 +4616,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4629,7 +4635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4651,7 +4657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4667,7 +4673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5802,6 +5808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
@@ -6137,9 +6144,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"block2 0.6.2",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"ide-agent",
|
||||
@@ -6176,6 +6184,11 @@ dependencies = [
|
||||
"ide-vfs",
|
||||
"ide-web",
|
||||
"ide-workspace",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-web-kit 0.3.2",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.28"
|
||||
version = "0.1.32"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+51
-3
@@ -1,5 +1,13 @@
|
||||
# Third-party licenses
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`plugins/lombok/lombok-1.18.48-openidea.1.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059).
|
||||
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
|
||||
notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar.
|
||||
Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md).
|
||||
|
||||
## JetBrains IntelliJ Platform icons
|
||||
|
||||
`src/ui/expui/**` contains SVG icons from the IntelliJ Platform "new UI" (expui)
|
||||
@@ -12,12 +20,13 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
|
||||
`python/python-requirements` (`fileTypes/requirements`); `python/pipenv`
|
||||
(`fileTypes/pipenv`); `python/python-pyproject` (`expui/toml.svg` →
|
||||
`fileTypes/pyprojectToml`); `plugins/kotlin/base` (`fileTypes/kotlin`,
|
||||
`kotlinScript`, `kotlinGradleScript`); `java/openapi` (`fileTypes/maven`);
|
||||
`kotlinScript`, `kotlinGradleScript`, `icons/expui/objectKotlin` → `kotlin/objectKotlin`); `java/openapi` (`fileTypes/maven`);
|
||||
`plugins/markdown` (`fileTypes/markdown`); `plugins/toml` (`fileTypes/toml`);
|
||||
`plugins/terminal` (`toolwindows/terminal`); `plugins/maven`
|
||||
(`images/expui/toolwindow/maven` → `toolwindows/maven`, `images/expui/build/maven*`
|
||||
→ `maven/`); `plugins/gradle` (`icons/expui/gradle` → `toolwindows/gradle`,
|
||||
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`);
|
||||
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`,
|
||||
`icons/expui/gradle`, `icons/gradleSubproject` → `gradle/`);
|
||||
`platform/external-system-impl` (`icons/newui/task` → `externalSystem/task`).
|
||||
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
- [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin
|
||||
@@ -26,6 +35,20 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
|
||||
`fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` →
|
||||
`fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below).
|
||||
|
||||
The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and
|
||||
`actions/showWriteAccess{,_dark}.svg`) are copied unmodified from
|
||||
`platform/icons/src/expui/actions/` at intellij-community commit
|
||||
[`e992ff4750b4c36afe2caadfa8b191a8f36fb7a2`](https://github.com/JetBrains/intellij-community/tree/e992ff4750b4c36afe2caadfa8b191a8f36fb7a2/platform/icons/src/expui/actions).
|
||||
Their original copyright headers and Apache-2.0 licensing are retained.
|
||||
|
||||
- [intellij-rust](https://github.com/intellij-rust/intellij-rust) (MIT):
|
||||
`src/main/resources/icons/expui/rustFile{,_dark}.svg` →
|
||||
`src/ui/expui/fileTypes/rust{,_dark}.svg`;
|
||||
`src/main/resources/icons/expui/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg` →
|
||||
`src/ui/expui/fileTypes/cargo{,_dark}.svg` and `cargoLock{,_dark}.svg`.
|
||||
Copyright (c) 2015 Aleksey Kladov, Evgeny Kurbatsky, Alexey Kudinkin and contributors;
|
||||
Copyright (c) 2016 JetBrains. MIT licence, full text below.
|
||||
|
||||
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
|
||||
|
||||
## JetBrains IntelliJ Platform diff algorithm
|
||||
@@ -42,7 +65,7 @@ from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`,
|
||||
JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
|
||||
`src/ui/openidea/**` holds OpenIDEA's own file-type icons drawn in the expui style for
|
||||
languages whose JetBrains icons are not open source (Go, Rust, Ruby, Dart, Lua, Zig,
|
||||
languages whose JetBrains icons are not open source (Go, Ruby, Dart, Lua, Zig,
|
||||
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
|
||||
`dotenv`, `readme`, `lock`, `license`) reuse the page outline of the Apache-2.0
|
||||
expui `fileTypes/h.svg`; that outline is modified from the original by JetBrains.
|
||||
@@ -53,6 +76,31 @@ The lettering in `src-tauri/icons/source/openidea-wordmark*.svg` is outlined fro
|
||||
Inter Tight (Copyright 2022 The Inter Project Authors, SIL Open Font License 1.1,
|
||||
https://github.com/rsms/inter-tight).
|
||||
|
||||
### MIT License (Rust and Cargo icons)
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Aleksey Kladov, Evgeny Kurbatsky, Alexey Kudinkin and contributors
|
||||
Copyright (c) 2016 JetBrains
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
### MIT License (makefile icon)
|
||||
|
||||
Copyright (c) 2016 Victor Kropp
|
||||
|
||||
@@ -915,7 +915,11 @@ impl Session {
|
||||
} else {
|
||||
"end_turn"
|
||||
};
|
||||
finished = st.turn.take().map(|t| (t, TurnEnd::Stop(stop)));
|
||||
finished = st.turn.take().map(|mut t| {
|
||||
// Claude Code reports a turn that failed as cancelled here.
|
||||
let failure = t.failure.take().filter(|_| !t.cancelled);
|
||||
(t, failure.unwrap_or(TurnEnd::Stop(stop)))
|
||||
});
|
||||
}
|
||||
}
|
||||
"assistant" if msg.get("parent_tool_use_id").is_none_or(Value::is_null) => {
|
||||
@@ -1139,7 +1143,8 @@ where
|
||||
/// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of
|
||||
/// its messages, or names none (the CLI's own error before the turn began).
|
||||
fn belongs(turn: &Turn, result: &Value) -> bool {
|
||||
if result.get("origin").is_some_and(|o| !o.is_null()) {
|
||||
// Prompts are sent with the origin `human`, and their results echo it.
|
||||
if result.get("origin").is_some_and(|o| !o.is_null() && o.get("kind").and_then(Value::as_str) != Some("human")) {
|
||||
return false;
|
||||
}
|
||||
let mut uuids = strings(result.get("user_message_uuids"));
|
||||
|
||||
@@ -738,6 +738,50 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
|
||||
assert!(cli.sent.lock()[0].iter().all(|m| m["type"] != "user"), "nothing was sent");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_turn_reports_its_failure() {
|
||||
let text = "Failed to authenticate: OAuth session expired and could not be refreshed";
|
||||
let lifecycle = |uuid: &str, state: &str| {
|
||||
Frame::Cli(json!({ "type": "command_lifecycle", "command_uuid": uuid, "state": state }))
|
||||
};
|
||||
let result = |uuid: &str| {
|
||||
Frame::Cli(json!({
|
||||
"type": "result", "subtype": "success", "is_error": true, "result": text, "stop_reason": "stop_sequence",
|
||||
"terminal_reason": "api_error", "origin": { "kind": "human" }, "user_message_uuids": [uuid],
|
||||
}))
|
||||
};
|
||||
// As Claude Code sends it, and with the turn's end ahead of its result.
|
||||
for lifecycle_first in [false, true] {
|
||||
let mut process = load_fixture("simple").remove(0);
|
||||
process.truncate(6);
|
||||
let Some(Frame::Shim(prompt)) = process.last() else { panic!("the fixture's prompt") };
|
||||
let uuid = prompt["uuid"].as_str().unwrap().to_owned();
|
||||
process.push(Frame::Cli(json!({
|
||||
"type": "assistant", "error": "authentication_failed", "parent_tool_use_id": null,
|
||||
"message": { "id": "m", "model": "<synthetic>", "content": [{ "type": "text", "text": text }] },
|
||||
})));
|
||||
if lifecycle_first {
|
||||
process.extend([lifecycle(&uuid, "cancelled"), result(&uuid)]);
|
||||
} else {
|
||||
process.extend([result(&uuid), lifecycle(&uuid, "cancelled")]);
|
||||
}
|
||||
let cli = Arc::new(Replay {
|
||||
processes: Mutex::new(VecDeque::from([process])),
|
||||
sent: Arc::default(),
|
||||
launched: Mutex::default(),
|
||||
errors: Arc::default(),
|
||||
});
|
||||
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
let h =
|
||||
Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
|
||||
let s = h.new_session().await;
|
||||
let Err(RpcError::Server { message, .. }) = h.prompt(&s, "hi").await else {
|
||||
panic!("the turn should fail (lifecycle first: {lifecycle_first})");
|
||||
};
|
||||
assert_eq!(message, format!("Claude Code authentication failed: {text}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Fork and undo against the real CLI, across connections, in a symlinked directory
|
||||
/// (macOS `/tmp`).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -829,6 +873,7 @@ fn which_result_ends_a_turn() {
|
||||
assert!(!belongs(&t, &json!({"user_message_uuids": ["a"]})), "a steer superseded it");
|
||||
assert!(belongs(&t, &json!({"subtype": "error_during_execution"})), "names no message");
|
||||
assert!(!belongs(&t, &json!({"origin": {"kind": "task-notification"}})), "Claude Code's own turn");
|
||||
assert!(belongs(&t, &json!({"origin": {"kind": "human"}, "user_message_uuids": ["b"]})), "the prompt's own result");
|
||||
assert!(belongs(&turn(&["a", "b"], true), &json!({"user_message_uuids": ["a"]})));
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ pub(crate) fn newest_install(command: &str) -> String {
|
||||
let path = ide_core::process::host_var_os("PATH")
|
||||
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()))
|
||||
.unwrap_or_default();
|
||||
newest_install_in(command, &path)
|
||||
// Windows: read afresh, so a tool the user installed since the IDE started is found too.
|
||||
newest_install_in(command, &ide_core::process::with_user_path(&path))
|
||||
}
|
||||
|
||||
fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
|
||||
|
||||
@@ -222,6 +222,13 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo
|
||||
{
|
||||
return Some(Choice::Uvx(p));
|
||||
}
|
||||
// Last resort: npx and uvx run natively, this one under OS emulation.
|
||||
if platform.os == Os::Windows
|
||||
&& platform.arch == Arch::Arm64
|
||||
&& let Some(b) = d.binary.get("windows-x86_64")
|
||||
{
|
||||
return Some(Choice::Binary(b));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -689,6 +696,20 @@ mod tests {
|
||||
const LINUX: Platform = Platform { os: Os::Linux, arch: Arch::X64 };
|
||||
const WINDOWS: Platform = Platform { os: Os::Windows, arch: Arch::X64 };
|
||||
|
||||
#[test]
|
||||
fn windows_arm_binary_prefers_native_then_x64() {
|
||||
let mut entry = parse(INDEX).unwrap().remove(0);
|
||||
let arm = Platform { os: Os::Windows, arch: Arch::Arm64 };
|
||||
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(_))));
|
||||
assert!(choose(&entry, Platform { os: Os::Linux, arch: Arch::Arm64 }, &|_| false).is_none());
|
||||
entry.distribution.npx = Some(serde_json::from_str(r#"{"package":"amp-acp"}"#).unwrap());
|
||||
assert!(matches!(choose(&entry, arm, &|_| true), Some(Choice::Npx(_))));
|
||||
let mut native = entry.distribution.binary["windows-x86_64"].clone();
|
||||
native.cmd = "native.exe".into();
|
||||
entry.distribution.binary.insert("windows-aarch64".into(), native);
|
||||
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(b)) if b.cmd == "native.exe"));
|
||||
}
|
||||
|
||||
const INDEX: &str = r#"{"version":"1.0.0","agents":[
|
||||
{"id":"amp-acp","name":"Amp","version":"0.10.0","description":"Amp over ACP",
|
||||
"repository":"https://github.com/x/amp-acp","authors":["x"],"license":"Apache-2.0",
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Resource processing by the project's build tool, then publishing to the live JVM's output directory.
|
||||
use crate::BuildModel;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct ResourceBuild {
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: String,
|
||||
pub env: BTreeMap<String, String>,
|
||||
pub kind: &'static str,
|
||||
}
|
||||
|
||||
fn literal(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
|
||||
}
|
||||
|
||||
pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> IdeResult<ResourceBuild> {
|
||||
fs::create_dir_all(cache)?;
|
||||
let manifest = cache.join("resource-output.txt");
|
||||
if manifest.exists() {
|
||||
fs::remove_file(&manifest)?;
|
||||
}
|
||||
let module = dunce::canonicalize(root.join(module))?;
|
||||
if let Some(p) = model.maven.as_ref().and_then(|m| m.projects.iter().find(|p| root.join(&p.dir) == module)) {
|
||||
let mut args = p.options.clone();
|
||||
args.extend([
|
||||
"process-resources".into(),
|
||||
"help:evaluate".into(),
|
||||
"-Dexpression=project.build.outputDirectory".into(),
|
||||
format!("-Doutput={}", manifest.display()),
|
||||
]);
|
||||
return Ok(ResourceBuild {
|
||||
command: p.command.clone(),
|
||||
args,
|
||||
cwd: p.dir.clone(),
|
||||
env: p.env.clone(),
|
||||
kind: "maven",
|
||||
});
|
||||
}
|
||||
if let Some(g) = &model.gradle
|
||||
&& let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module)
|
||||
{
|
||||
let script = cache.join("resources.init.gradle");
|
||||
fs::write(
|
||||
&script,
|
||||
format!(
|
||||
r#"gradle.projectsEvaluated {{
|
||||
def p = gradle.rootProject.project({})
|
||||
def resources = p.tasks.named('processResources')
|
||||
p.tasks.register('openideaUpdateResources') {{
|
||||
dependsOn resources
|
||||
doLast {{ new File({}).setText(resources.get().destinationDir.absolutePath, 'UTF-8') }}
|
||||
}}
|
||||
}}
|
||||
"#,
|
||||
literal(&p.path),
|
||||
literal(&manifest.to_string_lossy())
|
||||
),
|
||||
)?;
|
||||
return Ok(ResourceBuild {
|
||||
command: g.command.clone(),
|
||||
args: vec![
|
||||
"--init-script".into(),
|
||||
script.to_string_lossy().into_owned(),
|
||||
format!("{}:openideaUpdateResources", p.path.trim_end_matches(':')),
|
||||
],
|
||||
cwd: g.dir.clone(),
|
||||
env: g.env.clone(),
|
||||
kind: "gradle",
|
||||
});
|
||||
}
|
||||
Err(IdeError::invalid("The Spring Boot module must belong to a linked Maven or Gradle build"))
|
||||
}
|
||||
|
||||
pub fn publish(root: &Path, cache: &Path, class_paths: &[String], main_class: &str) -> IdeResult<()> {
|
||||
let output = PathBuf::from(fs::read_to_string(cache.join("resource-output.txt"))?.trim());
|
||||
if !output.is_absolute() || !output.is_dir() {
|
||||
return Err(IdeError::invalid("The resource build did not report an output directory"));
|
||||
}
|
||||
let output = dunce::canonicalize(output)?;
|
||||
let paths: Vec<_> = class_paths.iter().filter_map(|p| dunce::canonicalize(root.join(p)).ok()).collect();
|
||||
// A separate resource directory is already on the runtime classpath (e.g. build/resources/main).
|
||||
if paths.contains(&output) {
|
||||
return Ok(());
|
||||
}
|
||||
// jdtls uses bin/main for Gradle, rather than Gradle's build/resources/main.
|
||||
let class_file = main_class.split('.').collect::<PathBuf>().with_extension("class");
|
||||
let destination = paths.iter().find(|p| p.join(&class_file).is_file()).ok_or_else(|| {
|
||||
IdeError::invalid("Cannot find the running main class output directory; restart the application")
|
||||
})?;
|
||||
let previous: Vec<String> = fs::read(cache.join("published-resources.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default();
|
||||
let mut current = Vec::new();
|
||||
copy_resources(&output, &output, destination, &mut current)?;
|
||||
for rel in previous.iter().filter(|p| !current.contains(p)) {
|
||||
let path = destination.join(rel);
|
||||
if path.is_file() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
fs::write(
|
||||
cache.join("published-resources.json"),
|
||||
serde_json::to_vec(¤t).map_err(|e| IdeError::internal(e.to_string()))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_resources(root: &Path, from: &Path, to: &Path, copied: &mut Vec<String>) -> IdeResult<()> {
|
||||
for entry in fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if kind.is_dir() {
|
||||
copy_resources(root, &path, to, copied)?;
|
||||
} else if kind.is_file() && path.extension().is_none_or(|e| e != "class") {
|
||||
let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let dest = to.join(rel);
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(&path, dest)?;
|
||||
copied.push(rel.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resource_plans_use_the_selected_module_and_build_configuration() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
fs::create_dir(root.join("app")).unwrap();
|
||||
let mut model = BuildModel {
|
||||
maven: Some(crate::MavenModel {
|
||||
projects: vec![crate::MavenProject {
|
||||
dir: "app".into(),
|
||||
command: "/tools/mvnw".into(),
|
||||
options: vec!["-Pdev".into()],
|
||||
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let cache = root.join("cache");
|
||||
let maven = plan(&root, &model, Path::new("app"), &cache).unwrap();
|
||||
assert_eq!(maven.command, "/tools/mvnw");
|
||||
assert_eq!(maven.cwd, "app");
|
||||
assert_eq!(maven.args[0..2], ["-Pdev", "process-resources"]);
|
||||
assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into()));
|
||||
assert_eq!(maven.env["JAVA_HOME"], "/jdk");
|
||||
model.maven = None;
|
||||
model.gradle = Some(crate::GradleModel {
|
||||
command: "/tools/gradlew".into(),
|
||||
projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }],
|
||||
..Default::default()
|
||||
});
|
||||
let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap();
|
||||
assert_eq!(gradle.command, "/tools/gradlew");
|
||||
assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources");
|
||||
let script = fs::read_to_string(cache.join("resources.init.gradle")).unwrap();
|
||||
assert!(script.contains("processResources") && script.contains("destinationDir.absolutePath"));
|
||||
assert!(!script.contains("build/resources/main")); // The task owns custom output locations.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_maven_and_gradle_resource_outputs_to_the_runtime_classpath() {
|
||||
for layout in ["target/classes", "build/resources/main", "custom resource output"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let cache = root.join("cache");
|
||||
let output = root.join(layout);
|
||||
let runtime = if layout == "target/classes" { output.clone() } else { root.join("bin/main") };
|
||||
fs::create_dir_all(output.join("static")).unwrap();
|
||||
fs::create_dir_all(runtime.join("example")).unwrap();
|
||||
fs::create_dir_all(&cache).unwrap();
|
||||
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
|
||||
fs::write(output.join("static/index.html"), "after").unwrap();
|
||||
fs::write(output.join("unrelated.class"), "do not publish").unwrap();
|
||||
fs::write(runtime.join("example/Application.class"), "running").unwrap();
|
||||
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
|
||||
assert_eq!(fs::read_to_string(runtime.join("static/index.html")).unwrap(), "after");
|
||||
assert_eq!(fs::read_to_string(runtime.join("example/Application.class")).unwrap(), "running");
|
||||
if output != runtime {
|
||||
assert!(!runtime.join("unrelated.class").exists());
|
||||
fs::remove_file(output.join("static/index.html")).unwrap();
|
||||
publish(root, &cache, &[runtime.to_string_lossy().into_owned()], "example.Application").unwrap();
|
||||
assert!(!runtime.join("static/index.html").exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_separate_runtime_resource_directories_in_place_and_rejects_jar_only_runs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let output = root.join("build/resources/main");
|
||||
let cache = root.join("cache");
|
||||
fs::create_dir_all(&output).unwrap();
|
||||
fs::create_dir(&cache).unwrap();
|
||||
fs::write(cache.join("resource-output.txt"), output.to_string_lossy().as_bytes()).unwrap();
|
||||
publish(root, &cache, &[output.to_string_lossy().into_owned()], "a.Main").unwrap();
|
||||
assert!(publish(root, &cache, &["app.jar".into()], "a.Main").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Metadata fingerprints for successful Maven launch builds.
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LaunchFingerprint {
|
||||
version: u32,
|
||||
parameters: String,
|
||||
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
}
|
||||
|
||||
fn scan(
|
||||
path: &Path,
|
||||
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
ancestors: &mut HashSet<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let meta = match fs::metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
files.insert(path.to_path_buf(), None);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if meta.is_dir() {
|
||||
let canonical = dunce::canonicalize(path)?;
|
||||
if !ancestors.insert(canonical.clone()) {
|
||||
return Err(IdeError::internal("cyclic launch input directory"));
|
||||
}
|
||||
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
|
||||
files.insert(path.to_path_buf(), None);
|
||||
for entry in fs::read_dir(path)? {
|
||||
scan(&entry?.path(), files, ancestors, cancel)?;
|
||||
}
|
||||
ancestors.remove(&canonical);
|
||||
} else if meta.is_file() {
|
||||
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
|
||||
} else {
|
||||
return Err(IdeError::internal("unsupported launch input file"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `None` means an input cannot be resolved safely: build normally without reuse.
|
||||
/// All filesystem work belongs on the caller's blocking thread.
|
||||
pub fn launch_fingerprint(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<LaunchFingerprint>> {
|
||||
let Some(m) = &model.maven else { return Ok(None) };
|
||||
let projects = maven_launch_projects(root, m, module, test);
|
||||
if projects.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
let mut environment: BTreeMap<String, String> = std::env::vars()
|
||||
.map(|(key, value)| (key.to_ascii_uppercase(), value))
|
||||
.filter(|(key, _)| {
|
||||
key.starts_with("MAVEN_")
|
||||
|| key.starts_with("JAVA_")
|
||||
|| key.starts_with("JDK_")
|
||||
|| matches!(
|
||||
key.as_str(),
|
||||
"PATH" | "HOME" | "LANG" | "LC_ALL" | "_JAVA_OPTIONS" | "M2_HOME" | "MAVEN_HOME"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Explicit step values override the inherited environment just as they do in Command.
|
||||
for step in &plan.steps {
|
||||
environment.extend(step.env.iter().map(|(key, value)| (key.to_ascii_uppercase(), value.clone())));
|
||||
for dir in step.cwd.ancestors() {
|
||||
inputs.extend([dir.join(".mvn"), dir.join("mvnw"), dir.join("mvnw.cmd")]);
|
||||
if dir == root {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let program = process::program_path(&step.program, &step.env);
|
||||
inputs.push(program.clone());
|
||||
// Maven installed via a symlink still reads conf from its distribution.
|
||||
if let Ok(program) = dunce::canonicalize(program)
|
||||
&& let Some(home) = program.parent().and_then(Path::parent)
|
||||
{
|
||||
for conf in ["conf", "libexec/conf"] {
|
||||
inputs.extend([home.join(conf).join("settings.xml"), home.join(conf).join("toolchains.xml")]);
|
||||
}
|
||||
}
|
||||
if !environment.contains_key("JAVA_HOME") {
|
||||
let java = process::program_path(if cfg!(windows) { "java.exe" } else { "java" }, &step.env);
|
||||
inputs.push(java.clone());
|
||||
if let Ok(java) = dunce::canonicalize(java)
|
||||
&& let Some(home) = java.parent().and_then(Path::parent)
|
||||
{
|
||||
inputs.push(home.join("release"));
|
||||
}
|
||||
}
|
||||
for (i, arg) in step.args.iter().enumerate() {
|
||||
if matches!(arg.as_str(), "-s" | "--settings" | "-gs" | "--global-settings" | "-t" | "--toolchains")
|
||||
&& let Some(file) = step.args.get(i + 1)
|
||||
{
|
||||
inputs.push(step.cwd.join(file));
|
||||
}
|
||||
for prefix in ["--settings=", "--global-settings=", "--toolchains="] {
|
||||
if let Some(file) = arg.strip_prefix(prefix) {
|
||||
inputs.push(step.cwd.join(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
inputs.extend([home.join(".m2/settings.xml"), home.join(".m2/toolchains.xml")]);
|
||||
}
|
||||
for key in ["MAVEN_HOME", "M2_HOME"] {
|
||||
if let Some(home) = environment.get(key) {
|
||||
inputs.extend([Path::new(home).join("conf/settings.xml"), Path::new(home).join("conf/toolchains.xml")]);
|
||||
}
|
||||
}
|
||||
if let Some(home) = environment.get("JAVA_HOME") {
|
||||
inputs.extend([
|
||||
Path::new(home).join("release"),
|
||||
Path::new(home).join("lib/modules"),
|
||||
Path::new(home).join("lib/tools.jar"),
|
||||
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
|
||||
]);
|
||||
}
|
||||
let steps: Vec<_> =
|
||||
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
|
||||
// Profiles and plugins can read arbitrary environment variables. Keep a signature
|
||||
// of the inherited environment without persisting unrelated credentials.
|
||||
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
|
||||
let mut inherited_hash = std::hash::DefaultHasher::new();
|
||||
inherited.hash(&mut inherited_hash);
|
||||
let parameters =
|
||||
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let mut files = BTreeMap::new();
|
||||
for path in inputs {
|
||||
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
|
||||
}
|
||||
// Store project inputs relative to the project, including each file's relative path.
|
||||
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
|
||||
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, rel: &str, value: &str) {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, value).unwrap();
|
||||
}
|
||||
|
||||
fn fixture(root: &Path) -> BuildModel {
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
|
||||
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>lib</artifactId><build><sourceDirectory>code</sourceDirectory><resources><resource><directory>conf</directory></resource></resources></build></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"app/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
|
||||
);
|
||||
write(root, "lib/src/main/kotlin/Channel.kt", "value");
|
||||
write(root, "app/src/main/resources/app.properties", "key=value");
|
||||
write(root, "lib/conf/custom.properties", "custom=value");
|
||||
write(root, "lib/code/Custom.java", "class Custom {}");
|
||||
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
|
||||
}
|
||||
|
||||
fn fingerprint(root: &Path, model: &BuildModel) -> Option<LaunchFingerprint> {
|
||||
launch_fingerprint(root, model, &root.join("app"), false, None, &CancellationToken::new()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_inputs_match_but_source_resource_pom_add_delete_and_mtime_invalidate() {
|
||||
for rel in [
|
||||
"lib/src/main/kotlin/Channel.kt",
|
||||
"app/src/main/resources/app.properties",
|
||||
"lib/conf/custom.properties",
|
||||
"lib/code/Custom.java",
|
||||
"app/pom.xml",
|
||||
"lib/pom.xml",
|
||||
"pom.xml",
|
||||
".mvn/maven.config",
|
||||
"lib/src/main/groovy/New.groovy",
|
||||
"app/src/test/scala/Test.scala",
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let before = fingerprint(root, &model);
|
||||
assert!(before.is_some());
|
||||
assert_eq!(before, fingerprint(root, &model), "unchanged {rel}");
|
||||
let serialized = serde_json::to_vec(&before).unwrap();
|
||||
assert_eq!(before, serde_json::from_slice(&serialized).unwrap());
|
||||
let value = if rel.ends_with("pom.xml") {
|
||||
format!("{}<!-- changed -->", fs::read_to_string(root.join(rel)).unwrap())
|
||||
} else {
|
||||
"changed input".to_owned()
|
||||
};
|
||||
write(root, rel, &value);
|
||||
let changed = fingerprint(root, &model);
|
||||
assert_ne!(before, changed, "changed/added {rel}");
|
||||
let file = fs::OpenOptions::new().write(true).open(root.join(rel)).unwrap();
|
||||
file.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42)).unwrap();
|
||||
let touched = fingerprint(root, &model);
|
||||
assert_ne!(changed, touched, "mtime-only {rel}");
|
||||
fs::remove_file(root.join(rel)).unwrap();
|
||||
assert_ne!(touched, fingerprint(root, &model), "deleted {rel}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let mut model = fixture(root);
|
||||
let before = fingerprint(root, &model);
|
||||
write(root, "lib/target/classes/Channel.class", "JDT stub");
|
||||
write(root, "app/target/classes/Main.class", "JDT stub");
|
||||
assert_eq!(before, fingerprint(root, &model));
|
||||
let mut previous = before.clone();
|
||||
for option in ["-Pdev", "-Pprod", "-DskipTests", "-o"] {
|
||||
model.maven.as_mut().unwrap().projects[0].options = vec![option.into()];
|
||||
let current = fingerprint(root, &model);
|
||||
assert_ne!(previous, current, "{option}");
|
||||
previous = current;
|
||||
}
|
||||
model.maven.as_mut().unwrap().projects[0].options.clear();
|
||||
model.maven.as_mut().unwrap().projects[0]
|
||||
.env
|
||||
.insert("JAVA_HOME".into(), root.join("jdk").to_string_lossy().into_owned());
|
||||
let jdk = fingerprint(root, &model);
|
||||
assert_ne!(before, jdk);
|
||||
write(root, "jdk/release", "JAVA_VERSION=21");
|
||||
assert_ne!(jdk, fingerprint(root, &model));
|
||||
model.maven.as_mut().unwrap().projects[0].env.clear();
|
||||
let test = launch_fingerprint(root, &model, &root.join("app"), true, None, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(before, Some(test));
|
||||
write(root, "mvnw", "wrapper changed");
|
||||
assert_ne!(before, fingerprint(root, &model));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_roots_and_cancel_never_claim_up_to_date() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
"<project><artifactId>lib</artifactId><build><sourceDirectory>${unknown}/src</sourceDirectory></build></project>",
|
||||
);
|
||||
assert!(
|
||||
launch_fingerprint(root, &model, &root.join("app"), false, None, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let model = fixture(root);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert!(launch_fingerprint(root, &model, &root.join("app"), false, None, &cancel).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn external_parent_and_selected_settings_files_invalidate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("project");
|
||||
let repo = dir.path().join("repository");
|
||||
write(
|
||||
&repo,
|
||||
"demo/parent/1/parent-1.pom",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></project>",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"pom.xml",
|
||||
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><relativePath/></parent><artifactId>app</artifactId></project>",
|
||||
);
|
||||
let mut model = crate::load(&root, &root.join("cache"), Some(repo.clone()));
|
||||
let settings = dir.path().join("custom-settings.xml");
|
||||
fs::write(&settings, "<settings/>").unwrap();
|
||||
model.maven.as_mut().unwrap().projects[0].options = vec!["-s".into(), settings.to_string_lossy().into_owned()];
|
||||
let get =
|
||||
|| launch_fingerprint(&root, &model, &root, false, Some(repo.clone()), &CancellationToken::new()).unwrap();
|
||||
let before = get();
|
||||
assert!(before.is_some());
|
||||
write(
|
||||
&repo,
|
||||
"demo/parent/1/parent-1.pom",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><!-- changed --></project>",
|
||||
);
|
||||
let parent = get();
|
||||
assert_ne!(before, parent);
|
||||
fs::write(settings, "<settings><activeProfiles><activeProfile>dev</activeProfile></activeProfiles></settings>")
|
||||
.unwrap();
|
||||
assert_ne!(parent, get());
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@
|
||||
//! background with cancellation and parses the compiler output into messages
|
||||
//! for the Build tool window, the Problems view and the editor.
|
||||
|
||||
mod fingerprint;
|
||||
mod parse;
|
||||
|
||||
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
|
||||
|
||||
use crate::process::{self, Stream};
|
||||
use crate::{BuildModel, maven};
|
||||
use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity};
|
||||
@@ -237,12 +240,7 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
|
||||
if !m.projects.iter().any(|p| p.dir == rel) {
|
||||
return Err(IdeError::not_found("no linked Maven project found for module"));
|
||||
}
|
||||
let top = tops
|
||||
.iter()
|
||||
.filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir)))
|
||||
.max_by_key(|t| t.dir.len())
|
||||
.copied()
|
||||
.unwrap_or(tops[0]);
|
||||
let top = maven_reactor(m, &rel).ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
|
||||
let top_dir = subdir(root, &top.dir);
|
||||
if module == top_dir {
|
||||
return Ok(vec![step(top, &[])]);
|
||||
@@ -611,6 +609,90 @@ pub fn has_non_java_sources(module: &Path) -> bool {
|
||||
walk(&module.join("src"), 12)
|
||||
}
|
||||
|
||||
fn reactor_projects<'a>(m: &'a maven::MavenModel, top: &'a maven::MavenProject) -> Vec<&'a maven::MavenProject> {
|
||||
let mut projects = Vec::new();
|
||||
let mut pending = vec![top];
|
||||
let mut seen = HashSet::new();
|
||||
while let Some(p) = pending.pop() {
|
||||
if !seen.insert(&p.pom) {
|
||||
continue;
|
||||
}
|
||||
projects.push(p);
|
||||
pending.extend(m.projects.iter().filter(|child| p.modules.contains(&child.pom)));
|
||||
}
|
||||
projects
|
||||
}
|
||||
|
||||
fn maven_reactor<'a>(m: &'a maven::MavenModel, module: &str) -> Option<&'a maven::MavenProject> {
|
||||
m.projects.iter().filter(|p| !p.aggregated).find(|top| reactor_projects(m, top).iter().any(|p| p.dir == module))
|
||||
}
|
||||
|
||||
/// Launch module first, followed by its upstream modules in the same Maven reactor.
|
||||
/// External artifacts and unrelated linked reactors are never rebuilt for this launch.
|
||||
fn maven_launch_projects<'a>(
|
||||
root: &Path,
|
||||
m: &'a maven::MavenModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
) -> Vec<&'a maven::MavenProject> {
|
||||
let rel = rel_str(root, module);
|
||||
let Some(top) = maven_reactor(m, &rel) else { return Vec::new() };
|
||||
let reactor = reactor_projects(m, top);
|
||||
let Some(start) = reactor.iter().find(|p| p.dir == rel) else { return Vec::new() };
|
||||
let mut pending = vec![*start];
|
||||
let mut seen = HashSet::new();
|
||||
let mut projects = Vec::new();
|
||||
while let Some(p) = pending.pop() {
|
||||
if !seen.insert(&p.pom) {
|
||||
continue;
|
||||
}
|
||||
projects.push(p);
|
||||
for dep in &p.dependencies {
|
||||
if !dep.resolved() || (dep.scope == "test" && (!test || p.pom != start.pom)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(upstream) = reactor.iter().find(|u| {
|
||||
u.group_id == dep.group_id
|
||||
&& u.artifact_id == dep.artifact_id
|
||||
&& dep.version.as_ref().is_none_or(|v| *v == u.version)
|
||||
}) {
|
||||
pending.push(*upstream);
|
||||
}
|
||||
}
|
||||
}
|
||||
projects
|
||||
}
|
||||
|
||||
/// Whether a launch requires the external JVM compiler. Maven includes upstream
|
||||
/// reactor dependencies; adapter selection remains a separate, module-local decision.
|
||||
pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Path, test: bool) -> bool {
|
||||
if let Some(m) = &model.maven {
|
||||
let projects = maven_launch_projects(root, m, module, test);
|
||||
if !projects.is_empty() {
|
||||
return projects.iter().any(|p| has_non_java_sources(&subdir(root, &p.dir)));
|
||||
}
|
||||
}
|
||||
has_non_java_sources(module)
|
||||
}
|
||||
|
||||
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
|
||||
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
|
||||
// JDT may have left newer error stubs in Maven's output directories.
|
||||
BuildKind::Rebuild
|
||||
} else {
|
||||
BuildKind::Build
|
||||
};
|
||||
let mut plan = plan(root, model, kind, Some(module))?;
|
||||
if plan.system == BuildSystem::Maven && !test {
|
||||
for step in &mut plan.steps {
|
||||
if let Some(goal) = step.args.last_mut() {
|
||||
*goal = "compile".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// Compiles the module at `module` with its build system before a JVM launch and
|
||||
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
|
||||
pub fn build_for_launch(
|
||||
@@ -620,7 +702,7 @@ pub fn build_for_launch(
|
||||
test: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Vec<PathBuf>> {
|
||||
let plan = plan(root, model, BuildKind::Build, Some(module))?;
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
let result = run(root, &plan, cancel, &|_| {})?;
|
||||
if !result.success {
|
||||
let errors: Vec<String> = result
|
||||
@@ -635,6 +717,14 @@ pub fn build_for_launch(
|
||||
.collect();
|
||||
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
|
||||
}
|
||||
if plan.system == BuildSystem::Maven
|
||||
&& let Some(m) = &model.maven
|
||||
{
|
||||
return Ok(maven_launch_projects(root, m, module, test)
|
||||
.iter()
|
||||
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
|
||||
.collect());
|
||||
}
|
||||
Ok(launch_class_dirs(plan.system, module, test))
|
||||
}
|
||||
|
||||
@@ -821,6 +911,142 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn reactor_model() -> BuildModel {
|
||||
let project = |dir: &str, deps: &[&str]| maven::MavenProject {
|
||||
pom: format!("{dir}/pom.xml"),
|
||||
dir: dir.into(),
|
||||
group_id: "probe".into(),
|
||||
artifact_id: dir.into(),
|
||||
version: "1".into(),
|
||||
aggregated: true,
|
||||
dependencies: deps
|
||||
.iter()
|
||||
.map(|d| maven::MavenDependency {
|
||||
group_id: "probe".into(),
|
||||
artifact_id: (*d).into(),
|
||||
version: Some("1".into()),
|
||||
scope: "compile".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut projects = vec![
|
||||
project("app", &["middle"]),
|
||||
project("middle", &["lib"]),
|
||||
project("lib", &["app"]),
|
||||
project("unrelated", &[]),
|
||||
];
|
||||
projects.insert(
|
||||
0,
|
||||
maven::MavenProject {
|
||||
pom: "pom.xml".into(),
|
||||
artifact_id: "reactor".into(),
|
||||
command: "mvnw".into(),
|
||||
modules: projects.iter().map(|p| p.pom.clone()).collect(),
|
||||
options: vec!["-s".into(), "settings.xml".into()],
|
||||
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
BuildModel { maven: Some(maven::MavenModel { projects, ..Default::default() }), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_launch_detection_follows_only_reactor_dependencies() {
|
||||
for extension in ["kt", "groovy", "scala"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let mut model = reactor_model();
|
||||
write(root, "app/src/main/java/Application.java", "class Application {}");
|
||||
write(root, &format!("unrelated/src/main/java/Other.{extension}"), "");
|
||||
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
|
||||
write(root, &format!("lib/src/main/java/Channel.{extension}"), "");
|
||||
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), false));
|
||||
assert_eq!(
|
||||
maven_launch_projects(root, model.maven.as_ref().unwrap(), &root.join("app"), false).len(),
|
||||
3,
|
||||
"cycles terminate"
|
||||
);
|
||||
let m = model.maven.as_mut().unwrap();
|
||||
m.projects[2].dependencies[0].scope = "test".into();
|
||||
assert!(
|
||||
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
|
||||
"upstream test dependencies are not transitive"
|
||||
);
|
||||
let m = model.maven.as_mut().unwrap();
|
||||
m.projects[1].dependencies[0].scope = "test".into();
|
||||
m.projects[2].dependencies[0].scope = "compile".into();
|
||||
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
|
||||
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), true));
|
||||
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("external".into());
|
||||
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), true));
|
||||
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("1".into());
|
||||
model.maven.as_mut().unwrap().projects[3].aggregated = false;
|
||||
model.maven.as_mut().unwrap().projects[0].modules.retain(|p| p != "lib/pom.xml");
|
||||
assert!(
|
||||
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
|
||||
"another linked reactor is external"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_launch_steps_use_reactor_settings_and_launch_scope() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
let model = reactor_model();
|
||||
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
|
||||
let step = &plan.steps[0];
|
||||
assert_eq!(step.cwd, root);
|
||||
assert_eq!(step.program, "mvnw");
|
||||
assert_eq!(step.env["JAVA_HOME"], "/jdk");
|
||||
assert_eq!(
|
||||
step.args,
|
||||
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
write(
|
||||
root,
|
||||
"mvnw",
|
||||
"#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n",
|
||||
);
|
||||
let wrapper = root.join("mvnw");
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut model = reactor_model();
|
||||
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
|
||||
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(
|
||||
dirs,
|
||||
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
|
||||
.map(|p| root.join(p))
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"mvnw",
|
||||
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
|
||||
);
|
||||
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
|
||||
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_java_launch_class_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
//! Private Maven builds for JDT's Kotlin binary libraries. Never use launch outputs.
|
||||
|
||||
use crate::BuildModel;
|
||||
use crate::compile::{self, BuildEvent, BuildKind, BuildMessage};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Module {
|
||||
key: String,
|
||||
project_uri: String,
|
||||
output: PathBuf,
|
||||
test_output: Option<PathBuf>,
|
||||
dependencies: Vec<Dependency>,
|
||||
has_kotlin: bool,
|
||||
kotlin_goals: Vec<KotlinGoal>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct KotlinGoal {
|
||||
execution: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct Dependency {
|
||||
key: String,
|
||||
scope: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
classifier: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct Library {
|
||||
pub path: PathBuf,
|
||||
pub test: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectLibraries {
|
||||
pub project_uri: String,
|
||||
pub libraries: Vec<Library>,
|
||||
}
|
||||
|
||||
pub struct Outcome {
|
||||
pub projects: Vec<ProjectLibraries>,
|
||||
pub messages: Vec<BuildMessage>,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
/// Inspect sources, excluding generated/build/VCS trees and never following symlinks.
|
||||
pub fn has_kotlin(root: &Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return false };
|
||||
entries.flatten().any(|entry| {
|
||||
let Ok(kind) = entry.file_type() else { return false };
|
||||
if kind.is_file() {
|
||||
entry.path().extension().is_some_and(|e| e == "kt")
|
||||
} else {
|
||||
kind.is_dir()
|
||||
&& !matches!(
|
||||
entry.file_name().to_str(),
|
||||
Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea")
|
||||
)
|
||||
&& has_kotlin(&entry.path())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output
|
||||
/// redirector loaded before Maven calculates its lifecycle execution plans.
|
||||
pub fn build(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
bundle: &Path,
|
||||
generation: &Path,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outcome> {
|
||||
std::fs::create_dir_all(generation)?;
|
||||
let mut plan = compile::plan(root, model, BuildKind::Build, None)?;
|
||||
if plan.system != compile::BuildSystem::Maven {
|
||||
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
|
||||
}
|
||||
let mut manifests = Vec::new();
|
||||
for (index, step) in plan.steps.iter_mut().enumerate() {
|
||||
step.args.push("--fail-at-end".into());
|
||||
let directory = generation.join(format!("reactor-{index}"));
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
manifests.push(directory.join("modules.json"));
|
||||
let mut extensions = vec![bundle.to_path_buf()];
|
||||
step.args.retain(|arg| {
|
||||
if let Some(value) = arg.strip_prefix("-Dmaven.ext.class.path=") {
|
||||
extensions.extend(std::env::split_paths(value));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
let extension_path = std::env::join_paths(extensions).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
step.args.push(format!("-Dmaven.ext.class.path={}", extension_path.to_string_lossy()));
|
||||
step.args.push(format!("-Dopenidea.kotlin.staging={}", directory.join("staging").display()));
|
||||
}
|
||||
let mut messages = Vec::new();
|
||||
// Independent linked reactors must still run after another reactor's javac fails.
|
||||
for step in &plan.steps {
|
||||
let mut reactor = plan.clone();
|
||||
reactor.steps = vec![step.clone()];
|
||||
let result = compile::run(root, &reactor, cancel, &|event| {
|
||||
if let BuildEvent::Output { text, .. } = event {
|
||||
tracing::debug!("kotlin bridge: {}", text.trim_end());
|
||||
}
|
||||
})?;
|
||||
messages.extend(result.messages);
|
||||
}
|
||||
// javac diagnostics belong to JDT, including when a Kotlin target was skipped.
|
||||
messages.retain(|message| {
|
||||
message.path.as_ref().is_some_and(|path| PathBuf::from(path.clone()).extension().is_some_and(|e| e == "kt"))
|
||||
});
|
||||
let mut modules = Vec::new();
|
||||
for manifest in manifests {
|
||||
let parsed: Vec<Module> = serde_json::from_slice(&std::fs::read(manifest)?)
|
||||
.map_err(|e| IdeError::internal(format!("Kotlin module manifest: {e}")))?;
|
||||
modules.extend(parsed);
|
||||
}
|
||||
messages.extend(kotlin_failures(&modules));
|
||||
if messages.iter().any(|message| message.severity == ide_core::Severity::Error) {
|
||||
return Ok(Outcome { projects: Vec::new(), messages, success: false });
|
||||
}
|
||||
let pending = generation.join("libraries.pending");
|
||||
let published = generation.join("libraries");
|
||||
std::fs::create_dir_all(&pending)?;
|
||||
let mut libraries = BTreeMap::new();
|
||||
for (index, module) in modules.iter().enumerate() {
|
||||
let mut outputs = ModuleLibraries::default();
|
||||
for (source, target, name) in [
|
||||
(Some(&module.output), &mut outputs.main, format!("main-{index}")),
|
||||
(module.test_output.as_ref(), &mut outputs.test, format!("test-{index}")),
|
||||
] {
|
||||
let Some(source) = source else { continue };
|
||||
if !source.starts_with(generation) {
|
||||
return Err(IdeError::internal("Kotlin build output escaped staging"));
|
||||
}
|
||||
if copy_kotlin(source, &pending.join(&name), cancel)? {
|
||||
*target = Some(published.join(name));
|
||||
}
|
||||
}
|
||||
libraries.insert(module.key.clone(), outputs);
|
||||
}
|
||||
publish(&pending, &published, cancel)?;
|
||||
Ok(Outcome { projects: consumers(&modules, &libraries), messages, success: true })
|
||||
}
|
||||
|
||||
fn kotlin_failures(modules: &[Module]) -> Vec<BuildMessage> {
|
||||
let mut messages = Vec::new();
|
||||
for module in modules {
|
||||
if !module.has_kotlin {
|
||||
continue;
|
||||
}
|
||||
if module.kotlin_goals.is_empty() {
|
||||
messages.push(kotlin_failure(module, "compile target was not executed"));
|
||||
}
|
||||
for goal in &module.kotlin_goals {
|
||||
if goal.status != "success" {
|
||||
messages.push(kotlin_failure(module, &format!("{} {}", goal.execution, goal.status)));
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
fn kotlin_failure(module: &Module, reason: &str) -> BuildMessage {
|
||||
BuildMessage {
|
||||
severity: ide_core::Severity::Error,
|
||||
text: format!("Kotlin compilation for {}: {reason}", module.key),
|
||||
path: None,
|
||||
range: None,
|
||||
code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(pending: &Path, published: &Path, cancel: &CancellationToken) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
std::fs::rename(pending, published)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ModuleLibraries {
|
||||
main: Option<PathBuf>,
|
||||
test: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn consumers(modules: &[Module], libraries: &BTreeMap<String, ModuleLibraries>) -> Vec<ProjectLibraries> {
|
||||
modules
|
||||
.iter()
|
||||
.map(|consumer| {
|
||||
let mut visible = Vec::<Library>::new();
|
||||
let mut add = |path: &Option<PathBuf>, test: bool| {
|
||||
if let Some(path) = path {
|
||||
if let Some(previous) = visible.iter_mut().find(|entry| &entry.path == path) {
|
||||
previous.test &= test;
|
||||
} else {
|
||||
visible.push(Library { path: path.clone(), test });
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(own) = libraries.get(&consumer.key) {
|
||||
add(&own.test, true);
|
||||
add(&own.main, false);
|
||||
}
|
||||
// Maven's resolved artifacts already apply exclusions, mediation and optional
|
||||
// propagation. Preserve their classpath order, after the module's own output.
|
||||
for dependency in &consumer.dependencies {
|
||||
let Some(output) = libraries.get(&dependency.key) else { continue };
|
||||
let test = match dependency.scope.as_str() {
|
||||
"compile" | "provided" | "system" | "" => false,
|
||||
"test" | "runtime" => true,
|
||||
_ => continue,
|
||||
};
|
||||
match (dependency.kind.as_deref(), dependency.classifier.as_deref()) {
|
||||
(Some("test-jar"), None | Some("tests")) | (Some("jar"), Some("tests")) => add(&output.test, test),
|
||||
(None | Some("jar"), None | Some("")) => add(&output.main, test),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ProjectLibraries { project_uri: consumer.project_uri.clone(), libraries: visible }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn copy_kotlin(source: &Path, destination: &Path, cancel: &CancellationToken) -> IdeResult<bool> {
|
||||
let Ok(entries) = std::fs::read_dir(source) else { return Ok(false) };
|
||||
let mut found = false;
|
||||
for entry in entries {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let entry = entry?;
|
||||
let kind = entry.file_type()?;
|
||||
let path = entry.path();
|
||||
let output = destination.join(entry.file_name());
|
||||
if kind.is_dir() {
|
||||
found |= copy_kotlin(&path, &output, cancel)?;
|
||||
} else if kind.is_file() {
|
||||
let bytes = std::fs::read(&path)?;
|
||||
let class = path.extension().is_some_and(|e| e == "class");
|
||||
let metadata = path.extension().is_some_and(|e| e == "kotlin_module");
|
||||
if metadata || (class && kotlin_class(&bytes).unwrap_or(false)) {
|
||||
std::fs::create_dir_all(destination)?;
|
||||
std::fs::write(output, bytes)?;
|
||||
found |= class;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Read the actual class-level SourceFile attribute, rather than searching strings
|
||||
/// (a Java constant containing "Something.kt" must never become a Kotlin library).
|
||||
fn kotlin_class(bytes: &[u8]) -> Option<bool> {
|
||||
struct Reader<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
impl<'a> Reader<'a> {
|
||||
fn take(&mut self, count: usize) -> Option<&'a [u8]> {
|
||||
let value = self.bytes.get(self.pos..self.pos.checked_add(count)?)?;
|
||||
self.pos += count;
|
||||
Some(value)
|
||||
}
|
||||
fn u2(&mut self) -> Option<usize> {
|
||||
Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?) as usize)
|
||||
}
|
||||
fn u4(&mut self) -> Option<usize> {
|
||||
Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?) as usize)
|
||||
}
|
||||
fn attributes(&mut self) -> Option<()> {
|
||||
for _ in 0..self.u2()? {
|
||||
self.u2()?;
|
||||
let n = self.u4()?;
|
||||
self.take(n)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
let mut reader = Reader { bytes, pos: 0 };
|
||||
if reader.take(4)? != [0xca, 0xfe, 0xba, 0xbe] {
|
||||
return None;
|
||||
}
|
||||
reader.take(4)?;
|
||||
let count = reader.u2()?;
|
||||
let mut utf8 = BTreeMap::new();
|
||||
let mut index = 1;
|
||||
while index < count {
|
||||
match reader.take(1)?[0] {
|
||||
1 => {
|
||||
let n = reader.u2()?;
|
||||
utf8.insert(index, reader.take(n)?);
|
||||
}
|
||||
3 | 4 | 9..=12 | 17 | 18 => {
|
||||
reader.take(4)?;
|
||||
}
|
||||
5 | 6 => {
|
||||
reader.take(8)?;
|
||||
index += 1;
|
||||
}
|
||||
7 | 8 | 16 | 19 | 20 => {
|
||||
reader.take(2)?;
|
||||
}
|
||||
15 => {
|
||||
reader.take(3)?;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
reader.take(6)?;
|
||||
let interfaces = reader.u2()?;
|
||||
reader.take(interfaces.checked_mul(2)?)?;
|
||||
for _ in 0..2 {
|
||||
for _ in 0..reader.u2()? {
|
||||
reader.take(6)?;
|
||||
reader.attributes()?;
|
||||
}
|
||||
}
|
||||
for _ in 0..reader.u2()? {
|
||||
let name = reader.u2()?;
|
||||
let length = reader.u4()?;
|
||||
if utf8.get(&name).copied() == Some(b"SourceFile".as_slice()) && length == 2 {
|
||||
let source = reader.u2()?;
|
||||
return Some(utf8.get(&source)?.ends_with(b".kt"));
|
||||
}
|
||||
reader.take(length)?;
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_kotlin_target_must_succeed_even_when_maven_fails_elsewhere() {
|
||||
let mut modules: Vec<Module> = serde_json::from_str(r#"[
|
||||
{"key":"lib","projectUri":"lib","output":"lib","dependencies":[],"hasKotlin":true,
|
||||
"kotlinGoals":[{"execution":"main:compile","status":"success"},{"execution":"test:test-compile","status":"success"}]},
|
||||
{"key":"java","projectUri":"java","output":"java","dependencies":[],"hasKotlin":false,"kotlinGoals":[]}
|
||||
]"#).unwrap();
|
||||
assert!(kotlin_failures(&modules).is_empty());
|
||||
for status in ["failed", "skipped"] {
|
||||
modules[0].kotlin_goals[1].status = status.into();
|
||||
let failures = kotlin_failures(&modules);
|
||||
assert_eq!(failures.len(), 1);
|
||||
assert!(failures[0].text.contains(&format!("test:test-compile {status}")));
|
||||
}
|
||||
modules[0].kotlin_goals.clear();
|
||||
assert_eq!(kotlin_failures(&modules).len(), 1);
|
||||
modules[0].has_kotlin = false;
|
||||
assert!(kotlin_failures(&modules).is_empty());
|
||||
assert!(
|
||||
serde_json::from_str::<Vec<Module>>(
|
||||
r#"[{"key":"old","projectUri":"old","output":"old","dependencies":[]}]"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_mavens_resolved_closure_and_scopes() {
|
||||
let json = r#"[
|
||||
{"key":"zapp","projectUri":"app","output":"app", "hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"mid","scope":"compile","optional":false},{"key":"lib","scope":"compile","optional":false}]},
|
||||
{"key":"mid","projectUri":"mid","output":"mid", "hasKotlin":false,"kotlinGoals":[],"dependencies":[
|
||||
{"key":"lib","scope":"compile","optional":false},
|
||||
{"key":"optional","scope":"compile","optional":true},
|
||||
{"key":"test","scope":"test","optional":false},
|
||||
{"key":"provided","scope":"provided","optional":false}]},
|
||||
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]}
|
||||
]"#;
|
||||
let modules: Vec<Module> = serde_json::from_str(json).unwrap();
|
||||
let libraries = ["zapp", "lib", "optional", "test", "provided"]
|
||||
.into_iter()
|
||||
.map(|key| (key.to_owned(), ModuleLibraries { main: Some(PathBuf::from(key)), test: None }))
|
||||
.collect();
|
||||
let result = consumers(&modules, &libraries);
|
||||
assert_eq!(
|
||||
result[0].libraries,
|
||||
vec![Library { path: "zapp".into(), test: false }, Library { path: "lib".into(), test: false },]
|
||||
);
|
||||
assert_eq!(result[1].libraries.len(), 4);
|
||||
assert!(result[1].libraries.iter().find(|l| l.path == Path::new("test")).unwrap().test);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outputs_are_private_unless_maven_resolves_a_test_jar() {
|
||||
let modules: Vec<Module> = serde_json::from_str(r#"[
|
||||
{"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]},
|
||||
{"key":"app","projectUri":"app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"compile","type":"jar","classifier":null}]},
|
||||
{"key":"test-app","projectUri":"test-app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"test","type":"test-jar","classifier":"tests"}]}
|
||||
]"#).unwrap();
|
||||
let libraries =
|
||||
BTreeMap::from([("lib".into(), ModuleLibraries { main: Some("main".into()), test: Some("test".into()) })]);
|
||||
let result = consumers(&modules, &libraries);
|
||||
assert_eq!(
|
||||
result[0].libraries,
|
||||
vec![Library { path: "test".into(), test: true }, Library { path: "main".into(), test: false }]
|
||||
);
|
||||
assert_eq!(result[1].libraries, vec![Library { path: "main".into(), test: false }]);
|
||||
assert_eq!(result[2].libraries, vec![Library { path: "test".into(), test: true }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_complete_directories_and_cancellation_keeps_them_private() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let pending = directory.path().join("pending");
|
||||
let final_path = directory.path().join("published");
|
||||
std::fs::create_dir(&pending).unwrap();
|
||||
std::fs::write(pending.join("Channel.class"), "complete").unwrap();
|
||||
let cancelled = CancellationToken::new();
|
||||
cancelled.cancel();
|
||||
assert!(publish(&pending, &final_path, &cancelled).is_err());
|
||||
assert!(!final_path.exists());
|
||||
assert!(pending.exists());
|
||||
publish(&pending, &final_path, &CancellationToken::new()).unwrap();
|
||||
assert!(!pending.exists());
|
||||
assert_eq!(std::fs::read(final_path.join("Channel.class")).unwrap(), b"complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_runs_another_build_system_with_maven_output_properties() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
std::fs::write(directory.path().join("Cargo.toml"), "").unwrap();
|
||||
let error = build(
|
||||
directory.path(),
|
||||
&BuildModel::default(),
|
||||
Path::new("unused.jar"),
|
||||
&directory.path().join("private"),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.message, "Kotlin bridge requires a Maven build root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_find_deleted_sources_in_build_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("target/generated")).unwrap();
|
||||
std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap();
|
||||
assert!(!has_kotlin(dir.path()));
|
||||
std::fs::write(dir.path().join("New.kt"), "").unwrap();
|
||||
assert!(has_kotlin(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sourcefile_and_rejects_truncated_classes() {
|
||||
let mut class = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52, 0, 3, 1, 0, 10];
|
||||
class.extend_from_slice(b"SourceFile");
|
||||
class.extend_from_slice(&[1, 0, 10]);
|
||||
class.extend_from_slice(b"Channel.kt");
|
||||
class.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 2, 0, 2]);
|
||||
assert_eq!(kotlin_class(&class), Some(true));
|
||||
for end in 0..class.len() {
|
||||
assert!(kotlin_class(&class[..end]).is_none());
|
||||
}
|
||||
let offset = 26;
|
||||
class[offset..offset + 10].copy_from_slice(b"Other.java");
|
||||
assert_eq!(kotlin_class(&class), Some(false));
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@
|
||||
//! and merges what the last sync ([`sync_maven`] / [`sync_gradle`], which run
|
||||
//! Maven / Gradle in the background) cached for the project.
|
||||
|
||||
pub mod application_update;
|
||||
pub mod compile;
|
||||
pub mod gradle;
|
||||
pub mod kotlin_bridge;
|
||||
pub mod maven;
|
||||
pub mod pom_edit;
|
||||
mod process;
|
||||
|
||||
@@ -601,6 +601,72 @@ impl Resolver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Files and source/resource trees consumed by a launch build. Unresolved custom
|
||||
/// roots disable reuse rather than silently omitting an input.
|
||||
pub(crate) fn launch_inputs(
|
||||
root: &Path,
|
||||
projects: &[&MavenProject],
|
||||
local_repo: Option<PathBuf>,
|
||||
) -> Option<Vec<PathBuf>> {
|
||||
fn roots(el: &Element, parent: &str, dir: &Path, out: &mut Vec<PathBuf>) -> Option<()> {
|
||||
let input =
|
||||
matches!(el.name.as_str(), "sourceDirectory" | "testSourceDirectory" | "sourceDir" | "testSourceDir")
|
||||
|| (matches!(parent, "sources" | "sourceDirs" | "testSources" | "testSourceDirs")
|
||||
&& matches!(el.name.as_str(), "source" | "sourceDir" | "testSourceDir"))
|
||||
|| (matches!(parent, "resource" | "testResource" | "source" | "testSource") && el.name == "directory")
|
||||
|| (parent == "filters" && el.name == "filter");
|
||||
// Profile activation may depend on the presence of an otherwise unrelated file.
|
||||
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
|
||||
if input || activation {
|
||||
// Other properties can be overridden by -D, profiles or settings.
|
||||
// Without Maven's effective roots, conservatively disable reuse for them.
|
||||
let value = el
|
||||
.text
|
||||
.trim()
|
||||
.replace("${project.basedir}", &dir.to_string_lossy())
|
||||
.replace("${basedir}", &dir.to_string_lossy());
|
||||
if value.contains("${") {
|
||||
return None;
|
||||
}
|
||||
if !value.is_empty() {
|
||||
out.push(dir.join(value));
|
||||
}
|
||||
}
|
||||
for child in &el.children {
|
||||
roots(child, &el.name, dir, out)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
let mut resolver = Resolver::new(local_repo.or_else(default_local_repo));
|
||||
let mut out = Vec::new();
|
||||
for project in projects {
|
||||
let dir = root.join(&project.dir);
|
||||
// Include all conventional language/resource roots, including nonstandard files under src.
|
||||
out.push(dir.join("src"));
|
||||
let pom = root.join(&project.pom);
|
||||
resolver.context(&pom, 0);
|
||||
let mut path = pom;
|
||||
let mut seen = HashSet::new();
|
||||
loop {
|
||||
let canonical = dunce::canonicalize(&path).ok()?;
|
||||
if !seen.insert(canonical) || seen.len() > MAX_PARENT_DEPTH {
|
||||
return None;
|
||||
}
|
||||
out.push(path.clone());
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
|
||||
let raw = resolver.pom(&path)?;
|
||||
let Some(parent) = raw.parent else { break };
|
||||
path = resolver.parent_path(path.parent()?, &parent)?;
|
||||
}
|
||||
}
|
||||
// Also track imported BOMs read while resolving inheritance.
|
||||
out.extend(resolver.poms.into_keys());
|
||||
out.sort();
|
||||
out.dedup();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- plugins
|
||||
|
||||
/// `maven-clean-plugin` → `clean`, `spring-boot-maven-plugin` → `spring-boot`.
|
||||
|
||||
@@ -37,7 +37,7 @@ fn kill_tree(child: &mut Child) {
|
||||
/// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's
|
||||
/// search path — the login shell's `PATH` plus version-manager shims — since the
|
||||
/// app's own `PATH` can be minimal and `Command` would not see `mvn` there.
|
||||
fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
|
||||
pub(crate) fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
|
||||
let path = std::path::Path::new(program);
|
||||
if !path.is_relative() || path.components().count() > 1 {
|
||||
return path.to_path_buf();
|
||||
@@ -58,6 +58,18 @@ pub(crate) fn run(
|
||||
cwd: &Path,
|
||||
env: &BTreeMap<String, String>,
|
||||
cancel: &CancellationToken,
|
||||
on_line: impl FnMut(Stream, &str),
|
||||
) -> IdeResult<Output> {
|
||||
run_with_env(program, args, cwd, env, false, cancel, on_line)
|
||||
}
|
||||
|
||||
pub(crate) fn run_with_env(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
cwd: &Path,
|
||||
env: &BTreeMap<String, String>,
|
||||
clear_env: bool,
|
||||
cancel: &CancellationToken,
|
||||
mut on_line: impl FnMut(Stream, &str),
|
||||
) -> IdeResult<Output> {
|
||||
tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display());
|
||||
@@ -66,7 +78,7 @@ pub(crate) fn run(
|
||||
ide_core::output_encoding::fix(program, args, env, |k| std::env::var(k).ok(), "UTF-8", cfg!(windows));
|
||||
let program = program_path(program, env);
|
||||
let mut command = Command::new(&program);
|
||||
command.args(args).current_dir(cwd).envs(env).envs(encoding.env).stdin(Stdio::null());
|
||||
command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null());
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -74,6 +86,9 @@ pub(crate) fn run(
|
||||
command.process_group(0);
|
||||
}
|
||||
ide_core::process::prepare(&mut command);
|
||||
if clear_env {
|
||||
command.env_clear().envs(env).envs(encoding.env);
|
||||
}
|
||||
let mut child =
|
||||
command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {}: {e}", program.display())))?;
|
||||
let (tx, rx) = mpsc::channel::<(Stream, String)>();
|
||||
|
||||
+104
-8
@@ -1,6 +1,6 @@
|
||||
//! WAR modules and build-tool-owned archive resolution (including custom output names).
|
||||
use crate::BuildModel;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
@@ -47,6 +47,50 @@ pub struct WebBuild {
|
||||
pub args: Vec<String>,
|
||||
pub cwd: String,
|
||||
pub env: BTreeMap<String, String>,
|
||||
maven_project: Option<(Vec<String>, std::path::PathBuf)>,
|
||||
}
|
||||
|
||||
impl WebBuild {
|
||||
/// Resolve the selected module's effective identity using the build's options and environment.
|
||||
/// Runs off the UI thread, before launching the reactor build.
|
||||
pub fn resolve_project(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
env: &BTreeMap<String, String>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<()> {
|
||||
let Some((args, output)) = &self.maven_project else {
|
||||
return Ok(());
|
||||
};
|
||||
if output.exists() {
|
||||
fs::remove_file(output)?;
|
||||
}
|
||||
let cwd = root.join(&self.cwd).canonicalize()?;
|
||||
let mut env = env.clone();
|
||||
if !env.iter().any(|(k, v)| k.eq_ignore_ascii_case("MAVEN_BASEDIR") && !v.is_empty()) {
|
||||
// -f changes Maven's .mvn search origin; keep the reactor's launcher settings.
|
||||
let base = cwd.ancestors().find(|p| p.join(".mvn").is_dir()).unwrap_or(&cwd);
|
||||
env.retain(|k, _| !k.eq_ignore_ascii_case("MAVEN_BASEDIR"));
|
||||
env.insert("MAVEN_BASEDIR".into(), base.to_string_lossy().into_owned());
|
||||
}
|
||||
let result = crate::process::run_with_env(&self.command, args, &cwd, &env, true, cancel, |_, _| {})?;
|
||||
if !result.success {
|
||||
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", result.text.trim())));
|
||||
}
|
||||
let id = fs::read_to_string(output)?;
|
||||
let parts: Vec<_> = id.trim().split(':').collect();
|
||||
if parts.len() != 4 || parts[2] != "war" || parts.iter().any(|s| s.is_empty()) {
|
||||
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", id.trim())));
|
||||
}
|
||||
let project = format!("{}:{}:{}", parts[0], parts[1], parts[3]);
|
||||
self.args.push(format!("-Dexpression=session.projectMap({project}).artifact.file"));
|
||||
self.maven_project = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn groovy_literal(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
|
||||
}
|
||||
|
||||
pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResult<WebBuild> {
|
||||
@@ -81,21 +125,36 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
|
||||
.replace('\\', "/");
|
||||
args.extend(["-pl".into(), module, "-am".into()]);
|
||||
}
|
||||
// help:evaluate runs once, in the reactor's first project. Query the selected
|
||||
// POM's effective identity first; profiles and -D options can change its GAV.
|
||||
let output = cache.join("artifact-project.txt");
|
||||
let mut probe = top.options.clone();
|
||||
probe.extend([
|
||||
"-f".into(),
|
||||
root.join(&p.pom).to_string_lossy().into_owned(),
|
||||
"help:evaluate".into(),
|
||||
"-Dexpression=project.id".into(),
|
||||
format!("-Doutput={}", output.display()),
|
||||
]);
|
||||
args.extend([
|
||||
"-DskipTests".into(),
|
||||
"package".into(),
|
||||
"help:evaluate".into(),
|
||||
"-Dexpression=project.artifact.file".into(),
|
||||
format!("-Doutput={}", manifest.display()),
|
||||
]);
|
||||
return Ok(WebBuild { command: top.command.clone(), args, cwd: top.dir.clone(), env: p.env.clone() });
|
||||
return Ok(WebBuild {
|
||||
command: top.command.clone(),
|
||||
args,
|
||||
cwd: top.dir.clone(),
|
||||
env: p.env.clone(),
|
||||
maven_project: Some((probe, output)),
|
||||
});
|
||||
}
|
||||
if let Some(path) = id.strip_prefix("gradle:") {
|
||||
let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
|
||||
if !artifacts(root, model).iter().any(|a| a.id == id) {
|
||||
return Err(IdeError::invalid("Choose a Gradle WAR module"));
|
||||
}
|
||||
let literal = |s: &str| format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"));
|
||||
let script = cache.join("war.init.gradle");
|
||||
fs::write(
|
||||
&script,
|
||||
@@ -105,12 +164,12 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
|
||||
def warTask = p.tasks.named('war')
|
||||
p.tasks.register('openideaTomcatArtifact') {{
|
||||
dependsOn warTask
|
||||
doLast {{ new File({}).text = warTask.get().archiveFile.get().asFile.absolutePath }}
|
||||
doLast {{ new File({}).setText(warTask.get().archiveFile.get().asFile.absolutePath, 'UTF-8') }}
|
||||
}}
|
||||
}}
|
||||
"#,
|
||||
literal(path),
|
||||
literal(&manifest.to_string_lossy())
|
||||
groovy_literal(path),
|
||||
groovy_literal(&manifest.to_string_lossy())
|
||||
),
|
||||
)?;
|
||||
let task = format!("{}:openideaTomcatArtifact", path.trim_end_matches(':'));
|
||||
@@ -119,6 +178,7 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
|
||||
args: vec!["--init-script".into(), script.to_string_lossy().into_owned(), task],
|
||||
cwd: g.dir.clone(),
|
||||
env: g.env.clone(),
|
||||
maven_project: None,
|
||||
});
|
||||
}
|
||||
Err(IdeError::invalid("Choose a WAR artifact"))
|
||||
@@ -136,7 +196,9 @@ mod tests {
|
||||
assert_eq!(a.len(), 1);
|
||||
assert_eq!(a[0].name, "web");
|
||||
let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base with spaces")).unwrap();
|
||||
assert!(plan.args.contains(&"-Dexpression=project.artifact.file".into()));
|
||||
let (probe, _) = plan.maven_project.as_ref().unwrap();
|
||||
assert!(probe.contains(&"-Dexpression=project.id".into()));
|
||||
assert!(probe.contains(&tmp.path().join("pom.xml").to_string_lossy().into_owned()));
|
||||
assert!(
|
||||
plan.args.iter().any(|a| a.ends_with("base with spaces/artifact-path.txt")
|
||||
|| a.ends_with("base with spaces\\artifact-path.txt"))
|
||||
@@ -152,6 +214,13 @@ mod tests {
|
||||
let plan = build(tmp.path(), &model, "maven:web/pom.xml", &tmp.path().join("base")).unwrap();
|
||||
assert_eq!(plan.cwd, "");
|
||||
assert_eq!(&plan.args[..3], &["-pl", "web", "-am"]);
|
||||
assert!(
|
||||
plan.maven_project
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.0
|
||||
.contains(&tmp.path().join("web/pom.xml").to_string_lossy().into_owned())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn gradle_war_uses_archive_provider_and_ignores_comments() {
|
||||
@@ -169,4 +238,31 @@ mod tests {
|
||||
assert!(fs::read_to_string(tmp.path().join("base/war.init.gradle")).unwrap().contains("archiveFile.get()"));
|
||||
assert!(!crate::gradle::has_war_plugin("// plugins { war }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_manifest_paths_preserve_windows_characters() {
|
||||
for path in [r"C:\工作 区\Tomcat's cache\artifact-path.txt", r"\\server\share\artifact-path.txt"] {
|
||||
let literal = groovy_literal(path);
|
||||
assert_eq!(literal[1..literal.len() - 1].replace("\\'", "'").replace("\\\\", "\\"), path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_root_and_nested_modules_write_utf8_archive_paths() {
|
||||
for module in [":", ":apps:timesheet"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = if module == ":" { "" } else { "apps/timesheet" };
|
||||
fs::create_dir_all(tmp.path().join(dir)).unwrap();
|
||||
fs::write(tmp.path().join("settings.gradle"), "rootProject.name = 'timesheet'\ninclude 'apps:timesheet'")
|
||||
.unwrap();
|
||||
fs::write(tmp.path().join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap();
|
||||
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
|
||||
let cache = tmp.path().join("工作 cache");
|
||||
let plan = build(tmp.path(), &model, &format!("gradle:{module}"), &cache).unwrap();
|
||||
assert!(plan.args.contains(&format!("{}:openideaTomcatArtifact", module.trim_end_matches(':'))));
|
||||
let script = fs::read_to_string(cache.join("war.init.gradle")).unwrap();
|
||||
assert!(script.contains(&format!("rootProject.project('{}')", module)));
|
||||
assert!(script.contains("archiveFile.get().asFile.absolutePath, 'UTF-8'"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! The file list on the OS clipboard: what Explorer copies when the user
|
||||
//! copies files, so the project view can paste them, and what the project view
|
||||
//! writes so Explorer can paste the files it copied (IDEA does both).
|
||||
//! writes so Explorer can paste the files it copied (IDEA does both). The
|
||||
//! written list comes with the paths as text, so a paste into an editor
|
||||
//! inserts them instead of doing nothing.
|
||||
//!
|
||||
//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL
|
||||
//! terminated list of absolute paths, wide when `fWide` is set. The data is set
|
||||
@@ -10,6 +12,9 @@
|
||||
//! macOS keeps one `public.file-url` item per file on the general
|
||||
//! `NSPasteboard`; `NSURL` reads and writes them, so Finder's copies paste here
|
||||
//! and ours paste in Finder.
|
||||
//! On macOS, [`files`], [`has_files`] and [`set_files`] must run on the main
|
||||
//! thread: AppKit and WebKit also access the general pasteboard there, and its
|
||||
//! mutable type cache cannot be protected by a mutex in this crate.
|
||||
//!
|
||||
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
|
||||
//! empty and [`set_files`] fails there.
|
||||
@@ -20,7 +25,8 @@ use std::path::PathBuf;
|
||||
const HEADER: usize = 20;
|
||||
|
||||
/// The absolute paths on the clipboard, empty when it holds no file list.
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
/// Read failures remain errors so callers can retain an internal copy.
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
imp::files()
|
||||
}
|
||||
|
||||
@@ -29,9 +35,8 @@ pub fn has_files() -> bool {
|
||||
imp::has_files()
|
||||
}
|
||||
|
||||
/// Replaces the clipboard with `paths`. False when it is busy or holds nothing
|
||||
/// to write; the clipboard is a shared resource, so callers treat this as
|
||||
/// best-effort.
|
||||
/// Replaces the clipboard with `paths`. True only when the file list was
|
||||
/// accepted; writing just the accompanying text is not a successful hand-off.
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
if paths.is_empty() {
|
||||
return false;
|
||||
@@ -111,6 +116,8 @@ mod imp {
|
||||
|
||||
/// `CF_HDROP`, the clipboard format holding a file list.
|
||||
const CF_HDROP: u32 = 15;
|
||||
/// `CF_UNICODETEXT`, so an editor paste of copied files inserts the paths.
|
||||
const CF_UNICODETEXT: u32 = 13;
|
||||
|
||||
/// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here.
|
||||
type Handle = *mut c_void;
|
||||
@@ -156,25 +163,29 @@ mod imp {
|
||||
(unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0
|
||||
}
|
||||
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
if !has_files() || !open() {
|
||||
return Vec::new();
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
if !open() {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
if !has_files() {
|
||||
unsafe { CloseClipboard() };
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let memory = unsafe { GetClipboardData(CF_HDROP) };
|
||||
let paths = if memory.is_null() {
|
||||
Vec::new()
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
// SAFETY: the clipboard keeps the block alive while it is open, and
|
||||
// `GlobalSize` bounds the bytes read from it.
|
||||
unsafe {
|
||||
let data = GlobalLock(memory);
|
||||
if data.is_null() {
|
||||
Vec::new()
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory));
|
||||
let paths = decode(bytes);
|
||||
GlobalUnlock(memory);
|
||||
paths
|
||||
Ok(paths)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -182,12 +193,37 @@ mod imp {
|
||||
paths
|
||||
}
|
||||
|
||||
/// An `HGLOBAL` holding `text` as UTF-16 with a terminating NUL, and its
|
||||
/// byte size. Null when the allocation failed.
|
||||
fn global_text(text: &str) -> (Handle, usize) {
|
||||
let mut units: Vec<u16> = text.encode_utf16().collect();
|
||||
units.push(0);
|
||||
let bytes = units.len() * 2;
|
||||
unsafe {
|
||||
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes);
|
||||
if memory.is_null() {
|
||||
return (ptr::null_mut(), 0);
|
||||
}
|
||||
let data = GlobalLock(memory);
|
||||
if data.is_null() {
|
||||
GlobalFree(memory);
|
||||
return (ptr::null_mut(), 0);
|
||||
}
|
||||
ptr::copy_nonoverlapping(units.as_ptr() as *const u8, data as *mut u8, bytes);
|
||||
GlobalUnlock(memory);
|
||||
(memory, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
let bytes = encode(paths);
|
||||
if bytes.len() <= HEADER {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: the block is allocated, filled, handed to the clipboard and
|
||||
// The paths as text, newline-separated, so pasting into an editor
|
||||
// inserts them (IDEA writes the list and the text together).
|
||||
let text = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\r\n");
|
||||
// SAFETY: the blocks are allocated, filled, handed to the clipboard and
|
||||
// freed only on the paths where the clipboard did not take ownership.
|
||||
unsafe {
|
||||
let memory = GlobalAlloc(GMEM_MOVEABLE, bytes.len());
|
||||
@@ -205,17 +241,26 @@ mod imp {
|
||||
GlobalFree(memory);
|
||||
return false;
|
||||
}
|
||||
// Replacing the list also drops the text the user copied before.
|
||||
// Replacing the list also drops the text the user copied before;
|
||||
// the paths' own text is set back with it.
|
||||
if EmptyClipboard() == 0 {
|
||||
CloseClipboard();
|
||||
GlobalFree(memory);
|
||||
return false;
|
||||
}
|
||||
let taken = !SetClipboardData(CF_HDROP, memory).is_null();
|
||||
CloseClipboard();
|
||||
if !taken {
|
||||
GlobalFree(memory);
|
||||
}
|
||||
let (text_memory, _) = global_text(&text);
|
||||
if !text_memory.is_null() {
|
||||
// The text alone still pastes somewhere useful.
|
||||
let text_taken = !SetClipboardData(CF_UNICODETEXT, text_memory).is_null();
|
||||
if !text_taken {
|
||||
GlobalFree(text_memory);
|
||||
}
|
||||
}
|
||||
CloseClipboard();
|
||||
taken
|
||||
}
|
||||
}
|
||||
@@ -223,13 +268,14 @@ mod imp {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use objc2::ClassType;
|
||||
use objc2::rc::{Retained, autoreleasepool};
|
||||
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
|
||||
use objc2::{ClassType, MainThreadMarker};
|
||||
use objc2_app_kit::{
|
||||
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
|
||||
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
|
||||
NSPasteboardWriting,
|
||||
};
|
||||
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSURL};
|
||||
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// `NSURL` as the one class to read, limited to file URLs: a web link the
|
||||
@@ -237,6 +283,7 @@ mod imp {
|
||||
fn read_file_urls<R>(
|
||||
f: impl FnOnce(&NSPasteboard, &NSArray<AnyClass>, &NSDictionary<NSPasteboardReadingOptionKey, AnyObject>) -> R,
|
||||
) -> R {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
let classes = NSArray::from_slice(&[NSURL::class()]);
|
||||
@@ -256,23 +303,28 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
read_file_urls(|board, classes, options| {
|
||||
// SAFETY: as in `has_files`; every object read for `NSURL` is one.
|
||||
let Some(objects) = (unsafe { board.readObjectsForClasses_options(classes, Some(options)) }) else {
|
||||
return Vec::new();
|
||||
return if unsafe { board.canReadObjectForClasses_options(classes, Some(options)) } {
|
||||
Err(std::io::Error::other("Cannot read clipboard files"))
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
};
|
||||
};
|
||||
objects
|
||||
Ok(objects
|
||||
.iter()
|
||||
.filter_map(|object| object.downcast::<NSURL>().ok())
|
||||
.filter_map(|url| url.to_file_path())
|
||||
// Finder ends a folder's URL in `/`; the project view names it without.
|
||||
.map(|path| path.components().collect())
|
||||
.collect()
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
// Folders get a directory URL (ending in `/`), as Finder writes them.
|
||||
let urls: Vec<Retained<NSURL>> = paths
|
||||
@@ -285,9 +337,16 @@ mod imp {
|
||||
let writers: Vec<&ProtocolObject<dyn NSPasteboardWriting>> =
|
||||
urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect();
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
// Replacing the list also drops the text the user copied before.
|
||||
// Replacing the list also drops the text the user copied before;
|
||||
// the paths' own text is set back with it, so an editor paste
|
||||
// inserts them (IDEA writes the list and the text together).
|
||||
board.clearContents();
|
||||
board.writeObjects(&NSArray::from_slice(&writers))
|
||||
let written = board.writeObjects(&NSArray::from_slice(&writers));
|
||||
let text = NSString::from_str(
|
||||
&paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\n"),
|
||||
);
|
||||
board.setString_forType(&text, unsafe { NSPasteboardTypeString });
|
||||
written
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -300,8 +359,8 @@ mod imp {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
Vec::new()
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn set_files(_paths: &[PathBuf]) -> bool {
|
||||
|
||||
@@ -19,4 +19,4 @@ tokio-util.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] }
|
||||
@@ -12,6 +12,8 @@ use std::collections::BTreeMap;
|
||||
pub struct EditorGeneralSettings {
|
||||
/// Change font size with ⌘ + mouse wheel.
|
||||
pub wheel_zoom: bool,
|
||||
pub markdown_image_folder: String,
|
||||
pub markdown_image_name_pattern: String,
|
||||
/// Drag-and-drop of selected text inside the editor.
|
||||
pub drag_and_drop: bool,
|
||||
/// When the caret moves out of view: `minimize` scrolling | `center` the caret line.
|
||||
@@ -35,6 +37,8 @@ impl Default for EditorGeneralSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wheel_zoom: false,
|
||||
markdown_image_folder: "assets".into(),
|
||||
markdown_image_name_pattern: "image-{timestamp}".into(),
|
||||
drag_and_drop: true,
|
||||
caret_scroll: "minimize".into(),
|
||||
soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(),
|
||||
@@ -367,6 +371,12 @@ pub struct TextAttributesOverride {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguageCodeStyle {
|
||||
/// Java formatter: `default` | `intellij` | `custom` (other languages ignore it).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub formatter: Option<String>,
|
||||
/// Eclipse formatter XML; relative paths start at the project root.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub formatter_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_tab: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -434,6 +444,8 @@ pub struct EncodingSettings {
|
||||
pub project: String,
|
||||
/// Fallback for `.properties` files.
|
||||
pub properties: String,
|
||||
/// Display properties Unicode escapes as characters and save non-ASCII as escapes.
|
||||
pub native_to_ascii: bool,
|
||||
/// Create UTF-8 files: `never` (no BOM) | `always` (with BOM) | `windows` (with BOM on Windows).
|
||||
pub utf8_bom: String,
|
||||
}
|
||||
@@ -444,6 +456,7 @@ impl Default for EncodingSettings {
|
||||
global: "utf-8".into(),
|
||||
project: "utf-8".into(),
|
||||
properties: "iso-8859-1".into(),
|
||||
native_to_ascii: false,
|
||||
utf8_bom: "never".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,15 @@ pub type IdeResult<T> = Result<T, IdeError>;
|
||||
pub struct IdeError {
|
||||
pub kind: ErrorKind,
|
||||
pub message: String,
|
||||
/// Structured detail for errors the UI acts on; `null` for plain errors.
|
||||
#[specta(optional)]
|
||||
pub data: Option<ErrorData>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ErrorData {
|
||||
Tool { tool: String, missing: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -24,6 +33,7 @@ pub enum ErrorKind {
|
||||
/// Document version mismatch; the UI must resync the full text.
|
||||
OutOfSync,
|
||||
Cancelled,
|
||||
TimedOut,
|
||||
/// A required external tool (language server, git, debugger) is unavailable.
|
||||
Unavailable,
|
||||
Io,
|
||||
@@ -33,7 +43,21 @@ pub enum ErrorKind {
|
||||
|
||||
impl IdeError {
|
||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||
Self { kind, message: message.into() }
|
||||
Self { kind, message: message.into(), data: None }
|
||||
}
|
||||
pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
|
||||
if self.data.is_none() {
|
||||
self.data = Some(ErrorData::Tool { tool: tool.into(), missing: false });
|
||||
}
|
||||
self
|
||||
}
|
||||
pub fn missing_tool(tool: impl Into<String>) -> Self {
|
||||
let tool = tool.into();
|
||||
Self {
|
||||
kind: ErrorKind::NotFound,
|
||||
message: format!("{tool} is not installed"),
|
||||
data: Some(ErrorData::Tool { tool, missing: true }),
|
||||
}
|
||||
}
|
||||
pub fn not_found(m: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::NotFound, m)
|
||||
@@ -65,3 +89,30 @@ impl From<std::io::Error> for IdeError {
|
||||
Self::new(kind, e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tool_errors_round_trip_without_changing_diagnostics() {
|
||||
let message = "[error] file.css: Unexpected token\n 1 | }\n";
|
||||
let error = IdeError::invalid(message).with_tool("prettier");
|
||||
let decoded: IdeError = serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap();
|
||||
assert_eq!(decoded.message, message);
|
||||
assert_eq!(decoded.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
let missing = IdeError::missing_tool("prettier").with_tool("prettier");
|
||||
assert_eq!(missing.kind, ErrorKind::NotFound);
|
||||
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_errors_need_no_data() {
|
||||
let error: IdeError = serde_json::from_str(r#"{"kind":"unavailable","message":"no server"}"#).unwrap();
|
||||
assert!(error.data.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_value(error).unwrap(),
|
||||
serde_json::json!({"kind":"unavailable","message":"no server","data":null})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,20 @@ use std::sync::Arc;
|
||||
pub enum IdeEvent {
|
||||
/// Project window membership or selection changed.
|
||||
WindowGroupsChanged,
|
||||
ProjectTrustChanged {
|
||||
root: crate::IpcPath,
|
||||
trusted: bool,
|
||||
},
|
||||
/// Files or directories changed on disk (after debouncing, ignore rules applied).
|
||||
FsChanged {
|
||||
created: Vec<crate::IpcPath>,
|
||||
modified: Vec<crate::IpcPath>,
|
||||
removed: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
|
||||
ExcludedDirsChanged {
|
||||
dirs: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// The backend changed a document (reload from disk, formatting, refactoring).
|
||||
/// The UI applies `changes` on top of `base_version`, yielding `version`.
|
||||
DocPatched {
|
||||
|
||||
@@ -237,10 +237,110 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf>
|
||||
find_executable_in(name, search, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()))
|
||||
}
|
||||
|
||||
/// The user's own `PATH` entries on Windows (`HKCU\Environment`), the part of the
|
||||
/// merged `PATH` a process may not inherit — an IDE started before the variable was
|
||||
/// edited, or as a different user, sees only the machine entries. `%VAR%` references
|
||||
/// (`REG_EXPAND_SZ`) are expanded against the current environment. `None` elsewhere:
|
||||
/// there a login shell already supplies the user's whole `PATH`.
|
||||
#[cfg(windows)]
|
||||
pub fn user_path() -> Option<OsString> {
|
||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows_sys::Win32::System::Registry::{
|
||||
HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW,
|
||||
RegOpenKeyExW,
|
||||
};
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let mut key: HKEY = std::ptr::null_mut();
|
||||
if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let read = || {
|
||||
// Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand.
|
||||
let mut kind = 0u32;
|
||||
let mut len = 0u32;
|
||||
let name = wide("Path");
|
||||
let status = RegGetValueW(
|
||||
key,
|
||||
std::ptr::null(),
|
||||
name.as_ptr(),
|
||||
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
|
||||
&mut kind,
|
||||
std::ptr::null_mut(),
|
||||
&mut len,
|
||||
);
|
||||
if status != ERROR_SUCCESS || len == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
|
||||
let status = RegGetValueW(
|
||||
key,
|
||||
std::ptr::null(),
|
||||
name.as_ptr(),
|
||||
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
|
||||
&mut kind,
|
||||
buf.as_mut_ptr() as *mut _,
|
||||
&mut len,
|
||||
);
|
||||
if status != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf16_lossy(&buf);
|
||||
Some(text.trim_end_matches('\0').to_owned())
|
||||
};
|
||||
let value = read();
|
||||
RegCloseKey(key);
|
||||
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
|
||||
#[cfg(not(windows))]
|
||||
pub fn user_path() -> Option<OsString> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `path` followed by the [`user_path`] entries it lacks.
|
||||
pub fn with_user_path(path: &OsStr) -> OsString {
|
||||
user_path().map_or_else(|| path.to_owned(), |user| append_path_entries(path, &user))
|
||||
}
|
||||
|
||||
fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
|
||||
let mut dirs: Vec<std::path::PathBuf> = std::env::split_paths(path).filter(|d| !d.as_os_str().is_empty()).collect();
|
||||
for dir in std::env::split_paths(extra).filter(|d| !d.as_os_str().is_empty()) {
|
||||
if !dirs.contains(&dir) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn user_path_reads_the_current_users_path() {
|
||||
// This host has a user `Path` only when the test account set one; either way the call
|
||||
// must not fail, and a present value is a non-empty `;`-joined list of directories.
|
||||
if let Some(path) = user_path() {
|
||||
let dirs: Vec<_> = std::env::split_paths(&path).collect();
|
||||
assert!(!dirs.is_empty(), "user PATH should hold at least one directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
|
||||
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
|
||||
assert_eq!(append_path_entries(&join(&["a", "b"]), &join(&["b", "c"])), join(&["a", "b", "c"]));
|
||||
assert_eq!(append_path_entries(OsStr::new(""), &join(&["c"])), join(&["c"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_env_adds_only_what_the_process_lacks_and_the_overlay_wins() {
|
||||
set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]);
|
||||
|
||||
@@ -103,10 +103,19 @@ impl Default for DeploymentSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// An SFTP server and how project folders map onto it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DeploymentType {
|
||||
#[default]
|
||||
Sftp,
|
||||
Local,
|
||||
}
|
||||
|
||||
/// A deployment filesystem and how project folders map onto it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentServer {
|
||||
pub kind: DeploymentType,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`].
|
||||
@@ -148,3 +157,23 @@ pub struct RunTarget {
|
||||
pub node: String,
|
||||
pub java: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deployment_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deployment_type_defaults_and_round_trips() {
|
||||
let legacy: DeploymentServer =
|
||||
serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap();
|
||||
assert_eq!(legacy.kind, DeploymentType::Sftp);
|
||||
assert_eq!(legacy.ssh_config, "ssh");
|
||||
for kind in [DeploymentType::Sftp, DeploymentType::Local] {
|
||||
let server = DeploymentServer { kind, ..legacy.clone() };
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
|
||||
server
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,6 +276,8 @@ pub struct AppearanceSettings {
|
||||
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
|
||||
pub use_custom_font: bool,
|
||||
pub ui_font_family: String,
|
||||
/// Font tried when `ui_font_family` lacks a glyph; empty = none.
|
||||
pub ui_font_fallback: String,
|
||||
pub contrast_scrollbars: bool,
|
||||
/// Adjust colours for red-green vision deficiency.
|
||||
pub red_green_vision: bool,
|
||||
@@ -285,11 +287,14 @@ pub struct AppearanceSettings {
|
||||
pub project_colors_in_toolbar: bool,
|
||||
pub dnd_with_alt_only: bool,
|
||||
pub menu_icons: bool,
|
||||
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
|
||||
pub main_menu_style: String,
|
||||
/// `subpixel` | `greyscale` | `none`
|
||||
pub ide_antialiasing: String,
|
||||
pub editor_antialiasing: String,
|
||||
pub show_tool_window_bars: bool,
|
||||
pub show_tool_window_names: bool,
|
||||
pub show_tool_window_badges: bool,
|
||||
pub widescreen_tool_windows: bool,
|
||||
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
|
||||
/// (status bar context menu); widgets not listed use their default.
|
||||
@@ -323,6 +328,7 @@ impl Default for AppearanceSettings {
|
||||
zoom: 100,
|
||||
use_custom_font: false,
|
||||
ui_font_family: "Inter".into(),
|
||||
ui_font_fallback: String::new(),
|
||||
contrast_scrollbars: false,
|
||||
red_green_vision: false,
|
||||
different_tool_window_background: false,
|
||||
@@ -331,10 +337,12 @@ impl Default for AppearanceSettings {
|
||||
project_colors_in_toolbar: true,
|
||||
dnd_with_alt_only: false,
|
||||
menu_icons: true,
|
||||
main_menu_style: "hamburger".into(),
|
||||
ide_antialiasing: "greyscale".into(),
|
||||
editor_antialiasing: "greyscale".into(),
|
||||
show_tool_window_bars: true,
|
||||
show_tool_window_names: false,
|
||||
show_tool_window_badges: true,
|
||||
widescreen_tool_windows: false,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
@@ -342,6 +350,15 @@ impl Default for AppearanceSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Imported editor colour scheme on top of a built-in base.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomColorScheme {
|
||||
pub name: String,
|
||||
pub parent: String,
|
||||
pub attributes: BTreeMap<String, TextAttributesOverride>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct EditorSettings {
|
||||
@@ -357,6 +374,8 @@ pub struct EditorSettings {
|
||||
pub format_on_save: bool,
|
||||
/// Editor colour scheme id; `theme-default` follows the IDE theme.
|
||||
pub color_scheme: String,
|
||||
/// Imported editor schemes, stored in user settings.
|
||||
pub custom_schemes: BTreeMap<String, CustomColorScheme>,
|
||||
/// Color Scheme edits: scheme id → attribute id → override.
|
||||
pub color_overrides: BTreeMap<String, BTreeMap<String, TextAttributesOverride>>,
|
||||
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
|
||||
@@ -404,6 +423,7 @@ impl Default for EditorSettings {
|
||||
format_on_save: false,
|
||||
color_scheme: "theme-default".into(),
|
||||
color_overrides: BTreeMap::new(),
|
||||
custom_schemes: BTreeMap::new(),
|
||||
semantic_highlighting: false,
|
||||
rainbow_brackets: false,
|
||||
ligatures: false,
|
||||
@@ -434,6 +454,8 @@ impl Default for EditorSettings {
|
||||
pub struct FileSettings {
|
||||
/// Save on focus loss / tab switch, like IDEA.
|
||||
pub auto_save: bool,
|
||||
/// Re-check open files against disk when the IDE window gains focus.
|
||||
pub sync_on_focus: bool,
|
||||
pub exclude: Vec<String>,
|
||||
/// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`].
|
||||
pub ignored_names: Vec<String>,
|
||||
@@ -465,6 +487,7 @@ impl Default for FileSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_save: true,
|
||||
sync_on_focus: true,
|
||||
exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()],
|
||||
ignored_names: crate::ignored::DEFAULT_IGNORED_NAMES.iter().map(|s| (*s).into()).collect(),
|
||||
trim_trailing_whitespace: true,
|
||||
@@ -485,9 +508,50 @@ pub struct LanguagesSettings {
|
||||
pub rust: RustSettings,
|
||||
pub go: GoSettings,
|
||||
pub typescript: TypeScriptSettings,
|
||||
pub prettier: PrettierSettings,
|
||||
pub java: JavaSettings,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PrettierSettings {
|
||||
/// Empty path: project node_modules, managed installation, then PATH.
|
||||
pub path: String,
|
||||
pub languages: Vec<String>,
|
||||
/// Prefer Prettier even when a language server offers formatting.
|
||||
pub prefer: bool,
|
||||
pub run_on_save: bool,
|
||||
}
|
||||
|
||||
impl Default for PrettierSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: String::new(),
|
||||
languages: [
|
||||
"markdown",
|
||||
"yaml",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"html",
|
||||
"graphql",
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"json",
|
||||
"jsonc",
|
||||
"vue",
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
prefer: false,
|
||||
run_on_save: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PythonSettings {
|
||||
@@ -546,6 +610,35 @@ pub struct GoSettings {
|
||||
pub goroot: String,
|
||||
/// Empty = `go env GOPATH`.
|
||||
pub gopath: String,
|
||||
pub build_tags: GoBuildTags,
|
||||
pub modules: GoModules,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GoModules {
|
||||
pub env: BTreeMap<String, String>,
|
||||
pub auto_vendor: bool,
|
||||
}
|
||||
|
||||
impl Default for GoModules {
|
||||
fn default() -> Self {
|
||||
Self { env: BTreeMap::new(), auto_vendor: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GoBuildTags {
|
||||
/// Empty values inherit the selected toolchain's defaults.
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub version: String,
|
||||
pub compiler: String,
|
||||
/// Empty = automatic, "1" = enabled, "0" = disabled.
|
||||
pub cgo: String,
|
||||
pub custom: String,
|
||||
pub experiments: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -672,6 +765,44 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ui_font_fallback_defaults_empty_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
|
||||
assert!(old.appearance.ui_font_fallback.is_empty());
|
||||
for family in ["", "Songti SC", "Maple Mono NF CN"] {
|
||||
let mut settings = Settings::default();
|
||||
settings.appearance.ui_font_fallback = family.into();
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["appearance"]["uiFontFallback"], family);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.appearance.ui_font_fallback, family);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_window_badges_default_on_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
|
||||
assert!(old.appearance.show_tool_window_badges);
|
||||
assert!(Settings::default().appearance.show_tool_window_badges);
|
||||
for enabled in [false, true] {
|
||||
let mut settings = Settings::default();
|
||||
settings.appearance.show_tool_window_badges = enabled;
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["appearance"]["showToolWindowBadges"], enabled);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.appearance.show_tool_window_badges, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_menu_style_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
|
||||
assert_eq!(old.appearance.main_menu_style, "hamburger");
|
||||
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
|
||||
assert_eq!(merged.appearance.main_menu_style, "merged");
|
||||
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_font_weights_default_to_regular_and_bold() {
|
||||
let json = serde_json::to_value(Settings::default()).unwrap();
|
||||
@@ -754,3 +885,19 @@ mod gradle_settings_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod go_module_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn old_go_settings_inherit_module_defaults() {
|
||||
let old: GoSettings = serde_json::from_str(r#"{"goroot":"/go"}"#).unwrap();
|
||||
assert!(old.modules.auto_vendor);
|
||||
assert!(old.modules.env.is_empty());
|
||||
let configured: GoSettings =
|
||||
serde_json::from_str(r#"{"modules":{"autoVendor":false,"env":{"GOPRIVATE":"example.com/private"}}}"#)
|
||||
.unwrap();
|
||||
assert!(!configured.modules.auto_vendor);
|
||||
assert_eq!(configured.modules.env["GOPRIVATE"], "example.com/private");
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,44 @@ pub struct WorkspaceEdit {
|
||||
pub files: Vec<FileEdit>,
|
||||
}
|
||||
|
||||
impl WorkspaceEdit {
|
||||
/// Coalesces aliases of the same file before applying edits against its original text.
|
||||
/// Resource operations separate batches because later edits may use the new contents.
|
||||
pub fn deduplicate(&mut self) {
|
||||
let mut files = Vec::new();
|
||||
let mut batch = std::collections::HashMap::<std::path::PathBuf, usize>::new();
|
||||
let mut resolve_aliases = true;
|
||||
for file in std::mem::take(&mut self.files) {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
let path = if resolve_aliases {
|
||||
dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
let index = *batch.entry(path.clone()).or_insert_with(|| {
|
||||
files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() });
|
||||
files.len() - 1
|
||||
});
|
||||
let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() };
|
||||
for edit in edits {
|
||||
if !own.contains(&edit) {
|
||||
own.push(edit);
|
||||
}
|
||||
}
|
||||
}
|
||||
op => {
|
||||
batch.clear();
|
||||
// Later paths refer to a filesystem state the resource operation has yet to create.
|
||||
resolve_aliases = false;
|
||||
files.push(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FileEdit {
|
||||
@@ -203,3 +241,34 @@ pub struct OutlineItem {
|
||||
|
||||
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
|
||||
pub type MatchIndices = Vec<u32>;
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod workspace_edit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dir.path().join("main.cpp");
|
||||
let alias = dir.path().join("alias.cpp");
|
||||
std::fs::write(&real, "add()").unwrap();
|
||||
std::os::unix::fs::symlink(&real, &alias).unwrap();
|
||||
let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() };
|
||||
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] };
|
||||
let mut workspace = WorkspaceEdit {
|
||||
files: vec![
|
||||
edit(real.clone()),
|
||||
edit(alias),
|
||||
FileEdit::Create { path: dir.path().join("other").into(), overwrite: false },
|
||||
edit(real),
|
||||
],
|
||||
};
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace.files.len(), 3);
|
||||
let FileEdit::Edit { edits, .. } = &workspace.files[0] else { panic!() };
|
||||
assert_eq!(edits, &[change]);
|
||||
let once = workspace.clone();
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace, once);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,16 @@ pub fn search_path() -> OsString {
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
|
||||
let shell_path = login_env().and_then(|env| env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone()));
|
||||
let shims: Vec<PathBuf> = version_managers::managers().iter().map(|m| m.shims()).collect();
|
||||
let dirs = search_dirs(host_var_os("PATH").as_deref(), shell_path.as_deref(), home.as_deref(), &shims);
|
||||
// The Windows user `PATH` may not reach a process started before it was set; append its
|
||||
// entries after the inherited ones so missing user tools still resolve.
|
||||
let user_path = crate::process::user_path();
|
||||
let dirs = search_dirs(
|
||||
host_var_os("PATH").as_deref(),
|
||||
shell_path.as_deref(),
|
||||
user_path.as_deref(),
|
||||
home.as_deref(),
|
||||
&shims,
|
||||
);
|
||||
std::env::join_paths(dirs).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -56,10 +65,12 @@ fn is_executable(path: &Path) -> bool {
|
||||
fn search_dirs(
|
||||
process_path: Option<&OsStr>,
|
||||
shell_path: Option<&OsStr>,
|
||||
user_path: Option<&OsStr>,
|
||||
home: Option<&Path>,
|
||||
shims: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = process_path.into_iter().chain(shell_path).flat_map(std::env::split_paths).collect();
|
||||
let mut dirs: Vec<PathBuf> =
|
||||
process_path.into_iter().chain(shell_path).chain(user_path).flat_map(std::env::split_paths).collect();
|
||||
if let Some(home) = home {
|
||||
dirs.extend(USER_DIRS.iter().map(|d| home.join(d)));
|
||||
dirs.extend(nvm_bins(&home.join(".nvm/versions/node")));
|
||||
@@ -80,6 +91,8 @@ const USER_DIRS: &[&str] = &[
|
||||
".cargo/bin",
|
||||
".local/bin",
|
||||
"go/bin",
|
||||
// `dotnet tool install --global` puts fsautocomplete, csharp-ls and friends here.
|
||||
".dotnet/tools",
|
||||
".bun/bin",
|
||||
".deno/bin",
|
||||
".npm-global/bin",
|
||||
@@ -96,6 +109,12 @@ const USER_DIRS: &[&str] = &[
|
||||
|
||||
const SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/go/bin", "/usr/local/bin", "/usr/bin", "/bin"];
|
||||
|
||||
/// Whether `dir` is one of the system's own bin directories, which [`search_path`] adds
|
||||
/// whatever `PATH` holds.
|
||||
pub fn is_system_dir(dir: &Path) -> bool {
|
||||
SYSTEM_DIRS.iter().any(|d| Path::new(d) == dir)
|
||||
}
|
||||
|
||||
/// The `bin` directories of nvm's Node versions in `dir`, newest first.
|
||||
fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut versions: Vec<(Vec<u64>, PathBuf)> = std::fs::read_dir(dir)
|
||||
@@ -164,21 +183,42 @@ mod tests {
|
||||
Some(OsStr::new("/usr/bin:.:node_modules/.bin:/opt/tools")),
|
||||
// nushell prints `$PATH` as is.
|
||||
Some(OsStr::new("$PATH:/opt/tools:/home/me/.asdf/shims")),
|
||||
None,
|
||||
Some(Path::new("/home/me")),
|
||||
&shims,
|
||||
);
|
||||
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
|
||||
assert_eq!(dirs[..3], [PathBuf::from("/usr/bin"), "/opt/tools".into(), "/home/me/.asdf/shims".into()]);
|
||||
assert!(at("/home/me/.npm-global/bin") < at("/home/me/.pyenv/shims"));
|
||||
// Global .NET tools (fsautocomplete, csharp-ls) install here, off PATH.
|
||||
assert!(at("/home/me/.dotnet/tools") < at("/usr/local/bin"));
|
||||
assert!(at("/home/me/.local/share/mise/shims") < at("/usr/local/bin"));
|
||||
assert!(dirs.iter().all(|d| d.is_absolute()));
|
||||
let unique: HashSet<&PathBuf> = dirs.iter().collect();
|
||||
assert_eq!(unique.len(), dirs.len());
|
||||
// A `:` can't be in a `PATH` entry: those below such a home are left out.
|
||||
let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, Some(Path::new("/home/a:b")), &[]);
|
||||
let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, None, Some(Path::new("/home/a:b")), &[]);
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_dirs_append_user_path_entries_once_after_the_inherited_path() {
|
||||
let (system, dup, usertool) =
|
||||
if cfg!(windows) { ("C:/system", "C:/dup", "C:/usertool") } else { ("/system", "/dup", "/usertool") };
|
||||
let home = Path::new(if cfg!(windows) { "C:/home/me" } else { "/home/me" });
|
||||
let join = |dirs: &[&str]| std::env::join_paths(dirs.iter().map(PathBuf::from)).unwrap();
|
||||
let inherited = join(&[system, dup]);
|
||||
let user = join(&[dup, usertool]);
|
||||
let dirs = search_dirs(Some(inherited.as_os_str()), None, Some(user.as_os_str()), Some(home), &[]);
|
||||
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
|
||||
// User entries follow the inherited PATH, de-duplicated, ahead of the fallbacks.
|
||||
assert_eq!(dirs.iter().filter(|d| *d == Path::new(dup)).count(), 1);
|
||||
assert!(at(dup) < at(usertool));
|
||||
// And before this platform's home/system fallbacks.
|
||||
let fallback = dirs.iter().position(|d| d == &home.join(USER_DIRS[0])).unwrap();
|
||||
assert!(at(usertool) < fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvm_versions_newest_first() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-nvm-{}", std::process::id()));
|
||||
|
||||
@@ -348,8 +348,17 @@ pub struct AdapterIo {
|
||||
pub port: Option<u16>,
|
||||
/// Recent stderr lines of the adapter process, for error messages.
|
||||
pub stderr: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
/// (category, line) printed by the debuggee when it shares the adapter's stdio.
|
||||
pub program_output: Option<ProgramOutput>,
|
||||
}
|
||||
|
||||
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
|
||||
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
|
||||
|
||||
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
|
||||
const SHARES_STDIO: &str = "delve";
|
||||
const DELVE_BANNER: &str = "DAP server listening at:";
|
||||
|
||||
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
|
||||
|
||||
/// Starts adapters. Tests substitute in-memory adapters.
|
||||
@@ -385,6 +394,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: None,
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output: None,
|
||||
});
|
||||
}
|
||||
let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) {
|
||||
@@ -407,6 +417,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: None,
|
||||
port: Some(*port),
|
||||
stderr,
|
||||
program_output: None,
|
||||
});
|
||||
}
|
||||
if spec.adapter == DEBUGPY_TOOL {
|
||||
@@ -445,8 +456,14 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let Some(err) = child.stderr.take() {
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone());
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
|
||||
}
|
||||
match port {
|
||||
None => {
|
||||
@@ -458,11 +475,12 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
Some(port) => {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone());
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
|
||||
}
|
||||
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
|
||||
@@ -484,6 +502,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: Some(child),
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -495,9 +514,15 @@ fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
|
||||
stream: R,
|
||||
name: String,
|
||||
sink: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
program: Option<(ProgramSink, &'static str)>,
|
||||
) {
|
||||
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| {
|
||||
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
|
||||
if let Some((tx, category)) = &program
|
||||
&& !line.starts_with(DELVE_BANNER)
|
||||
{
|
||||
let _ = tx.send((category, format!("{line}\n")));
|
||||
}
|
||||
push_line(&sink, line);
|
||||
}));
|
||||
}
|
||||
@@ -549,6 +574,18 @@ async fn connect_with_retries(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn program_output_is_forwarded_without_the_adapter_banner() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let sink = Arc::default();
|
||||
let text: &[u8] = b"DAP server listening at: 127.0.0.1:1\n[hello two words]\nno newline";
|
||||
pump_lines(text, "delve".into(), Arc::clone(&sink), Some((tx, "stdout")));
|
||||
assert_eq!(rx.recv().await, Some(("stdout", "[hello two words]\n".into())));
|
||||
assert_eq!(rx.recv().await, Some(("stdout", "no newline\n".into())));
|
||||
assert_eq!(rx.recv().await, None);
|
||||
assert_eq!(sink.lock().len(), 3);
|
||||
}
|
||||
|
||||
/// Managed tools win over `PATH`, and debugpy falls back to the private copy.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
|
||||
@@ -697,6 +697,14 @@ impl DebugManager {
|
||||
};
|
||||
let session =
|
||||
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
|
||||
if let Some(mut output) = io.program_output {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some((category, output)) = output.recv().await {
|
||||
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
|
||||
}
|
||||
});
|
||||
}
|
||||
self.sessions.lock().insert(id, session.clone());
|
||||
self.order.lock().push(id);
|
||||
// Announced before the event loop runs, so it precedes every adapter event.
|
||||
|
||||
@@ -32,7 +32,14 @@ impl AdapterLauncher for FakeLauncher {
|
||||
tokio::spawn(fake_adapter(server, log, script));
|
||||
let (r, w) = tokio::io::split(client);
|
||||
Box::pin(async move {
|
||||
Ok(AdapterIo { reader: Box::new(r), writer: Box::new(w), child: None, port: None, stderr: Arc::default() })
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
port: None,
|
||||
stderr: Arc::default(),
|
||||
program_output: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -898,6 +905,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
tree_sitter: None,
|
||||
language_servers: vec![],
|
||||
debugger: Some(ide_language::DebuggerConfig {
|
||||
|
||||
@@ -7,10 +7,11 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] }
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString", "CFURL"] }
|
||||
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
|
||||
+239
-4
@@ -1,8 +1,7 @@
|
||||
//! The font families installed on this machine, for the font pickers in
|
||||
//! Settings (editor, typography fallback, terminal, UI font).
|
||||
//!
|
||||
//! Each platform asks the font system the WebView itself resolves CSS
|
||||
//! `font-family` names against, so every listed name renders: CoreText on
|
||||
//! Each platform asks the native font system for installed families: CoreText on
|
||||
//! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs.
|
||||
//! The list is read on first use and cached until explicitly refreshed; the
|
||||
//! query can take a few hundred milliseconds, so callers run it off the UI and
|
||||
@@ -84,14 +83,164 @@ pub fn parse_fc_list(output: &str) -> Vec<(String, bool)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Binary font bundle: little-endian u32 JSON-header length, a face metadata
|
||||
/// array, then raw font bytes. Paths never cross IPC; only CoreText-enumerated
|
||||
/// files under user font directories can be read, selected by family name.
|
||||
/// Includes only the nearest regular/bold faces and their optional italics.
|
||||
pub fn user_font_data(family: &str) -> std::io::Result<Vec<u8>> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let entries = imp::user_faces();
|
||||
bundle_family(family, &entries)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = family;
|
||||
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "User font loading is macOS only"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
#[derive(Debug)]
|
||||
struct UserFace {
|
||||
family: String,
|
||||
path: std::path::PathBuf,
|
||||
weight: u16,
|
||||
italic: bool,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn user_font_path(path: &std::path::Path, roots: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
|
||||
let path = path.canonicalize().ok()?;
|
||||
roots.iter().any(|root| path.starts_with(root)).then_some(path)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn family_faces<'a>(family: &str, entries: &'a [UserFace]) -> Vec<&'a UserFace> {
|
||||
let mut selected: Vec<&UserFace> = Vec::new();
|
||||
for italic in [false, true] {
|
||||
for weight in [400u16, 700] {
|
||||
let closest = entries
|
||||
.iter()
|
||||
.filter(|e| e.family.eq_ignore_ascii_case(family.trim()) && e.italic == italic)
|
||||
.min_by_key(|e| (e.weight.abs_diff(weight), e.weight, &e.path));
|
||||
if let Some(face) = closest
|
||||
&& !selected.iter().any(|f| std::ptr::eq(*f, face))
|
||||
{
|
||||
selected.push(face);
|
||||
}
|
||||
}
|
||||
}
|
||||
selected
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn bundle_family(family: &str, entries: &[UserFace]) -> std::io::Result<Vec<u8>> {
|
||||
#[derive(Serialize)]
|
||||
struct Face {
|
||||
weight: u16,
|
||||
style: &'static str,
|
||||
offset: usize,
|
||||
length: usize,
|
||||
}
|
||||
let mut faces = Vec::new();
|
||||
let mut data = Vec::new();
|
||||
let mut files = BTreeMap::new();
|
||||
// Bound large CJK families to regular/bold and their nearest italic faces.
|
||||
for entry in family_faces(family, entries) {
|
||||
// Reject a file replaced by a symlink after enumeration.
|
||||
if entry.path.canonicalize()? != entry.path {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Font file changed"));
|
||||
}
|
||||
let (offset, length) = if let Some(range) = files.get(&entry.path) {
|
||||
*range
|
||||
} else {
|
||||
let bytes = std::fs::read(&entry.path)?;
|
||||
let range = (data.len(), bytes.len());
|
||||
data.extend(bytes);
|
||||
files.insert(entry.path.clone(), range);
|
||||
range
|
||||
};
|
||||
faces.push(Face {
|
||||
weight: entry.weight,
|
||||
style: if entry.italic { "italic" } else { "normal" },
|
||||
offset,
|
||||
length,
|
||||
});
|
||||
}
|
||||
if faces.is_empty() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "No installed user font family"));
|
||||
}
|
||||
let header = serde_json::to_vec(&faces)?;
|
||||
let mut out = (header.len() as u32).to_le_bytes().to_vec();
|
||||
out.extend(header);
|
||||
out.extend(data);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType};
|
||||
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType, CFURL};
|
||||
use objc2_core_text::{
|
||||
CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait,
|
||||
kCTFontTraitsAttribute,
|
||||
kCTFontTraitsAttribute, kCTFontURLAttribute, kCTFontWeightTrait,
|
||||
};
|
||||
|
||||
pub(crate) fn user_faces() -> Vec<super::UserFace> {
|
||||
let mut roots = vec![std::path::PathBuf::from("/Library/Fonts")];
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
roots.push(std::path::PathBuf::from(home).join("Library/Fonts"));
|
||||
}
|
||||
let roots: Vec<_> = roots.into_iter().filter_map(|r| r.canonicalize().ok()).collect();
|
||||
// SAFETY: CoreText descriptors carry documented CFString, CFURL and trait dictionary values.
|
||||
unsafe {
|
||||
let collection = CTFontCollection::from_available_fonts(None);
|
||||
let Some(descriptors) = collection.matching_font_descriptors() else { return Vec::new() };
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
descriptors
|
||||
.cast_unchecked::<CTFontDescriptor>()
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
let family = d.attribute(kCTFontFamilyNameAttribute)?.downcast::<CFString>().ok()?.to_string();
|
||||
let path = d.attribute(kCTFontURLAttribute)?.downcast::<CFURL>().ok()?.to_file_path()?;
|
||||
let path = super::user_font_path(&path, &roots)?;
|
||||
let traits = d.attribute(kCTFontTraitsAttribute)?.downcast::<CFDictionary>().ok()?;
|
||||
let traits = traits.cast_unchecked::<CFString, CFType>();
|
||||
let symbolic = traits
|
||||
.get(kCTFontSymbolicTrait)
|
||||
.and_then(|n| n.downcast::<CFNumber>().ok())
|
||||
.and_then(|n| n.as_i64())
|
||||
.unwrap_or(0) as u32;
|
||||
let weight = traits
|
||||
.get(kCTFontWeightTrait)
|
||||
.and_then(|n| n.downcast::<CFNumber>().ok())
|
||||
.and_then(|n| n.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
// CoreText's normalized weight values corresponding to CSS's nine standard weights.
|
||||
let weight = [
|
||||
(-0.8, 100),
|
||||
(-0.6, 200),
|
||||
(-0.4, 300),
|
||||
(0.0, 400),
|
||||
(0.23, 500),
|
||||
(0.3, 600),
|
||||
(0.4, 700),
|
||||
(0.56, 800),
|
||||
(0.62, 900),
|
||||
]
|
||||
.into_iter()
|
||||
.min_by(|(a, _), (b, _)| (weight - a).abs().total_cmp(&(weight - b).abs()))?
|
||||
.1;
|
||||
let italic = symbolic & CTFontSymbolicTraits::TraitItalic.0 != 0;
|
||||
if !seen.insert((family.clone(), path.clone(), weight, italic)) {
|
||||
return None;
|
||||
}
|
||||
Some(super::UserFace { family, path, weight, italic })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every face CoreText makes available to this process, as (family, fixed pitch).
|
||||
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
|
||||
// SAFETY: CoreText's documented types: the matching descriptors array
|
||||
@@ -210,6 +359,92 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn selects_only_regular_bold_and_matching_italics_from_dense_families() {
|
||||
let entries: Vec<_> = [false, true]
|
||||
.into_iter()
|
||||
.flat_map(|italic| {
|
||||
(100..=900).step_by(100).map(move |weight| UserFace {
|
||||
family: "CJK".into(),
|
||||
path: format!("{weight}-{italic}.ttf").into(),
|
||||
weight,
|
||||
italic,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let selected = family_faces(" cjk ", &entries);
|
||||
assert_eq!(
|
||||
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
|
||||
[(400, false), (700, false), (400, true), (700, true),]
|
||||
);
|
||||
assert!(family_faces("unknown", &entries).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_nearest_available_weights_once_with_optional_italics() {
|
||||
let entries: Vec<_> = [(300, false), (500, false), (800, false), (500, true)]
|
||||
.into_iter()
|
||||
.map(|(weight, italic)| UserFace {
|
||||
family: "Sparse".into(),
|
||||
path: format!("{weight}-{italic}.ttf").into(),
|
||||
weight,
|
||||
italic,
|
||||
})
|
||||
.collect();
|
||||
let selected = family_faces("Sparse", &entries);
|
||||
assert_eq!(
|
||||
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
|
||||
[(300, false), (800, false), (500, true),]
|
||||
);
|
||||
assert_eq!(family_faces("Sparse", &entries[..1]).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_only_reads_enumerated_family_faces() {
|
||||
let root = std::env::current_dir().unwrap().join(format!("../../.font-bundle-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let root = root.canonicalize().unwrap();
|
||||
let regular = root.join("regular.ttf");
|
||||
let bold = root.join("bold.ttf");
|
||||
std::fs::write(®ular, [1, 2, 3]).unwrap();
|
||||
std::fs::write(&bold, [4, 5]).unwrap();
|
||||
let entries = [
|
||||
UserFace { family: "Test Nerd Font".into(), path: regular.clone(), weight: 400, italic: false },
|
||||
UserFace { family: "Test Nerd Font".into(), path: bold, weight: 700, italic: false },
|
||||
// A non-selected face is never opened, even if its file has disappeared.
|
||||
UserFace { family: "Test Nerd Font".into(), path: root.join("thin.ttf"), weight: 100, italic: false },
|
||||
];
|
||||
let result = bundle_family("test nerd font", &entries).unwrap();
|
||||
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
|
||||
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
|
||||
assert_eq!(faces[0]["weight"], 400);
|
||||
assert_eq!(faces[1]["weight"], 700);
|
||||
assert_eq!(faces.as_array().unwrap().len(), 2);
|
||||
assert_eq!(&result[4 + len..], &[1, 2, 3, 4, 5]);
|
||||
// Existing files are not inputs: neither paths nor unenumerated families grant access.
|
||||
assert!(bundle_family(regular.to_str().unwrap(), &entries).is_err());
|
||||
assert!(bundle_family("Unenumerated", &entries).is_err());
|
||||
assert!(user_font_path(®ular, &[]).is_none());
|
||||
assert!(user_font_path(®ular, std::slice::from_ref(&root)).is_some());
|
||||
// Multiple selected descriptors in a collection still transfer the file only once.
|
||||
let shared = [
|
||||
UserFace { family: "Collection".into(), path: regular.clone(), weight: 400, italic: false },
|
||||
UserFace { family: "Collection".into(), path: regular, weight: 700, italic: false },
|
||||
];
|
||||
let result = bundle_family("Collection", &shared).unwrap();
|
||||
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
|
||||
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
|
||||
assert_eq!(faces[0]["offset"], faces[1]["offset"]);
|
||||
assert_eq!(&result[4 + len..], &[1, 2, 3]);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = root.join("outside.ttf");
|
||||
std::os::unix::fs::symlink(std::env::current_exe().unwrap(), &link).unwrap();
|
||||
assert!(user_font_path(&link, std::slice::from_ref(&root)).is_none());
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn fam(name: &str, monospaced: bool) -> FontFamily {
|
||||
FontFamily { name: name.into(), monospaced }
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ pub fn availability(root: &Path) -> GhAvailability {
|
||||
}
|
||||
let (repo, login) = std::thread::scope(|s| {
|
||||
let repo = s.spawn(|| {
|
||||
let cmd = command(root, &["repo", "view", "--json", "nameWithOwner,url,defaultBranchRef"])?;
|
||||
let cmd = command(root, &["repo", "view", "--json", REPO_FIELDS])?;
|
||||
process::run(cmd, QUICK, None)
|
||||
});
|
||||
let login = s.spawn(|| run(root, &["api", "user", "--jq", ".login"]));
|
||||
@@ -278,6 +278,53 @@ pub fn add_comment(root: &Path, number: u32, body: &str) -> IdeResult<()> {
|
||||
run(root, &["pr", "comment", &n, "--body", body]).map(drop)
|
||||
}
|
||||
|
||||
/// A GitHub API call through `gh api`: GraphQL (`endpoint` `graphql`, `body`
|
||||
/// `{"query", "variables"}`) or REST (`repos/{owner}/{repo}/…`, gh fills in the
|
||||
/// placeholders from the project's remote).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GhApiRequest {
|
||||
/// `GET`, `POST`, `PATCH`, `PUT`, `DELETE`.
|
||||
pub method: String,
|
||||
pub endpoint: String,
|
||||
/// JSON request body, sent on stdin.
|
||||
pub body: Option<String>,
|
||||
/// Follow REST `Link` pages; the result is the array of all pages (`--slurp`).
|
||||
pub paginate: bool,
|
||||
}
|
||||
|
||||
const METHODS: &[&str] = &["GET", "POST", "PATCH", "PUT", "DELETE"];
|
||||
|
||||
/// `gh api` arguments for `req`.
|
||||
pub fn api_args(req: &GhApiRequest) -> IdeResult<Vec<String>> {
|
||||
let method = req.method.to_ascii_uppercase();
|
||||
if !METHODS.contains(&method.as_str()) {
|
||||
return Err(IdeError::invalid(format!("unsupported HTTP method {}", req.method)));
|
||||
}
|
||||
let endpoint = req.endpoint.trim();
|
||||
if endpoint.is_empty() || endpoint.starts_with('-') || endpoint.contains("://") {
|
||||
return Err(IdeError::invalid(format!("not a GitHub API endpoint: {endpoint}")));
|
||||
}
|
||||
let mut args: Vec<String> = vec!["api".into(), "--method".into(), method, endpoint.into()];
|
||||
if req.body.is_some() {
|
||||
args.push("--input".into());
|
||||
args.push("-".into());
|
||||
}
|
||||
if req.paginate {
|
||||
args.push("--paginate".into());
|
||||
args.push("--slurp".into());
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// Runs `req`; returns the response body (JSON text, empty for `204 No Content`).
|
||||
pub fn api(root: &Path, req: &GhApiRequest) -> IdeResult<String> {
|
||||
let args = api_args(req)?;
|
||||
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
let out = process::run_with_input(command(root, &refs)?, req.body.as_deref(), QUICK, None)?;
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(&out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
/// Revisions to diff a pull request's files with, present in the local repository.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -34,6 +34,12 @@ pub struct GhRepo {
|
||||
pub name_with_owner: String,
|
||||
pub url: String,
|
||||
pub default_branch: Option<String>,
|
||||
/// Merge methods the repository allows (all of them when gh does not say).
|
||||
pub merge_commit_allowed: bool,
|
||||
pub squash_merge_allowed: bool,
|
||||
pub rebase_merge_allowed: bool,
|
||||
/// The signed-in user's permission: `ADMIN`, `MAINTAIN`, `WRITE`, `TRIAGE`, `READ`; empty when unknown.
|
||||
pub viewer_permission: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -152,6 +158,8 @@ pub struct PullRequestDetails {
|
||||
pub base_oid: String,
|
||||
/// `MERGEABLE`, `CONFLICTING`, `UNKNOWN`.
|
||||
pub mergeable: String,
|
||||
/// GitHub's merge box state: `CLEAN`, `UNSTABLE`, `HAS_HOOKS`, `BLOCKED`, `BEHIND`, `DIRTY`, `DRAFT`, `UNKNOWN`.
|
||||
pub merge_state_status: String,
|
||||
pub is_cross_repository: bool,
|
||||
pub merged_at: Option<String>,
|
||||
pub closed_at: Option<String>,
|
||||
@@ -242,7 +250,7 @@ impl RawSummary {
|
||||
pub const LIST_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions";
|
||||
|
||||
/// Fields requested from `gh pr view`.
|
||||
pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
|
||||
pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,mergeStateStatus,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
|
||||
|
||||
pub fn parse_list(json: &str) -> serde_json::Result<Vec<PullRequestSummary>> {
|
||||
let raw: Vec<RawSummary> = serde_json::from_str(json)?;
|
||||
@@ -348,6 +356,7 @@ struct RawDetails {
|
||||
head_ref_oid: String,
|
||||
base_ref_oid: String,
|
||||
mergeable: String,
|
||||
merge_state_status: String,
|
||||
is_cross_repository: bool,
|
||||
merged_at: Option<String>,
|
||||
closed_at: Option<String>,
|
||||
@@ -442,6 +451,7 @@ pub fn parse_view(json: &str) -> serde_json::Result<PullRequestDetails> {
|
||||
head_oid: raw.head_ref_oid,
|
||||
base_oid: raw.base_ref_oid,
|
||||
mergeable: raw.mergeable,
|
||||
merge_state_status: raw.merge_state_status,
|
||||
is_cross_repository: raw.is_cross_repository,
|
||||
merged_at: raw.merged_at.filter(|s| !s.is_empty()),
|
||||
closed_at: raw.closed_at.filter(|s| !s.is_empty()),
|
||||
@@ -534,6 +544,10 @@ struct RawRepo {
|
||||
name_with_owner: String,
|
||||
url: String,
|
||||
default_branch_ref: Option<RawBranchRef>,
|
||||
merge_commit_allowed: Option<bool>,
|
||||
squash_merge_allowed: Option<bool>,
|
||||
rebase_merge_allowed: Option<bool>,
|
||||
viewer_permission: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
@@ -542,13 +556,21 @@ struct RawBranchRef {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// `gh repo view --json nameWithOwner,url,defaultBranchRef`.
|
||||
/// Fields requested from `gh repo view`.
|
||||
pub const REPO_FIELDS: &str =
|
||||
"nameWithOwner,url,defaultBranchRef,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission";
|
||||
|
||||
/// `gh repo view --json` with [`REPO_FIELDS`].
|
||||
pub fn parse_repo(json: &str) -> serde_json::Result<GhRepo> {
|
||||
let raw: RawRepo = serde_json::from_str(json)?;
|
||||
Ok(GhRepo {
|
||||
name_with_owner: raw.name_with_owner,
|
||||
url: raw.url,
|
||||
default_branch: raw.default_branch_ref.map(|b| b.name).filter(|n| !n.is_empty()),
|
||||
merge_commit_allowed: raw.merge_commit_allowed.unwrap_or(true),
|
||||
squash_merge_allowed: raw.squash_merge_allowed.unwrap_or(true),
|
||||
rebase_merge_allowed: raw.rebase_merge_allowed.unwrap_or(true),
|
||||
viewer_permission: raw.viewer_permission.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -60,9 +60,30 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
|
||||
|
||||
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
|
||||
/// a non-zero exit is reported in [`Output::success`], not as an error.
|
||||
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
pub(crate) fn run(cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
run_with_input(cmd, None, timeout, cancel)
|
||||
}
|
||||
|
||||
/// [`run`] with `input` written to the child's stdin (then closed).
|
||||
pub(crate) fn run_with_input(
|
||||
mut cmd: Command,
|
||||
input: Option<&str>,
|
||||
timeout: Duration,
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> IdeResult<Output> {
|
||||
prepare(&mut cmd);
|
||||
if input.is_some() {
|
||||
cmd.stdin(Stdio::piped());
|
||||
}
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) {
|
||||
let input = input.to_owned();
|
||||
// A writer thread: a large body must not block on a full pipe while gh is not reading yet.
|
||||
std::thread::spawn(move || {
|
||||
use std::io::Write;
|
||||
let _ = stdin.write_all(input.as_bytes());
|
||||
});
|
||||
}
|
||||
let out = child.stdout.take().map(read_all);
|
||||
let err = child.stderr.take().map(read_all);
|
||||
let deadline = Instant::now() + timeout;
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"[email protected]","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
|
||||
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"[email protected]","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
|
||||
@@ -22,6 +22,7 @@ fn pr_view() {
|
||||
assert_eq!(pr.summary.number, 14768);
|
||||
assert_eq!(pr.summary.head_ref, "babakks/refresh-token-bump-deps");
|
||||
assert_eq!(pr.head_oid, "6ec1ba54542baca6cc5932582634104c83f36a28");
|
||||
assert_eq!(pr.merge_state_status, "UNKNOWN");
|
||||
assert_eq!(pr.files.len(), 2);
|
||||
assert_eq!(pr.files[0].change_type, "MODIFIED");
|
||||
assert_eq!(pr.commits[0].headline, "chore(deps): bump github.com/cli/oauth to v1.3.0");
|
||||
@@ -69,6 +70,16 @@ fn repo_view() {
|
||||
.unwrap();
|
||||
assert_eq!(r.name_with_owner, "l0ng-ai/openidea");
|
||||
assert_eq!(r.default_branch.as_deref(), Some("main"));
|
||||
assert!(r.merge_commit_allowed && r.squash_merge_allowed && r.rebase_merge_allowed, "unknown: allowed");
|
||||
assert_eq!(r.viewer_permission, "");
|
||||
|
||||
let r = parse_repo(
|
||||
r#"{"mergeCommitAllowed":true,"nameWithOwner":"o/r","url":"","rebaseMergeAllowed":false,"squashMergeAllowed":false,"viewerPermission":"WRITE"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(r.merge_commit_allowed);
|
||||
assert!(!r.squash_merge_allowed && !r.rebase_merge_allowed);
|
||||
assert_eq!(r.viewer_permission, "WRITE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -87,3 +98,24 @@ fn list_arguments() {
|
||||
assert!(joined.contains("--limit 500 --state merged --author @me"));
|
||||
assert_eq!(args.last().map(String::as_str), Some("review-requested:@me fix login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_arguments() {
|
||||
let req =
|
||||
GhApiRequest { method: "post".into(), endpoint: "graphql".into(), body: Some("{}".into()), paginate: false };
|
||||
assert_eq!(api_args(&req).unwrap(), ["api", "--method", "POST", "graphql", "--input", "-"]);
|
||||
let req = GhApiRequest {
|
||||
method: "GET".into(),
|
||||
endpoint: "repos/{owner}/{repo}/labels".into(),
|
||||
body: None,
|
||||
paginate: true,
|
||||
};
|
||||
assert_eq!(
|
||||
api_args(&req).unwrap(),
|
||||
["api", "--method", "GET", "repos/{owner}/{repo}/labels", "--paginate", "--slurp"]
|
||||
);
|
||||
for bad in ["--hostname", "https://evil.example/x", ""] {
|
||||
assert!(api_args(&GhApiRequest { endpoint: bad.into(), ..req.clone() }).is_err(), "{bad}");
|
||||
}
|
||||
assert!(api_args(&GhApiRequest { method: "TRACE".into(), ..req }).is_err());
|
||||
}
|
||||
@@ -48,7 +48,7 @@ parking_lot.workspace = true
|
||||
dunce.workspace = true
|
||||
dirs.workspace = true
|
||||
uuid.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
|
||||
tempfile.workspace = true
|
||||
@@ -44,6 +44,7 @@ struct State {
|
||||
project: Changes,
|
||||
/// Project roots whose `postStartCommand` ran in this process.
|
||||
post_started: HashSet<PathBuf>,
|
||||
post_commands: std::collections::HashMap<PathBuf, CancellationToken>,
|
||||
}
|
||||
|
||||
pub struct Environment {
|
||||
@@ -78,7 +79,12 @@ impl Environment {
|
||||
|
||||
pub(crate) fn project_closed(&self) {
|
||||
if self.project_env {
|
||||
self.state.lock().project.clear();
|
||||
let mut state = self.state.lock();
|
||||
for (_, token) in state.post_commands.drain() {
|
||||
token.cancel();
|
||||
}
|
||||
state.project.clear();
|
||||
drop(state);
|
||||
self.publish();
|
||||
}
|
||||
}
|
||||
@@ -144,6 +150,7 @@ impl Environment {
|
||||
/// a failure is a notification.
|
||||
pub(crate) fn spawn_post_start(&self, cmd: LifecycleCommand, root: PathBuf, sink: SharedSink) {
|
||||
let cancel = self.cancel.child_token();
|
||||
self.state.lock().post_commands.insert(root.clone(), cancel.clone());
|
||||
let spawned = std::thread::Builder::new().name("openidea-post-start".into()).spawn(move || {
|
||||
run_post_start(&cmd, &root, &sink, &cancel);
|
||||
});
|
||||
@@ -263,6 +270,9 @@ fn run_post_start(cmd: &LifecycleCommand, root: &Path, sink: &SharedSink, cancel
|
||||
/// Runs `argv` in `root` with the children's environment, its stdout and stderr line by line
|
||||
/// to `on_line`; `Err` says how it failed.
|
||||
fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &dyn Fn(String)) -> Result<(), String> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let (program, args) = argv.split_first().ok_or("empty command")?;
|
||||
let mut child = ide_core::process::prepare(&mut Command::new(program))
|
||||
.args(args)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Select the document's formatting provider before executing it on this host.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::settings::PrettierSettings;
|
||||
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
|
||||
use ide_language::FormatterConfig;
|
||||
use ide_lsp::types::FormatOptions;
|
||||
use ide_tools::{Tools, formatter};
|
||||
|
||||
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
|
||||
config.is_some_and(|c| c.tool == "prettier")
|
||||
&& settings.languages.iter().any(|l| l == language)
|
||||
&& (settings.prefer || !lsp)
|
||||
}
|
||||
|
||||
pub async fn format(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
let project = host.project(window)?;
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let settings = project.settings.effective();
|
||||
let prettier = &settings.languages.prettier;
|
||||
let language = project.languages.get(&snap.language);
|
||||
let config = language.as_ref().and_then(|l| l.formatter.as_ref());
|
||||
let candidate =
|
||||
config.is_some_and(|c| c.tool == "prettier") && prettier.languages.iter().any(|l| l == snap.language.as_str());
|
||||
let lsp = if candidate && !prettier.prefer { project.lsp.can_format(doc).await? } else { false };
|
||||
if !external_selected(config, snap.language.as_str(), prettier, lsp) {
|
||||
if options.on_save && !settings.editor.format_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return project.lsp.format(doc, range, options).await;
|
||||
}
|
||||
if options.on_save && !prettier.run_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
host.require_trust(window)?;
|
||||
if snap.read_only {
|
||||
return Err(IdeError::invalid("The file is read-only"));
|
||||
}
|
||||
let config = config.unwrap().clone();
|
||||
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path);
|
||||
let tools = Tools::new(&host.data_dir);
|
||||
let whole_selection = range.is_some() && !config.range;
|
||||
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
|
||||
let text = ide_text::Document::new(&snap.text);
|
||||
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
|
||||
let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?;
|
||||
command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?);
|
||||
command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets));
|
||||
let formatted = formatter::run(command, &snap.text, &Default::default())?;
|
||||
Ok(text
|
||||
.diff(&formatted)
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| TextEdit {
|
||||
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
|
||||
new_text: c.insert,
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
if edits.is_ok() && whole_selection {
|
||||
project.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Info,
|
||||
title: "Prettier".into(),
|
||||
body: Some(
|
||||
"Prettier does not support selection formatting for this language; formatted the whole file".into(),
|
||||
),
|
||||
});
|
||||
}
|
||||
edits.map_err(|e| e.with_tool("prettier"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn language_enablement_and_preference_preserve_lsp_formatters() {
|
||||
let languages = ide_language::LanguageRegistry::load(None).unwrap();
|
||||
let mut settings = PrettierSettings::default();
|
||||
for lang in languages.all() {
|
||||
for lsp in [false, true] {
|
||||
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
|
||||
assert_eq!(
|
||||
external_selected(lang.formatter.as_ref(), lang.id.as_str(), &settings, lsp),
|
||||
configured && !lsp,
|
||||
"{}",
|
||||
lang.id
|
||||
);
|
||||
}
|
||||
}
|
||||
let ts = languages.get(&ide_core::LanguageId::new("typescript")).unwrap();
|
||||
settings.prefer = true;
|
||||
assert!(external_selected(ts.formatter.as_ref(), "typescript", &settings, true));
|
||||
settings.languages.clear();
|
||||
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
|
||||
assert!(!external_selected(None, "rust", &settings, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Saved Kotlin -> isolated Maven output -> marked JDT libraries.
|
||||
|
||||
use ide_build::kotlin_bridge::{self, Outcome, ProjectLibraries};
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink};
|
||||
use ide_lsp::LspManager;
|
||||
use ide_workspace::SettingsStore;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
const DELAY: Duration = Duration::from_millis(750);
|
||||
const SOURCE: &str = "kotlin-bridge";
|
||||
const COMMAND: &str = "openidea.kotlin.replace";
|
||||
|
||||
#[derive(Default)]
|
||||
struct Revision {
|
||||
generation: u64,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
impl Revision {
|
||||
fn change(&mut self) {
|
||||
self.cancel.cancel();
|
||||
self.generation += 1;
|
||||
self.cancel = CancellationToken::new();
|
||||
}
|
||||
fn current(&self, generation: u64) -> bool {
|
||||
self.generation == generation && !self.cancel.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
struct MavenOptions {
|
||||
offline: bool,
|
||||
profiles: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct KotlinBridge {
|
||||
root: PathBuf,
|
||||
cache: PathBuf,
|
||||
bundle: Option<PathBuf>,
|
||||
settings: Arc<SettingsStore>,
|
||||
lsp: Arc<LspManager>,
|
||||
sink: SharedSink,
|
||||
trusted: Arc<AtomicBool>,
|
||||
lifetime: CancellationToken,
|
||||
revision: Mutex<Revision>,
|
||||
changed: Notify,
|
||||
options: Mutex<MavenOptions>,
|
||||
worker: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl KotlinBridge {
|
||||
pub(crate) fn new(
|
||||
root: PathBuf,
|
||||
cache: PathBuf,
|
||||
data: &Path,
|
||||
settings: Arc<SettingsStore>,
|
||||
lsp: Arc<LspManager>,
|
||||
sink: SharedSink,
|
||||
trusted: Arc<AtomicBool>,
|
||||
) -> Arc<Self> {
|
||||
let options = std::fs::read(cache.join("kotlin-bridge-options.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default();
|
||||
Arc::new(Self {
|
||||
root,
|
||||
cache,
|
||||
bundle: ide_sdk::java::kotlin_bridge_bundle(data),
|
||||
settings,
|
||||
lsp,
|
||||
sink,
|
||||
trusted,
|
||||
lifetime: CancellationToken::new(),
|
||||
revision: Mutex::new(Revision::default()),
|
||||
changed: Notify::new(),
|
||||
options: Mutex::new(options),
|
||||
worker: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn start(self: &Arc<Self>, runtime: &tokio::runtime::Handle) {
|
||||
let bridge = self.clone();
|
||||
*self.worker.lock() = Some(runtime.spawn(async move { bridge.run().await }));
|
||||
self.schedule();
|
||||
}
|
||||
|
||||
pub(crate) fn configure_maven(&self, offline: bool, profiles: Vec<String>) {
|
||||
let options = MavenOptions { offline, profiles };
|
||||
if let Ok(bytes) = serde_json::to_vec(&options) {
|
||||
let _ = std::fs::create_dir_all(&self.cache);
|
||||
let _ = std::fs::write(self.cache.join("kotlin-bridge-options.json"), bytes);
|
||||
}
|
||||
*self.options.lock() = options;
|
||||
self.schedule();
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(&self) {
|
||||
self.stop();
|
||||
let worker = self.worker.lock().take();
|
||||
if let Some(worker) = worker {
|
||||
let _ = worker.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn schedule(&self) {
|
||||
if self.lifetime.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
self.revision.lock().change();
|
||||
self.changed.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) fn files_changed<'a>(&self, mut paths: impl Iterator<Item = &'a PathBuf>) {
|
||||
let settings = self.settings.effective();
|
||||
if paths.any(|path| relevant_change(&self.root, path, &settings)) {
|
||||
self.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stop(&self) {
|
||||
self.lifetime.cancel();
|
||||
self.revision.lock().cancel.cancel();
|
||||
}
|
||||
|
||||
async fn replace(&self, projects: &[ProjectLibraries], cancel: &CancellationToken) -> ide_core::IdeResult<()> {
|
||||
let snapshot = serde_json::to_value(projects).map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
|
||||
let response = self.lsp.jdtls_command_cancellable(&self.root, COMMAND, vec![snapshot], cancel).await?;
|
||||
if response.as_u64().is_none() {
|
||||
return Err(ide_core::IdeError::unavailable(
|
||||
"Kotlin bridge command did not acknowledge its library snapshot",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run(self: Arc<Self>) {
|
||||
let mut diagnostics = BTreeSet::new();
|
||||
let mut last_failure = String::new();
|
||||
let mut initialized = false;
|
||||
// Also retain directories from unacknowledged replacements: JDT may have applied them.
|
||||
let mut published = Vec::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = self.lifetime.cancelled() => return,
|
||||
_ = self.changed.notified() => {},
|
||||
}
|
||||
let (generation, cancel) = {
|
||||
let state = self.revision.lock();
|
||||
(state.generation, state.cancel.clone())
|
||||
};
|
||||
if !self.trusted.load(Ordering::Acquire) {
|
||||
continue;
|
||||
}
|
||||
if !debounce(&cancel, &self.lifetime).await {
|
||||
continue;
|
||||
}
|
||||
let root = self.root.clone();
|
||||
let cache = self.cache.clone();
|
||||
let settings = self.settings.effective();
|
||||
let has_sources = tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&root, &cache, &settings);
|
||||
let enabled =
|
||||
model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root);
|
||||
(model, enabled)
|
||||
})
|
||||
.await;
|
||||
let Ok((mut model, enabled)) = has_sources else { continue };
|
||||
if !self.revision.lock().current(generation) {
|
||||
continue;
|
||||
}
|
||||
if !enabled {
|
||||
if self.cache.join("kotlin-bridge").exists()
|
||||
&& let Err(error) = self.replace(&[], &cancel).await
|
||||
{
|
||||
if !cancel.is_cancelled() {
|
||||
tracing::warn!("Kotlin library withdrawal: {error}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for old in published.drain(..) {
|
||||
remove_directory(old).await;
|
||||
}
|
||||
self.clear_diagnostics(&mut diagnostics);
|
||||
last_failure.clear();
|
||||
continue;
|
||||
}
|
||||
if let Some(maven) = &mut model.maven {
|
||||
let options = self.options.lock();
|
||||
for module in &mut maven.projects {
|
||||
if options.offline {
|
||||
module.options.push("-o".into());
|
||||
}
|
||||
if !options.profiles.is_empty() {
|
||||
module.options.push(format!("-P{}", options.profiles.join(",")));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(bundle) = self.bundle.clone() else {
|
||||
self.failure("Kotlin bridge bundle is unavailable".into(), &mut diagnostics, &mut last_failure);
|
||||
continue;
|
||||
};
|
||||
// Clear entries persisted by an earlier incarnation only before our first build.
|
||||
if !initialized {
|
||||
if let Err(error) = self.replace(&[], &cancel).await {
|
||||
if !cancel.is_cancelled() {
|
||||
self.failure(error.message, &mut diagnostics, &mut last_failure);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
initialized = true;
|
||||
}
|
||||
let directory = self.cache.join(format!("kotlin-bridge/{}", uuid::Uuid::new_v4()));
|
||||
let staging = directory.clone();
|
||||
let root = self.root.clone();
|
||||
let token = cancel.clone();
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || kotlin_bridge::build(&root, &model, &bundle, &staging, &token))
|
||||
.await;
|
||||
if !self.revision.lock().current(generation) || self.lifetime.is_cancelled() {
|
||||
remove_directory(directory).await;
|
||||
continue;
|
||||
}
|
||||
match result {
|
||||
Ok(Ok(Outcome { success: true, projects, .. })) => {
|
||||
match self.replace(&projects, &cancel).await {
|
||||
Ok(()) => {
|
||||
for old in published.drain(..) {
|
||||
remove_directory(old).await;
|
||||
}
|
||||
published.push(directory);
|
||||
self.clear_diagnostics(&mut diagnostics);
|
||||
last_failure.clear();
|
||||
}
|
||||
Err(error) => {
|
||||
// Keep both snapshots until an acknowledged replacement.
|
||||
published.push(directory);
|
||||
if !cancel.is_cancelled() {
|
||||
self.failure(error.message, &mut diagnostics, &mut last_failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let text = match other {
|
||||
Ok(Ok(outcome)) => outcome
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.severity == Severity::Error)
|
||||
.map(|m| m.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
Ok(Err(error)) => error.message,
|
||||
Err(error) => error.to_string(),
|
||||
};
|
||||
self.failure(text, &mut diagnostics, &mut last_failure);
|
||||
remove_directory(directory).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_diagnostics(&self, paths: &mut BTreeSet<PathBuf>) {
|
||||
for path in std::mem::take(paths) {
|
||||
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items: Vec::new() });
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(&self, text: String, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
|
||||
let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text };
|
||||
let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API.");
|
||||
self.clear_diagnostics(paths);
|
||||
let path = self.root.join("pom.xml");
|
||||
paths.insert(path.clone());
|
||||
self.sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.into(),
|
||||
source: SOURCE.into(),
|
||||
items: vec![Diagnostic {
|
||||
range: Range { start: Pos { line: 0, col: 0 }, end: Pos { line: 0, col: 0 } },
|
||||
severity: Severity::Error,
|
||||
message: text.clone(),
|
||||
source: Some(SOURCE.into()),
|
||||
server: Some(SOURCE.into()),
|
||||
code: None,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
}],
|
||||
});
|
||||
if previous != &text {
|
||||
self.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Error,
|
||||
title: "Kotlin editor compilation failed".into(),
|
||||
body: Some(text.clone()),
|
||||
});
|
||||
*previous = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool {
|
||||
let input = path.extension().is_some_and(|e| e == "kt")
|
||||
|| path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd")
|
||||
|| path.components().any(|c| c.as_os_str() == ".mvn");
|
||||
let Ok(relative) = path.strip_prefix(root) else {
|
||||
// Content roots outside the project only contribute Kotlin sources.
|
||||
return path.extension().is_some_and(|e| e == "kt");
|
||||
};
|
||||
// Build outputs and generated sources (e.g. KSP's `.kt` under target/) never trigger,
|
||||
// so a build cannot schedule itself.
|
||||
if relative.components().any(|c| {
|
||||
matches!(
|
||||
c.as_os_str().to_str(),
|
||||
Some("target" | "build" | "out" | "bin" | ".git" | "node_modules" | ".openidea")
|
||||
)
|
||||
}) || ide_vfs::ExcludeMatcher::new(&settings.exclude_globs()).is_excluded(relative)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if input {
|
||||
return true;
|
||||
}
|
||||
if path.extension().is_some() {
|
||||
return false;
|
||||
}
|
||||
// Works for deleted directories too; the module POM remains on disk.
|
||||
path.ancestors().take_while(|ancestor| ancestor.starts_with(root)).any(|ancestor| {
|
||||
ancestor.file_name().is_some_and(|name| name == "src")
|
||||
&& ancestor.parent().is_some_and(|module| module.join("pom.xml").is_file())
|
||||
})
|
||||
}
|
||||
|
||||
async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool {
|
||||
tokio::select! {
|
||||
_ = lifetime.cancelled() => false,
|
||||
_ = cancel.cancelled() => false,
|
||||
_ = tokio::time::sleep(DELAY) => true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_directory(path: PathBuf) {
|
||||
let _ = tokio::task::spawn_blocking(move || std::fs::remove_dir_all(path)).await;
|
||||
}
|
||||
|
||||
impl DocumentObserver for KotlinBridge {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
|
||||
self.files_changed(std::iter::once(&path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_package_directory_does_not_trigger() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
|
||||
assert!(!relevant_change(dir.path(), &dir.path().join("target/classes/probe"), &Default::default()));
|
||||
let generated = dir.path().join("target/generated-sources/ksp/main/kotlin/Gen.kt");
|
||||
assert!(!relevant_change(dir.path(), &generated, &Default::default()));
|
||||
assert!(relevant_change(dir.path(), &dir.path().join("src/main/kotlin/A.kt"), &Default::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_source_package_triggers_unless_excluded() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("module")).unwrap();
|
||||
std::fs::write(dir.path().join("module/pom.xml"), "").unwrap();
|
||||
let path = dir.path().join("module/src/main/kotlin/pkg");
|
||||
let mut settings = ide_core::settings::Settings::default();
|
||||
assert!(relevant_change(dir.path(), &path, &settings));
|
||||
settings.project_structure.excluded.push("module/src/main/kotlin".into());
|
||||
assert!(!relevant_change(dir.path(), &path, &settings));
|
||||
assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extensionless_root_file_does_not_trigger() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("pom.xml"), "").unwrap();
|
||||
assert!(!relevant_change(dir.path(), &dir.path().join("LICENSE"), &Default::default()));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn burst_saves_cancel_the_pending_debounce() {
|
||||
let mut state = Revision::default();
|
||||
state.change();
|
||||
let token = state.cancel.clone();
|
||||
let lifetime = CancellationToken::new();
|
||||
let pending_lifetime = lifetime.clone();
|
||||
let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await });
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(DELAY - Duration::from_millis(1)).await;
|
||||
assert!(!waiting.is_finished());
|
||||
for _ in 0..20 {
|
||||
state.change();
|
||||
}
|
||||
assert!(!waiting.await.unwrap());
|
||||
let current = state.generation;
|
||||
assert!(debounce(&state.cancel, &lifetime).await);
|
||||
assert!(state.current(current));
|
||||
lifetime.cancel();
|
||||
assert!(!debounce(&state.cancel, &lifetime).await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_and_cancelled_builds_cannot_publish() {
|
||||
let mut state = Revision::default();
|
||||
state.change();
|
||||
let first = state.generation;
|
||||
let token = state.cancel.clone();
|
||||
state.change();
|
||||
assert!(token.is_cancelled());
|
||||
assert!(!state.current(first));
|
||||
assert!(state.current(state.generation));
|
||||
state.cancel.cancel();
|
||||
assert!(!state.current(state.generation));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod environment;
|
||||
mod formatter;
|
||||
mod java_runtime;
|
||||
mod kotlin_bridge;
|
||||
pub mod notebook;
|
||||
pub mod ops;
|
||||
mod project;
|
||||
@@ -12,6 +14,7 @@ mod proxy;
|
||||
mod requests;
|
||||
pub mod rpc;
|
||||
pub mod shell_env;
|
||||
pub mod trust;
|
||||
|
||||
pub use project::{Project, apply_git_executable, apply_workspace_edit, history_retention};
|
||||
|
||||
@@ -44,6 +47,7 @@ pub struct HostConfig {
|
||||
}
|
||||
|
||||
pub struct Host {
|
||||
pub trust: trust::TrustStore,
|
||||
pub config_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
@@ -67,6 +71,7 @@ pub struct Host {
|
||||
/// One project per window, keyed by window label.
|
||||
projects: RwLock<HashMap<String, Arc<Project>>>,
|
||||
requests: requests::Requests,
|
||||
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
@@ -77,6 +82,7 @@ impl Host {
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
let host = Self {
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
@@ -95,6 +101,7 @@ impl Host {
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -119,6 +126,10 @@ impl Host {
|
||||
.ok_or_else(|| IdeError::not_found("no project is open in this window"))
|
||||
}
|
||||
|
||||
pub fn require_trust(&self, window: &str) -> IdeResult<()> {
|
||||
self.project(window)?.require_trust()
|
||||
}
|
||||
|
||||
/// The window that owns what `window` starts.
|
||||
pub fn owner_window<'a>(&self, window: &'a str) -> &'a str {
|
||||
(self.owner)(window)
|
||||
@@ -139,9 +150,15 @@ impl Host {
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]);
|
||||
self.shutdown_project_workers_blocking(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
);
|
||||
let lsp = project.lsp.clone();
|
||||
self.runtime.spawn(async move { lsp.shutdown_all().await });
|
||||
}
|
||||
@@ -149,7 +166,14 @@ impl Host {
|
||||
|
||||
/// Synchronous project/window destruction must finish owned kernel cleanup,
|
||||
/// not enqueue work which can be lost as the desktop runtime exits.
|
||||
fn shutdown_notebooks_blocking(&self, notebooks: Vec<Arc<notebook::NotebookService>>) {
|
||||
fn shutdown_project_workers_blocking(
|
||||
&self,
|
||||
notebooks: Vec<Arc<notebook::NotebookService>>,
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
) {
|
||||
for bridge in &bridges {
|
||||
bridge.stop();
|
||||
}
|
||||
for service in ¬ebooks {
|
||||
service.stop_admission();
|
||||
}
|
||||
@@ -157,6 +181,9 @@ impl Host {
|
||||
let wait = move || {
|
||||
let worker = std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
@@ -177,7 +204,11 @@ impl Host {
|
||||
|
||||
/// Called before the desktop runtime exits, including update/restart exits.
|
||||
pub fn shutdown_notebooks_on_exit(&self) {
|
||||
self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.clone()).collect());
|
||||
let projects = self.projects();
|
||||
self.shutdown_project_workers_blocking(
|
||||
projects.iter().map(|project| project.notebooks.clone()).collect(),
|
||||
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
|
||||
@@ -188,12 +219,18 @@ impl Host {
|
||||
self.terminals.kill_all();
|
||||
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
|
||||
for project in &projects {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.notebooks.stop_admission();
|
||||
project.agents.shutdown();
|
||||
}
|
||||
// Kernel cleanup owns its interrupt/termination/reaping deadlines. An
|
||||
// outer LSP timeout must never abort it before owned children are reaped.
|
||||
for project in &projects {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
project.notebooks.shutdown().await;
|
||||
}
|
||||
let stop_language_servers = async {
|
||||
@@ -212,7 +249,19 @@ impl Host {
|
||||
self.requests.begin(id)
|
||||
}
|
||||
|
||||
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
|
||||
let token = self.begin_request(id);
|
||||
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
|
||||
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
|
||||
token.cancel();
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
pub fn end_request(&self, id: RequestId) {
|
||||
for ids in self.project_requests.write().values_mut() {
|
||||
ids.retain(|entry| *entry != id);
|
||||
}
|
||||
self.requests.end(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -407,6 +407,24 @@ impl NotebookService {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn restrict(&self) {
|
||||
let documents: Vec<_> = self.registry.lock().documents.values().cloned().collect();
|
||||
for document in &documents {
|
||||
let mut state = document.state.lock();
|
||||
state.generation += 1;
|
||||
if let Some(batch) = &state.active {
|
||||
batch.cancel.cancel();
|
||||
}
|
||||
}
|
||||
for document in documents {
|
||||
let _ = self.kernels.shutdown_kernel(&document.path).await;
|
||||
let batch = document.state.lock().active.clone();
|
||||
if let Err(error) = self.drain(&document, batch).await {
|
||||
tracing::warn!("notebook restriction: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
self.stop_admission();
|
||||
self.kernels.shutdown().await;
|
||||
|
||||
@@ -17,6 +17,7 @@ pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeRe
|
||||
}
|
||||
|
||||
pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
|
||||
host.require_trust(window)?;
|
||||
let mut providers = host.project(window)?.agents.providers();
|
||||
ide_agent::registry::mark_updates(®istry_paths(host), &mut providers);
|
||||
Ok(providers)
|
||||
@@ -88,6 +89,7 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
|
||||
}
|
||||
|
||||
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let root = project.root.clone();
|
||||
let requested = request.project_root.take();
|
||||
@@ -99,6 +101,7 @@ pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> Ide
|
||||
|
||||
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
|
||||
pub async fn prompt(host: &Host, window: &str, task: String, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.prompt(&task, prompt, now).await
|
||||
}
|
||||
|
||||
@@ -109,26 +112,31 @@ pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeRe
|
||||
|
||||
/// Sends a queued message now, interrupting the current turn.
|
||||
pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.send_queued_now(&task, &queued)
|
||||
}
|
||||
|
||||
/// Changes a session setting (model, mode, thinking effort…).
|
||||
pub async fn set_config(host: &Host, window: &str, task: String, option: String, value: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.set_config(&task, &option, value).await
|
||||
}
|
||||
|
||||
/// Copies the task's conversation into a new task, up to its first `turns` turns (all when `None`).
|
||||
pub async fn fork(host: &Host, window: &str, task: String, turns: Option<u32>) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.fork(&task, turns).await
|
||||
}
|
||||
|
||||
/// Drops the task's turns after its first `turns`; returns the message of the first dropped one.
|
||||
pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult<Option<AgentPrompt>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.rollback(&task, turns).await
|
||||
}
|
||||
|
||||
/// What the agent reports about itself when it starts, without opening a session.
|
||||
pub async fn probe(host: &Host, window: &str, provider: String, refresh: bool) -> IdeResult<AgentProbe> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.probe(&provider, refresh).await
|
||||
}
|
||||
|
||||
@@ -139,6 +147,7 @@ pub fn cancel(host: &Host, window: &str, task: String, subagent: Option<String>)
|
||||
|
||||
/// Answers a permission request; `option: None` cancels it.
|
||||
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.respond(&task, permission, option)
|
||||
}
|
||||
|
||||
@@ -151,15 +160,18 @@ pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> {
|
||||
}
|
||||
|
||||
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.delete(&task, remove_worktree).await
|
||||
}
|
||||
|
||||
pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<AgentChange>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.changes(&task).await
|
||||
}
|
||||
|
||||
/// Content of `path` when the task started (left side of its diff).
|
||||
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
|
||||
}
|
||||
|
||||
|
||||
@@ -39,12 +39,13 @@ fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option<
|
||||
let snap = project.documents.text_snapshot(doc).ok()?;
|
||||
return (!snap.large).then_some((snap.language, snap.text));
|
||||
}
|
||||
let loaded = ide_vfs::load_text(path).ok()?;
|
||||
let loaded = project.documents.load_file(path).ok()?;
|
||||
if loaded.binary || loaded.large || loaded.too_large {
|
||||
return None;
|
||||
}
|
||||
let language = languages.detect(path, loaded.text.lines().next());
|
||||
Some((language, loaded.text))
|
||||
let text = ide_text::normalize_line_endings(&loaded.text).into_owned();
|
||||
let language = languages.detect(path, text.lines().next());
|
||||
Some((language, text))
|
||||
}
|
||||
|
||||
// ---- Inspect Code ----
|
||||
|
||||
@@ -76,6 +76,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
|
||||
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
|
||||
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
@@ -93,9 +94,11 @@ pub async fn build_sync(
|
||||
profiles: Vec<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let token = host.begin_request(request);
|
||||
let bridge_profiles = profiles.clone();
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
@@ -135,6 +138,9 @@ pub async fn build_sync(
|
||||
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
|
||||
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
|
||||
// to update the project configurations re-resolves them (#436).
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.configure_maven(offline, bridge_profiles);
|
||||
}
|
||||
let lsp = project.lsp.clone();
|
||||
let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect();
|
||||
tokio::spawn(async move {
|
||||
@@ -223,11 +229,12 @@ pub async fn build_run(
|
||||
clear: Vec<std::path::PathBuf>,
|
||||
on_event: Channel<BuildEvent>,
|
||||
) -> IdeResult<BuildResult> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
|
||||
let settings = project.settings.effective();
|
||||
let config_dir = host.config_dir.clone();
|
||||
let token = host.begin_request(request);
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
for path in clear {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
|
||||
@@ -16,6 +16,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
|
||||
/// How to run `config` with coverage: the command to launch and where its report will be,
|
||||
/// or which tool is missing.
|
||||
pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult<CoveragePrepared> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
|
||||
blocking(move || {
|
||||
@@ -26,7 +27,8 @@ pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> I
|
||||
}
|
||||
|
||||
/// Reads the report a coverage run left behind.
|
||||
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
host.require_trust(window)?;
|
||||
blocking(move || ide_coverage::collect(&report)).await
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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> {
|
||||
host.require_trust(window)?;
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
@@ -22,6 +23,21 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
Ok(project) => project.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
};
|
||||
if config.adapter == "delve" && config.request == "launch" {
|
||||
let root = host.project(window).map(|p| p.root.clone()).unwrap_or_default();
|
||||
let settings = settings.clone();
|
||||
config = host
|
||||
.blocking(move || {
|
||||
ide_sdk::go::debug_environment(&root, &settings, &mut config.env);
|
||||
ide_sdk::go::apply_to_debug(
|
||||
&mut config.env,
|
||||
&mut config.extra_json,
|
||||
&settings.languages.go.build_tags,
|
||||
)?;
|
||||
Ok(config)
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
if config.adapter == ide_dap::kotlin::ADAPTER {
|
||||
let data_dir = host.data_dir.clone();
|
||||
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
@@ -61,6 +77,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
};
|
||||
host.require_trust(window)?;
|
||||
let result = host.debugger.start_with(config, owner, custom).await;
|
||||
flush_output();
|
||||
result
|
||||
@@ -292,11 +309,12 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
|
||||
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
|
||||
pub async fn debug_listen_for_jvm(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
request: ide_core::RequestId,
|
||||
jvm_host: String,
|
||||
port: u16,
|
||||
) -> IdeResult<u16> {
|
||||
host.require_trust(window)?;
|
||||
let token = host.begin_request(request);
|
||||
let result = jdwp::listen_for_jvm(&jvm_host, port, token).await;
|
||||
host.end_request(request);
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport};
|
||||
use ide_ssh::{FsConnection, RemoteEntry};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Reports a transfer as a background task (status bar progress) whose Cancel fires `token`.
|
||||
fn progress(
|
||||
sink: SharedSink,
|
||||
title: &str,
|
||||
token: CancellationToken,
|
||||
) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) {
|
||||
let id = TaskId::next();
|
||||
let cancel = token;
|
||||
let handle = ide_core::task::on_cancel(id, move || cancel.cancel());
|
||||
let title = title.to_owned();
|
||||
let (s, t) = (sink.clone(), title.clone());
|
||||
let report = move |path: &str, i: usize, n: usize| {
|
||||
let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) };
|
||||
let message = Some(path.to_owned());
|
||||
s.emit(IdeEvent::Task { id, title: t.clone(), message, fraction, done: false, cancellable: true });
|
||||
};
|
||||
let done = move || {
|
||||
drop(handle);
|
||||
sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true, cancellable: false })
|
||||
};
|
||||
(report, done)
|
||||
}
|
||||
|
||||
fn local(paths: Vec<IpcPath>) -> Vec<PathBuf> {
|
||||
paths.into_iter().map(|p| p.to_path_buf()).collect()
|
||||
}
|
||||
|
||||
/// Eligible paths per server, without contacting any deployment host.
|
||||
pub async fn deploy_coverage(window: &str, state: &Host, paths: Vec<IpcPath>) -> IdeResult<Vec<DeploymentCoverage>> {
|
||||
let project = state.project(window)?;
|
||||
Ok(ide_remote::coverage(&project.settings.effective(), &project.root, &local(paths)))
|
||||
}
|
||||
|
||||
/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`).
|
||||
pub async fn deploy_upload(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
paths: Vec<IpcPath>,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Uploading", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Download from…: the remote counterparts of files and folders.
|
||||
pub async fn deploy_download(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
paths: Vec<IpcPath>,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Deletes the remote counterparts of files and folders.
|
||||
pub async fn deploy_delete_remote(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
paths: Vec<IpcPath>,
|
||||
server: Option<String>,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await
|
||||
}
|
||||
|
||||
/// Sync with Deployed: local and remote files under `path`, side by side.
|
||||
pub async fn deploy_compare(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
path: IpcPath,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<SyncEntry>> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await;
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Applies the Sync with Deployed choices.
|
||||
pub async fn deploy_sync(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
steps: Vec<SyncStep>,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Synchronizing", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Where `path` is deployed (remote path, web URL); `None` when not mapped.
|
||||
pub async fn deploy_info(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
path: IpcPath,
|
||||
server: Option<String>,
|
||||
) -> IdeResult<Option<DeployedPath>> {
|
||||
let project = state.project(window)?;
|
||||
state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await
|
||||
}
|
||||
|
||||
/// Remote Host tool window: the server root of `server`, resolved on the host.
|
||||
pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult<String> {
|
||||
let settings = match state.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => state.global_settings.effective(),
|
||||
};
|
||||
state.remote.server_root(&settings, &server).await
|
||||
}
|
||||
|
||||
/// Entries of a folder on the selected filesystem (`~`: its home directory).
|
||||
pub async fn remote_list(state: &Host, connection: FsConnection, path: String) -> IdeResult<Vec<RemoteEntry>> {
|
||||
state.remote.list(&connection, &path).await
|
||||
}
|
||||
|
||||
/// Opens a remote file for editing: a local copy, uploaded back on save.
|
||||
pub async fn remote_open(state: &Host, connection: FsConnection, path: String) -> IdeResult<IpcPath> {
|
||||
Ok(state.remote.open_remote(&connection, &path).await?.into())
|
||||
}
|
||||
|
||||
/// The filesystem connection and path a cached editing copy stands for.
|
||||
pub fn remote_of(state: &Host, path: IpcPath) -> Option<(FsConnection, String)> {
|
||||
state.remote.remote_of(&path)
|
||||
}
|
||||
|
||||
pub async fn remote_delete(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.remote_delete(&connection, &path).await
|
||||
}
|
||||
|
||||
pub async fn remote_mkdir(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.remote_mkdir(&connection, &path).await
|
||||
}
|
||||
|
||||
pub async fn remote_rename(state: &Host, connection: FsConnection, from: String, to: String) -> IdeResult<()> {
|
||||
state.remote.remote_rename(&connection, &from, &to).await
|
||||
}
|
||||
|
||||
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.test_connection(&connection, &path).await
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
|
||||
d.add("deploy_coverage", |c| async move { deploy_coverage(&c.window, &c.host, c.arg("paths")?).await });
|
||||
d.add("deploy_upload", |c| async move {
|
||||
deploy_upload(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_download", |c| async move {
|
||||
deploy_download(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_delete_remote", |c| async move {
|
||||
deploy_delete_remote(&c.window, &c.host, c.arg("paths")?, c.arg("server")?).await
|
||||
});
|
||||
d.add("deploy_compare", |c| async move {
|
||||
deploy_compare(&c.window, &c.host, c.arg("path")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_sync", |c| async move {
|
||||
deploy_sync(&c.window, &c.host, c.arg("steps")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_info", |c| async move { deploy_info(&c.window, &c.host, c.arg("path")?, c.arg("server")?).await });
|
||||
d.add("deploy_server_root", |c| async move { deploy_server_root(&c.window, &c.host, c.arg("server")?).await });
|
||||
d.add("remote_list", |c| async move { remote_list(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_open", |c| async move { remote_open(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
|
||||
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_rename", |c| async move {
|
||||
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("deploy_test_connection", |c| async move {
|
||||
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await
|
||||
});
|
||||
}
|
||||
@@ -38,6 +38,18 @@ pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
|
||||
host.debugger.refresh_source_mirrors(host.owner_window(window)).await
|
||||
}
|
||||
|
||||
/// Recovers external changes missed by the project's file watchers.
|
||||
pub async fn sync_from_disk(host: &Host, window: &str) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
if project.settings.effective().files.sync_on_focus {
|
||||
project.documents.sync_from_disk();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.snapshot(doc)).await
|
||||
@@ -45,3 +57,6 @@ pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<Docume
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -30,8 +30,15 @@ pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<
|
||||
}
|
||||
|
||||
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
|
||||
pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult<String> {
|
||||
host.project(window)?.documents.set_encoding(doc, &encoding, reload)
|
||||
pub async fn doc_set_encoding(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
encoding: String,
|
||||
reload: bool,
|
||||
) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await
|
||||
}
|
||||
|
||||
pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> {
|
||||
@@ -117,7 +124,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await });
|
||||
d.add("doc_ui_mode", |c| async move { doc_ui_mode(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("doc_set_encoding", |c| async move {
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?)
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await
|
||||
});
|
||||
d.add("doc_set_line_ending", |c| async move {
|
||||
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
use super::*;
|
||||
use crate::{HostConfig, Project};
|
||||
use ide_core::{IdeEvent, TextChange, event::NotifyLevel};
|
||||
use parking_lot::Mutex;
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
struct Fixture {
|
||||
host: Host,
|
||||
project: Arc<Project>,
|
||||
events: Arc<Mutex<Vec<IdeEvent>>>,
|
||||
path: PathBuf,
|
||||
outside: PathBuf,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
// Keep watcher delivery out of these tests: the op must recover it itself.
|
||||
let excluded = root.join("node_modules");
|
||||
std::fs::create_dir_all(&excluded).unwrap();
|
||||
let path = excluded.join("file.txt");
|
||||
let outside = base.join("outside.txt");
|
||||
std::fs::write(&path, "original\n").unwrap();
|
||||
std::fs::write(&outside, "outside\n").unwrap();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured = events.clone();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| {
|
||||
let events = captured.clone();
|
||||
Arc::new(move |event| events.lock().push(event))
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
Self { _temp: temp, host, project, events, path, outside }
|
||||
}
|
||||
|
||||
async fn open(&self, path: &Path) -> DocumentInfo {
|
||||
open(&self.host, "main", path.to_path_buf()).await.unwrap()
|
||||
}
|
||||
|
||||
async fn sync(&self) {
|
||||
sync_from_disk(&self.host, "main").await.unwrap();
|
||||
}
|
||||
|
||||
fn document_events(&self) -> Vec<IdeEvent> {
|
||||
self.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, IdeEvent::DocPatched { .. } | IdeEvent::DocDirty { .. } | IdeEvent::Notify { .. }))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn changed_clean_document_reloads_once() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
std::fs::write(&f.path, "externally changed\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, "externally changed\n");
|
||||
assert!(!loaded.dirty);
|
||||
assert!(loaded.version > original.version);
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, loaded.version);
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unchanged_document_has_no_patch_or_event() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, original.text);
|
||||
assert_eq!(loaded.version, original.version);
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn dirty_document_keeps_edits_and_warns_once() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
edit(
|
||||
&f.host,
|
||||
"main",
|
||||
original.id,
|
||||
original.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "local ".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
f.events.lock().clear();
|
||||
std::fs::write(&f.path, "external replacement\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, "local original\n");
|
||||
assert!(loaded.dirty);
|
||||
assert!(matches!(f.document_events().as_slice(), [IdeEvent::Notify { level: NotifyLevel::Warning, .. }]));
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn deleted_document_keeps_its_buffer_and_other_documents_still_reload() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
let outside = f.open(&f.outside).await;
|
||||
std::fs::remove_file(&f.path).unwrap();
|
||||
std::fs::write(&f.outside, "outside changed after deletion\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, original.text);
|
||||
assert_eq!(loaded.version, original.version);
|
||||
assert_eq!(f.project.documents.snapshot(outside.id).unwrap().text, "outside changed after deletion\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn outside_project_document_reloads() {
|
||||
let f = Fixture::new();
|
||||
assert!(!f.outside.starts_with(&f.project.root));
|
||||
let original = f.open(&f.outside).await;
|
||||
std::fs::write(&f.outside, "outside changed externally\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "outside changed externally\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn setting_defaults_on_and_disabled_sync_keeps_buffer() {
|
||||
let f = Fixture::new();
|
||||
assert!(f.project.settings.effective().files.sync_on_focus);
|
||||
assert!(serde_json::from_value::<ide_core::settings::FileSettings>(serde_json::json!({})).unwrap().sync_on_focus);
|
||||
let original = f.open(&f.outside).await;
|
||||
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", false.into()).unwrap();
|
||||
std::fs::write(&f.outside, "external change while disabled\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, original.version);
|
||||
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", true.into()).unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change while disabled\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn properties_reload_preserves_native_to_ascii_decoding() {
|
||||
let f = Fixture::new();
|
||||
let path = f.outside.with_extension("properties");
|
||||
std::fs::write(&path, "key=\\u4F60\n").unwrap();
|
||||
let mut defaults = f.project.documents.file_defaults();
|
||||
defaults.native_to_ascii = true;
|
||||
f.project.documents.set_file_defaults(defaults);
|
||||
let original = f.open(&path).await;
|
||||
assert_eq!(original.text, "key=你\n");
|
||||
std::fs::write(&path, "key=\\u4E16\\u754C\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "key=世界\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn binary_and_virtual_documents_are_skipped() {
|
||||
let f = Fixture::new();
|
||||
let path = f.outside.with_extension("bin");
|
||||
std::fs::write(&path, b"\0\0binary payload").unwrap();
|
||||
let binary = f.open(&path).await;
|
||||
assert!(binary.read_only);
|
||||
let virtual_doc =
|
||||
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
|
||||
std::fs::write(&path, "now ordinary text").unwrap();
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(binary.id).unwrap().version, binary.version);
|
||||
assert_eq!(f.project.documents.snapshot(virtual_doc.id).unwrap().text, "virtual");
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn synchronization_only_changes_the_calling_windows_documents() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.outside).await;
|
||||
let other_root = f.outside.parent().unwrap().join("other-project");
|
||||
std::fs::create_dir(&other_root).unwrap();
|
||||
let other = f.host.open_project("other", &other_root).unwrap();
|
||||
let other_doc = open(&f.host, "other", f.outside.clone()).await.unwrap();
|
||||
std::fs::write(&f.outside, "external change in shared file\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change in shared file\n");
|
||||
assert_eq!(other.documents.snapshot(other_doc.id).unwrap().text, other_doc.text);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
/// Re-reads the login shell (and the window's project `remoteEnv` where it applies);
|
||||
/// running terminals and processes keep their environment.
|
||||
pub async fn reload(host: &Arc<Host>, window: &str) -> IdeResult<EnvReloaded> {
|
||||
let root = host.project(window).ok().filter(|p| !p.light).map(|p| p.root.clone());
|
||||
let root = host.project(window).ok().filter(|p| !p.light && p.is_trusted()).map(|p| p.root.clone());
|
||||
let sink = host.window_sink(window);
|
||||
let h = host.clone();
|
||||
host.blocking(move || h.environment.reload(root.as_deref(), &sink)).await
|
||||
|
||||
@@ -1,8 +1,73 @@
|
||||
use super::Channel;
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::{IdeResult, RequestId};
|
||||
use ide_vfs::FileEntry;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn entry_kind(host: &Host, path: PathBuf) -> IdeResult<ide_vfs::EntryKind> {
|
||||
host.blocking(move || {
|
||||
let meta = std::fs::metadata(path)?;
|
||||
Ok(if meta.is_dir() { ide_vfs::EntryKind::Dir } else { ide_vfs::EntryKind::File })
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn compare_directories(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
left: PathBuf,
|
||||
right: PathBuf,
|
||||
request: RequestId,
|
||||
on_progress: Channel<ide_vfs::DirectoryCompareProgress>,
|
||||
) -> IdeResult<Vec<ide_vfs::DirectoryCompareEntry>> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = host
|
||||
.blocking(move || {
|
||||
let last = std::cell::Cell::new(std::time::Instant::now() - std::time::Duration::from_secs(1));
|
||||
ide_vfs::compare_directories(&left, &right, Some(&project.root), &project.exclude_globs(), &cancel, |p| {
|
||||
// Bound IPC traffic even when hashing multi-gigabyte files.
|
||||
if last.get().elapsed() >= std::time::Duration::from_millis(80) || p.total == Some(p.compared) {
|
||||
let _ = on_progress.send(p);
|
||||
last.set(std::time::Instant::now());
|
||||
}
|
||||
})
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn copy_merge(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
source_root: PathBuf,
|
||||
destination_root: PathBuf,
|
||||
path: PathBuf,
|
||||
overwrite: bool,
|
||||
) -> IdeResult<()> {
|
||||
if path.as_os_str().is_empty() || path.components().any(|c| !matches!(c, std::path::Component::Normal(_))) {
|
||||
return Err(ide_core::IdeError::invalid("expected a relative comparison entry path"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let from = source_root.join(&path);
|
||||
let to = destination_root.join(&path);
|
||||
if overwrite {
|
||||
project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
ide_vfs::copy_merge(
|
||||
&from,
|
||||
&to,
|
||||
overwrite,
|
||||
Some(&project.root),
|
||||
&project.exclude_globs(),
|
||||
(&source_root, &destination_root),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Files of a deleted folder snapshotted in Local History.
|
||||
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
|
||||
|
||||
@@ -49,6 +114,10 @@ pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_file_bytes(host: &Host, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
|
||||
host.blocking(move || ide_vfs::create_file_bytes(&path, &data)).await
|
||||
}
|
||||
|
||||
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
|
||||
/// state). Returns whether the file is read-only now.
|
||||
pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) -> IdeResult<bool> {
|
||||
|
||||
+147
-45
@@ -1,11 +1,13 @@
|
||||
//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls).
|
||||
pub(crate) mod cache;
|
||||
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use super::build::load;
|
||||
use ide_build::compile;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
|
||||
use ide_lsp::jdtls::JavaLaunchInfo;
|
||||
use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cancellable with `cancel_request(request)`.
|
||||
@@ -20,7 +22,8 @@ pub async fn java_resolve_launch(
|
||||
build: bool,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = resolve_launch(host, window, module, test, build, &cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
@@ -34,62 +37,123 @@ async fn resolve_launch(
|
||||
build: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let started = std::time::Instant::now();
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
// Let jdtls finish its build before the external compiler writes Kotlin/Scala classes.
|
||||
let mut info = project.lsp.java_launch(&module, test, build, cancel).await?;
|
||||
let built = if build && compile::has_non_java_sources(&module) {
|
||||
let (root, cache, module, settings) =
|
||||
(project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone());
|
||||
let cancel = cancel.clone();
|
||||
let (model, external, delegated) = {
|
||||
let project = project.clone();
|
||||
let settings = settings.clone();
|
||||
let module = module.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &cancel)
|
||||
project.java_launch_snapshots.lock().initialize();
|
||||
let model = load(&project.root, &project.build_cache_dir(), &settings);
|
||||
let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test);
|
||||
let delegated = build
|
||||
&& model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module))
|
||||
&& (non_java || cache::has_record(&project.root, &project.build_cache_dir(), &module));
|
||||
let external = non_java || delegated;
|
||||
(model, external, delegated)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??
|
||||
} else {
|
||||
Vec::new()
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
};
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
if !class_paths.contains(&p) {
|
||||
class_paths.push(p);
|
||||
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?;
|
||||
let lsp_ms = started.elapsed().as_millis();
|
||||
let data_dir = host.data_dir.clone();
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _external_build = external_build;
|
||||
let built = if delegated {
|
||||
let outputs = project.java_launch_snapshots.lock().build(
|
||||
&project.root,
|
||||
&model,
|
||||
&module,
|
||||
test,
|
||||
ide_sdk::apply::maven_local_repository(&project.root, &settings),
|
||||
&cancel,
|
||||
)?;
|
||||
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
|
||||
info.build = Some(JavaBuildStatus::Succeeded);
|
||||
info.errors.clear();
|
||||
info.error_count = 0;
|
||||
copies
|
||||
} else if external {
|
||||
compile::build_for_launch(&project.root, &model, &module, test, &cancel)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
if !class_paths.contains(&p) {
|
||||
class_paths.push(p);
|
||||
}
|
||||
}
|
||||
info.class_paths = class_paths;
|
||||
let compile_ms = started.elapsed().as_millis() - lsp_ms;
|
||||
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
|
||||
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
|
||||
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
|
||||
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
|
||||
let release = ide_sdk::java::project_java_version(&module);
|
||||
let jdks = ide_sdk::java::installed_jdks();
|
||||
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
|
||||
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
|
||||
});
|
||||
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
|
||||
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
|
||||
let shorten =
|
||||
ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
|
||||
info.shorten_command_line = Some(shorten.as_str().to_owned());
|
||||
if info.error_count > 0 {
|
||||
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
|
||||
let managed = ide_tools::managed_bundle(&data_dir, "lombok");
|
||||
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
|
||||
let root = project.root.clone();
|
||||
// Detection may read the project's sources.
|
||||
let lombok = ide_sdk::lombok::lombok_agent(&root, own, managed, repo);
|
||||
info.hints.extend(lombok.problem());
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
tracing::info!(
|
||||
lsp_ms,
|
||||
compile_ms,
|
||||
jdk_ms = started.elapsed().as_millis() - lsp_ms - compile_ms,
|
||||
total_ms = started.elapsed().as_millis(),
|
||||
"Java launch resolved"
|
||||
);
|
||||
Ok(info)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
|
||||
let mut copies = Vec::new();
|
||||
for (index, source) in built.iter().enumerate() {
|
||||
let dest = snapshot.join(index.to_string());
|
||||
let original = source.to_string_lossy();
|
||||
info.class_paths.retain(|p| p != original.as_ref());
|
||||
let mut modular = false;
|
||||
for path in &mut info.module_paths {
|
||||
if path == original.as_ref() {
|
||||
*path = dest.to_string_lossy().into_owned();
|
||||
modular = true;
|
||||
}
|
||||
}
|
||||
if !modular {
|
||||
copies.push(dest);
|
||||
}
|
||||
}
|
||||
info.class_paths = class_paths;
|
||||
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
|
||||
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
|
||||
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
|
||||
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
|
||||
let release = ide_sdk::java::project_java_version(&module);
|
||||
let jdks = ide_sdk::java::installed_jdks();
|
||||
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
|
||||
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
|
||||
});
|
||||
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
|
||||
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
|
||||
let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
|
||||
info.shorten_command_line = Some(shorten.as_str().to_owned());
|
||||
if info.error_count > 0 {
|
||||
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
|
||||
let managed = ide_tools::managed_bundle(&host.data_dir, "lombok");
|
||||
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
|
||||
let root = project.root.clone();
|
||||
// Detection may read the project's sources.
|
||||
let lombok = tokio::task::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
info.hints.extend(lombok.problem());
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(info)
|
||||
copies
|
||||
}
|
||||
|
||||
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
|
||||
pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult<u16> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_debug_server(&module).await
|
||||
@@ -101,6 +165,7 @@ pub async fn java_build_workspace(
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_build(&module, &CancellationToken::new()).await
|
||||
@@ -115,3 +180,40 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let app = root.path().join("app/target/classes");
|
||||
let lib = root.path().join("lib/target/classes");
|
||||
for dir in [&app, &lib] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("Channel.class"), "Maven bytecode").unwrap();
|
||||
}
|
||||
let mut info = JavaLaunchInfo {
|
||||
class_paths: vec![app.to_string_lossy().into_owned(), "stdlib.jar".into()],
|
||||
module_paths: vec![lib.to_string_lossy().into_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
let snapshot = tempfile::tempdir().unwrap();
|
||||
for (i, source) in [&app, &lib].iter().enumerate() {
|
||||
ide_vfs::copy(source, &snapshot.path().join(i.to_string())).unwrap();
|
||||
}
|
||||
let paths = use_snapshot(&mut info, &[app.clone(), lib.clone()], snapshot.path());
|
||||
assert_eq!(paths, [snapshot.path().join("0")]);
|
||||
assert_eq!(info.class_paths, ["stdlib.jar"]);
|
||||
assert_eq!(info.module_paths, [snapshot.path().join("1").to_string_lossy().into_owned()]);
|
||||
std::fs::write(app.join("Channel.class"), "JDT error stub").unwrap();
|
||||
std::fs::remove_dir_all(lib).unwrap();
|
||||
for index in ["0", "1"] {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(snapshot.path().join(index).join("Channel.class")).unwrap(),
|
||||
"Maven bytecode"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Immutable Maven outputs retained in the project cache, including across close/reopen.
|
||||
use ide_build::compile::{self, LaunchFingerprint};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct Outputs {
|
||||
fingerprint: Option<LaunchFingerprint>,
|
||||
pub snapshot: PathBuf,
|
||||
pub originals: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Outputs {
|
||||
fn reusable(&self, fingerprint: &Option<LaunchFingerprint>, snapshots: &Path) -> bool {
|
||||
fingerprint.is_some()
|
||||
&& self.fingerprint == *fingerprint
|
||||
&& self.snapshot.parent() == Some(snapshots)
|
||||
&& !self.originals.is_empty()
|
||||
&& (0..self.originals.len()).all(|i| self.snapshot.join(i.to_string()).is_dir())
|
||||
}
|
||||
}
|
||||
|
||||
fn record_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
|
||||
Some(cache.join("java-launch/records").join(module.strip_prefix(root).ok()?).join(if test {
|
||||
"test.json"
|
||||
} else {
|
||||
"main.json"
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
|
||||
[false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file()))
|
||||
}
|
||||
|
||||
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
|
||||
pub(crate) struct Session {
|
||||
cache: PathBuf,
|
||||
initialized: bool,
|
||||
unreferenced: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) fn new(cache: PathBuf) -> Self {
|
||||
Self { cache, initialized: false, unreferenced: Vec::new() }
|
||||
}
|
||||
|
||||
pub(super) fn initialize(&mut self) {
|
||||
if !self.initialized {
|
||||
self.initialized = true;
|
||||
if let Err(error) = sweep(&self.cache) {
|
||||
tracing::warn!(%error, "Cannot clean previous Java launch snapshots");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
model: &ide_build::BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outputs> {
|
||||
self.initialize();
|
||||
let cache = &self.cache;
|
||||
let record =
|
||||
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
|
||||
let snapshots = cache.join("java-launch/snapshots");
|
||||
let fingerprint = || match compile::launch_fingerprint(root, model, module, test, local_repo.clone(), cancel) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(e) if cancel.is_cancelled() => Err(e),
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "Cannot fingerprint Maven launch; rebuilding");
|
||||
Ok(None)
|
||||
}
|
||||
};
|
||||
let before = fingerprint()?;
|
||||
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
|
||||
if let Some(outputs) = previous.as_ref()
|
||||
&& outputs.reusable(&before, &snapshots)
|
||||
{
|
||||
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
|
||||
return Ok(previous.unwrap());
|
||||
}
|
||||
// Retain a non-reusable record even on failure, so removing the final mixed
|
||||
// source still delegates to Maven to clean its old classes on the next launch.
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
fs::write(&record, b"null")?;
|
||||
if let Some(previous) = previous {
|
||||
self.unreferenced.push(previous.snapshot);
|
||||
}
|
||||
let originals = compile::build_for_launch(root, model, module, test, cancel)?;
|
||||
fs::create_dir_all(&snapshots)?;
|
||||
let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep();
|
||||
// Retain every new snapshot even if copying or publishing its record fails.
|
||||
self.unreferenced.push(snapshot.clone());
|
||||
for (i, source) in originals.iter().enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
ide_vfs::copy(source, &snapshot.join(i.to_string()))?;
|
||||
}
|
||||
let after = fingerprint()?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut outputs = Outputs { fingerprint: None, snapshot, originals };
|
||||
if before.is_some() && before == after {
|
||||
outputs.fingerprint = before;
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
|
||||
serde_json::to_writer(file.as_file_mut(), &outputs).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
file.persist(&record).map_err(|e| IdeError::from(e.error))?;
|
||||
self.unreferenced.retain(|path| path != &outputs.snapshot);
|
||||
}
|
||||
Ok(outputs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
// Recheck all records: another module or launch mode may reference a path.
|
||||
if let Ok(referenced) = referenced_snapshots(&self.cache) {
|
||||
let snapshots = self.cache.join("java-launch/snapshots");
|
||||
for path in &self.unreferenced {
|
||||
remove_unreferenced(path, &snapshots, &referenced);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn referenced_snapshots(cache: &Path) -> std::io::Result<HashSet<PathBuf>> {
|
||||
let mut referenced = HashSet::new();
|
||||
let records = cache.join("java-launch/records");
|
||||
if !records.try_exists()? {
|
||||
return Ok(referenced);
|
||||
}
|
||||
let mut pending = vec![records];
|
||||
while let Some(dir) = pending.pop() {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_dir() {
|
||||
pending.push(entry.path());
|
||||
} else if kind.is_file() && entry.path().extension().is_some_and(|ext| ext == "json") {
|
||||
let outputs: Option<Outputs> = serde_json::from_slice(&fs::read(entry.path())?)?;
|
||||
if let Some(outputs) = outputs
|
||||
&& let Ok(path) = fs::canonicalize(outputs.snapshot)
|
||||
{
|
||||
referenced.insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(referenced)
|
||||
}
|
||||
|
||||
fn remove_unreferenced(path: &Path, snapshots: &Path, referenced: &HashSet<PathBuf>) {
|
||||
// Only real launch-* directories directly inside snapshots, never symlinks.
|
||||
if path.parent() != Some(snapshots)
|
||||
|| !path.file_name().is_some_and(|name| name.to_string_lossy().starts_with("launch-"))
|
||||
|| !fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_dir())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let (Ok(path), Ok(snapshots)) = (fs::canonicalize(path), fs::canonicalize(snapshots))
|
||||
&& path.parent() == Some(snapshots.as_path())
|
||||
&& !referenced.contains(&path)
|
||||
&& let Err(error) = fs::remove_dir_all(&path)
|
||||
{
|
||||
tracing::warn!(%error, path = %path.display(), "Cannot remove Java launch snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
fn sweep(cache: &Path) -> std::io::Result<()> {
|
||||
let referenced = referenced_snapshots(cache)?;
|
||||
let snapshots = cache.join("java-launch/snapshots");
|
||||
if snapshots.try_exists()? {
|
||||
for entry in fs::read_dir(&snapshots)? {
|
||||
remove_unreferenced(&entry?.path(), &snapshots, &referenced);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn fixture(root: &Path) -> ide_build::BuildModel {
|
||||
fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let script = root.join("mvnw");
|
||||
fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
|
||||
ide_build::load(root, &root.join("cache"), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let mut build = || session.build(root, &model, root, false, None, &cancel);
|
||||
let first = build().unwrap();
|
||||
let snapshot = first.snapshot.clone();
|
||||
drop(first);
|
||||
// Dropping an Outputs value leaves the persisted snapshot reusable.
|
||||
let second = build().unwrap();
|
||||
assert_eq!(snapshot, second.snapshot);
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1);
|
||||
fs::write(root.join("target/classes/Main.class"), "JDT stub").unwrap();
|
||||
assert_eq!(snapshot, build().unwrap().snapshot);
|
||||
assert_eq!(fs::read_to_string(snapshot.join("0/Main.class")).unwrap(), "first");
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
|
||||
let updated = build().unwrap();
|
||||
assert_ne!(snapshot, updated.snapshot);
|
||||
assert_eq!(fs::read_to_string(updated.snapshot.join("0/Main.class")).unwrap(), "updated");
|
||||
assert!(snapshot.join("0/Main.class").is_file(), "previous JVMs may still load it");
|
||||
fs::remove_dir_all(updated.snapshot.join("0")).unwrap();
|
||||
let rebuilt = build().unwrap();
|
||||
assert_ne!(rebuilt.snapshot, updated.snapshot);
|
||||
fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap();
|
||||
assert!(build().is_err());
|
||||
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
|
||||
fs::remove_file(root.join("src/main/kotlin/broken")).unwrap();
|
||||
assert_ne!(rebuilt.snapshot, build().unwrap().snapshot, "a failed build invalidates success");
|
||||
cancel.cancel();
|
||||
assert!(build().is_err());
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5);
|
||||
}
|
||||
#[test]
|
||||
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
|
||||
let second = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(second.snapshot.is_dir());
|
||||
// Another mode can keep a superseded snapshot alive.
|
||||
let test_record = cache.join("java-launch/records/nested/test.json");
|
||||
fs::create_dir_all(test_record.parent().unwrap()).unwrap();
|
||||
fs::write(&test_record, serde_json::to_vec(&first).unwrap()).unwrap();
|
||||
drop(session);
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(second.snapshot.is_dir());
|
||||
let mut session = Session::new(cache.clone());
|
||||
let reused = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_eq!(reused.snapshot, second.snapshot);
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap();
|
||||
let third = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert!(second.snapshot.is_dir());
|
||||
drop(session);
|
||||
assert!(!second.snapshot.exists());
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(third.snapshot.is_dir());
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_sweep_only_removes_unreferenced_direct_snapshot_directories() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = dir.path().join("cache");
|
||||
let snapshots = cache.join("java-launch/snapshots");
|
||||
let retained = snapshots.join("launch-retained");
|
||||
let orphan = snapshots.join("launch-orphan");
|
||||
let other = snapshots.join("other");
|
||||
let nested = other.join("launch-nested");
|
||||
let outside = dir.path().join("launch-outside");
|
||||
for path in [&retained, &orphan, &nested, &outside] {
|
||||
fs::create_dir_all(path).unwrap();
|
||||
}
|
||||
fs::write(outside.join("classes"), "keep").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, snapshots.join("launch-link")).unwrap();
|
||||
fs::write(snapshots.join("launch-file"), "keep").unwrap();
|
||||
let record = cache.join("java-launch/records/deep/module/test.json");
|
||||
fs::create_dir_all(record.parent().unwrap()).unwrap();
|
||||
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() };
|
||||
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
|
||||
let mut session = Session::new(cache);
|
||||
session.initialize();
|
||||
assert!(!orphan.exists());
|
||||
assert!(retained.is_dir());
|
||||
assert!(nested.is_dir());
|
||||
assert!(outside.join("classes").is_file());
|
||||
assert!(snapshots.join("launch-link").is_symlink());
|
||||
assert!(snapshots.join("launch-file").is_file());
|
||||
// Initialization must not sweep snapshots still in use by this session.
|
||||
fs::create_dir_all(&orphan).unwrap();
|
||||
session.initialize();
|
||||
assert!(orphan.is_dir());
|
||||
session.unreferenced.extend([outside.clone(), snapshots.join("../../launch-outside"), nested]);
|
||||
drop(session);
|
||||
assert!(outside.join("classes").is_file());
|
||||
assert!(other.join("launch-nested").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_record_publish_retains_snapshot_until_session_drop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
let mut text = fs::read_to_string(&script).unwrap();
|
||||
text.push_str("rm cache/java-launch/records/main.json\nmkdir cache/java-launch/records/main.json\n");
|
||||
fs::write(script, text).unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut session = Session::new(cache.clone());
|
||||
assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err());
|
||||
assert_eq!(session.unreferenced.len(), 1);
|
||||
let snapshot = session.unreferenced[0].clone();
|
||||
assert!(snapshot.join("0/Main.class").is_file());
|
||||
drop(session);
|
||||
assert!(!snapshot.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_during_build_do_not_publish_a_success_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
let mut text = fs::read_to_string(&script).unwrap();
|
||||
text.push_str("echo edited-during-build >> src/main/kotlin/Main.kt\n");
|
||||
fs::write(script, text).unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut session = Session::new(cache.clone());
|
||||
let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap();
|
||||
assert!(outputs.fingerprint.is_none());
|
||||
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
|
||||
assert!(outputs.snapshot.join("0/Main.class").is_file());
|
||||
drop(session);
|
||||
assert!(!outputs.snapshot.exists());
|
||||
}
|
||||
#[test]
|
||||
fn deleting_the_last_mixed_source_keeps_maven_delegation_after_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap();
|
||||
assert!(!compile::launch_has_non_java_sources(root, &model, root, false));
|
||||
assert!(has_record(root, &cache, root));
|
||||
assert!(session.build(root, &model, root, false, None, &cancel).is_err());
|
||||
assert!(has_record(root, &cache, root));
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap();
|
||||
let restored = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_ne!(first.snapshot, restored.snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Bounded, read-only JVM presentation for visible files, also served remotely.
|
||||
use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId};
|
||||
use ide_syntax::{JvmPresentation, Snapshot};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::Read,
|
||||
path::PathBuf,
|
||||
sync::{Mutex, OnceLock},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
pub const PREFIX_BYTES: u64 = 64 * 1024;
|
||||
const MAX_BATCH: usize = 64;
|
||||
const MAX_CACHE: usize = 4096;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JvmFilePresentation {
|
||||
pub path: PathBuf,
|
||||
pub presentation: JvmPresentation,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Cached {
|
||||
modified: SystemTime,
|
||||
size: u64,
|
||||
presentation: JvmPresentation,
|
||||
}
|
||||
|
||||
fn prefix(path: &PathBuf) -> std::io::Result<String> {
|
||||
let mut bytes = Vec::new();
|
||||
File::open(path)?.take(PREFIX_BYTES).read_to_end(&mut bytes)?;
|
||||
// A truncated final UTF-8 character must not invalidate the entire prefix.
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
pub async fn jvm_file_presentations(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
) -> IdeResult<Vec<JvmFilePresentation>> {
|
||||
if paths.len() > MAX_BATCH {
|
||||
return Err(IdeError::invalid("at most 64 JVM files per batch"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
static CACHE: OnceLock<Mutex<HashMap<PathBuf, Cached>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(Default::default);
|
||||
let mut results = Vec::new();
|
||||
for path in paths {
|
||||
let language = match path.extension().and_then(|e| e.to_str()) {
|
||||
Some("java") => LanguageId::new("java"),
|
||||
Some("kt") => LanguageId::new("kotlin"),
|
||||
_ => continue,
|
||||
};
|
||||
let Ok(meta) = path.metadata() else { continue };
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(modified) = meta.modified() else { continue };
|
||||
let hit =
|
||||
cache.lock().unwrap().get(&path).filter(|c| c.modified == modified && c.size == meta.len()).cloned();
|
||||
let presentation = if let Some(hit) = hit {
|
||||
hit.presentation
|
||||
} else {
|
||||
let Ok(source) = prefix(&path) else { continue };
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
|
||||
let presentation = project.structure.jvm_presentation(
|
||||
&Snapshot::text(&language, &source),
|
||||
stem,
|
||||
&CancellationToken::new(),
|
||||
)?;
|
||||
let mut cache = cache.lock().unwrap();
|
||||
if cache.len() >= MAX_CACHE {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(path.clone(), Cached { modified, size: meta.len(), presentation: presentation.clone() });
|
||||
presentation
|
||||
};
|
||||
results.push(JvmFilePresentation { path, presentation });
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn huge_files_read_only_a_prefix() {
|
||||
let path = std::env::temp_dir().join(format!("openidea-icon-prefix-{}.java", std::process::id()));
|
||||
let file = File::create(&path).unwrap();
|
||||
file.set_len(100 * 1024 * 1024).unwrap();
|
||||
assert_eq!(prefix(&path).unwrap().len(), PREFIX_BYTES as usize);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -83,12 +83,18 @@ pub async fn lsp_implementations(
|
||||
result
|
||||
}
|
||||
|
||||
/// Contents of a virtual document (library class, decompiled code) that a
|
||||
/// language server returned a non-`file:` URI for. Cached per project.
|
||||
/// Contents of a read-only language-server document, including JDT library
|
||||
/// classes and disk class files. Cached per project.
|
||||
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
|
||||
host.project(window)?.lsp.virtual_document(&uri).await
|
||||
}
|
||||
|
||||
/// 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);
|
||||
host.project(window)?.lsp.java_class_file(&path, archive).await
|
||||
}
|
||||
|
||||
/// References of the symbol at `pos`. `project_wide` also searches project files
|
||||
/// outside the language server's analysis scope; bulk scans over many symbols
|
||||
/// leave it off. Cancel with `cancel_request(request)`.
|
||||
@@ -208,7 +214,7 @@ pub async fn lsp_format(
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
host.project(window)?.lsp.format(doc, range, options).await
|
||||
crate::formatter::format(host, window, doc, range, options).await
|
||||
}
|
||||
|
||||
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
|
||||
@@ -263,7 +269,13 @@ pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> {
|
||||
}
|
||||
|
||||
pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> {
|
||||
host.project(window)?.lsp.restart(server).await
|
||||
let project = host.project(window)?;
|
||||
let java = project.lsp.servers().iter().any(|s| s.id == server && s.name == "jdtls");
|
||||
project.lsp.restart(server).await?;
|
||||
if java && let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.schedule();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
|
||||
@@ -290,6 +302,58 @@ pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command:
|
||||
host.project(window)?.lsp.execute_command(doc, command).await
|
||||
}
|
||||
|
||||
/// Resolves an interactive command without applying edits or executing it.
|
||||
pub async fn lsp_resolve_command(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
server: ServerId,
|
||||
command: Command,
|
||||
answers: Vec<FormAnswer>,
|
||||
) -> IdeResult<Option<CommandForm>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.resolve_command(doc, server, command, answers, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Applies an explicitly invoked action after the interactive answers have been validated.
|
||||
pub async fn lsp_execute_code_action(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
action: CodeAction,
|
||||
answers: Vec<FormAnswer>,
|
||||
) -> IdeResult<Vec<ide_core::IpcPath>> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = async {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
if let Some(edit) = action.edit {
|
||||
let documents = project.documents.clone();
|
||||
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))??;
|
||||
}
|
||||
match action.command {
|
||||
Some(command) => project
|
||||
.lsp
|
||||
.execute_action_command(doc, action.server, command, answers, cancel)
|
||||
.await
|
||||
.map(|paths| paths.into_iter().map(ide_core::IpcPath::from).collect()),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_completion", |c| async move {
|
||||
@@ -315,6 +379,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_implementations", |c| async move {
|
||||
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
|
||||
});
|
||||
d.add(
|
||||
"lsp_resolve_class_file",
|
||||
|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_references", |c| async move {
|
||||
lsp_references(
|
||||
@@ -374,6 +442,29 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
|
||||
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
|
||||
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) });
|
||||
d.add("lsp_resolve_command", |c| async move {
|
||||
lsp_resolve_command(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("request")?,
|
||||
c.arg("doc")?,
|
||||
c.arg("server")?,
|
||||
c.arg("command")?,
|
||||
c.arg("answers")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_execute_code_action", |c| async move {
|
||||
lsp_execute_code_action(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("request")?,
|
||||
c.arg("doc")?,
|
||||
c.arg("action")?,
|
||||
c.arg("answers")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_execute_command", |c| async move {
|
||||
lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await
|
||||
});
|
||||
|
||||
@@ -8,11 +8,13 @@ pub mod build;
|
||||
pub mod bytes;
|
||||
pub mod coverage;
|
||||
pub mod debug;
|
||||
pub mod deploy;
|
||||
pub mod doc;
|
||||
pub mod env;
|
||||
pub mod fs;
|
||||
pub mod history;
|
||||
pub mod java;
|
||||
pub mod jvm_icons;
|
||||
pub mod lsp;
|
||||
pub mod nav;
|
||||
pub mod notebook;
|
||||
@@ -80,6 +82,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
search::register(d);
|
||||
run::register(d);
|
||||
env::register(d);
|
||||
deploy::register(d);
|
||||
}
|
||||
|
||||
impl Host {
|
||||
|
||||
@@ -54,6 +54,7 @@ pub async fn reload(host: &Host, window: &str, id: String) -> IdeResult<Notebook
|
||||
}
|
||||
|
||||
pub async fn restart(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
host.require_trust(window)?;
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
tokio::spawn(async move { notebooks.reload(&id, true).await })
|
||||
.await
|
||||
@@ -84,8 +85,9 @@ pub async fn execute(
|
||||
request: RequestId,
|
||||
on_output: Channel<NotebookOutputEvent>,
|
||||
) -> IdeResult<NotebookSnapshot> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let _request = RequestGuard { host, request, cancel: cancel.clone() };
|
||||
// Reserve the WHOLE batch and capture source before asynchronous discovery.
|
||||
let admission = project.notebooks.admit(&id, cells, cancel.clone())?;
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::sync::Arc;
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectInfo {
|
||||
pub trusted: bool,
|
||||
pub root: ide_core::IpcPath,
|
||||
pub name: String,
|
||||
pub is_git: bool,
|
||||
@@ -22,6 +23,7 @@ pub fn info(project: &Project) -> ProjectInfo {
|
||||
let settings = project.settings.effective();
|
||||
let custom = settings.general.project_name.trim();
|
||||
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 {
|
||||
@@ -146,6 +148,14 @@ pub fn set_settings(
|
||||
}
|
||||
}
|
||||
let affected = || if project_scope { current.iter().cloned().collect() } else { host.projects() };
|
||||
if key == "editor" || key == "editor.encodings" || key == "editor.encodings.nativeToAscii" {
|
||||
let affected = affected();
|
||||
host.runtime.spawn_blocking(move || {
|
||||
for project in affected {
|
||||
project.documents.reload_properties();
|
||||
}
|
||||
});
|
||||
}
|
||||
// File type associations re-detect open documents (off the command thread).
|
||||
if key == "files" || key.starts_with("files.fileType") {
|
||||
let affected = affected();
|
||||
@@ -162,8 +172,16 @@ pub fn set_settings(
|
||||
host.apply_proxy();
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
if ["languages", "tools", "languageServers"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) {
|
||||
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
|
||||
if code_style || toolchain {
|
||||
for project in affected() {
|
||||
if toolchain && let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.schedule();
|
||||
}
|
||||
let lsp = project.lsp.clone();
|
||||
host.runtime.spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::time::Duration;
|
||||
|
||||
/// Detected configs followed by user-defined ones (user ids win on conflict).
|
||||
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let root = project.root.clone();
|
||||
let user = ide_run::load_user_configs(&root)?;
|
||||
@@ -21,6 +22,11 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
let key = crate::project::project_key(&member);
|
||||
let name = member.file_name().unwrap_or_default().to_string_lossy();
|
||||
for mut config in configs {
|
||||
for task in &mut config.extras.before_launch {
|
||||
if task.kind == "run" {
|
||||
task.target = format!("workspace:{}:{}", key, task.target);
|
||||
}
|
||||
}
|
||||
config.id = format!("workspace:{}:{}", key, config.id);
|
||||
config.name = format!("{name} / {}", config.name);
|
||||
config.cwd = Some(config.cwd.map(|p| member.join(&*p)).unwrap_or_else(|| member.clone()).into());
|
||||
@@ -65,6 +71,7 @@ pub fn save_configs(
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
@@ -97,6 +104,10 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
} else {
|
||||
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
|
||||
}
|
||||
if config.kind == "go" {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags);
|
||||
}
|
||||
if config.command == ide_sdk::godot::COMMAND {
|
||||
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
|
||||
}
|
||||
@@ -112,12 +123,33 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
}
|
||||
if config.kind == "go" && config.extras.params.get("scope").is_some_and(|s| s == "file") {
|
||||
let root = project.root.clone();
|
||||
config = host
|
||||
.blocking(move || {
|
||||
ide_run::go::prepare_test_file(&root, &mut config)?;
|
||||
Ok(config)
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
let encoding = settings.console.encoding;
|
||||
let options = StartOptions {
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) },
|
||||
};
|
||||
host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c)))
|
||||
project.require_trust()?;
|
||||
let mut sink = project.sink.clone();
|
||||
if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) {
|
||||
let previous = sink;
|
||||
let root = project.root.clone();
|
||||
sink = std::sync::Arc::new(move |event: ide_core::IdeEvent| {
|
||||
if matches!(&event, ide_core::IdeEvent::RunExited { .. }) {
|
||||
ide_sdk::libraries::invalidate_go(&root);
|
||||
}
|
||||
previous.emit(event);
|
||||
});
|
||||
}
|
||||
host.processes.start(&project.root, &config, &options, sink, Box::new(move |c| on_output(c)))
|
||||
}
|
||||
|
||||
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
|
||||
|
||||
@@ -5,6 +5,78 @@ use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_run::RunConfig;
|
||||
|
||||
fn application_cache(project: &crate::project::Project, id: &str) -> std::path::PathBuf {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
id.hash(&mut hash);
|
||||
project.cache_dir.join("application-update").join(format!("{:x}", hash.finish()))
|
||||
}
|
||||
|
||||
pub async fn run_application_build(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
config: RunConfig,
|
||||
module: std::path::PathBuf,
|
||||
) -> IdeResult<RunConfig> {
|
||||
host.require_trust(window)?;
|
||||
if config.kind != "spring-boot" || config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
|
||||
return Err(IdeError::invalid("Application resource updates require a local Spring Boot configuration"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective());
|
||||
let plan = ide_build::application_update::plan(
|
||||
&project.root,
|
||||
&model,
|
||||
&module,
|
||||
&application_cache(&project, &config.id),
|
||||
)?;
|
||||
let mut env = plan.env;
|
||||
env.extend(config.env);
|
||||
Ok(RunConfig {
|
||||
id: format!("application-build:{}", config.id),
|
||||
name: format!("Update {}", config.name),
|
||||
kind: plan.kind.into(),
|
||||
command: plan.command,
|
||||
args: plan.args,
|
||||
cwd: Some(project.root.join(plan.cwd).into()),
|
||||
env,
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: ide_run::RunExtras {
|
||||
include_system_env: config.extras.include_system_env,
|
||||
env_files: config.extras.env_files,
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn run_application_publish(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
config: RunConfig,
|
||||
class_paths: Vec<String>,
|
||||
main_class: String,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_build::application_update::publish(
|
||||
&project.root,
|
||||
&application_cache(&project, &config.id),
|
||||
&class_paths,
|
||||
&main_class,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Configuration templates: per-type defaults applied to new configurations.
|
||||
pub fn run_templates(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
@@ -50,6 +122,7 @@ pub async fn run_test_reports(
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResult<ide_run::tomcat::TomcatHome> {
|
||||
host.require_trust(window)?;
|
||||
let root = host.project(window)?.root.clone();
|
||||
tokio::task::spawn_blocking(move || ide_run::tomcat::validate_home(&root.join(home)))
|
||||
.await
|
||||
@@ -57,6 +130,7 @@ pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResu
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<ide_build::web::WebArtifact>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective());
|
||||
@@ -67,6 +141,7 @@ pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<id
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> IdeResult<Option<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
if config.extras.params.get("deployment").is_some_and(|v| v != "artifact") {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -81,22 +156,21 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| artifacts.first().map(|a| &a.id))
|
||||
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
|
||||
let plan = ide_build::web::build(
|
||||
let mut plan = ide_build::web::build(
|
||||
&project.root,
|
||||
&model,
|
||||
id,
|
||||
&ide_run::tomcat::base_dir(&project.cache_dir, &config.id),
|
||||
)?;
|
||||
let mut env = plan.env;
|
||||
env.extend(config.env.clone());
|
||||
Ok(Some(RunConfig {
|
||||
plan.env.extend(config.env.clone());
|
||||
let mut build = RunConfig {
|
||||
id: format!("tomcat-build:{}", config.id),
|
||||
name: format!("Build {}", config.name),
|
||||
kind: if id.starts_with("maven:") { "maven" } else { "gradle" }.into(),
|
||||
command: plan.command,
|
||||
args: plan.args,
|
||||
cwd: Some(std::path::PathBuf::from(plan.cwd).into()),
|
||||
env,
|
||||
command: plan.command.clone(),
|
||||
args: vec![],
|
||||
cwd: Some(std::path::PathBuf::from(&plan.cwd).into()),
|
||||
env: plan.env.clone(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
@@ -106,13 +180,21 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
|
||||
env_files: config.extras.env_files,
|
||||
..Default::default()
|
||||
},
|
||||
}))
|
||||
};
|
||||
plan.resolve_project(
|
||||
&project.root,
|
||||
&ide_run::resolved_env(&project.root, &build)?,
|
||||
&ide_core::CancellationToken::new(),
|
||||
)?;
|
||||
build.args = plan.args;
|
||||
Ok(Some(build))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mode: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if !host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
|
||||
return Err(IdeError::invalid("Start this Tomcat configuration before updating it"));
|
||||
@@ -124,6 +206,12 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_application_build", |c| async move {
|
||||
run_application_build(&c.host, &c.window, c.arg("config")?, c.arg("module")?).await
|
||||
});
|
||||
d.add("run_application_publish", |c| async move {
|
||||
run_application_publish(&c.host, &c.window, c.arg("config")?, c.arg("classPaths")?, c.arg("mainClass")?).await
|
||||
});
|
||||
d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await });
|
||||
d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await });
|
||||
d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await });
|
||||
|
||||
@@ -22,12 +22,14 @@ pub struct LocatedTools {
|
||||
|
||||
/// Installed interpreters / toolchains of `kind`; project-local ones first.
|
||||
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
|
||||
host.require_trust(window)?;
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The interpreter the project uses: the configured one, else its virtualenv.
|
||||
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
@@ -35,6 +37,7 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -56,11 +59,12 @@ pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<i
|
||||
|
||||
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
|
||||
pub async fn sdk_locate_tools(
|
||||
_host: &Host,
|
||||
_window: &str,
|
||||
host: &Host,
|
||||
window: &str,
|
||||
names: Vec<String>,
|
||||
near: Option<PathBuf>,
|
||||
) -> IdeResult<LocatedTools> {
|
||||
host.require_trust(window)?;
|
||||
tokio::task::spawn_blocking(move || LocatedTools {
|
||||
tools: ide_sdk::locate::locate_tools(&names, near.as_deref()),
|
||||
compile_dir: ide_sdk::locate::compile_dir().into(),
|
||||
@@ -72,13 +76,14 @@ pub async fn sdk_locate_tools(
|
||||
/// `base -m venv location`; output lines stream through `on_output`.
|
||||
pub async fn sdk_python_create_venv(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
base: PathBuf,
|
||||
location: PathBuf,
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<Sdk> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
|
||||
let _ = on_output.send(line);
|
||||
@@ -92,11 +97,12 @@ pub async fn sdk_python_create_venv(
|
||||
|
||||
pub async fn sdk_python_packages(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
python: PathBuf,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<PythonPackage>> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
@@ -107,7 +113,7 @@ pub async fn sdk_python_packages(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn sdk_python_pip(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
python: PathBuf,
|
||||
requirement: String,
|
||||
uninstall: bool,
|
||||
@@ -115,7 +121,8 @@ pub async fn sdk_python_pip(
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let send = |line: String| {
|
||||
let _ = on_output.send(line);
|
||||
|
||||
@@ -75,7 +75,7 @@ pub async fn start(
|
||||
&query,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| documents.text_of(p),
|
||||
&|p| documents.search_text_of(p),
|
||||
&cancel,
|
||||
&|mut file| {
|
||||
if dir != &root {
|
||||
|
||||
@@ -124,7 +124,7 @@ pub async fn syntax_identifiers_in_files(
|
||||
let snap = project.documents.text_snapshot(doc).ok()?;
|
||||
return (!snap.large).then_some((snap.language, Some((doc, snap.version)), snap.text));
|
||||
}
|
||||
let text = ide_vfs::load_text(path).ok()?.text;
|
||||
let text = project.text_of(path).ok()?;
|
||||
let first_line = text.lines().next();
|
||||
Some((languages.detect(path, first_line), None, text))
|
||||
};
|
||||
|
||||
@@ -3,12 +3,28 @@ use crate::Host;
|
||||
use ide_core::{IdeResult, TerminalId};
|
||||
use ide_terminal::{TerminalInfo, TerminalOptions};
|
||||
|
||||
pub fn spawn(host: &Host, window: &str, options: TerminalOptions, on_output: Emit<String>) -> IdeResult<TerminalInfo> {
|
||||
// Python › "Activate virtualenv": typed into the new shell, like PyCharm.
|
||||
pub fn spawn(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
mut options: TerminalOptions,
|
||||
on_output: Emit<String>,
|
||||
) -> IdeResult<TerminalInfo> {
|
||||
if !host.project(window).is_ok_and(|p| p.is_trusted()) {
|
||||
// Project terminal settings can otherwise start a command as soon as a tab restores.
|
||||
let global = host.global_settings.effective().terminal;
|
||||
options.shell = None;
|
||||
options.args.clear();
|
||||
options.env = global.env;
|
||||
}
|
||||
let (shell, args) = ide_terminal::resolve_shell(options.shell.as_deref(), &options.args);
|
||||
options.shell = Some(shell.clone());
|
||||
options.args = args;
|
||||
// Python › "Activate virtualenv": typed into the same shell the PTY starts.
|
||||
let activation = host
|
||||
.project(window)
|
||||
.ok()
|
||||
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref()));
|
||||
.filter(|p| p.is_trusted())
|
||||
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell));
|
||||
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
|
||||
if let Some(line) = activation {
|
||||
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::path::PathBuf;
|
||||
|
||||
/// The installer, reporting the executables chosen in the window's settings.
|
||||
fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
|
||||
host.require_trust(window)?;
|
||||
let data = host.data_dir.clone();
|
||||
let (settings, root) = match host.project(window) {
|
||||
Ok(project) => (project.settings.effective(), project.root.clone()),
|
||||
@@ -99,11 +100,11 @@ pub fn jdk_download_vendors(_host: &Host, _window: &str) -> Vec<JdkVendor> {
|
||||
/// Java versions `vendor` publishes; cancellable with `cancel_request(request)`.
|
||||
pub async fn jdk_download_releases(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
_window: &str,
|
||||
vendor: String,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JdkReleases> {
|
||||
let tools = tools(host, window)?;
|
||||
let tools = Tools::new(&host.data_dir);
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
@@ -122,14 +123,14 @@ pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult<ide_core::
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn jdk_download(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
_window: &str,
|
||||
vendor: String,
|
||||
version: u32,
|
||||
location: Option<PathBuf>,
|
||||
request: RequestId,
|
||||
on_event: Channel<InstallEvent>,
|
||||
) -> IdeResult<Sdk> {
|
||||
let tools = tools(host, window)?;
|
||||
let tools = Tools::new(&host.data_dir);
|
||||
let location = location.filter(|p| !p.as_os_str().is_empty());
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
|
||||
@@ -7,7 +7,9 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let settings = project.settings.effective();
|
||||
apply_git_executable(&settings);
|
||||
if project.is_trusted() {
|
||||
apply_git_executable(&settings);
|
||||
}
|
||||
let repos = project.repos();
|
||||
if repos.is_empty() {
|
||||
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
|
||||
|
||||
@@ -187,10 +187,13 @@ pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -
|
||||
}
|
||||
|
||||
/// Gutter change markers for a file, computed against its current (unsaved) text.
|
||||
pub fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
|
||||
pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
|
||||
let project = host.project(window)?;
|
||||
let text = project.text_of(&path)?;
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
|
||||
blocking(move || {
|
||||
let text = project.text_of(&path)?;
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
|
||||
@@ -210,8 +213,8 @@ pub async fn vcs_blame(
|
||||
) -> IdeResult<Vec<BlameLine>> {
|
||||
let project = host.project(window)?;
|
||||
// An explicit text wins (merge view annotates its side panes, not the worktree).
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
blocking(move || {
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
project.with_repo_of(&path, |r| {
|
||||
r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?)
|
||||
})
|
||||
@@ -256,6 +259,7 @@ pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
|
||||
}
|
||||
|
||||
pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
}
|
||||
@@ -268,6 +272,7 @@ pub async fn vcs_add_to_ignore(
|
||||
paths: Vec<PathBuf>,
|
||||
destination: IgnoreDestination,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
for path in &paths {
|
||||
@@ -289,16 +294,19 @@ pub async fn vcs_add_to_ignore(
|
||||
}
|
||||
|
||||
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.stage_patch(&patch, &|path| project.vcs_encoding(path, Some(":0"))))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
|
||||
}
|
||||
@@ -310,6 +318,7 @@ pub async fn vcs_commit(
|
||||
message: String,
|
||||
options: CommitOptions,
|
||||
) -> IdeResult<String> {
|
||||
host.require_trust(window)?;
|
||||
// Commit hooks may run for a long time.
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
@@ -328,6 +337,7 @@ pub async fn vcs_checkout(
|
||||
create: bool,
|
||||
options: Option<ide_vcs::CheckoutOptions>,
|
||||
) -> IdeResult<ide_vcs::CheckoutOutcome> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let options = options.unwrap_or_default();
|
||||
blocking(move || {
|
||||
@@ -346,6 +356,7 @@ pub async fn vcs_create_branch(
|
||||
start: Option<String>,
|
||||
checkout: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let recorded = checkout.then_some("Git checkout");
|
||||
blocking(move || {
|
||||
@@ -357,6 +368,7 @@ pub async fn vcs_create_branch(
|
||||
/// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
|
||||
/// Returns the undone commit's message.
|
||||
pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option<PathBuf>, commit: String) -> IdeResult<String> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await
|
||||
}
|
||||
@@ -367,6 +379,7 @@ pub async fn vcs_delete_branch(
|
||||
repo: Option<PathBuf>,
|
||||
branch: String,
|
||||
) -> IdeResult<ide_vcs::DeletedBranch> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch_restorable(&branch))).await
|
||||
}
|
||||
@@ -378,6 +391,7 @@ pub async fn vcs_restore_branch(
|
||||
repo: Option<PathBuf>,
|
||||
deleted: ide_vcs::DeletedBranch,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_branch(&deleted))).await
|
||||
}
|
||||
@@ -389,16 +403,19 @@ pub async fn vcs_rename_branch(
|
||||
from: String,
|
||||
to: String,
|
||||
) -> 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
|
||||
}
|
||||
|
||||
pub async fn vcs_merge(host: &Host, window: &str, repo: Option<PathBuf>, branch: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge(&branch))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_rebase(host: &Host, window: &str, repo: Option<PathBuf>, onto: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase(&onto))).await
|
||||
}
|
||||
@@ -411,6 +428,7 @@ pub async fn vcs_stash_push(
|
||||
include_untracked: bool,
|
||||
keep_index: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
|
||||
@@ -429,6 +447,7 @@ pub async fn vcs_stash_apply(
|
||||
reinstate_index: bool,
|
||||
branch: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| match branch.as_deref() {
|
||||
@@ -440,6 +459,7 @@ pub async fn vcs_stash_apply(
|
||||
}
|
||||
|
||||
pub async fn vcs_stash_drop(host: &Host, window: &str, repo: Option<PathBuf>, index: u32) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stash_drop(index))).await
|
||||
}
|
||||
@@ -453,6 +473,7 @@ pub async fn vcs_remote(
|
||||
op: RemoteOp,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
@@ -510,14 +531,18 @@ pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: S
|
||||
|
||||
/// Stages only the selected hunks of a file's working-tree changes.
|
||||
/// Indices refer to `vcs_diff_hunks(index_text, worktree_text)`.
|
||||
pub fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
|
||||
pub async fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let encoding = project.vcs_encoding(&path, Some(":0"))?;
|
||||
let current = ide_vfs::load_text_as(&path, encoding.name())?.text;
|
||||
project.with_repo_of(&path, |r| {
|
||||
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
|
||||
r.stage_hunks(&path, ¤t, &selected, encoding)
|
||||
blocking(move || {
|
||||
let encoding = project.vcs_encoding(&path, Some(":0"))?;
|
||||
let current = project.documents.load_file_as(&path, encoding.charset.name())?.text;
|
||||
project.with_repo_of(&path, |r| {
|
||||
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
|
||||
r.stage_hunks(&path, ¤t, &selected, encoding)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Clones a repository (Get from VCS). Works without an open project.
|
||||
@@ -550,7 +575,8 @@ pub async fn vcs_init(host: &Host, _window: &str, dir: PathBuf) -> IdeResult<()>
|
||||
|
||||
/// Settings › Git › Test: runs `git --version` on `path`, or on the auto-detected
|
||||
/// executable when `path` is empty.
|
||||
pub async fn vcs_git_version(_host: &Host, _window: &str, path: String) -> IdeResult<GitExecutableInfo> {
|
||||
pub async fn vcs_git_version(host: &Host, window: &str, path: String) -> IdeResult<GitExecutableInfo> {
|
||||
host.require_trust(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let explicit = path.trim();
|
||||
let exe = if explicit.is_empty() {
|
||||
@@ -592,6 +618,7 @@ pub async fn vcs_operation(
|
||||
op: GitOperation,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
@@ -632,6 +659,7 @@ pub async fn vcs_create_tag(
|
||||
message: Option<String>,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.create_tag(&name, &commit, message.as_deref(), force)))
|
||||
.await
|
||||
@@ -650,6 +678,7 @@ pub async fn vcs_merge_with(
|
||||
branches: Vec<String>,
|
||||
options: MergeOptions,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge_with(&branches, &options))
|
||||
@@ -665,6 +694,7 @@ pub async fn vcs_rebase_with(
|
||||
upstream: String,
|
||||
options: RebaseOptions,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase_with(&upstream, &options))
|
||||
@@ -678,6 +708,7 @@ pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
|
||||
}
|
||||
|
||||
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.edit_remote(&edit))).await
|
||||
}
|
||||
@@ -695,6 +726,7 @@ pub async fn vcs_add_worktree(
|
||||
commitish: String,
|
||||
new_branch: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
|
||||
@@ -709,10 +741,38 @@ pub async fn vcs_remove_worktree(
|
||||
path: PathBuf,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_compare_scope(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<CompareScope> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || Ok(project.repos().compare_scope(&paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_compare_paths(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
revision: String,
|
||||
repositories: Option<Vec<PathBuf>>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<CompareResult> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = blocking(move || {
|
||||
let settings = project.settings.effective();
|
||||
if project.is_trusted() {
|
||||
apply_git_executable(&settings);
|
||||
}
|
||||
project.repos().compare_paths(&paths, &revision, repositories.as_deref(), &cancel)
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Compare with Local: tracked files that differ between `rev` and the working tree.
|
||||
pub async fn vcs_diff_with_worktree(
|
||||
host: &Host,
|
||||
@@ -736,6 +796,19 @@ pub async fn vcs_diff_revisions(
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
|
||||
/// Request tab's three-dot diff.
|
||||
pub async fn vcs_merge_base(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
a: String,
|
||||
b: String,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.merge_base(&a, &b))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_create_patch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -771,12 +844,14 @@ pub async fn vcs_apply_patch(
|
||||
repo: Option<PathBuf>,
|
||||
patch: String,
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await
|
||||
}
|
||||
|
||||
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
|
||||
pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec<PathBuf>) -> IdeResult<ShelvedChangeList> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
in_primary_repo(&project, &paths)?;
|
||||
@@ -793,17 +868,26 @@ pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult<Vec<ShelvedChangeList
|
||||
|
||||
/// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the
|
||||
/// merge UI); with `remove`, deletes it from the shelf.
|
||||
pub async fn vcs_unshelve(host: &Host, window: &str, id: String, remove: bool) -> IdeResult<ApplyOutcome> {
|
||||
pub async fn vcs_unshelve(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: String,
|
||||
remove: bool,
|
||||
path: Option<String>,
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove))
|
||||
project.with_repo_recorded(None, Some("Unshelve"), |r| {
|
||||
ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref())
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_delete(host: &Host, window: &str, id: String) -> IdeResult<()> {
|
||||
pub fn vcs_shelf_delete(host: &Host, window: &str, id: String, path: Option<String>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::delete(&shelf_dir(&project.root), &id)
|
||||
ide_vcs::shelf::delete_path(&shelf_dir(&project.root), &id, path.as_deref())
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> IdeResult<()> {
|
||||
@@ -811,9 +895,19 @@ pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) ->
|
||||
ide_vcs::shelf::rename(&shelf_dir(&project.root), &id, &name)
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_patch(host: &Host, window: &str, id: String) -> IdeResult<String> {
|
||||
pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option<String>) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::patch_of(&shelf_dir(&project.root), &id)
|
||||
ide_vcs::shelf::selected_patch(&shelf_dir(&project.root), &id, path.as_deref())
|
||||
}
|
||||
|
||||
pub async fn vcs_shelf_diff(host: &Host, window: &str, id: String) -> IdeResult<Vec<ide_vcs::shelf::ShelfDiffFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_shelf_import(host: &Host, window: &str, name: String, patch: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await
|
||||
}
|
||||
|
||||
pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult<ChangelistState> {
|
||||
@@ -843,7 +937,7 @@ pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch:
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_roots", |c| async move { vcs_roots(&c.host, &c.window) });
|
||||
d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
|
||||
d.add("vcs_blame", |c| async move {
|
||||
vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await
|
||||
@@ -927,7 +1021,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
"vcs_diff_hunks",
|
||||
|c| async move { Ok(vcs_diff_hunks(&c.host, &c.window, c.arg("oldText")?, c.arg("newText")?)) },
|
||||
);
|
||||
d.add("vcs_stage_hunks", |c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?) });
|
||||
d.add(
|
||||
"vcs_stage_hunks",
|
||||
|c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?).await },
|
||||
);
|
||||
d.add("vcs_clone", |c| async move {
|
||||
vcs_clone(&c.host, &c.window, c.arg("request")?, c.arg("url")?, c.arg("dest")?, c.sender("onOutput")?).await
|
||||
});
|
||||
@@ -970,12 +1067,27 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_remove_worktree", |c| async move {
|
||||
vcs_remove_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("force")?).await
|
||||
});
|
||||
d.add("vcs_compare_scope", |c| async move { vcs_compare_scope(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add("vcs_compare_paths", |c| async move {
|
||||
vcs_compare_paths(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("paths")?,
|
||||
c.arg("revision")?,
|
||||
c.arg("repositories")?,
|
||||
c.arg("request")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_diff_with_worktree", |c| async move {
|
||||
vcs_diff_with_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("rev")?).await
|
||||
});
|
||||
d.add("vcs_diff_revisions", |c| async move {
|
||||
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("vcs_merge_base", |c| async move {
|
||||
vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await
|
||||
});
|
||||
d.add("vcs_create_patch", |c| async move {
|
||||
vcs_create_patch(&c.host, &c.window, c.arg("repo")?, c.arg("source")?).await
|
||||
});
|
||||
@@ -988,10 +1100,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
);
|
||||
d.add("vcs_shelve", |c| async move { vcs_shelve(&c.host, &c.window, c.arg("name")?, c.arg("paths")?).await });
|
||||
d.add("vcs_shelves", |c| async move { vcs_shelves(&c.host, &c.window) });
|
||||
d.add("vcs_unshelve", |c| async move { vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?).await });
|
||||
d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("vcs_unshelve", |c| async move {
|
||||
vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?, c.arg("path")?).await
|
||||
});
|
||||
d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?, c.arg("path")?) });
|
||||
d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) });
|
||||
d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("vcs_shelf_diff", |c| async move { vcs_shelf_diff(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("vcs_shelf_import", |c| async move {
|
||||
vcs_shelf_import(&c.host, &c.window, c.arg("name")?, c.arg("patch")?).await
|
||||
});
|
||||
d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?, c.arg("path")?) });
|
||||
d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) });
|
||||
d.add("vcs_changelists_save", |c| async move { vcs_changelists_save(&c.host, &c.window, c.arg("changelists")?) });
|
||||
d.add(
|
||||
|
||||
+163
-13
@@ -15,9 +15,11 @@ use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoo
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Everything bound to one open project folder.
|
||||
pub struct Project {
|
||||
pub(crate) trusted: Arc<AtomicBool>,
|
||||
pub root: PathBuf,
|
||||
/// Window-scoped event sink, for commands that emit project events.
|
||||
pub sink: SharedSink,
|
||||
@@ -40,6 +42,8 @@ pub struct Project {
|
||||
pub detected_roots: Arc<RwLock<Vec<SourceFolder>>>,
|
||||
/// Per-project cache directory (build tool sync results…).
|
||||
pub cache_dir: PathBuf,
|
||||
/// Superseded launch snapshots retained until this project session ends.
|
||||
pub(crate) java_launch_snapshots: Mutex<crate::ops::java::cache::Session>,
|
||||
/// Database tool window: data sources and their connections.
|
||||
pub db: Arc<ide_db::DbManager>,
|
||||
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
|
||||
@@ -48,6 +52,7 @@ pub struct Project {
|
||||
pub agents: Arc<ide_agent::AgentManager>,
|
||||
/// Native notebooks and owned kernels, scoped to this project incarnation.
|
||||
pub notebooks: Arc<crate::notebook::NotebookService>,
|
||||
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
pub(crate) watcher: Watcher,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
@@ -80,7 +85,9 @@ impl Host {
|
||||
self.close_project(window);
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
// Before anything the project starts: its children get the project's environment.
|
||||
let post_start = self.environment.project_opened(&root, &sink);
|
||||
let trusted = Arc::new(AtomicBool::new(self.trust.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));
|
||||
let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root)));
|
||||
let excludes = exclude_globs(&root, &settings.effective(), &detected_roots.read());
|
||||
@@ -114,6 +121,7 @@ impl Host {
|
||||
)?;
|
||||
mark("index");
|
||||
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
|
||||
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
lsp.set_content_roots(extra_roots.clone());
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
@@ -121,6 +129,8 @@ impl Host {
|
||||
let root = root.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
let config_dir = self.config_dir.clone();
|
||||
let sink = sink.clone();
|
||||
let formatter_problem = std::sync::Mutex::new(None);
|
||||
lsp.set_config_hook(Box::new(move |cfg, server_root| {
|
||||
let member = ide_workspace::workspace::Workspace::load(&root).ok().flatten().and_then(|w| {
|
||||
w.projects.into_iter().filter(|p| server_root.starts_with(p)).max_by_key(|p| p.components().count())
|
||||
@@ -134,8 +144,40 @@ impl Host {
|
||||
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
|
||||
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
|
||||
if cfg.name == "jdtls" {
|
||||
let bundles: Vec<PathBuf> =
|
||||
ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect();
|
||||
let problem = ide_lsp::jdtls::configure_formatter(
|
||||
cfg,
|
||||
effective.editor.code_style.languages.get("java"),
|
||||
root,
|
||||
&data_dir,
|
||||
effective.language_servers.get("jdtls"),
|
||||
)
|
||||
.err()
|
||||
.map(|e| e.to_string());
|
||||
let mut previous = formatter_problem.lock().unwrap();
|
||||
if problem != *previous {
|
||||
if let Some(body) = &problem {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
title: "Java formatter configuration unavailable".into(),
|
||||
body: Some(body.clone()),
|
||||
});
|
||||
}
|
||||
*previous = problem;
|
||||
}
|
||||
drop(previous);
|
||||
ide_lsp::jdtls::configure_source_paths(
|
||||
cfg,
|
||||
ide_workspace::roots::unmanaged_java_source_paths(
|
||||
root,
|
||||
server_root,
|
||||
&effective.project_structure,
|
||||
),
|
||||
);
|
||||
let bundles: Vec<PathBuf> = ide_tools::managed_bundle(&data_dir, "java-debug")
|
||||
.into_iter()
|
||||
.chain(ide_sdk::java::usages_bundle(&data_dir))
|
||||
.chain(ide_sdk::java::kotlin_bridge_bundle(&data_dir))
|
||||
.collect();
|
||||
let min_java = ide_sdk::java::jdtls_min_java(cfg);
|
||||
let jdks = ide_sdk::java::jdtls_jdks(min_java, &data_dir);
|
||||
let lombok = ide_sdk::lombok::lombok_agent(
|
||||
@@ -208,10 +250,25 @@ impl Host {
|
||||
root: root.clone(),
|
||||
sink: sink.clone(),
|
||||
runtime: self.runtime.clone(),
|
||||
trusted: trusted.clone(),
|
||||
}));
|
||||
|
||||
let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new(
|
||||
root.clone(),
|
||||
self.data_dir.join("cache").join(&project_key).join("build"),
|
||||
&self.data_dir,
|
||||
settings.clone(),
|
||||
lsp.clone(),
|
||||
sink.clone(),
|
||||
trusted.clone(),
|
||||
);
|
||||
documents.add_observer(kotlin_bridge.clone());
|
||||
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
|
||||
for repo in repos.read().all() {
|
||||
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
}
|
||||
let watcher = {
|
||||
let kotlin_bridge = kotlin_bridge.clone();
|
||||
let repos = repos.clone();
|
||||
let settings = settings.clone();
|
||||
let index = index.clone();
|
||||
@@ -223,6 +280,8 @@ impl Host {
|
||||
let history = history.clone();
|
||||
let remote = self.remote.clone();
|
||||
let runtime = self.runtime.clone();
|
||||
let lsp = lsp.clone();
|
||||
let trusted = trusted.clone();
|
||||
Watcher::start(
|
||||
&root.clone(),
|
||||
excludes.clone(),
|
||||
@@ -230,6 +289,8 @@ impl Host {
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
|
||||
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
|
||||
// Atomic saves by external tools surface as `created`.
|
||||
documents.on_disk_changed(&changed);
|
||||
// A framework's root marker (`angular.json`) created or deleted: its
|
||||
@@ -239,7 +300,7 @@ impl Host {
|
||||
documents.redetect_languages();
|
||||
}
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
if !batch.removed.is_empty() {
|
||||
if trusted.load(Ordering::Acquire) && !batch.removed.is_empty() {
|
||||
delete_deployed(
|
||||
&runtime,
|
||||
remote.clone(),
|
||||
@@ -308,7 +369,7 @@ impl Host {
|
||||
let content_watchers = extra_roots
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?;
|
||||
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history, Some(&kotlin_bridge))?;
|
||||
Some((r.clone(), w))
|
||||
})
|
||||
.collect();
|
||||
@@ -329,6 +390,7 @@ impl Host {
|
||||
);
|
||||
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
|
||||
let project = Arc::new(Project {
|
||||
trusted,
|
||||
root,
|
||||
sink,
|
||||
languages,
|
||||
@@ -343,10 +405,14 @@ impl Host {
|
||||
syntax,
|
||||
detected_roots,
|
||||
cache_dir: self.data_dir.join("cache").join(&project_key),
|
||||
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
|
||||
self.data_dir.join("cache").join(&project_key).join("build"),
|
||||
)),
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
kotlin_bridge: Some(kotlin_bridge),
|
||||
watcher,
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
_scratch_watcher: scratch_watcher,
|
||||
@@ -358,7 +424,16 @@ impl Host {
|
||||
weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
{
|
||||
let weak = Arc::downgrade(&project);
|
||||
project.lsp.set_project_excludes_hook(Box::new(move || {
|
||||
weak.upgrade().map(|p| p.exclude_globs()).unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.start(&self.runtime);
|
||||
}
|
||||
if let Some(cmd) = post_start {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
@@ -438,6 +513,7 @@ impl Host {
|
||||
sink.clone(),
|
||||
);
|
||||
let project = Arc::new(Project {
|
||||
trusted: Arc::new(AtomicBool::new(true)),
|
||||
sink,
|
||||
languages,
|
||||
settings,
|
||||
@@ -451,10 +527,14 @@ impl Host {
|
||||
syntax,
|
||||
detected_roots: Arc::default(),
|
||||
cache_dir: self.data_dir.join("cache").join(&project_key),
|
||||
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
|
||||
self.data_dir.join("cache").join(&project_key).join("build"),
|
||||
)),
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
kotlin_bridge: None,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: None,
|
||||
@@ -468,6 +548,18 @@ impl Host {
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn is_trusted(&self) -> bool {
|
||||
self.trusted.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn require_trust(&self) -> IdeResult<()> {
|
||||
if self.is_trusted() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::unavailable("Project is in Restricted Mode. Trust the project to enable execution."))
|
||||
}
|
||||
}
|
||||
|
||||
/// LightEdit: follow external changes to the files of `dir` (its own entries only).
|
||||
pub fn watch_light_dir(&self, dir: &Path) {
|
||||
if !self.light {
|
||||
@@ -542,7 +634,11 @@ impl Project {
|
||||
|
||||
/// The project's git repositories.
|
||||
pub fn repos(&self) -> Repos {
|
||||
self.repos.read().clone()
|
||||
let repos = self.repos.read().clone();
|
||||
for repo in repos.all() {
|
||||
repo.set_execution_allowed(self.is_trusted());
|
||||
}
|
||||
repos
|
||||
}
|
||||
|
||||
/// Runs `f` with the repository whose root is `at`, the project's primary repository
|
||||
@@ -574,7 +670,9 @@ impl Project {
|
||||
|
||||
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
let settings = self.settings.effective();
|
||||
apply_git_executable(&settings);
|
||||
if self.is_trusted() {
|
||||
apply_git_executable(&settings);
|
||||
}
|
||||
repo.set_ignored_names(settings.ignored_names());
|
||||
f(repo)
|
||||
}
|
||||
@@ -681,7 +779,7 @@ impl Project {
|
||||
limit: q.limit,
|
||||
};
|
||||
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&root.join(rel), None));
|
||||
match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) {
|
||||
match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.search_text_of(p), cancel) {
|
||||
Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit },
|
||||
Err(e) => {
|
||||
tracing::debug!("reference candidates: {}", e.message);
|
||||
@@ -730,16 +828,32 @@ impl Project {
|
||||
if watchers.iter().any(|(p, _)| *p == r) {
|
||||
continue;
|
||||
}
|
||||
if let Some(w) =
|
||||
content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history)
|
||||
{
|
||||
if let Some(w) = content_root_watcher(
|
||||
&r,
|
||||
&excludes,
|
||||
&self.sink,
|
||||
&self.index,
|
||||
&self.documents,
|
||||
&self.history,
|
||||
self.kotlin_bridge.as_ref(),
|
||||
) {
|
||||
watchers.push((r, w));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the open document's selected encoding, or the same detection/settings as opening it.
|
||||
pub fn vcs_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<&'static encoding_rs::Encoding> {
|
||||
pub fn vcs_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<ide_vcs::TextEncoding> {
|
||||
let charset = self.file_encoding(path, rev)?;
|
||||
let encoding = ide_vcs::TextEncoding::from(charset);
|
||||
Ok(if self.documents.native_to_ascii_for(path) {
|
||||
encoding.with_ascii_conversion(ide_vfs::decode_properties_escapes, ide_vfs::encode_properties_ascii)
|
||||
} else {
|
||||
encoding
|
||||
})
|
||||
}
|
||||
|
||||
fn file_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<&'static encoding_rs::Encoding> {
|
||||
if let Some(id) = self.documents.by_path(path) {
|
||||
let info = self.documents.snapshot(id)?;
|
||||
return encoding_rs::Encoding::for_label(info.encoding.as_bytes())
|
||||
@@ -760,7 +874,7 @@ impl Project {
|
||||
pub fn text_of(&self, path: &Path) -> IdeResult<String> {
|
||||
match self.documents.text_of(path) {
|
||||
Some(text) => Ok(text),
|
||||
None => Ok(ide_vfs::load_text_with_fallback(path, self.documents.file_defaults().fallback_for(path))?.text),
|
||||
None => Ok(ide_text::normalize_line_endings(&self.documents.load_file(path)?.text).into_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -768,6 +882,8 @@ impl Project {
|
||||
/// Applies a multi-file edit through the document layer so open editors update live.
|
||||
pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> {
|
||||
use ide_core::FileEdit;
|
||||
let mut edit = edit.clone();
|
||||
edit.deduplicate();
|
||||
for file in &edit.files {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
@@ -852,7 +968,9 @@ fn content_root_watcher(
|
||||
index: &Arc<ProjectIndex>,
|
||||
documents: &Arc<Documents>,
|
||||
history: &Arc<LocalHistory>,
|
||||
kotlin_bridge: Option<&Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
) -> Option<Watcher> {
|
||||
let kotlin_bridge = kotlin_bridge.cloned();
|
||||
let (index, documents, history) = (index.clone(), documents.clone(), history.clone());
|
||||
Watcher::start(
|
||||
dir,
|
||||
@@ -861,6 +979,9 @@ fn content_root_watcher(
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
if let Some(bridge) = &kotlin_bridge {
|
||||
bridge.files_changed(changed.iter().chain(&batch.removed));
|
||||
}
|
||||
documents.on_disk_changed(&changed);
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
}),
|
||||
@@ -961,6 +1082,7 @@ impl DocumentObserver for HistoryObserver {
|
||||
|
||||
/// Uploads saved files: remote files' local copies, and automatic upload to the default server.
|
||||
struct DeployObserver {
|
||||
trusted: Arc<AtomicBool>,
|
||||
remote: Arc<ide_remote::RemoteService>,
|
||||
settings: Arc<SettingsStore>,
|
||||
root: PathBuf,
|
||||
@@ -970,6 +1092,9 @@ struct DeployObserver {
|
||||
|
||||
impl DocumentObserver for DeployObserver {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
|
||||
if !self.trusted.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let (remote, settings, root, sink, path) =
|
||||
(self.remote.clone(), self.settings.clone(), self.root.clone(), self.sink.clone(), path.to_path_buf());
|
||||
self.runtime.spawn(async move {
|
||||
@@ -1111,3 +1236,28 @@ mod index_event_tests {
|
||||
assert!(vcs_path_changed(&index));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod workspace_edit_tests {
|
||||
use super::*;
|
||||
use ide_core::{FileEdit, LanguageId, Pos, Range, TextEdit, WorkspaceEdit};
|
||||
|
||||
#[test]
|
||||
fn rename_via_symlink_updates_the_open_document_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dir.path().join("main.cpp");
|
||||
let alias = dir.path().join("alias.cpp");
|
||||
std::fs::write(&real, "add();\n").unwrap();
|
||||
std::os::unix::fs::symlink(&real, &alias).unwrap();
|
||||
let documents = Documents::new(Arc::new(|_: IdeEvent| {}), Box::new(|_, _| LanguageId::new("cpp")));
|
||||
let doc = documents.open(&alias).unwrap();
|
||||
let changes = |path: PathBuf| FileEdit::Edit {
|
||||
path: path.into(),
|
||||
edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }],
|
||||
};
|
||||
apply_workspace_edit(&documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)] })
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n");
|
||||
assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n");
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
pub const PROTOCOL_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -138,6 +138,15 @@ impl Dispatcher {
|
||||
/// terminals, runs and VCS status.
|
||||
pub fn standard() -> Self {
|
||||
let mut d = Self::default();
|
||||
d.add("project_trust_status", |c| async move { c.host.trust.status(&c.arg::<PathBuf>("path")?) })
|
||||
.add("project_trust_list", |c| async move { c.host.trust.decisions() })
|
||||
.add("project_trust_set", |c| async move {
|
||||
c.host.set_project_trust(&c.arg::<PathBuf>("path")?, c.arg("trusted")?, c.arg("folder")?).await
|
||||
})
|
||||
.add("project_trust_remove_location", |c| async move {
|
||||
c.host.trust.remove_location(&c.arg::<PathBuf>("path")?)?;
|
||||
c.host.refresh_project_trust().await
|
||||
});
|
||||
d.add("open_project", |c| async move {
|
||||
let path: PathBuf = c.arg("path")?;
|
||||
let project = ops::project::open(&c.host, &c.window, &path).await?;
|
||||
@@ -148,6 +157,9 @@ impl Dispatcher {
|
||||
c.host.close_project(&c.window);
|
||||
Ok(())
|
||||
})
|
||||
.add("jvm_file_presentations", |c| async move {
|
||||
ops::jvm_icons::jvm_file_presentations(&c.host, &c.window, c.arg("paths")?).await
|
||||
})
|
||||
.add("project_roots", |c| async move { ops::project::roots(&c.host, &c.window) })
|
||||
.add("settings_get", |c| async move { Ok(ops::project::settings(&c.host, &c.window)) })
|
||||
.add("settings_set", |c| async move {
|
||||
@@ -167,7 +179,11 @@ impl Dispatcher {
|
||||
})
|
||||
.add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::<TaskId>("id")?)) })
|
||||
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await })
|
||||
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_file_bytes", |c| async move {
|
||||
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
|
||||
})
|
||||
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
|
||||
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
|
||||
.add("fs_set_writable", |c| async move {
|
||||
@@ -175,6 +191,28 @@ impl Dispatcher {
|
||||
})
|
||||
.add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_copy_merge", |c| async move {
|
||||
ops::fs::copy_merge(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("sourceRoot")?,
|
||||
c.arg("destinationRoot")?,
|
||||
c.arg("path")?,
|
||||
c.arg("overwrite")?,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.add("fs_compare_directories", |c| async move {
|
||||
ops::fs::compare_directories(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("left")?,
|
||||
c.arg("right")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onProgress")?,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) })
|
||||
@@ -183,6 +221,7 @@ impl Dispatcher {
|
||||
})
|
||||
.add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await })
|
||||
.add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await })
|
||||
.add("doc_sync_from_disk", |c| async move { ops::doc::sync_from_disk(&c.host, &c.window).await })
|
||||
.add("doc_snapshot", |c| async move { ops::doc::snapshot(&c.host, &c.window, c.arg("doc")?).await })
|
||||
.add("search_start", |c| async move {
|
||||
let on_event = c.channel("onEvent")?;
|
||||
@@ -284,6 +323,7 @@ impl Handler for ServerHandler {
|
||||
let Some(handler) = self.dispatcher.handlers.get(call.cmd.as_str()).cloned() else {
|
||||
return Err((codes::METHOD_NOT_FOUND, format!("command {} is not available remotely", call.cmd)));
|
||||
};
|
||||
self.host.authorize_command(&call.window, &call.cmd).map_err(error_reply)?;
|
||||
let c = Call { host: self.host.clone(), window: call.window, args: call.args, peer: self.peer.clone() };
|
||||
// Requests run on blocking-capable threads: wait for the command here.
|
||||
self.host.runtime.block_on(handler(c)).map_err(error_reply)
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
//! User-owned project execution decisions. Project settings never grant trust.
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrustDecisions {
|
||||
pub projects: Vec<PathBuf>,
|
||||
pub locations: Vec<PathBuf>,
|
||||
pub restricted: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
pub struct TrustStore {
|
||||
file: PathBuf,
|
||||
state: Mutex<IdeResult<TrustDecisions>>,
|
||||
}
|
||||
|
||||
impl TrustStore {
|
||||
pub fn load(config: &Path, data: &Path) -> Self {
|
||||
let file = data.join("project-trust.json");
|
||||
let state = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
let projects = ide_workspace::RecentProjects::load(config)
|
||||
.paths()
|
||||
.into_iter()
|
||||
.filter_map(|p| dunce::canonicalize(p).ok())
|
||||
.collect();
|
||||
let migrated = TrustDecisions { projects, ..Default::default() };
|
||||
persist(&file, &migrated).map(|()| migrated)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
};
|
||||
Self { file, state: Mutex::new(state) }
|
||||
}
|
||||
|
||||
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
|
||||
self.state.lock().clone()
|
||||
}
|
||||
|
||||
pub fn trusted(&self, path: &Path) -> IdeResult<bool> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let decisions = self.decisions()?;
|
||||
Ok(!decisions.restricted.contains(&root)
|
||||
&& (decisions.projects.contains(&root) || decisions.locations.iter().any(|p| root.starts_with(p))))
|
||||
}
|
||||
|
||||
pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
|
||||
let root = match dunce::canonicalize(path) {
|
||||
Ok(root) => root,
|
||||
Err(_) if !trusted && path.is_absolute() => path.to_path_buf(),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let mut state = self.state.lock();
|
||||
let mut next = state.clone()?;
|
||||
next.projects.retain(|p| p != &root);
|
||||
next.restricted.retain(|p| p != &root);
|
||||
if trusted {
|
||||
next.projects.push(root.clone());
|
||||
if folder && let Some(parent) = root.parent() {
|
||||
let parent = parent.to_path_buf();
|
||||
if !next.locations.contains(&parent) {
|
||||
next.locations.push(parent);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
next.restricted.push(root);
|
||||
}
|
||||
persist(&self.file, &next)?;
|
||||
*state = Ok(next);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_location(&self, path: &Path) -> IdeResult<()> {
|
||||
let mut state = self.state.lock();
|
||||
let mut next = state.clone()?;
|
||||
next.locations.retain(|p| p != path);
|
||||
persist(&self.file, &next)?;
|
||||
*state = Ok(next);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
|
||||
if let Some(parent) = file.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(state).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic(file, &bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl crate::Host {
|
||||
pub async fn set_project_trust(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
|
||||
self.trust.decide(path, trusted, folder)?;
|
||||
self.refresh_project_trust().await
|
||||
}
|
||||
|
||||
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 previous = project.trusted.swap(trusted, std::sync::atomic::Ordering::AcqRel);
|
||||
if previous == trusted {
|
||||
continue;
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.schedule();
|
||||
}
|
||||
if !trusted {
|
||||
project.lsp.set_execution_allowed(false);
|
||||
}
|
||||
project.repos();
|
||||
if trusted {
|
||||
if let Some(cmd) = self.environment.project_opened(&project.root, &project.sink) {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
project.lsp.set_execution_allowed(true);
|
||||
} else {
|
||||
self.environment.project_closed();
|
||||
project.agents.shutdown();
|
||||
self.processes.kill_project(&project.root);
|
||||
project.notebooks.restrict().await;
|
||||
let windows: Vec<_> = self
|
||||
.projects
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, current)| std::sync::Arc::ptr_eq(current, &project))
|
||||
.map(|(window, _)| window.clone())
|
||||
.collect();
|
||||
for window in windows {
|
||||
let requests = self.project_requests.write().remove(&window).unwrap_or_default();
|
||||
for request in requests {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
for session in self.debugger.sessions(Some(&window)) {
|
||||
let _ = self.debugger.stop(session.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
project.lsp.reconfigure().await;
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectTrustChanged { root: project.root.clone().into(), trusted });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands which only inspect/edit source or operate user-owned application state.
|
||||
/// New execution commands are denied until deliberately classified here.
|
||||
pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
if [
|
||||
"fs_",
|
||||
"doc_",
|
||||
"syntax_",
|
||||
"search_",
|
||||
"nav_",
|
||||
"history_",
|
||||
"spell_",
|
||||
"analysis_",
|
||||
"structural_",
|
||||
"refactor_",
|
||||
"bytes_",
|
||||
"window_",
|
||||
"window_group_",
|
||||
"term_",
|
||||
"settings_",
|
||||
"project_trust_",
|
||||
"ssh_",
|
||||
"git_credential",
|
||||
]
|
||||
.iter()
|
||||
.any(|p| cmd.starts_with(p))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
cmd,
|
||||
"app_info"
|
||||
| "app_build_info"
|
||||
| "app_log_location"
|
||||
| "app_check_update"
|
||||
| "app_startup_update_check"
|
||||
| "app_install_update"
|
||||
| "caches_schedule_invalidation"
|
||||
| "clear_caches"
|
||||
| "clipboard_file_list"
|
||||
| "clipboard_has_files"
|
||||
| "clipboard_set_file_list"
|
||||
| "jdk_download_vendors"
|
||||
| "jdk_download_releases"
|
||||
| "jdk_download_location"
|
||||
| "jdk_download"
|
||||
| "run_system_env"
|
||||
| "run_gradle_jvm_args_script"
|
||||
| "proxy_check_connection"
|
||||
| "docker_settings"
|
||||
| "docker_save_settings"
|
||||
| "docker_set_registry_password"
|
||||
| "docker_run_env"
|
||||
| "db_console_path"
|
||||
| "db_export_file"
|
||||
| "vcs_write_patch"
|
||||
| "app_metrics"
|
||||
| "app_quit"
|
||||
| "app_cancel_quit"
|
||||
| "app_restart"
|
||||
| "languages"
|
||||
| "menu_refresh"
|
||||
| "menu_model"
|
||||
| "menu_configure"
|
||||
| "menu_set_disabled"
|
||||
| "lsp_servers"
|
||||
| "lsp_resend_diagnostics"
|
||||
| "recent_projects"
|
||||
| "remove_recent_project"
|
||||
| "open_project"
|
||||
| "close_project"
|
||||
| "project_current"
|
||||
| "project_roots"
|
||||
| "index_status"
|
||||
| "jvm_file_presentations"
|
||||
| "deploy_coverage"
|
||||
| "find_files"
|
||||
| "find_symbols"
|
||||
| "mark_recent"
|
||||
| "remove_recent"
|
||||
| "recent_files"
|
||||
| "web_goto"
|
||||
| "path_completions"
|
||||
| "web_unresolved_references"
|
||||
| "project_rename"
|
||||
| "light_edit_open"
|
||||
| "workspace_create"
|
||||
| "workspace_update"
|
||||
| "workspace_attach"
|
||||
| "cancel_request"
|
||||
| "cancel_task"
|
||||
| "log_frontend"
|
||||
| "env_reload"
|
||||
| "remote_invoke"
|
||||
| "remote_current"
|
||||
| "remote_disconnect"
|
||||
| "remote_connect"
|
||||
| "remote_list"
|
||||
| "remote_open"
|
||||
| "remote_of"
|
||||
| "web_browsers"
|
||||
| "web_url"
|
||||
| "web_open"
|
||||
| "debug_attachable_processes"
|
||||
| "agent_subscribe"
|
||||
| "agent_entries"
|
||||
| "agent_commands"
|
||||
| "agent_unqueue"
|
||||
| "agent_cancel"
|
||||
| "agent_archive"
|
||||
| "agent_unarchive"
|
||||
| "agent_registry_list"
|
||||
| "agent_registry_install"
|
||||
| "agent_registry_remove"
|
||||
| "coverage_import"
|
||||
| "system_font_families"
|
||||
| "user_font_data"
|
||||
| "plugin_subscribe"
|
||||
| "plugin_list"
|
||||
| "plugin_set_active_file"
|
||||
| "plugin_set_theme"
|
||||
| "plugin_set_theme_frame"
|
||||
| "plugin_catalog"
|
||||
| "notebook_open"
|
||||
| "notebook_get"
|
||||
| "notebook_edit"
|
||||
| "notebook_save"
|
||||
| "notebook_undo"
|
||||
| "notebook_redo"
|
||||
| "notebook_reload"
|
||||
| "notebook_close"
|
||||
| "notebook_interrupt"
|
||||
| "run_stop"
|
||||
| "run_list"
|
||||
| "debug_stop"
|
||||
| "debug_sessions"
|
||||
| "vcs_clone"
|
||||
| "vcs_init"
|
||||
| "vcs_status"
|
||||
| "vcs_roots"
|
||||
| "vcs_branch_info"
|
||||
| "vcs_line_hunks"
|
||||
| "vcs_file_at"
|
||||
| "vcs_blame"
|
||||
| "vcs_log"
|
||||
| "vcs_commit_files"
|
||||
| "vcs_commits_changes"
|
||||
| "vcs_branches"
|
||||
| "vcs_stashes"
|
||||
| "vcs_conflict_versions"
|
||||
| "vcs_diff_text"
|
||||
| "vcs_diff_hunks"
|
||||
| "vcs_detected_git"
|
||||
| "vcs_config_get"
|
||||
| "vcs_tags"
|
||||
| "vcs_remotes"
|
||||
| "vcs_worktrees"
|
||||
| "vcs_diff_with_worktree"
|
||||
| "vcs_compare_scope"
|
||||
| "vcs_compare_paths"
|
||||
| "vcs_diff_revisions"
|
||||
| "vcs_merge_base"
|
||||
| "vcs_shelves"
|
||||
| "vcs_shelf_patch"
|
||||
| "vcs_changelists_load"
|
||||
) || cmd.starts_with("plugin:")
|
||||
}
|
||||
|
||||
impl crate::Host {
|
||||
pub fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> {
|
||||
if restricted_command_allowed(command) {
|
||||
return Ok(());
|
||||
}
|
||||
self.project(window)?.require_trust()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrustStatus {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub trusted: bool,
|
||||
pub decided: bool,
|
||||
}
|
||||
impl TrustStore {
|
||||
pub fn status(&self, path: &Path) -> IdeResult<TrustStatus> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let trusted = self.trusted(&root)?;
|
||||
let state = self.decisions()?;
|
||||
Ok(TrustStatus { decided: trusted || state.restricted.contains(&root), root: root.into(), trusted })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use ide_core::{ChangeSet, NullSink, RequestId, TextChange};
|
||||
use ide_host::{
|
||||
Host, HostConfig,
|
||||
ops::{doc, search, vcs},
|
||||
};
|
||||
use ide_search::{SearchQuery, SearchScope};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
fn git(root: &Path, args: &[&str]) {
|
||||
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
fn host(base: &Path) -> Host {
|
||||
Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
async fn find(host: &Host, paths: Vec<PathBuf>, pattern: &str) -> Vec<ide_search::FileMatches> {
|
||||
let files = Arc::new(Mutex::new(Vec::new()));
|
||||
let found = files.clone();
|
||||
search::start(
|
||||
host,
|
||||
"main",
|
||||
RequestId(519),
|
||||
SearchQuery {
|
||||
pattern: pattern.into(),
|
||||
is_regex: false,
|
||||
case_sensitive: true,
|
||||
whole_word: false,
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
scope: SearchScope::Files { paths: paths.into_iter().map(Into::into).collect() },
|
||||
max_matches: 0,
|
||||
context: Default::default(),
|
||||
},
|
||||
Arc::new(move |event| {
|
||||
if let search::SearchEvent::File { file } = event {
|
||||
found.lock().unwrap().push(file);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
Arc::try_unwrap(files).unwrap().into_inner().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn properties_search_git_and_document_coordinates_agree() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
git(&root, &["init", "-b", "main"]);
|
||||
git(&root, &["config", "user.name", "Test"]);
|
||||
git(&root, &["config", "user.email", "[email protected]"]);
|
||||
git(&root, &["config", "commit.gpgsign", "false"]);
|
||||
git(&root, &["config", "core.autocrlf", "false"]);
|
||||
let path = root.join("messages.properties");
|
||||
let original = "hello=\\u4f60\\u597d\\uD83D\\uDE00target\nsecond=unchanged\nescaped=line\\u000Alogical\n";
|
||||
std::fs::write(&path, original).unwrap();
|
||||
let host = host(&base);
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "Properties"]);
|
||||
let mut defaults = project.documents.file_defaults();
|
||||
defaults.native_to_ascii = true;
|
||||
project.documents.set_file_defaults(defaults);
|
||||
let displayed = "hello=你好😀target\nsecond=unchanged\nescaped=line\\u000Alogical\n";
|
||||
assert_eq!(project.text_of(&path).unwrap(), displayed);
|
||||
let closed = find(&host, vec![path.clone()], "target").await;
|
||||
let closed_json = serde_json::to_value(&closed).unwrap();
|
||||
let opened = doc::open(&host, "main", path.clone()).await.unwrap();
|
||||
assert_eq!(opened.text, displayed);
|
||||
assert_eq!(serde_json::to_value(find(&host, vec![path.clone()], "target").await).unwrap(), closed_json);
|
||||
assert_eq!(closed[0].matches[0].ranges, vec![(10, 16)]);
|
||||
assert_eq!(find(&host, vec![path.clone()], "你好").await[0].matches.len(), 1);
|
||||
assert!(find(&host, vec![path.clone()], r"\u4f60").await.is_empty());
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
|
||||
assert_eq!(vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().as_deref(), Some(displayed));
|
||||
assert!(vcs::vcs_blame(&host, "main", path.clone(), None, None).await.unwrap().iter().all(|l| l.author == "Test"));
|
||||
doc::edit(
|
||||
&host,
|
||||
"main",
|
||||
opened.id,
|
||||
opened.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: r"\u4E16\u754C".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap();
|
||||
assert_eq!(hunks.len(), 1);
|
||||
assert_eq!(hunks[0].old_text, "hello=你好😀target\n");
|
||||
doc::save(&host, "main", opened.id).await.unwrap();
|
||||
let saved = std::fs::read(&path).unwrap();
|
||||
assert!(saved.is_ascii());
|
||||
assert!(String::from_utf8(saved.clone()).unwrap().contains(r"\u4F60\u597D\uD83D\uDE00\u4E16\u754C"));
|
||||
vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).await.unwrap();
|
||||
let repo = ide_vcs::Repo::discover(&root).unwrap().unwrap();
|
||||
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), saved);
|
||||
repo.unstage(std::slice::from_ref(&path)).unwrap();
|
||||
// Line staging must match the decoded base even when Git stores lowercase escapes.
|
||||
let patch = ide_vcs::build_partial_patch("messages.properties", displayed, &project.text_of(&path).unwrap(), &[0]);
|
||||
repo.stage_patch(&patch, &|p| project.vcs_encoding(p, Some(":0"))).unwrap();
|
||||
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), saved);
|
||||
repo.unstage(std::slice::from_ref(&path)).unwrap();
|
||||
let snap = project.documents.snapshot(opened.id).unwrap();
|
||||
project.documents.replace_all(opened.id, displayed).unwrap();
|
||||
doc::save(&host, "main", opened.id).await.unwrap();
|
||||
assert!(project.documents.snapshot(opened.id).unwrap().version > snap.version);
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
|
||||
// Partial commits identify hunks in displayed line coordinates as well.
|
||||
let partial_text = displayed.replace("target", r"\u4E16\u754C").replace("logical", "changed");
|
||||
project.documents.replace_all(opened.id, &partial_text).unwrap();
|
||||
doc::save(&host, "main", opened.id).await.unwrap();
|
||||
let hunks = ide_vcs::diff_hunks(displayed, &partial_text);
|
||||
assert_eq!(hunks.len(), 2);
|
||||
let selected = &hunks[0];
|
||||
let options = ide_vcs::CommitOptions {
|
||||
partial: vec![ide_vcs::PartialCommitFile {
|
||||
path: path.clone().into(),
|
||||
hunks: vec![ide_vcs::HunkRange {
|
||||
start: selected.start,
|
||||
lines: selected.lines,
|
||||
old_start: selected.old_start,
|
||||
old_lines: selected.old_lines,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
repo.commit_with("Selected properties change", &options, &|p| project.vcs_encoding(p, None)).unwrap();
|
||||
let head = vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().unwrap();
|
||||
assert_eq!(head, displayed.replace("target", "世界"));
|
||||
assert!(repo.file_bytes_at(&path, None).unwrap().unwrap().is_ascii());
|
||||
let closed_path = root.join("closed.properties");
|
||||
std::fs::write(&closed_path, r"value=\u4F60\u597D\uD83D\uDE00target").unwrap();
|
||||
let query = SearchQuery {
|
||||
pattern: "target".into(),
|
||||
is_regex: false,
|
||||
case_sensitive: true,
|
||||
whole_word: false,
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
scope: SearchScope::Files { paths: vec![closed_path.clone().into()] },
|
||||
max_matches: 0,
|
||||
context: Default::default(),
|
||||
};
|
||||
search::replace_in_files(
|
||||
&host,
|
||||
"main",
|
||||
RequestId(520),
|
||||
query,
|
||||
r"\u4E16\u754C".into(),
|
||||
false,
|
||||
vec![ide_search::ReplaceTarget {
|
||||
path: closed_path.clone().into(),
|
||||
only: Some(vec![ide_core::Pos { line: 0, col: 10 }]),
|
||||
}],
|
||||
ide_host::ops::Channel::new(|_| {}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&closed_path).unwrap(), r"value=\u4F60\u597D\uD83D\uDE00\u4E16\u754C");
|
||||
assert!(project.documents.by_path(&closed_path).is_none());
|
||||
assert_eq!(project.text_of(&closed_path).unwrap(), "value=你好😀世界");
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -100,6 +100,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
"postStartCommand": ["/bin/sh", "-c", "echo \"$FROM_PROJECT\" >> started.txt; echo post-start-output"],
|
||||
}"#,
|
||||
);
|
||||
host.trust.decide(&project, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap();
|
||||
assert_eq!(child_var("FROM_PROJECT"), "two-demo");
|
||||
assert!(child_var("PATH").starts_with("/project/bin:"), "{}", child_var("PATH"));
|
||||
@@ -158,6 +159,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
// Reopening the project doesn't run postStartCommand again (once per server start).
|
||||
host.close_project("main");
|
||||
assert_eq!(child_var("FROM_PROJECT"), "", "remoteEnv leaves with the project");
|
||||
host.trust.decide(&project, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert_eq!(std::fs::read_to_string(&started).unwrap(), "two-demo\n");
|
||||
@@ -165,6 +167,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
// A failing postStartCommand is a notification; the project opens regardless.
|
||||
let failing = tmp.join("failing");
|
||||
write(&failing.join(".devcontainer.json"), r#"{ "postStartCommand": "echo oops >&2; exit 7" }"#);
|
||||
host.trust.decide(&failing, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &failing)).unwrap();
|
||||
wait_for("the failure notification", || {
|
||||
events.0.lock().iter().any(|e| {
|
||||
@@ -176,6 +179,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
// A broken file is a warning, not an error.
|
||||
let broken = tmp.join("broken");
|
||||
write(&broken.join(".devcontainer.json"), "{ \"remoteEnv\": ");
|
||||
host.trust.decide(&broken, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &broken)).unwrap();
|
||||
assert!(events.0.lock().iter().any(|e| matches!(e,
|
||||
IdeEvent::Notify { level: NotifyLevel::Warning, title, .. } if title == "devcontainer.json was not applied")));
|
||||
|
||||
@@ -70,17 +70,84 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
let client = Arc::new(Client::default());
|
||||
let peer = Peer::connect(cr, cw, client.clone());
|
||||
|
||||
let info: ServerInfo = peer.request("initialize", json!({ "version": 1 }), None).await.unwrap();
|
||||
let info: ServerInfo = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
|
||||
assert_eq!(info.protocol, rpc::PROTOCOL_VERSION);
|
||||
|
||||
let opened = invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
|
||||
assert_eq!(opened["name"], "demo");
|
||||
assert_eq!(opened["trusted"], false);
|
||||
invoke(&peer, "index_status", json!({})).await.unwrap();
|
||||
assert_eq!(invoke(&peer, "lsp_servers", json!({})).await.unwrap(), json!([]));
|
||||
invoke(&peer, "lsp_resend_diagnostics", json!({})).await.unwrap();
|
||||
let files = invoke(&peer, "find_files", json!({"query": "main", "limit": 10})).await.unwrap();
|
||||
assert!(files.is_array());
|
||||
for command in ["build_sync", "run_start", "sdk_locate_tools", "notebook_execute", "debug_start"] {
|
||||
let error = invoke(&peer, command, json!({})).await.unwrap_err();
|
||||
assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}");
|
||||
}
|
||||
let call = Invoke { window: "unknown".into(), cmd: "coverage_collect".into(), args: json!({}) };
|
||||
assert!(peer.request::<_, Value>("invoke", call, None).await.is_err());
|
||||
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
|
||||
let current = invoke(&peer, "project_current", json!({})).await.unwrap();
|
||||
assert_eq!(current["name"], "demo");
|
||||
|
||||
let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap();
|
||||
assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}");
|
||||
|
||||
// Directory comparisons, streamed progress and confirmed copies share the remote filesystem.
|
||||
let left = project.join("compare-left");
|
||||
let right = project.join("compare-right");
|
||||
std::fs::create_dir_all(&left).unwrap();
|
||||
std::fs::create_dir_all(&right).unwrap();
|
||||
std::fs::write(left.join("中文.bin"), [0, 255, 1]).unwrap();
|
||||
std::fs::write(right.join("中文.bin"), [0, 254, 1]).unwrap();
|
||||
assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left})).await.unwrap(), "dir");
|
||||
assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left.join("中文.bin")})).await.unwrap(), "file");
|
||||
let compare_args =
|
||||
|request| json!({"left": left, "right": right, "request": request, "onProgress": {"$channel": 520}});
|
||||
let rows = invoke(&peer, "fs_compare_directories", compare_args(520)).await.unwrap();
|
||||
assert_eq!(rows[0]["status"], "different");
|
||||
assert!(client.channels.lock().iter().any(|c| c.id == 520 && c.data["compared"] == 1));
|
||||
let copy_args =
|
||||
|overwrite| json!({"sourceRoot": left, "destinationRoot": right, "path": "中文.bin", "overwrite": overwrite});
|
||||
let error = invoke(&peer, "fs_copy_merge", copy_args(false)).await.unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::AlreadyExists);
|
||||
invoke(&peer, "fs_copy_merge", copy_args(true)).await.unwrap();
|
||||
let rows = invoke(&peer, "fs_compare_directories", compare_args(521)).await.unwrap();
|
||||
assert_eq!(rows[0]["status"], "equal");
|
||||
invoke(&peer, "cancel_request", json!({"id": 522})).await.unwrap();
|
||||
let error = invoke(&peer, "fs_compare_directories", compare_args(522)).await.unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
let error = invoke(
|
||||
&peer,
|
||||
"fs_copy_merge",
|
||||
json!({"sourceRoot": left, "destinationRoot": right, "path": "../escape", "overwrite": true}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
|
||||
// JVM icons use the same read-only protocol as local windows.
|
||||
let java = project.join("src/Foo.java");
|
||||
let kotlin = project.join("src/Singleton.kt");
|
||||
std::fs::write(&java, "abstract class Foo {}").unwrap();
|
||||
std::fs::write(&kotlin, "object Singleton {}").unwrap();
|
||||
let icons = invoke(&peer, "jvm_file_presentations", json!({ "paths": [java, kotlin] })).await.unwrap();
|
||||
assert_eq!(icons[0]["presentation"]["kind"], "abstractClass");
|
||||
assert_eq!(icons[1]["presentation"]["kind"], "object");
|
||||
// Replacing a file invalidates its cached metadata, including same-size writes.
|
||||
std::fs::write(&java, "interface Foo {} ").unwrap();
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&java)
|
||||
.unwrap()
|
||||
.set_modified(std::time::SystemTime::UNIX_EPOCH)
|
||||
.unwrap();
|
||||
let icons = invoke(&peer, "jvm_file_presentations", json!({ "paths": [java] })).await.unwrap();
|
||||
assert_eq!(icons[0]["presentation"]["kind"], "interface");
|
||||
let oversized = invoke(&peer, "jvm_file_presentations", json!({ "paths": vec![java.clone(); 65] })).await;
|
||||
assert!(oversized.is_err());
|
||||
|
||||
// Edit and save a document.
|
||||
let file = project.join("src/main.py");
|
||||
let doc = invoke(&peer, "doc_open", json!({ "path": file })).await.unwrap();
|
||||
@@ -91,6 +158,14 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "print('needle')\nprint('more')\n");
|
||||
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "original\n").unwrap();
|
||||
let external = invoke(&peer, "doc_open", json!({ "path": outside })).await.unwrap();
|
||||
std::fs::write(&outside, "external change\n").unwrap();
|
||||
invoke(&peer, "doc_sync_from_disk", json!({})).await.unwrap();
|
||||
let snapshot = invoke(&peer, "doc_snapshot", json!({ "doc": external["id"] })).await.unwrap();
|
||||
assert_eq!(snapshot["text"], "external change\n");
|
||||
|
||||
// Commands shared with the desktop shell (camelCase argument names, as Tauri takes them).
|
||||
invoke(&peer, "doc_set_line_ending", json!({ "doc": id, "lineEnding": "crLf" })).await.unwrap();
|
||||
invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap();
|
||||
@@ -170,3 +245,133 @@ impl EventType for EventMessage {
|
||||
serde_json::to_value(&self.event).unwrap()["type"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn local_deployment_runs_on_the_project_host() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
let target = tmp.path().join("mounted");
|
||||
std::fs::create_dir(&project).unwrap();
|
||||
std::fs::create_dir(&target).unwrap();
|
||||
let project = dunce::canonicalize(project).unwrap();
|
||||
let file = project.join("local.txt");
|
||||
std::fs::write(&file, "host file").unwrap();
|
||||
let slot = Arc::new(OnceLock::new());
|
||||
let sink_slot = slot.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |w| rpc::event_sink(sink_slot.clone(), Some(w.to_owned()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let dispatcher = Dispatcher::standard();
|
||||
for cmd in [
|
||||
"deploy_coverage",
|
||||
"deploy_upload",
|
||||
"deploy_download",
|
||||
"deploy_delete_remote",
|
||||
"deploy_compare",
|
||||
"deploy_sync",
|
||||
"deploy_info",
|
||||
"deploy_server_root",
|
||||
"deploy_test_connection",
|
||||
"remote_list",
|
||||
"remote_open",
|
||||
"remote_of",
|
||||
"remote_delete",
|
||||
"remote_mkdir",
|
||||
"remote_rename",
|
||||
] {
|
||||
assert!(dispatcher.commands().contains(&cmd), "missing host command {cmd}");
|
||||
}
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(rpc::serve(host, dispatcher, slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
|
||||
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
|
||||
let deployment = json!({"servers": [{"id":"local", "kind":"local", "rootPath":target}], "defaultServer":"local"});
|
||||
invoke(&peer, "settings_set", json!({"scope":"project", "key":"deployment", "value":deployment.to_string()}))
|
||||
.await
|
||||
.unwrap();
|
||||
let coverage = invoke(&peer, "deploy_coverage", json!({"paths":[file]})).await.unwrap();
|
||||
assert_eq!(coverage[0]["server"], "local");
|
||||
assert_eq!(coverage[0]["paths"], json!([ide_core::IpcPath::from(&file)]));
|
||||
invoke(&peer, "deploy_test_connection", json!({"connection":{"kind":"local"}, "path":target})).await.unwrap();
|
||||
let report = invoke(&peer, "deploy_upload", json!({"paths":[file], "request":1001})).await.unwrap();
|
||||
assert_eq!(report["files"], 1, "{report}");
|
||||
assert_eq!(std::fs::read_to_string(target.join("local.txt")).unwrap(), "host file");
|
||||
let entries = invoke(&peer, "remote_list", json!({"connection":{"kind":"local"}, "path":target})).await.unwrap();
|
||||
assert_eq!(entries[0]["name"], "local.txt");
|
||||
std::fs::write(target.join("local.txt"), "host download").unwrap();
|
||||
let report = invoke(&peer, "deploy_download", json!({"paths":[file], "request":1002})).await.unwrap();
|
||||
assert_eq!(report["files"], 1);
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "host download");
|
||||
invoke(&peer, "deploy_delete_remote", json!({"paths":[file]})).await.unwrap();
|
||||
assert!(!target.join("local.txt").exists());
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("properties-project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let path = root.join("messages.properties");
|
||||
std::fs::write(&path, r"value=\u4F60\u597D\uD83D\uDE00!").unwrap();
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(rpc::serve(host, Dispatcher::standard(), peer_slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
|
||||
invoke(&peer, "open_project", json!({"path":root})).await.unwrap();
|
||||
let settings = invoke(&peer, "settings_get", json!({})).await.unwrap();
|
||||
assert_eq!(settings["editor"]["encodings"]["nativeToAscii"], false);
|
||||
let opened = invoke(&peer, "doc_open", json!({"path":path})).await.unwrap();
|
||||
let doc = opened["id"].clone();
|
||||
invoke(&peer, "settings_set", json!({"scope":"project", "key":"editor.encodings.nativeToAscii", "value":"true"}))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut snap = Value::Null;
|
||||
for _ in 0..500 {
|
||||
snap = invoke(&peer, "doc_snapshot", json!({"doc":doc})).await.unwrap();
|
||||
if snap["text"] == "value=你好😀!" {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(snap["text"], "value=你好😀!");
|
||||
assert_eq!(snap["dirty"], false);
|
||||
invoke(
|
||||
&peer,
|
||||
"doc_edit",
|
||||
json!({"doc":doc, "baseVersion":snap["version"],
|
||||
"changes":{"changes":[{"from":10,"to":11,"insert":"界"}]}}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
invoke(&peer, "doc_save", json!({"doc":doc})).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), r"value=\u4F60\u597D\uD83D\uDE00\u754C");
|
||||
invoke(&peer, "doc_set_encoding", json!({"doc":doc,"encoding":"utf-8","reload":true})).await.unwrap();
|
||||
let reloaded = invoke(&peer, "doc_snapshot", json!({"doc":doc})).await.unwrap();
|
||||
assert_eq!(reloaded["text"], "value=你好😀界");
|
||||
assert_eq!(reloaded["dirty"], false);
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
peer.kill();
|
||||
}
|
||||
@@ -21,6 +21,7 @@ async fn removed_detections_stay_removed_and_overrides_restore() {
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("package.json"), r#"{"scripts":{"dev":"node app.js","test":"node test.js"}}"#).unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let detected = ops::run::configs(&host, "main").unwrap();
|
||||
assert!(detected.len() >= 2);
|
||||
@@ -58,6 +59,7 @@ async fn gradle_defaults_are_not_saved_as_explicit_run_choices() {
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
@@ -77,3 +79,24 @@ async fn gradle_defaults_are_not_saved_as_explicit_run_choices() {
|
||||
assert_eq!(saved.env["JAVA_HOME"], "/explicit-jdk");
|
||||
assert_eq!(saved.command, gradle.command);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let member = root.join("native");
|
||||
std::fs::create_dir(&member).unwrap();
|
||||
std::fs::write(member.join("CMakeLists.txt"), "project(demo)").unwrap();
|
||||
let workspace = root.join("workspace");
|
||||
ide_workspace::workspace::Workspace::save(&workspace, "Native", vec![member.clone()], true).unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.trust.decide(&workspace, true, false).unwrap();
|
||||
host.trust.decide(&member, true, false).unwrap();
|
||||
host.open_project("main", &workspace).unwrap();
|
||||
let configs = ops::run::configs(&host, "main").unwrap();
|
||||
let build = configs.iter().find(|c| c.kind == "cmake" && c.args.first().is_some_and(|a| a == "--build")).unwrap();
|
||||
assert_eq!(build.cwd.as_deref(), Some(member.as_path()));
|
||||
for task in &build.extras.before_launch {
|
||||
assert!(configs.iter().any(|c| c.id == task.target && c.cwd == build.cwd));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Own test process: the inherited shell deliberately differs from the login shell.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn terminal_activation_uses_the_resolved_default_shell() {
|
||||
// SAFETY: no runtime or worker threads have been started.
|
||||
unsafe { std::env::set_var("SHELL", "/not-the-terminal/docker") };
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
let bin = root.join(".venv/bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::write(root.join(".venv/pyvenv.cfg"), "home = /usr/bin\n").unwrap();
|
||||
std::fs::write(bin.join("python"), "").unwrap();
|
||||
for script in ["activate", "activate.fish", "activate.csh", "activate.nu", "Activate.ps1"] {
|
||||
std::fs::write(bin.join(script), "echo activated > activation.marker\n").unwrap();
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
|
||||
runtime.block_on(async {
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().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(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
for shell in [None, Some("".to_owned()), Some(" \t ".to_owned())] {
|
||||
let terminal = ops::term::spawn(
|
||||
&host,
|
||||
"main",
|
||||
ide_terminal::TerminalOptions {
|
||||
cwd: root.clone().into(),
|
||||
shell,
|
||||
args: vec![],
|
||||
env: Default::default(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shell_integration: false,
|
||||
},
|
||||
Arc::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !std::fs::read_to_string(root.join("activation.marker")).is_ok_and(|s| s.trim() == "activated") {
|
||||
assert!(Instant::now() < deadline, "default terminal shell must run the activation script");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
host.terminals.kill(terminal.id).unwrap();
|
||||
std::fs::remove_file(root.join("activation.marker")).unwrap();
|
||||
}
|
||||
host.shutdown().await;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
use ide_core::NullSink;
|
||||
#[cfg(unix)]
|
||||
use ide_host::ops;
|
||||
use ide_host::{Host, HostConfig, trust::TrustStore};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
#[cfg(unix)]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn host(base: &Path, environment: bool) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
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: environment,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn application_commands_work_in_restricted_projects_and_without_a_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let host = host(&tmp.path().join("app"), false);
|
||||
assert!(host.project("welcome").is_err());
|
||||
assert!(ide_host::ops::project::roots(&host, "welcome").is_err());
|
||||
assert!(!host.open_project("main", &root).unwrap().is_trusted());
|
||||
for command in [
|
||||
"app_info",
|
||||
"app_build_info",
|
||||
"app_log_location",
|
||||
"app_check_update",
|
||||
"app_startup_update_check",
|
||||
"app_install_update",
|
||||
"caches_schedule_invalidation",
|
||||
"clear_caches",
|
||||
"clipboard_file_list",
|
||||
"clipboard_has_files",
|
||||
"clipboard_set_file_list",
|
||||
"jdk_download_vendors",
|
||||
"jdk_download_releases",
|
||||
"jdk_download_location",
|
||||
"jdk_download",
|
||||
"run_system_env",
|
||||
"run_gradle_jvm_args_script",
|
||||
"proxy_check_connection",
|
||||
"docker_settings",
|
||||
"docker_save_settings",
|
||||
"docker_set_registry_password",
|
||||
"docker_run_env",
|
||||
"db_console_path",
|
||||
"db_export_file",
|
||||
"vcs_write_patch",
|
||||
] {
|
||||
assert!(ide_host::trust::restricted_command_allowed(command), "{command}");
|
||||
for window in ["main", "welcome"] {
|
||||
assert!(host.authorize_command(window, command).is_ok(), "{window}: {command}");
|
||||
}
|
||||
}
|
||||
for command in [
|
||||
"run_start",
|
||||
"build_sync",
|
||||
"debug_start",
|
||||
"sdk_detect",
|
||||
"tools_list",
|
||||
"tools_install",
|
||||
"tools_uninstall",
|
||||
"plugin_install_dir",
|
||||
"plugin_reload",
|
||||
"vcs_git_version",
|
||||
"docker_compose_op",
|
||||
"db_execute",
|
||||
"unknown_command",
|
||||
] {
|
||||
assert!(!ide_host::trust::restricted_command_allowed(command), "{command}");
|
||||
for window in ["main", "welcome"] {
|
||||
assert!(host.authorize_command(window, command).is_err(), "{window}: {command}");
|
||||
}
|
||||
}
|
||||
// These requests validate the vendor before any network access, independently of project trust.
|
||||
for window in ["main", "welcome"] {
|
||||
let error = ide_host::ops::tools::jdk_download_releases(
|
||||
&host,
|
||||
window,
|
||||
"unknown-vendor".into(),
|
||||
ide_core::RequestId(901),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
let error = ide_host::ops::tools::jdk_download(
|
||||
&host,
|
||||
window,
|
||||
"unknown-vendor".into(),
|
||||
25,
|
||||
None,
|
||||
ide_core::RequestId(902),
|
||||
ide_host::ops::Channel::new(|_| {}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
}
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_user_decisions_locations_revocation_and_one_time_migration() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("config");
|
||||
let data = tmp.path().join("data");
|
||||
let a = tmp.path().join("projects/a");
|
||||
let b = tmp.path().join("projects/b");
|
||||
std::fs::create_dir_all(a.join(".openidea")).unwrap();
|
||||
std::fs::create_dir_all(b.join(".openidea")).unwrap();
|
||||
std::fs::write(a.join(".openidea/project-trust.json"), r#"{"trusted":true}"#).unwrap();
|
||||
std::fs::write(b.join(".openidea/project-trust.json"), r#"{"trusted":true}"#).unwrap();
|
||||
let recent = ide_workspace::RecentProjects::load(&config);
|
||||
recent.touch(&a, None).unwrap();
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
assert!(trust.trusted(&a).unwrap());
|
||||
assert!(!trust.status(&b).unwrap().decided);
|
||||
recent.touch(&b, None).unwrap();
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
assert!(!trust.trusted(&b).unwrap(), "migration is not repeated");
|
||||
trust.decide(&a, true, true).unwrap();
|
||||
assert!(trust.trusted(&b).unwrap());
|
||||
trust.decide(&b, false, false).unwrap();
|
||||
assert!(!trust.trusted(&b).unwrap(), "revocation overrides a trusted location");
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
assert!(!trust.trusted(&b).unwrap());
|
||||
trust.remove_location(&dunce::canonicalize(a.parent().unwrap()).unwrap()).unwrap();
|
||||
assert!(trust.trusted(&a).unwrap());
|
||||
trust.decide(&a, false, false).unwrap();
|
||||
assert!(!TrustStore::load(&config, &data).trusted(&a).unwrap());
|
||||
std::fs::write(data.join("project-trust.json"), "broken").unwrap();
|
||||
assert!(TrustStore::load(&config, &data).trusted(&a).is_err(), "corruption fails closed");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn script(root: &Path, name: &str, marker: &str) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = root.join(name);
|
||||
std::fs::write(&path, format!("#!/bin/sh\nprintf executed > '{marker}'\n")).unwrap();
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn malicious_project_stays_editable_then_starts_in_place_after_trust() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("malicious");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
script(&root, "hook", "hook.marker");
|
||||
script(&root, "server", "lsp.marker");
|
||||
script(&root, "gradlew", "build.marker");
|
||||
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
std::fs::write(root.join("main.py"), "print('safe')\n").unwrap();
|
||||
std::fs::write(root.join(".devcontainer.json"), r#"{"postStartCommand":["./hook"]}"#).unwrap();
|
||||
std::fs::write(root.join(".openidea/settings.toml"), format!(
|
||||
"[languageServers.trust-witness]\nlanguages = ['python']\ncommand = {:?}\n[languageServers.pyright]\nenabled = false\n", root.join("server").to_str().unwrap()
|
||||
)).unwrap();
|
||||
let host = host(&tmp.path().join("app"), true);
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert!(!project.is_trusted());
|
||||
let doc = ops::doc::open(&host, "main", root.join("main.py")).await.unwrap();
|
||||
ops::doc::save(&host, "main", doc.id).await.unwrap();
|
||||
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
||||
assert!(project.index.status().files > 0);
|
||||
let sync = || {
|
||||
ops::build::build_sync(
|
||||
&host,
|
||||
"main",
|
||||
ide_build::BuildTool::Gradle,
|
||||
ide_core::RequestId(472),
|
||||
false,
|
||||
vec![],
|
||||
ops::Channel::new(|_| {}),
|
||||
)
|
||||
};
|
||||
assert!(sync().await.is_err(), "restricted build marker: {}", root.join("build.marker").exists());
|
||||
assert!(
|
||||
ops::sdk::sdk_locate_tools(&host, "missing-window", vec!["server".into()], Some(root.clone())).await.is_err()
|
||||
);
|
||||
for command in [
|
||||
"run_start",
|
||||
"debug_start",
|
||||
"notebook_execute",
|
||||
"coverage_collect",
|
||||
"profiler_collect",
|
||||
"docker_compose_op",
|
||||
"agent_prompt",
|
||||
"sdk_detect",
|
||||
"build_sync",
|
||||
] {
|
||||
assert!(host.authorize_command("main", command).is_err(), "{command}");
|
||||
assert!(host.authorize_command("missing-window", command).is_err(), "{command}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
for marker in ["hook.marker", "lsp.marker", "build.marker"] {
|
||||
assert!(!root.join(marker).exists(), "{marker}");
|
||||
}
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
assert!(Arc::ptr_eq(&project, &host.project("main").unwrap()));
|
||||
assert_eq!(project.documents.snapshot(doc.id).unwrap().text, doc.text);
|
||||
sync().await.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !root.join("hook.marker").exists() || !root.join("lsp.marker").exists() {
|
||||
assert!(Instant::now() < deadline, "trusted services must start");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(root.join("build.marker").exists());
|
||||
host.set_project_trust(&root, false, false).await.unwrap();
|
||||
assert!(!project.is_trusted());
|
||||
assert!(sync().await.is_err());
|
||||
ops::doc::save(&host, "main", doc.id).await.unwrap();
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restricted_terminal_keeps_user_input_but_ignores_project_startup_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let host = host(&tmp.path().join("app"), false);
|
||||
host.global_settings
|
||||
.set(
|
||||
ide_workspace::SettingsScope::Global,
|
||||
"terminal.env",
|
||||
serde_json::json!({"ZDOTDIR": root, "ENV": "/dev/null", "BASH_ENV": "/dev/null"}),
|
||||
)
|
||||
.unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let terminal = ops::term::spawn(
|
||||
&host,
|
||||
"main",
|
||||
ide_terminal::TerminalOptions {
|
||||
cwd: root.clone().into(),
|
||||
shell: Some("/bin/sh".into()),
|
||||
args: vec!["-c".into(), "touch automatic.marker".into()],
|
||||
env: Default::default(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shell_integration: false,
|
||||
},
|
||||
Arc::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
host.terminals.write(terminal.id, "printf user > terminal.marker; exit\n").unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !root.join("terminal.marker").exists() {
|
||||
assert!(Instant::now() < deadline, "user terminal input must run");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(!root.join("automatic.marker").exists());
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn trusted_locations_use_canonical_directory_boundaries() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("trusted/project");
|
||||
let other = tmp.path().join("trusted-other/project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
std::os::unix::fs::symlink(other.parent().unwrap(), root.parent().unwrap().join("escape")).unwrap();
|
||||
let trust = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
|
||||
trust.decide(&root, true, true).unwrap();
|
||||
assert!(!trust.trusted(&other).unwrap());
|
||||
let alias = root.parent().unwrap().join("escape/project");
|
||||
assert!(!trust.trusted(&alias).unwrap());
|
||||
assert_eq!(trust.status(&alias).unwrap().root.as_ref(), dunce::canonicalize(&other).unwrap());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restricted_git_views_ignore_project_executables_and_mutations_cannot_run_hooks() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let git = |args: &[&str]| {
|
||||
let output = std::process::Command::new("git").current_dir(&root).args(args).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
};
|
||||
git(&["init", "-q"]);
|
||||
git(&["config", "user.name", "Test"]);
|
||||
git(&["config", "user.email", "[email protected]"]);
|
||||
git(&["config", "commit.gpgsign", "false"]);
|
||||
git(&["config", "core.hooksPath", ".git/hooks"]);
|
||||
let file = root.join("safe.txt");
|
||||
std::fs::write(&file, "safe\n").unwrap();
|
||||
script(&root, ".git/hooks/pre-commit", "git-hook.marker");
|
||||
let executable = root.join(".openidea/git-witness");
|
||||
std::fs::write(&executable, "#!/bin/sh\nprintf executed > git-executable.marker\nexec git \"$@\"\n").unwrap();
|
||||
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".openidea/settings.toml"),
|
||||
format!("[vcs.git]\nexecutable = {:?}\n", executable.to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
let host = host(&tmp.path().join("app"), false);
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert!(!project.with_repo(|repo| repo.status()).unwrap().is_empty());
|
||||
assert!(ops::vcs::vcs_stage(&host, "main", vec![file.clone()]).await.is_err());
|
||||
assert!(!root.join("git-hook.marker").exists());
|
||||
assert!(!root.join("git-executable.marker").exists());
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
ops::vcs::vcs_stage(&host, "main", vec![file]).await.unwrap();
|
||||
project.with_repo(|repo| repo.commit("trusted", false, false)).unwrap();
|
||||
assert!(root.join("git-executable.marker").exists());
|
||||
assert!(root.join("git-hook.marker").exists());
|
||||
ide_vcs::set_git_executable(None);
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn revocation_still_applies_when_a_project_folder_was_removed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let host = host(&tmp.path().join("app"), false);
|
||||
host.trust.decide(&root, true, true).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
host.set_project_trust(&project.root, false, false).await.unwrap();
|
||||
assert!(!project.is_trusted());
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! The configured Git binary is process-wide, so it has its own test binary.
|
||||
#![cfg(unix)]
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restricted_status_does_not_select_the_project_git_for_other_windows() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
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,
|
||||
}));
|
||||
let root = base.join("malicious");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let marker = base.join("git.marker");
|
||||
let git = root.join("git");
|
||||
std::fs::write(&git, format!("#!/bin/sh\nprintf executed > '{}'\n", marker.display())).unwrap();
|
||||
std::fs::set_permissions(&git, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::write(root.join(".openidea/settings.toml"), format!("[vcs.git]\nexecutable = {:?}\n", git)).unwrap();
|
||||
ide_vcs::init(&root).unwrap();
|
||||
|
||||
host.open_project("main", &root).unwrap();
|
||||
ops::vcs::status(&host, "main").await.unwrap();
|
||||
|
||||
let _ = ide_vcs::git_version(None);
|
||||
assert!(!marker.exists(), "another window's next Git command would run the project's binary");
|
||||
}
|
||||
@@ -35,6 +35,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
@@ -53,7 +54,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
let mut defaults = project.documents.file_defaults();
|
||||
defaults.project_fallback = "windows-1252".into();
|
||||
project.documents.set_file_defaults(defaults);
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty());
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
|
||||
let from = old.find("value = 1").unwrap() + "value = ".len();
|
||||
let offset = old[..from].encode_utf16().count() as u32;
|
||||
project
|
||||
@@ -64,12 +65,12 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
ChangeSet { changes: vec![TextChange { from: offset, to: offset + 1, insert: "2".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap();
|
||||
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap();
|
||||
assert_eq!(hunks.len(), 1);
|
||||
assert_eq!((hunks[0].start, hunks[0].lines), (3, 1));
|
||||
assert_eq!(hunks[0].old_text, " int value = 1;\n");
|
||||
project.documents.save(doc.id).unwrap();
|
||||
vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).unwrap();
|
||||
vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).await.unwrap();
|
||||
let repo = ide_vcs::Repo::discover(&root).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(),
|
||||
@@ -95,7 +96,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
.unwrap();
|
||||
project.documents.save(doc.id).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty());
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
|
||||
assert!(vcs::vcs_blame(&host, "main", path.clone(), None, None).await.unwrap().iter().all(|l| l.author == "Test"));
|
||||
// Missing working files still use configured encoding for historical content.
|
||||
project.documents.close(doc.id);
|
||||
|
||||
@@ -38,6 +38,7 @@ async fn patches_read_and_write_the_requested_repository() {
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&project_root, true, false).unwrap();
|
||||
let project = host.open_project("main", &project_root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if project.repos().all().len() == 2 {
|
||||
|
||||
@@ -30,6 +30,7 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
|
||||
project_environment: false,
|
||||
});
|
||||
std::fs::write(root.join("internal-needle.txt"), "needle").unwrap();
|
||||
host.trust.decide(&root, 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());
|
||||
|
||||
@@ -53,6 +53,19 @@ pub struct LanguageConfig {
|
||||
pub language_servers: Vec<LanguageServerConfig>,
|
||||
#[serde(default)]
|
||||
pub debugger: Option<DebuggerConfig>,
|
||||
/// External formatter used when no language server can format, or selected in settings.
|
||||
#[serde(default)]
|
||||
pub formatter: Option<FormatterConfig>,
|
||||
}
|
||||
|
||||
/// An external formatting tool and its language-specific parser.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct FormatterConfig {
|
||||
pub tool: String,
|
||||
pub parser: String,
|
||||
#[serde(default)]
|
||||
pub range: bool,
|
||||
}
|
||||
|
||||
/// A CodeMirror mode for the files of a framework's projects.
|
||||
@@ -120,6 +133,9 @@ pub struct LanguageServerConfig {
|
||||
/// a JDK 21+); reported as the failure instead of launching it.
|
||||
#[serde(skip)]
|
||||
pub launch_error: Option<String>,
|
||||
/// Register source files with tsserver when no project configuration exists.
|
||||
#[serde(default)]
|
||||
pub tsserver_project: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -209,8 +209,9 @@ impl SyntaxCache {
|
||||
|m| &mut m.folding,
|
||||
|| {
|
||||
self.with_tree(doc, lang, version, text, |g, tree, text| {
|
||||
Some(folding::folding(g.spec, g.kinds(), tree, text))
|
||||
Some(folding::folding(g.spec, g.kinds(), tree, text, self.syntax.comment_tokens(lang)))
|
||||
})
|
||||
.or_else(|| Some(self.syntax.custom_folding(lang, text)))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! comment runs, import runs and `region` / `endregion` marker comments.
|
||||
|
||||
use super::grammars::Spec;
|
||||
use crate::{FoldKind, FoldRange};
|
||||
use crate::{FoldKind, FoldRange, LanguageConfig};
|
||||
use tree_sitter::{Language, Node, Tree, TreeCursor};
|
||||
|
||||
const PAIRS: &[(&str, &str)] = &[("{", "}"), ("[", "]"), ("(", ")"), ("#{", "}"), ("${", "}"), ("@{", "}")];
|
||||
@@ -85,7 +85,25 @@ impl KindTable {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn folding(spec: &Spec, kinds: &KindTable, tree: &Tree, text: &str) -> Vec<FoldRange> {
|
||||
#[derive(Default)]
|
||||
pub(super) struct CommentTokens {
|
||||
line: Option<String>,
|
||||
block: Option<(String, String)>,
|
||||
}
|
||||
|
||||
impl From<&LanguageConfig> for CommentTokens {
|
||||
fn from(config: &LanguageConfig) -> Self {
|
||||
Self { line: config.line_comment.clone(), block: config.block_comment.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn folding(
|
||||
spec: &Spec,
|
||||
kinds: &KindTable,
|
||||
tree: &Tree,
|
||||
text: &str,
|
||||
tokens: &CommentTokens,
|
||||
) -> Vec<FoldRange> {
|
||||
let mut out: Vec<FoldRange> = Vec::new();
|
||||
let mut comments: Vec<Comment> = Vec::new();
|
||||
let mut cursor = tree.walk();
|
||||
@@ -96,7 +114,7 @@ pub(crate) fn folding(spec: &Spec, kinds: &KindTable, tree: &Tree, text: &str) -
|
||||
start_row: start,
|
||||
end_row: last_content_row(&node, text),
|
||||
own_line: own_line(text, node.start_byte(), node.start_position().column),
|
||||
marker: region_marker(text.get(node.start_byte()..node.end_byte()).unwrap_or_default()),
|
||||
marker: region_marker(text.get(node.start_byte()..node.end_byte()).unwrap_or_default(), tokens),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -185,7 +203,7 @@ fn visit(cursor: &mut TreeCursor, mut f: impl FnMut(Node) -> bool) {
|
||||
}
|
||||
|
||||
pub(crate) fn is_comment(kind: &str) -> bool {
|
||||
kind.contains("comment")
|
||||
kind.contains("comment") || kind == "Comment"
|
||||
}
|
||||
|
||||
/// Row of the closing token when `node` is delimited by a matching bracket pair.
|
||||
@@ -220,21 +238,64 @@ fn own_line(text: &str, byte: usize, column: usize) -> bool {
|
||||
}
|
||||
|
||||
/// `Some(true)` for a region start marker, `Some(false)` for an end marker.
|
||||
fn region_marker(comment: &str) -> Option<bool> {
|
||||
let body = comment
|
||||
.trim_start_matches(['/', '*', '#', '-', ';', '%', '!', '<', ' ', '\t'])
|
||||
.trim_start_matches("--")
|
||||
.trim_start();
|
||||
let lower = body.get(..body.len().min(32))?.to_ascii_lowercase();
|
||||
if lower.starts_with("endregion") || lower.starts_with("#endregion") || lower.starts_with("</editor-fold") {
|
||||
/// Strip whole configured delimiters, preserving the `<` in editor-fold tags.
|
||||
pub(super) fn region_marker(comment: &str, tokens: &CommentTokens) -> Option<bool> {
|
||||
let comment = comment.trim();
|
||||
let body = if let Some((open, close)) = &tokens.block
|
||||
&& let Some(body) = comment.strip_prefix(open.as_str())
|
||||
{
|
||||
let body = body.trim_end().strip_suffix(close.as_str())?;
|
||||
if open == "/*" { body.trim_start_matches('*') } else { body }
|
||||
} else if let Some(open) = &tokens.line
|
||||
&& let Some(body) = comment.strip_prefix(open.as_str())
|
||||
{
|
||||
match open.as_str() {
|
||||
"//" => body.trim_start_matches('/'),
|
||||
";" => body.trim_start_matches(';'),
|
||||
_ => body,
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let body = body.trim();
|
||||
if body.contains(['\n', '\r']) {
|
||||
return None;
|
||||
}
|
||||
let lower = body.to_ascii_lowercase();
|
||||
let region = lower.strip_prefix('#').unwrap_or(&lower);
|
||||
let boundary = |tail: &str| !tail.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_');
|
||||
if region.strip_prefix("endregion").is_some_and(boundary)
|
||||
|| lower
|
||||
.strip_prefix("</editor-fold")
|
||||
.and_then(|tail| tail.strip_suffix('>'))
|
||||
.is_some_and(|tail| tail.trim().is_empty())
|
||||
{
|
||||
Some(false)
|
||||
} else if lower.starts_with("region") || lower.starts_with("#region") || lower.starts_with("<editor-fold") {
|
||||
} else if region.strip_prefix("region").is_some_and(boundary)
|
||||
|| lower.strip_prefix("<editor-fold").is_some_and(|tail| boundary(tail) && tail.contains('>'))
|
||||
{
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Languages without a bundled grammar still support own-line custom markers.
|
||||
pub(super) fn custom_folding(text: &str, tokens: &CommentTokens) -> Vec<FoldRange> {
|
||||
let comments: Vec<_> = text
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter_map(|(row, line)| {
|
||||
let marker = region_marker(line, tokens)?;
|
||||
Some(Comment { start_row: row, end_row: row, own_line: true, marker: Some(marker) })
|
||||
})
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
comment_folds(&comments, &mut out);
|
||||
out.sort_by_key(|f| f.start_line);
|
||||
out
|
||||
}
|
||||
|
||||
fn comment_folds(comments: &[Comment], out: &mut Vec<FoldRange>) {
|
||||
let mut regions: Vec<usize> = Vec::new();
|
||||
let mut run: Option<(usize, usize)> = None;
|
||||
|
||||
@@ -32,6 +32,7 @@ pub struct Syntax {
|
||||
grammars: Vec<Grammar>,
|
||||
/// Language id (and grammar name) -> index into `grammars`.
|
||||
by_lang: HashMap<String, usize>,
|
||||
comments: HashMap<String, folding::CommentTokens>,
|
||||
}
|
||||
|
||||
pub(crate) struct Grammar {
|
||||
@@ -107,7 +108,14 @@ impl Syntax {
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { grammars, by_lang }
|
||||
let mut comments = HashMap::new();
|
||||
for cfg in languages {
|
||||
comments.insert(cfg.id.as_str().to_owned(), folding::CommentTokens::from(cfg));
|
||||
if let Some(name) = &cfg.tree_sitter {
|
||||
comments.entry(name.clone()).or_insert_with(|| folding::CommentTokens::from(cfg));
|
||||
}
|
||||
}
|
||||
Self { grammars, by_lang, comments }
|
||||
}
|
||||
|
||||
/// Names of the bundled grammars (valid values for `tree-sitter = "..."`).
|
||||
@@ -161,8 +169,21 @@ impl Syntax {
|
||||
}
|
||||
|
||||
pub fn folding(&self, lang: &LanguageId, text: &str) -> Vec<FoldRange> {
|
||||
self.analyze(lang, text, |g, tree, text| Some(folding::folding(g.spec, g.kinds(), tree, text)))
|
||||
.unwrap_or_default()
|
||||
let tokens = self.comment_tokens(lang);
|
||||
self.analyze(lang, text, |g, tree, text| Some(folding::folding(g.spec, g.kinds(), tree, text, tokens)))
|
||||
.unwrap_or_else(|| self.custom_folding(lang, text))
|
||||
}
|
||||
|
||||
fn comment_tokens(&self, lang: &LanguageId) -> &folding::CommentTokens {
|
||||
static EMPTY: OnceLock<folding::CommentTokens> = OnceLock::new();
|
||||
self.comments.get(lang.as_str()).unwrap_or_else(|| EMPTY.get_or_init(Default::default))
|
||||
}
|
||||
|
||||
fn custom_folding(&self, lang: &LanguageId, text: &str) -> Vec<FoldRange> {
|
||||
if text.len() > MAX_PARSE_BYTES {
|
||||
return Vec::new();
|
||||
}
|
||||
folding::custom_folding(text, self.comment_tokens(lang))
|
||||
}
|
||||
|
||||
pub fn tags(&self, lang: &LanguageId, text: &str) -> Vec<SymbolTag> {
|
||||
|
||||
@@ -969,6 +969,85 @@ fn g() {}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folding_xml_custom_regions() {
|
||||
for (start, end) in [
|
||||
("<!--region要折叠的内容-->", "<!-- endregion -->"),
|
||||
("<!--region name-->", "<!--endregion-->"),
|
||||
("<!-- <editor-fold desc=\"Head\"> -->", "<!-- </editor-fold> -->"),
|
||||
] {
|
||||
let text = format!("<project>\n {start}\n <name>demo</name>\n {end}\n</project>\n");
|
||||
assert!(folds("xml", &text).contains(&(1, 3, FoldKind::Region)), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folding_custom_regions_use_configured_comment_tokens() {
|
||||
let fixture: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../../fixtures/folding/regions.json")).unwrap();
|
||||
let registry = LanguageRegistry::load(None).unwrap();
|
||||
let cache = SyntaxCache::new(std::sync::Arc::new(Syntax::new()));
|
||||
for language in fixture["languages"].as_array().unwrap() {
|
||||
let id = language.as_str().unwrap();
|
||||
let cfg = registry.get(&lang(id)).unwrap();
|
||||
let mut tokens = Vec::new();
|
||||
if let Some(line) = &cfg.line_comment {
|
||||
tokens.push((line.as_str(), ""));
|
||||
}
|
||||
if let Some((open, close)) = &cfg.block_comment {
|
||||
tokens.push((open.as_str(), close.as_str()));
|
||||
}
|
||||
for (open, close) in tokens {
|
||||
for space in ["", " ", " "] {
|
||||
// Word delimiters require a separator in the language itself.
|
||||
if space.is_empty() && open.ends_with(|c: char| c.is_ascii_alphanumeric()) {
|
||||
continue;
|
||||
}
|
||||
let wrap = |body: &str| format!(" {open}{space}{body}{space}{close}");
|
||||
for marker in fixture["markers"].as_array().unwrap() {
|
||||
let start = wrap(marker["start"].as_str().unwrap());
|
||||
let end = wrap(marker["end"].as_str().unwrap());
|
||||
let tokens = folding::CommentTokens::from(&cfg);
|
||||
assert_eq!(folding::region_marker(&start, &tokens), Some(true), "{id}: {start}");
|
||||
assert_eq!(folding::region_marker(&end, &tokens), Some(false), "{id}: {end}");
|
||||
// Ruby block delimiters must occupy separate lines; Haskell
|
||||
// interprets `--` followed immediately by a symbol as an operator.
|
||||
if (id == "ruby" && open == "=begin") || (id == "haskell" && space.is_empty()) {
|
||||
continue;
|
||||
}
|
||||
let text = format!("{start}\n\n{end}\n");
|
||||
let expected = vec![(0, 2, FoldKind::Region)];
|
||||
assert_eq!(folds(id, &text), expected, "{id}: {text}");
|
||||
let doc = ide_core::DocId::next();
|
||||
cache.open(doc, &lang(id), 0);
|
||||
let actual = cache.folding(doc, &lang(id), 0, &text);
|
||||
assert_eq!(cache.folding(doc, &lang(id), 0, &text), actual);
|
||||
cache.close(doc);
|
||||
assert_eq!(
|
||||
actual.iter().map(|f| (f.start_line, f.end_line, f.kind)).collect::<Vec<_>>(),
|
||||
expected,
|
||||
"cached {id}: {text}"
|
||||
);
|
||||
let nested = format!("{end}\n\n{start}\n{start}\n\n{end}\n{end}\n{start}\n");
|
||||
assert_eq!(
|
||||
folds(id, &nested),
|
||||
vec![(2, 6, FoldKind::Region), (3, 5, FoldKind::Region)],
|
||||
"nested {id}: {nested}"
|
||||
);
|
||||
}
|
||||
for invalid in fixture["invalid"].as_array().unwrap() {
|
||||
let start = wrap(invalid.as_str().unwrap());
|
||||
assert_eq!(
|
||||
folding::region_marker(&start, &folding::CommentTokens::from(&cfg)),
|
||||
None,
|
||||
"invalid {id}: {start}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folding_python_bodies_start_at_header() {
|
||||
let src = "import os\nimport sys\n\nclass A:\n def f(self):\n return [\n 1,\n ]\n\n\ndef g():\n pass\n";
|
||||
|
||||
@@ -7,6 +7,8 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
dunce.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
@@ -19,6 +21,7 @@ parking_lot.workspace = true
|
||||
tracing.workspace = true
|
||||
futures.workspace = true
|
||||
globset.workspace = true
|
||||
ignore.workspace = true
|
||||
toml.workspace = true
|
||||
percent-encoding = "2"
|
||||
|
||||
|
||||
@@ -661,7 +661,9 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
|
||||
}
|
||||
}
|
||||
}
|
||||
WorkspaceEdit { files }
|
||||
let mut edit = WorkspaceEdit { files };
|
||||
edit.deduplicate();
|
||||
edit
|
||||
}
|
||||
|
||||
/// Adds the changes of `extra`, another server's edit for the same request, to
|
||||
@@ -710,6 +712,14 @@ pub fn goto_response(conv: &Conv, r: lsp::GotoDefinitionResponse) -> Vec<Locatio
|
||||
}
|
||||
|
||||
pub fn dedup_locations(v: &mut Vec<Location>) {
|
||||
let mut canonical = std::collections::HashMap::new();
|
||||
for location in v.iter_mut().filter(|l| l.uri.is_none()) {
|
||||
let path =
|
||||
canonical.entry(location.path.clone()).or_insert_with(|| dunce::canonicalize(&location.path).ok()).clone();
|
||||
if let Some(path) = path {
|
||||
location.path = path.into();
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
v.retain(|l| seen.insert(l.clone()));
|
||||
}
|
||||
@@ -1405,3 +1415,24 @@ mod tests {
|
||||
assert!(document_link(&conv, here, link(None)).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod alias_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn references_to_a_file_and_its_symlink_are_one_location() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dir.path().join("main.cpp");
|
||||
let alias = dir.path().join("alias.cpp");
|
||||
std::fs::write(&real, "add()").unwrap();
|
||||
std::os::unix::fs::symlink(&real, &alias).unwrap();
|
||||
let mut locations: Vec<Location> = [real.clone(), alias]
|
||||
.into_iter()
|
||||
.map(|p| Location { path: p.into(), range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), uri: None })
|
||||
.collect();
|
||||
dedup_locations(&mut locations);
|
||||
assert_eq!(locations.len(), 1);
|
||||
assert_eq!(locations[0].path.as_ref(), dunce::canonicalize(real).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Give tsserver a project for unopened sources, without creating a config file.
|
||||
use crate::LspManager;
|
||||
use crate::server::{Running, Server};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
pub type ExcludesFn = dyn Fn() -> Vec<String> + Send + Sync;
|
||||
const COMMAND: &str = "typescript.tsserverRequest";
|
||||
const LIMIT: usize = 10_000;
|
||||
const SCAN_LIMIT: usize = 100_000;
|
||||
/// tsserver turns its language service off past 20 MiB of JavaScript in a project.
|
||||
const SCRIPT_BYTES_LIMIT: u64 = 16 << 20;
|
||||
const REFRESH_DELAY: Duration = Duration::from_millis(300);
|
||||
const TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct State {
|
||||
generation: u64,
|
||||
files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn supported(server: &Server, run: &Running) -> bool {
|
||||
server.config.tsserver_project
|
||||
&& run
|
||||
.caps
|
||||
.read()
|
||||
.raw
|
||||
.pointer("/executeCommandProvider/commands")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|commands| commands.iter().any(|c| c.as_str() == Some(COMMAND)))
|
||||
}
|
||||
|
||||
fn configured(dir: &Path) -> bool {
|
||||
["tsconfig.json", "jsconfig.json"].iter().any(|name| dir.join(name).is_file())
|
||||
}
|
||||
|
||||
fn is_script(ext: &str) -> bool {
|
||||
matches!(ext, "js" | "jsx" | "mjs" | "cjs")
|
||||
}
|
||||
|
||||
fn is_source(ext: &str) -> bool {
|
||||
is_script(ext) || matches!(ext, "ts" | "tsx" | "mts" | "cts")
|
||||
}
|
||||
|
||||
/// All-or-nothing at the caps: a partial project could displace an inferred
|
||||
/// project while omitting some of its files. Configured trees stay with tsserver.
|
||||
fn source_files(root: &Path, exclude_root: &Path, excludes: Vec<String>, limit: usize) -> Vec<PathBuf> {
|
||||
if root.ancestors().any(configured) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut ignores = ignore::gitignore::GitignoreBuilder::new(exclude_root);
|
||||
for pattern in excludes {
|
||||
if ignores.add_line(None, &pattern).is_err() {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
let Ok(ignores) = ignores.build() else { return Vec::new() };
|
||||
let mut walk = ignore::WalkBuilder::new(root);
|
||||
walk.require_git(false).follow_links(false).filter_entry(move |entry| {
|
||||
if entry.depth() == 0 {
|
||||
return true;
|
||||
}
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy();
|
||||
!matches!(name.as_ref(), "node_modules" | "dist" | "build" | "out" | "coverage" | "target" | ".git")
|
||||
&& !ignores.matched_path_or_any_parents(path, entry.file_type().is_some_and(|t| t.is_dir())).is_ignore()
|
||||
});
|
||||
let mut files = Vec::new();
|
||||
let mut script_bytes = 0;
|
||||
for (visited, entry) in walk.build().enumerate() {
|
||||
// Do not claim a tree is config-less if we could not inspect it fully.
|
||||
let Ok(entry) = entry else { return Vec::new() };
|
||||
if visited >= SCAN_LIMIT {
|
||||
return Vec::new();
|
||||
}
|
||||
if entry.file_type().is_some_and(|t| t.is_dir()) && configured(entry.path()) {
|
||||
return Vec::new();
|
||||
}
|
||||
let ext = entry.path().extension().and_then(|s| s.to_str()).unwrap_or_default();
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) && is_source(ext) {
|
||||
if is_script(ext) {
|
||||
script_bytes += entry.metadata().map_or(0, |m| m.len());
|
||||
if script_bytes > SCRIPT_BYTES_LIMIT {
|
||||
tracing::debug!("external TypeScript project exceeds its script size; retaining inferred projects");
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
files.push(entry.into_path());
|
||||
if files.len() > limit {
|
||||
tracing::debug!("external TypeScript project exceeds {limit} files; retaining inferred projects");
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
fn project_name(server: &Server) -> PathBuf {
|
||||
server.root.join(".openidea-external-project")
|
||||
}
|
||||
|
||||
async fn request(run: &Running, command: &str, args: Value) -> bool {
|
||||
let request = run.peer.request::<_, Value>(
|
||||
"workspace/executeCommand",
|
||||
json!({
|
||||
"command": COMMAND,
|
||||
"arguments": [command, args, { "isAsync": false, "expectsResult": true }]
|
||||
}),
|
||||
None,
|
||||
);
|
||||
match tokio::time::timeout(TIMEOUT, request).await {
|
||||
Ok(Ok(result)) if result.get("success").and_then(Value::as_bool) == Some(true) => true,
|
||||
result => {
|
||||
tracing::debug!("tsserver {command}: {result:?}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
pub fn set_project_excludes_hook(&self, hook: Box<ExcludesFn>) {
|
||||
*self.excludes_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_external_project(&self, server: &Server, run: &Running) {
|
||||
if !supported(server, run) {
|
||||
return;
|
||||
}
|
||||
let mut state = server.external_project.lock().await;
|
||||
if state.generation != run.generation {
|
||||
*state = State { generation: run.generation, files: Vec::new() };
|
||||
server.external_diagnostics.store(false, Ordering::Release);
|
||||
}
|
||||
let root = server.root.clone();
|
||||
let exclude_root = self.project_root_for(&root);
|
||||
let hook = self.excludes_hook.read().clone();
|
||||
let files = tokio::task::spawn_blocking(move || {
|
||||
source_files(&root, &exclude_root, hook.map(|h| h()).unwrap_or_default(), LIMIT)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if state.files == files {
|
||||
return;
|
||||
}
|
||||
if files.is_empty() {
|
||||
if request(run, "closeExternalProject", json!({ "projectFileName": project_name(server) })).await {
|
||||
state.files.clear();
|
||||
// Keep suppressing delayed diagnostics for unopened files from this instance.
|
||||
}
|
||||
return;
|
||||
}
|
||||
server.external_diagnostics.store(true, Ordering::Release);
|
||||
let roots: Vec<_> = files.iter().map(|p| json!({ "fileName": p })).collect();
|
||||
if request(
|
||||
run,
|
||||
"openExternalProject",
|
||||
json!({
|
||||
"projectFileName": project_name(server), "rootFiles": roots,
|
||||
// What an inferred project assumes, so sharing a project changes no diagnostics.
|
||||
"options": { "allowJs": true, "target": "ES2022", "module": "ESNext",
|
||||
"moduleResolution": "Node", "allowNonTsExtensions": true, "jsx": "preserve",
|
||||
"allowSyntheticDefaultImports": true, "strictNullChecks": true,
|
||||
"strictFunctionTypes": true, "moduleDetection": "force" }
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(
|
||||
"{}: registered {} external project files at {}",
|
||||
server.name,
|
||||
files.len(),
|
||||
server.root.display()
|
||||
);
|
||||
state.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-registers config-less projects after sources or configurations changed on disk.
|
||||
pub fn project_files_changed<'a>(&self, paths: impl IntoIterator<Item = &'a PathBuf>) {
|
||||
let relevant = paths.into_iter().any(|p| {
|
||||
p.extension().and_then(|s| s.to_str()).is_some_and(is_source)
|
||||
|| p.file_name().is_some_and(|n| n == "tsconfig.json" || n == "jsconfig.json")
|
||||
});
|
||||
if !relevant {
|
||||
return;
|
||||
}
|
||||
for server in self.servers.lock().iter().filter(|s| s.config.tsserver_project) {
|
||||
if server.external_refresh_queued.swap(true, Ordering::AcqRel) {
|
||||
continue;
|
||||
}
|
||||
let (me, server) = (self.me.clone(), server.clone());
|
||||
self.runtime.spawn(async move {
|
||||
tokio::time::sleep(REFRESH_DELAY).await;
|
||||
server.external_refresh_queued.store(false, Ordering::Release);
|
||||
if let (Some(me), Some(run)) = (me.upgrade(), server.running()) {
|
||||
me.refresh_external_project(&server, &run).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn close_external_project(&self, server: &Server, run: &Running) {
|
||||
let mut state = server.external_project.lock().await;
|
||||
if state.generation == run.generation && !state.files.is_empty() {
|
||||
request(run, "closeExternalProject", json!({ "projectFileName": project_name(server) })).await;
|
||||
state.files.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, path: &str) {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, "").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_sources_honouring_ignores_exclusions_and_roots() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
for path in [
|
||||
"a.ts",
|
||||
"src/b.js",
|
||||
"src/c.tsx",
|
||||
"readme.md",
|
||||
"node_modules/n.ts",
|
||||
"dist/d.js",
|
||||
"excluded/e.ts",
|
||||
"ignored/i.ts",
|
||||
"other/f.py",
|
||||
] {
|
||||
write(root, path);
|
||||
}
|
||||
std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
|
||||
assert_eq!(
|
||||
source_files(root, root, vec!["excluded".into()], LIMIT),
|
||||
vec![root.join("a.ts"), root.join("src/b.js"), root.join("src/c.tsx")]
|
||||
);
|
||||
assert!(source_files(root, root, vec![], 1).is_empty());
|
||||
std::fs::File::create(root.join("large.js")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
|
||||
assert!(source_files(root, root, vec![], LIMIT).is_empty());
|
||||
std::fs::remove_file(root.join("large.js")).unwrap();
|
||||
std::fs::File::create(root.join("large.ts")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
|
||||
assert_eq!(source_files(root, root, vec!["excluded".into()], LIMIT).len(), 4);
|
||||
std::fs::remove_file(root.join("large.ts")).unwrap();
|
||||
assert_eq!(source_files(&root.join("src"), root, vec![], LIMIT).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_ancestor_and_nested_configs_keep_existing_projects() {
|
||||
for name in ["tsconfig.json", "jsconfig.json"] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
write(root, "a.ts");
|
||||
write(root, "pkg/b.js");
|
||||
assert_eq!(source_files(root, root, vec![], LIMIT).len(), 2);
|
||||
write(root, &format!("pkg/{name}"));
|
||||
assert!(source_files(root, root, vec![], LIMIT).is_empty());
|
||||
std::fs::remove_file(root.join("pkg").join(name)).unwrap();
|
||||
write(root, name);
|
||||
assert!(source_files(root, root, vec![], LIMIT).is_empty());
|
||||
assert!(source_files(&root.join("pkg"), root, vec![], LIMIT).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additions_deletions_and_renames_change_membership() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "a.js");
|
||||
let first = source_files(dir.path(), dir.path(), vec![], LIMIT);
|
||||
write(dir.path(), "b.ts");
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT).len(), 2);
|
||||
std::fs::rename(dir.path().join("b.ts"), dir.path().join("b.txt")).unwrap();
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT), first);
|
||||
std::fs::remove_file(dir.path().join("a.js")).unwrap();
|
||||
assert!(source_files(dir.path(), dir.path(), vec![], LIMIT).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,10 @@ impl ClientHandler {
|
||||
};
|
||||
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
|
||||
// Files opened only for a request (references search) are not the user's problems.
|
||||
if self.server.suppresses_diagnostics(&path) {
|
||||
if (self.server.external_diagnostics.load(std::sync::atomic::Ordering::Acquire)
|
||||
&& !mgr.docs.lock().values().any(|d| d.path == path))
|
||||
|| self.server.suppresses_diagnostics(&path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let conv = mgr.conv(self.encoding());
|
||||
@@ -186,9 +189,12 @@ impl ClientHandler {
|
||||
// Convert before calling the hook: it re-enters document sync.
|
||||
let edit = convert::workspace_edit(&mgr.conv(self.encoding()), edit);
|
||||
let applied = match mgr.apply_edit_hook() {
|
||||
Some(hook) => hook(edit),
|
||||
Some(hook) => hook(edit.clone()),
|
||||
None => false,
|
||||
};
|
||||
if applied {
|
||||
let _ = mgr.applied_edits.send((self.server.id, edit));
|
||||
}
|
||||
let mut reply = json!({ "applied": applied });
|
||||
if !applied {
|
||||
reply["failureReason"] = json!("edit could not be applied");
|
||||
@@ -228,6 +234,9 @@ impl Handler for ClientHandler {
|
||||
}
|
||||
}
|
||||
"tsserver/request" => mgr.forward_tsserver_requests(self.server.clone(), self.generation, params),
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
|
||||
mgr.java_import_ready(self.server.clone(), self.generation);
|
||||
}
|
||||
// The Angular server's project loading, shown like `$/progress`; one
|
||||
// task while several projects load.
|
||||
"angular/projectLoadingStart" if !self.server.progress.lock().contains_key(ANGULAR_LOADING) => self
|
||||
@@ -285,6 +294,21 @@ impl Handler for ClientHandler {
|
||||
/// `initializationOptions` with its `settings` merged over them.
|
||||
pub(crate) fn all_settings(cfg: &ide_language::LanguageServerConfig) -> Value {
|
||||
let mut out = cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or(Value::Null);
|
||||
if cfg.name == "jdtls" {
|
||||
out = out.get("settings").cloned().unwrap_or_else(|| json!({}));
|
||||
if let Some(settings) = &cfg.settings {
|
||||
// Accept the initialization-options wrapper as well as LSP settings.
|
||||
if let Some(nested) = settings.get("settings") {
|
||||
merge(&mut out, nested.clone());
|
||||
}
|
||||
let mut direct = settings.clone();
|
||||
if let Some(direct) = direct.as_object_mut() {
|
||||
direct.remove("settings");
|
||||
}
|
||||
merge(&mut out, direct);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if let Some(settings) = &cfg.settings {
|
||||
merge(&mut out, settings.clone());
|
||||
}
|
||||
|
||||
+400
-14
@@ -17,6 +17,9 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SERVER: &str = "jdtls";
|
||||
|
||||
mod formatter;
|
||||
pub use formatter::configure_formatter;
|
||||
/// First start imports the Maven / Gradle model, which can take minutes.
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(240);
|
||||
/// A workspace build of a large multi-module project can take far longer than
|
||||
@@ -30,6 +33,97 @@ const MAX_LAUNCH_ERRORS: usize = 10;
|
||||
/// Build markers can be published just after `java/buildWorkspace` answers.
|
||||
const ERRORS_WAIT: Duration = Duration::from_secs(2);
|
||||
|
||||
const ACCESS_OPTIONS: [&str; 2] = [
|
||||
"org.eclipse.jdt.core.compiler.problem.forbiddenReference",
|
||||
"org.eclipse.jdt.core.compiler.problem.discouragedReference",
|
||||
];
|
||||
|
||||
/// Keep the source model in protocol settings so jdtls's invisible-project
|
||||
/// initialization and configuration use the same roots as workspace builds.
|
||||
pub fn configure_source_paths(cfg: &mut ide_language::LanguageServerConfig, paths: Option<Vec<String>>) {
|
||||
if cfg.name != SERVER {
|
||||
return;
|
||||
}
|
||||
if let Some(paths) = paths {
|
||||
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
|
||||
settings["java"]["project"]["sourcePaths"] = json!(paths);
|
||||
cfg.settings = Some(settings);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_paths_removed(
|
||||
current: &ide_language::LanguageServerConfig,
|
||||
next: &ide_language::LanguageServerConfig,
|
||||
) -> bool {
|
||||
current.name == SERVER
|
||||
&& crate::handler::all_settings(current).pointer("/java/project/sourcePaths").is_some()
|
||||
&& crate::handler::all_settings(next).pointer("/java/project/sourcePaths").is_none()
|
||||
}
|
||||
|
||||
pub(crate) fn source_trigger(root: &Path, paths: &[Value]) -> Option<PathBuf> {
|
||||
for path in paths.iter().filter_map(Value::as_str) {
|
||||
// Runs can start jdtls without an editor. Invisible-project import still
|
||||
// needs a Java trigger file; discovery is bounded and runs off async workers.
|
||||
let found = ignore::WalkBuilder::new(root.join(path))
|
||||
.standard_filters(false)
|
||||
.max_depth(Some(32))
|
||||
.build()
|
||||
.take(4000)
|
||||
.filter_map(Result::ok)
|
||||
.find(|e| {
|
||||
e.file_type().is_some_and(|t| t.is_file()) && e.path().extension().is_some_and(|ext| ext == "java")
|
||||
});
|
||||
if let Some(entry) = found {
|
||||
return Some(entry.into_path());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn compiler_defaults(cfg: &ide_language::LanguageServerConfig) -> Value {
|
||||
// updateSettings persists project options. Only use it when jdtls redirects
|
||||
// generated metadata into its workspace, and never touch an existing .settings.
|
||||
let metadata = cfg.args.iter().rev().find_map(|a| {
|
||||
a.strip_prefix("--jvm-arg=").unwrap_or(a).strip_prefix("-Djava.import.generatesMetadataFilesAtProjectRoot=")
|
||||
});
|
||||
if metadata != Some("false") {
|
||||
return json!({});
|
||||
}
|
||||
let settings = crate::handler::all_settings(cfg);
|
||||
let Some(url) = settings
|
||||
.pointer("/java/settings/url")
|
||||
.or_else(|| settings.pointer("/settings/java/settings/url"))
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
return json!({});
|
||||
};
|
||||
let path = crate::uri::uri_to_path(url).unwrap_or_else(|| PathBuf::from(url));
|
||||
let Ok(prefs) = std::fs::read_to_string(path) else { return json!({}) };
|
||||
let mut options = serde_json::Map::new();
|
||||
for line in prefs.lines() {
|
||||
if let Some((key, value)) = line.split_once('=')
|
||||
&& ACCESS_OPTIONS.contains(&key.trim())
|
||||
&& ["error", "warning", "info", "ignore"].contains(&value.trim())
|
||||
{
|
||||
options.insert(key.trim().into(), json!(value.trim()));
|
||||
}
|
||||
}
|
||||
Value::Object(options)
|
||||
}
|
||||
|
||||
fn cache_owned_maven_settings(root: &Path) -> bool {
|
||||
root.join("pom.xml").is_file() && matches!(root.join(".settings").try_exists(), Ok(false))
|
||||
}
|
||||
|
||||
fn project_path(uri: &str) -> Option<PathBuf> {
|
||||
// jdtls commands use Java's `file:/p` as well as LSP's `file:///p`.
|
||||
let normalized = match uri.strip_prefix("file:") {
|
||||
Some(rest) if !rest.starts_with("//") => format!("file://{rest}"),
|
||||
_ => uri.to_owned(),
|
||||
};
|
||||
crate::uri::uri_to_path(&normalized)
|
||||
}
|
||||
|
||||
/// Result of `java/buildWorkspace` (jdtls `BuildWorkspaceStatus`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -71,6 +165,8 @@ pub struct JavaLaunchInfo {
|
||||
pub project_root: Option<ide_core::IpcPath>,
|
||||
pub class_paths: Vec<String>,
|
||||
pub module_paths: Vec<String>,
|
||||
/// Path list separator of the host that runs the JVM.
|
||||
pub path_list_separator: String,
|
||||
/// JDK home jdtls compiles the project against.
|
||||
pub java_home: Option<ide_core::IpcPath>,
|
||||
/// The `java` launcher to use (filled in by the caller from the installed JDKs).
|
||||
@@ -96,6 +192,24 @@ pub struct JavaCompileError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Holds off JDT writers while an external compiler builds and snapshots launch outputs.
|
||||
/// Restores current settings even when preparation fails or is cancelled.
|
||||
pub struct JavaExternalBuild {
|
||||
server: Arc<Server>,
|
||||
run: Running,
|
||||
_lock: tokio::sync::OwnedMutexGuard<()>,
|
||||
}
|
||||
|
||||
impl Drop for JavaExternalBuild {
|
||||
fn drop(&mut self) {
|
||||
self.server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
let settings = crate::handler::all_settings(&self.server.effective_config());
|
||||
if let Err(e) = self.run.peer.notify("workspace/didChangeConfiguration", json!({ "settings": settings })) {
|
||||
tracing::debug!("jdtls: restoring autobuild: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error diagnostics sorted by file and position, capped at `limit`, and their total.
|
||||
pub fn compile_errors(
|
||||
diagnostics: Vec<(PathBuf, lsp_types::Diagnostic)>,
|
||||
@@ -126,14 +240,10 @@ pub fn parse_classpaths(v: &Value) -> JavaClasspath {
|
||||
.unwrap_or_default()
|
||||
};
|
||||
JavaClasspath {
|
||||
project_root: v.get("projectRoot").and_then(Value::as_str).map(|r| {
|
||||
// jdtls answers `file:/p/demo` (Java URI style) as well as `file:///p/demo`.
|
||||
let normalized = match r.strip_prefix("file:") {
|
||||
Some(rest) if !rest.starts_with("//") => format!("file://{rest}"),
|
||||
_ => r.to_owned(),
|
||||
};
|
||||
crate::uri::uri_to_path(&normalized).unwrap_or_else(|| PathBuf::from(r)).into()
|
||||
}),
|
||||
project_root: v
|
||||
.get("projectRoot")
|
||||
.and_then(Value::as_str)
|
||||
.map(|r| project_path(r).unwrap_or_else(|| PathBuf::from(r)).into()),
|
||||
class_paths: strings("classpaths"),
|
||||
module_paths: strings("modulepaths"),
|
||||
}
|
||||
@@ -174,6 +284,56 @@ async fn cancellable_wait<T>(cancel: &CancellationToken, wait: impl std::future:
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
/// m2e generates a project-specific forbidden-reference error, overriding
|
||||
/// workspace defaults. Apply those defaults only to cache-owned metadata.
|
||||
async fn java_configure_compiler(&self, server: &Server, run: &Running) -> IdeResult<bool> {
|
||||
let options = compiler_defaults(&server.effective_config());
|
||||
if options.as_object().is_none_or(|o| o.is_empty()) {
|
||||
return Ok(false);
|
||||
}
|
||||
let command = |name: &str, arguments: Value| {
|
||||
let params = json!({"command": name, "arguments": arguments});
|
||||
let peer = run.peer.clone();
|
||||
async move {
|
||||
tokio::time::timeout(
|
||||
COMMAND_TIMEOUT,
|
||||
peer.request::<_, Value>("workspace/executeCommand", params, None),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(Err(ide_jsonrpc::RpcError::Timeout))
|
||||
.map_err(|e| crate::rpc_err(SERVER, "workspace/executeCommand", e))
|
||||
}
|
||||
};
|
||||
let projects = command("java.project.getAll", json!([])).await?;
|
||||
let mut changed = false;
|
||||
for uri in projects.as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
let Some(root) = project_path(uri) else { continue };
|
||||
// An existing .settings is owned by the project, even if it currently
|
||||
// contains preferences for another Eclipse plugin.
|
||||
if !cache_owned_maven_settings(&root) {
|
||||
continue;
|
||||
}
|
||||
let keys: Vec<_> = options.as_object().unwrap().keys().collect();
|
||||
let current = command("java.project.getSettings", json!([uri, keys])).await?;
|
||||
if options.as_object().unwrap().iter().all(|(k, v)| current.get(k) == Some(v)) {
|
||||
continue;
|
||||
}
|
||||
command("java.project.updateSettings", json!([uri, options])).await?;
|
||||
changed = true;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub(crate) fn java_import_ready(self: &Arc<Self>, server: Arc<Server>, generation: u64) {
|
||||
let mgr = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let Some(run) = server.running_gen(generation) else { return };
|
||||
if let Err(e) = mgr.java_configure_compiler(&server, &run).await {
|
||||
tracing::warn!("jdtls: cannot apply compiler defaults: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The jdtls instance serving `path` (a module directory or source file),
|
||||
/// started if needed and waited for.
|
||||
async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc<Server>, Running)> {
|
||||
@@ -192,7 +352,7 @@ impl LspManager {
|
||||
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::Misconfigured | ide_core::event::ServerStatus::Crashed => {
|
||||
server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned())
|
||||
}
|
||||
_ => format!("jdtls is not ready ({status:?})"),
|
||||
@@ -209,9 +369,12 @@ impl LspManager {
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Value> {
|
||||
let started = Instant::now();
|
||||
let (server, run) = self.jdtls(path, cancel).await?;
|
||||
let ready_ms = started.elapsed().as_millis();
|
||||
let phase = params.get("command").and_then(Value::as_str).unwrap_or(method).to_owned();
|
||||
let fut = run.peer.request::<Value, Value>(method, params, Some(cancel.clone()));
|
||||
match tokio::time::timeout(timeout, fut).await {
|
||||
let result = match tokio::time::timeout(timeout, fut).await {
|
||||
Ok(r) => r.map_err(|e| crate::rpc_err(&server.name, method, e)),
|
||||
Err(_) => Err(IdeError::new(
|
||||
ErrorKind::Protocol,
|
||||
@@ -220,7 +383,15 @@ impl LspManager {
|
||||
timeout.as_secs()
|
||||
),
|
||||
)),
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
phase,
|
||||
ready_ms,
|
||||
request_ms = started.elapsed().as_millis() - ready_ms,
|
||||
success = result.is_ok(),
|
||||
"Java launch request"
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
/// `workspace/executeCommand` on the jdtls serving `path`.
|
||||
@@ -228,7 +399,8 @@ impl LspManager {
|
||||
self.jdtls_command_cancellable(path, command, arguments, &CancellationToken::new()).await
|
||||
}
|
||||
|
||||
async fn jdtls_command_cancellable(
|
||||
/// Cancellable delegate commands for project-owned background services.
|
||||
pub async fn jdtls_command_cancellable(
|
||||
&self,
|
||||
path: &Path,
|
||||
command: &str,
|
||||
@@ -283,10 +455,38 @@ impl LspManager {
|
||||
.map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e))
|
||||
}
|
||||
|
||||
/// Pause autobuild before resolving the launch classpath. That query takes the
|
||||
/// Java project's workspace scheduling rule, draining the interrupted autobuild.
|
||||
/// Keep this guard until Maven outputs have been copied out of JDT's reach.
|
||||
pub async fn java_external_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<JavaExternalBuild> {
|
||||
let (server, run) = self.jdtls(path, cancel).await?;
|
||||
let lock = cancellable_wait(cancel, server.java_build_lock.clone().lock_owned()).await?;
|
||||
server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let guard = JavaExternalBuild { server, run, _lock: lock };
|
||||
let settings = crate::handler::all_settings(&guard.server.effective_config());
|
||||
guard
|
||||
.run
|
||||
.peer
|
||||
.notify("workspace/didChangeConfiguration", json!({ "settings": settings }))
|
||||
.map_err(|e| crate::rpc_err(SERVER, "workspace/didChangeConfiguration", e))?;
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
/// Incremental build of the jdtls workspace (`java/buildWorkspace`), so
|
||||
/// class files are up to date before a launch.
|
||||
pub async fn java_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<JavaBuildStatus> {
|
||||
let v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
|
||||
let (server, _) = self.jdtls(path, cancel).await?;
|
||||
let _lock = cancellable_wait(cancel, server.java_build_lock.lock()).await?;
|
||||
let mut v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
|
||||
// The first build can finish the Maven import before ServiceReady is handled.
|
||||
let (server, run) = self.jdtls(path, cancel).await?;
|
||||
let changed = cancellable_wait(cancel, self.java_configure_compiler(&server, &run)).await??;
|
||||
// ServiceReady can update the options concurrently with that first build.
|
||||
let recheck = v.as_i64() == Some(2)
|
||||
&& compiler_defaults(&server.effective_config()).as_object().is_some_and(|o| !o.is_empty());
|
||||
if changed || recheck {
|
||||
v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
|
||||
}
|
||||
Ok(JavaBuildStatus::from_code(v.as_i64().unwrap_or(0)))
|
||||
}
|
||||
|
||||
@@ -351,6 +551,7 @@ impl LspManager {
|
||||
project_root: cp.project_root,
|
||||
class_paths: cp.class_paths,
|
||||
module_paths: cp.module_paths,
|
||||
path_list_separator: if cfg!(windows) { ";" } else { ":" }.into(),
|
||||
java_home: java_home.map(Into::into),
|
||||
java_exec: None,
|
||||
shorten_command_line: None,
|
||||
@@ -372,6 +573,59 @@ impl LspManager {
|
||||
collect()
|
||||
}
|
||||
|
||||
/// Resolve a disk/archive class to project source or the server's binary document.
|
||||
/// Archive URIs must come from JDT's Java model: fabricated handles cannot map debug lines.
|
||||
pub async fn java_class_file(&self, path: &Path, archive: Option<(PathBuf, String)>) -> IdeResult<String> {
|
||||
let cancel = CancellationToken::new();
|
||||
self.jdtls(&self.project_root, &cancel).await?;
|
||||
if let Some((jar, entry)) = archive {
|
||||
let entry = entry
|
||||
.strip_prefix("META-INF/versions/")
|
||||
.and_then(|s| s.split_once('/').map(|(_, s)| s))
|
||||
.unwrap_or(&entry);
|
||||
let class = entry.strip_suffix(".class").ok_or_else(|| IdeError::not_found("expected a class file"))?;
|
||||
let outer = class.split('$').next().unwrap_or(class);
|
||||
let (package, name) = outer.rsplit_once('/').unwrap_or(("", outer));
|
||||
let query = outer.replace('/', ".");
|
||||
let symbols = self.workspace_symbols(&query, cancel).await?;
|
||||
let package = package.replace('/', ".");
|
||||
let name = name.to_owned();
|
||||
let outer = outer.to_owned();
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
symbols
|
||||
.into_iter()
|
||||
.filter_map(|s| s.location.uri)
|
||||
.find(|uri| crate::virtual_doc::matches_class(uri, &jar, &package, &name))
|
||||
.ok_or_else(|| {
|
||||
IdeError::not_found(format!("jdtls could not resolve {} in {}", outer, jar.display()))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
let uri = crate::uri::path_to_uri_string(path);
|
||||
// classFileContents accepts file URIs, but java-debug's line mapping only accepts JDT handles.
|
||||
let doc = self.virtual_document(&uri).await?;
|
||||
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").split('$').next().unwrap_or("");
|
||||
let package = doc
|
||||
.text
|
||||
.lines()
|
||||
.find_map(|l| l.trim().strip_prefix("package ").and_then(|p| p.strip_suffix(';')))
|
||||
.unwrap_or("");
|
||||
let relative = PathBuf::from(package.replace('.', "/")).join(format!("{name}.java"));
|
||||
let key = "org.eclipse.jdt.ls.core.sourcePaths";
|
||||
let settings = self
|
||||
.jdtls_project_command(path, "java.project.getSettings", vec![json!(uri), json!([key])], &cancel)
|
||||
.await?;
|
||||
let roots: Vec<PathBuf> =
|
||||
settings[key].as_array().into_iter().flatten().filter_map(Value::as_str).map(PathBuf::from).collect();
|
||||
let source =
|
||||
tokio::task::spawn_blocking(move || roots.into_iter().map(|r| r.join(&relative)).find(|p| p.is_file()))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
Ok(source.map(|s| s.to_string_lossy().into_owned()).unwrap_or(uri))
|
||||
}
|
||||
|
||||
/// Starts (or reuses) java-debug's DAP server inside jdtls and returns its port.
|
||||
pub async fn java_debug_server(&self, path: &Path) -> IdeResult<u16> {
|
||||
let v = self.jdtls_command(path, "vscode.java.startDebugSession", Vec::new()).await.map_err(|e| {
|
||||
@@ -382,7 +636,7 @@ impl LspManager {
|
||||
}
|
||||
})?;
|
||||
// Hot code replace after each (auto-)build; errors only cost the feature.
|
||||
let settings = json!({ "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false,
|
||||
let settings = json!({ "debugSupportOnDecompiledSource": "on", "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false,
|
||||
"showLogicalStructure": true, "showToString": true, "maxStringLength": 0,
|
||||
// java-debug NPEs on a missing level (java.util.logging names).
|
||||
"logLevel": "WARNING" });
|
||||
@@ -400,6 +654,65 @@ impl LspManager {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn java_config() -> ide_language::LanguageServerConfig {
|
||||
let language: ide_language::LanguageConfig =
|
||||
toml::from_str(include_str!("../../../languages/java.toml")).unwrap();
|
||||
language.language_servers.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_settings_preserve_java_defaults_and_library_resolution() {
|
||||
let mut cfg = java_config();
|
||||
configure_source_paths(&mut cfg, Some(vec!["src/main/java".into(), "tests".into()]));
|
||||
let settings = crate::handler::all_settings(&cfg);
|
||||
assert_eq!(settings["java"]["project"]["sourcePaths"], json!(["src/main/java", "tests"]));
|
||||
assert_eq!(settings["java"]["project"]["referencedLibraries"], json!(["lib/**/*.jar"]));
|
||||
assert_eq!(settings["java"]["autobuild"]["enabled"], true);
|
||||
assert_eq!(settings["java"]["contentProvider"]["preferred"], "fernflowerContentProvider");
|
||||
assert!(settings.get("bundles").is_none());
|
||||
assert!(settings.get("settings").is_none(), "didChangeConfiguration takes java settings directly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmarked_and_managed_source_models_are_not_overridden() {
|
||||
let mut cfg = java_config();
|
||||
let initial = cfg.clone();
|
||||
configure_source_paths(&mut cfg, None);
|
||||
assert_eq!(cfg, initial);
|
||||
assert!(crate::handler::all_settings(&cfg).pointer("/java/project/sourcePaths").is_none());
|
||||
configure_source_paths(&mut cfg, Some(vec!["custom".into()]));
|
||||
assert!(source_paths_removed(&cfg, &initial));
|
||||
assert!(!source_paths_removed(&initial, &cfg));
|
||||
let mut other = initial;
|
||||
other.name = "other".into();
|
||||
let initial = other.clone();
|
||||
configure_source_paths(&mut other, Some(vec!["custom".into()]));
|
||||
assert_eq!(other, initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_java_protocol_settings_override_defaults_in_either_shape() {
|
||||
for wrapped in [false, true] {
|
||||
let mut cfg = java_config();
|
||||
let settings = json!({"java": {"project": {"referencedLibraries": ["vendor/*.jar"]}}});
|
||||
cfg.settings = Some(if wrapped { json!({"settings": settings}) } else { settings });
|
||||
configure_source_paths(&mut cfg, Some(vec!["custom".into()]));
|
||||
let settings = crate::handler::all_settings(&cfg);
|
||||
assert_eq!(settings["java"]["project"]["referencedLibraries"], json!(["vendor/*.jar"]));
|
||||
assert_eq!(settings["java"]["project"]["sourcePaths"], json!(["custom"]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_without_an_editor_discovers_a_trigger_inside_its_source_roots() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("src/demo/engine")).unwrap();
|
||||
let file = dir.path().join("src/demo/engine/Main.java");
|
||||
std::fs::write(&file, "package demo.engine; public class Main {}").unwrap();
|
||||
assert_eq!(source_trigger(dir.path(), &[json!("generated"), json!("src")]), Some(file));
|
||||
assert_eq!(source_trigger(dir.path(), &[json!("tests")]), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_ends_a_pending_wait() {
|
||||
let cancel = CancellationToken::new();
|
||||
@@ -436,6 +749,79 @@ mod tests {
|
||||
assert_eq!(shown, [(4, 5, "a1"), (10, 5, "a2")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiler_defaults_require_redirected_metadata_and_respect_server_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("compiler defaults 测试.prefs");
|
||||
std::fs::write(&file, format!("{}=warning\n{}=ignore\nother=error\n", ACCESS_OPTIONS[0], ACCESS_OPTIONS[1]))
|
||||
.unwrap();
|
||||
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
|
||||
cfg.initialization_options = Some(
|
||||
toml::Value::try_from(json!({"settings": {"java": {"settings": {"url": file.to_string_lossy()}}}}))
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(compiler_defaults(&cfg), json!({}));
|
||||
for prefix in ["", "--jvm-arg="] {
|
||||
cfg.args = vec![format!("{prefix}-Djava.import.generatesMetadataFilesAtProjectRoot=false")];
|
||||
assert_eq!(compiler_defaults(&cfg), json!({ACCESS_OPTIONS[0]: "warning", ACCESS_OPTIONS[1]: "ignore"}));
|
||||
}
|
||||
cfg.args.push("--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=true".into());
|
||||
assert_eq!(compiler_defaults(&cfg), json!({}), "the final JVM property wins");
|
||||
cfg.args.pop();
|
||||
cfg.settings = Some(json!({"java": {"settings": {"url": "https://example.invalid/custom.prefs"}}}));
|
||||
assert_eq!(compiler_defaults(&cfg), json!({}), "explicit server settings win");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_settings_are_never_updated_on_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
assert!(!cache_owned_maven_settings(root));
|
||||
std::fs::write(root.join("pom.xml"), "<project/>").unwrap();
|
||||
assert!(cache_owned_maven_settings(root));
|
||||
let lsp_uri = crate::uri::path_to_uri_string(root);
|
||||
let java_uri = lsp_uri.replacen("file://", "file:", 1);
|
||||
for uri in [lsp_uri, java_uri] {
|
||||
assert_eq!(project_path(&uri).as_deref(), Some(root));
|
||||
assert!(cache_owned_maven_settings(&project_path(&uri).unwrap()));
|
||||
}
|
||||
std::fs::create_dir(root.join(".settings")).unwrap();
|
||||
assert!(!cache_owned_maven_settings(root), "even unrelated Eclipse settings belong to the project");
|
||||
let prefs = root.join(".settings/org.eclipse.jdt.core.prefs");
|
||||
std::fs::write(&prefs, format!("{}=error\n", ACCESS_OPTIONS[0])).unwrap();
|
||||
assert!(!cache_owned_maven_settings(root));
|
||||
assert_eq!(std::fs::read_to_string(prefs).unwrap(), format!("{}=error\n", ACCESS_OPTIONS[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_warnings_do_not_hide_real_compile_errors() {
|
||||
let path = PathBuf::from("src/Main.java");
|
||||
let restriction =
|
||||
"Access restriction: The type 'BASE64Decoder' is not accessible (restriction on required library rt.jar)";
|
||||
let diagnostic = |severity, message: &str| lsp_types::Diagnostic {
|
||||
severity: Some(severity),
|
||||
message: message.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let warnings: Vec<_> = [restriction, "The type 'BASE64Encoder' is discouraged"]
|
||||
.into_iter()
|
||||
.map(|message| (path.clone(), diagnostic(lsp_types::DiagnosticSeverity::WARNING, message)))
|
||||
.collect();
|
||||
assert_eq!(compile_errors(warnings.clone(), 10), (Vec::new(), 0));
|
||||
let mut diagnostics = warnings;
|
||||
diagnostics.push((
|
||||
path.clone(),
|
||||
diagnostic(lsp_types::DiagnosticSeverity::ERROR, "Missing cannot be resolved to a type"),
|
||||
));
|
||||
let (errors, count) = compile_errors(diagnostics, 10);
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(errors[0].message, "Missing cannot be resolved to a type");
|
||||
// A project's explicit error severity must still be respected.
|
||||
let (_, count) =
|
||||
compile_errors(vec![(path, diagnostic(lsp_types::DiagnosticSeverity::ERROR, restriction))], 10);
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classpaths() {
|
||||
let v = json!({
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::editor_settings::LanguageCodeStyle;
|
||||
use ide_core::settings::LanguageServerSettings;
|
||||
use ide_language::LanguageServerConfig;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
const PROFILE: &str = "OpenIDEA IntelliJ IDEA";
|
||||
const XML: &str = include_str!("intellij.xml");
|
||||
|
||||
/// Protocol settings only: switching profiles reaches a running jdtls through
|
||||
/// didChangeConfiguration. Explicit language-server settings are applied later.
|
||||
pub fn configure_formatter(
|
||||
cfg: &mut LanguageServerConfig,
|
||||
style: Option<&LanguageCodeStyle>,
|
||||
project_root: &Path,
|
||||
data_dir: &Path,
|
||||
overrides: Option<&LanguageServerSettings>,
|
||||
) -> IdeResult<()> {
|
||||
if cfg.name != "jdtls" {
|
||||
return Ok(());
|
||||
}
|
||||
// jdtls retains omitted preferences. Send empty values when returning to
|
||||
// Default, while preserving any profile from the base server configuration.
|
||||
let inherited = crate::handler::all_settings(cfg);
|
||||
let defaults = json!({
|
||||
"url": inherited.pointer("/java/format/settings/url").cloned().unwrap_or(json!("")),
|
||||
"profile": inherited.pointer("/java/format/settings/profile").cloned().unwrap_or(json!("")),
|
||||
});
|
||||
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
|
||||
settings["java"]["format"]["settings"] = formatter_overrides(defaults, overrides);
|
||||
cfg.settings = Some(settings);
|
||||
let Some(style) = style else { return Ok(()) };
|
||||
let (path, profile) = match style.formatter.as_deref() {
|
||||
Some("intellij") => {
|
||||
let path = data_dir.join("lsp/jdtls/intellij.xml");
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
if std::fs::read_to_string(&path).ok().as_deref() != Some(XML) {
|
||||
ide_vfs::write_atomic(&path, XML.as_bytes())?;
|
||||
}
|
||||
(path, PROFILE)
|
||||
}
|
||||
Some("custom") => {
|
||||
let value = style.formatter_path.as_deref().unwrap_or_default().trim();
|
||||
if value.is_empty() {
|
||||
return Err(ide_core::IdeError::invalid("Choose an Eclipse formatter XML file."));
|
||||
}
|
||||
let path = project_root.join(value);
|
||||
// Fail without poisoning the server launch; code intelligence remains available.
|
||||
std::fs::File::open(&path).map_err(|error| {
|
||||
let path = path.display();
|
||||
ide_core::IdeError::invalid(format!("Cannot read Java formatter XML {path}: {error}"))
|
||||
})?;
|
||||
if !path.is_file() {
|
||||
return Err(ide_core::IdeError::invalid(format!("Not a formatter XML file: {}", path.display())));
|
||||
}
|
||||
(path, "")
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
|
||||
settings["java"]["format"]["settings"] = formatter_overrides(
|
||||
json!({
|
||||
"url": crate::uri::path_to_uri_string(&path),
|
||||
"profile": profile,
|
||||
}),
|
||||
overrides,
|
||||
);
|
||||
cfg.settings = Some(settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn formatter_overrides(mut format: serde_json::Value, overrides: Option<&LanguageServerSettings>) -> serde_json::Value {
|
||||
if let Some(overrides) = overrides {
|
||||
// all_settings merges protocol settings over initializationOptions.
|
||||
// Mirror explicit formatter keys so generated protocol values cannot
|
||||
// accidentally mask an advanced initializationOptions override.
|
||||
for layer in [
|
||||
overrides.initialization_options.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
|
||||
overrides.settings.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
|
||||
overrides.settings.as_ref().and_then(|v| v.pointer("/java/format/settings")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
for key in ["url", "profile"] {
|
||||
if let Some(value) = layer.get(key) {
|
||||
format[key] = value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
format
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config(name: &str) -> LanguageServerConfig {
|
||||
serde_json::from_value(json!({"name": name, "command": name})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_resolve_paths_preserve_settings_and_leave_other_servers_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("project with 空格");
|
||||
let data = dir.path().join("data");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let custom = root.join("style.xml");
|
||||
std::fs::write(&custom, XML).unwrap();
|
||||
for name in ["jdtls", "rust-analyzer"] {
|
||||
for mode in ["default", "intellij", "custom", "unknown"] {
|
||||
let mut cfg = LanguageServerConfig {
|
||||
name: name.into(),
|
||||
settings: Some(json!({"java": {"autobuild": {"enabled": false}}})),
|
||||
..config(name)
|
||||
};
|
||||
let initial = cfg.clone();
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some(mode.into()),
|
||||
formatter_path: Some("style.xml".into()),
|
||||
..Default::default()
|
||||
};
|
||||
configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap();
|
||||
if name != "jdtls" {
|
||||
assert_eq!(cfg, initial);
|
||||
continue;
|
||||
}
|
||||
let settings = crate::handler::all_settings(&cfg);
|
||||
if !["intellij", "custom"].contains(&mode) {
|
||||
assert_eq!(settings["java"]["format"]["settings"], json!({"url": "", "profile": ""}));
|
||||
continue;
|
||||
}
|
||||
let path = if mode == "custom" { custom.clone() } else { data.join("lsp/jdtls/intellij.xml") };
|
||||
assert_eq!(settings["java"]["format"]["settings"]["url"], crate::uri::path_to_uri_string(&path));
|
||||
assert_eq!(
|
||||
settings["java"]["format"]["settings"]["profile"],
|
||||
if mode == "custom" { "" } else { PROFILE }
|
||||
);
|
||||
assert_eq!(settings["java"]["autobuild"]["enabled"], false);
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), XML);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_custom_paths_are_nonfatal_and_do_not_set_launch_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for path in ["", "missing.xml", "."] {
|
||||
let mut cfg = config("jdtls");
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some("custom".into()),
|
||||
formatter_path: Some(path.into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), None).is_err());
|
||||
assert!(cfg.launch_error.is_none());
|
||||
assert_eq!(crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"], "");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_keeps_explicit_server_configuration_and_custom_accepts_absolute_paths() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("custom.xml");
|
||||
std::fs::write(&path, XML).unwrap();
|
||||
let mut cfg = config("jdtls");
|
||||
cfg.initialization_options = Some(
|
||||
toml::Value::try_from(json!({
|
||||
"settings": {"java": {"format": {"settings": {"url": "existing.xml", "profile": "Existing"}}}}
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
configure_formatter(&mut cfg, None, dir.path(), dir.path(), None).unwrap();
|
||||
assert_eq!(
|
||||
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
|
||||
json!({"url": "existing.xml", "profile": "Existing"})
|
||||
);
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some("custom".into()),
|
||||
formatter_path: Some(path.to_string_lossy().into_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
configure_formatter(&mut cfg, Some(&style), &dir.path().join("different-root"), dir.path(), None).unwrap();
|
||||
assert_eq!(
|
||||
crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"],
|
||||
crate::uri::path_to_uri_string(&path)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn advanced_formatter_options_win_for_each_profile_and_protocol_layer() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("custom.xml"), XML).unwrap();
|
||||
for mode in ["default", "intellij", "custom"] {
|
||||
for layer in ["initialization", "wrapped", "direct"] {
|
||||
let mut overrides = LanguageServerSettings {
|
||||
initialization_options: Some(json!({"settings": {"java": {"format": {"settings": {
|
||||
"url": "advanced.xml", "profile": "Advanced"
|
||||
}}}}})),
|
||||
..Default::default()
|
||||
};
|
||||
if layer != "initialization" {
|
||||
let value = json!({"java": {"format": {"settings": {"profile": "Protocol"}}}});
|
||||
overrides.settings = Some(if layer == "wrapped" { json!({"settings": value}) } else { value });
|
||||
}
|
||||
let mut cfg = config("jdtls");
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some(mode.into()),
|
||||
formatter_path: Some("custom.xml".into()),
|
||||
..Default::default()
|
||||
};
|
||||
configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), Some(&overrides)).unwrap();
|
||||
assert_eq!(
|
||||
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
|
||||
json!({
|
||||
"url": "advanced.xml",
|
||||
"profile": if layer == "initialization" { "Advanced" } else { "Protocol" },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
IntelliJ IDEA Java defaults, approximated using Eclipse JDT formatter options.
|
||||
Source revision: JetBrains/intellij-community 8fe3709d185cd13053d17b76d9690aef674d4268
|
||||
platform/code-style-api/src/com/intellij/psi/codeStyle/CommonCodeStyleSettings.java
|
||||
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleSettings.java
|
||||
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleDefaults.java
|
||||
java/java-frontback-impl/src/com/intellij/psi/codeStyle/JavaCodeStyleSettings.java
|
||||
JavaLanguageCodeStyleSettingsProvider.customizeDefaults does not override these values.
|
||||
https://www.jetbrains.com/help/idea/code-style-java.html describes the corresponding controls.
|
||||
Each option below names its IDEA setting; JDT has no exact equivalent for every rule.
|
||||
A formatter profile does not sort/group imports (Organize Imports is a separate action).
|
||||
Preserve IDEA KEEP_LINE_BREAKS=true using Wrap where necessary (WRAP_COMPACT) and
|
||||
join_wrapped_lines=false (Never join already wrapped lines). JDT NO_SPLIT joins some
|
||||
manual parameter/call/chain breaks even when joining is disabled. Preserving manual
|
||||
breaks takes priority: wrappable code exceeding 120 columns will also wrap, a known
|
||||
difference from IDEA defaults (DO_NOT_WRAP / WRAP_LONG_LINES=false).
|
||||
Alignment values: 16 = WRAP_COMPACT + INDENT_DEFAULT; 18 = WRAP_COMPACT + INDENT_ON_COLUMN.
|
||||
The comment width 999999 approximates WRAP_COMMENTS=false without JDT integer overflow.
|
||||
JDT has a shared blank-line preservation limit and different empty-body/Javadoc handling.
|
||||
-->
|
||||
<profiles version="23">
|
||||
<profile kind="CodeFormatterProfile" name="OpenIDEA IntelliJ IDEA" version="23">
|
||||
<!-- IndentOptions.USE_TAB_CHARACTER = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.tabulation.char" value="space"/>
|
||||
<!-- IndentOptions.TAB_SIZE = 4 -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.tabulation.size" value="4"/>
|
||||
<!-- IndentOptions.INDENT_SIZE = 4 -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indentation.size" value="4"/>
|
||||
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 (two 4-space units) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation" value="2"/>
|
||||
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer" value="2"/>
|
||||
<!-- CodeStyleSettings.RIGHT_MARGIN = 120; wrap where necessary to preserve manual line breaks (see known difference above) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.lineSplit" value="120"/>
|
||||
<!-- KEEP_LINE_BREAKS = true; Never join already wrapped lines -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.join_wrapped_lines" value="false"/>
|
||||
<!-- KEEP_BLANK_LINES_IN_DECLARATIONS / IN_CODE / BEFORE_RBRACE = 2 (JDT has one shared limit) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve" value="2"/>
|
||||
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration" value="18"/>
|
||||
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration" value="18"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation" value="16"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression" value="16"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call" value="16"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant" value="16"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation" value="16"/>
|
||||
<!-- METHOD_CALL_CHAIN_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation" value="16"/>
|
||||
<!-- ARRAY_INITIALIZER_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer" value="16"/>
|
||||
<!-- ASSIGNMENT_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_assignment" value="16"/>
|
||||
<!-- TERNARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression" value="16"/>
|
||||
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration" value="16"/>
|
||||
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration" value="16"/>
|
||||
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration" value="16"/>
|
||||
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration" value="16"/>
|
||||
<!-- RESOURCE_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_resources_in_try" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_multiplicative_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_additive_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_string_concatenation" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_shift_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_relational_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_bitwise_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_logical_operator" value="16"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_type_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_method_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block_in_case" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_switch" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_constant" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_lambda_body" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_array_initializer" value="end_of_line"/>
|
||||
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement" value="do not insert"/>
|
||||
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement" value="do not insert"/>
|
||||
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement" value="do not insert"/>
|
||||
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement" value="do not insert"/>
|
||||
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type" value="insert"/>
|
||||
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method" value="insert"/>
|
||||
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field" value="insert"/>
|
||||
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter" value="do not insert"/>
|
||||
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable" value="do not insert"/>
|
||||
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_enum_constant" value="do not insert"/>
|
||||
<!-- BLANK_LINES_BEFORE_PACKAGE = 0 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_package" value="0"/>
|
||||
<!-- BLANK_LINES_AFTER_PACKAGE = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_package" value="1"/>
|
||||
<!-- BLANK_LINES_BEFORE_IMPORTS = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_imports" value="1"/>
|
||||
<!-- BLANK_LINES_AFTER_IMPORTS = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_imports" value="1"/>
|
||||
<!-- BLANK_LINES_AROUND_FIELD / BLANK_LINES_AROUND_FIELD_IN_INTERFACE = 0 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_field" value="0"/>
|
||||
<!-- BLANK_LINES_AROUND_METHOD = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_method" value="1"/>
|
||||
<!-- BLANK_LINES_AROUND_METHOD_IN_INTERFACE = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_abstract_method" value="1"/>
|
||||
<!-- BLANK_LINES_AROUND_CLASS = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_member_type" value="1"/>
|
||||
<!-- BLANK_LINES_AFTER_CLASS_HEADER = 0 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration" value="0"/>
|
||||
<!-- BLANK_LINES_BEFORE_CLASS_END = 0 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_last_class_body_declaration" value="0"/>
|
||||
<!-- BLANK_LINES_AROUND_METHOD / AROUND_CLASS = 1; JDT chunks separate member kinds, not consecutive fields -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk" value="1"/>
|
||||
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header" value="true"/>
|
||||
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header" value="true"/>
|
||||
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header" value="true"/>
|
||||
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header" value="true"/>
|
||||
<!-- INDENT_CASE_FROM_SWITCH = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch" value="true"/>
|
||||
<!-- INDENT_BREAK_FROM_CASE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases" value="true"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_code_block_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_method_body_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_lambda_body_block_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_type_declaration_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_anonymous_type_declaration_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_enum_declaration_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_annotation_declaration_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_if_then_body_block_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_loop_body_block_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration" value="insert"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body" value="insert"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block" value="insert"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration" value="insert"/>
|
||||
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE / RBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line" value="true"/>
|
||||
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- ARRAY_INITIALIZER_RBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_lambda_arrow" value="insert"/>
|
||||
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_lambda_arrow" value="insert"/>
|
||||
<!-- WRAP_COMMENTS = false; KEEP_FIRST_COLUMN_COMMENT = true (JDT comment formatting disabled to preserve text) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.format_line_comments" value="false"/>
|
||||
<!-- WRAP_COMMENTS = false (JDT comment formatting disabled to preserve text) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.format_block_comments" value="false"/>
|
||||
<!-- JavaCodeStyleSettings.ENABLE_JAVADOC_FORMATTING = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.format_javadoc_comments" value="true"/>
|
||||
<!-- WRAP_COMMENTS = false; JDT has a separate comment width, so disable its width-based wrapping -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.line_length" value="999999"/>
|
||||
<!-- JavaCodeStyleSettings.JD_INDENT_ON_CONTINUATION = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.indent_parameter_description" value="false"/>
|
||||
<!-- JavaCodeStyleSettings.JD_PARAM_DESCRIPTION_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter" value="false"/>
|
||||
<!-- CodeStyleSettings.FORMATTER_TAGS_ENABLED = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.use_on_off_tags" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_simple_for_body_on_same_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_simple_while_body_on_same_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_simple_do_while_body_on_same_line" value="true"/>
|
||||
</profile>
|
||||
</profiles>
|
||||
+319
-18
@@ -9,6 +9,7 @@
|
||||
mod angular;
|
||||
mod caps;
|
||||
mod convert;
|
||||
mod external_project;
|
||||
mod file_ops;
|
||||
mod handler;
|
||||
mod inspect;
|
||||
@@ -89,6 +90,8 @@ struct Attached {
|
||||
|
||||
struct DocState {
|
||||
path: PathBuf,
|
||||
/// `path` with links resolved, to recognise a document under another name.
|
||||
canonical: PathBuf,
|
||||
uri: lsp::Uri,
|
||||
language: LanguageId,
|
||||
version: i32,
|
||||
@@ -126,6 +129,7 @@ impl Target {
|
||||
}
|
||||
|
||||
struct DocSnap {
|
||||
version: i32,
|
||||
path: PathBuf,
|
||||
uri: lsp::Uri,
|
||||
language: LanguageId,
|
||||
@@ -165,11 +169,15 @@ pub struct LspManager {
|
||||
servers: Mutex<Vec<Arc<Server>>>,
|
||||
docs: Mutex<HashMap<DocId, DocState>>,
|
||||
apply_edit_hook: RwLock<Option<ApplyEditHook>>,
|
||||
applied_edits: tokio::sync::broadcast::Sender<(ide_core::ServerId, WorkspaceEdit)>,
|
||||
config_hook: RwLock<Option<ConfigHook>>,
|
||||
prepare_hook: RwLock<Option<Arc<PrepareFn>>>,
|
||||
servers_hook: RwLock<Option<Arc<ServersFn>>>,
|
||||
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
|
||||
excludes_hook: RwLock<Option<Arc<external_project::ExcludesFn>>>,
|
||||
shutting_down: AtomicBool,
|
||||
execution_allowed: AtomicBool,
|
||||
java_usages_warned: AtomicBool,
|
||||
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
|
||||
shown_messages: Mutex<HashSet<(String, String)>>,
|
||||
/// Virtual (non-`file:`) URIs a server returned, by the server that owns them.
|
||||
@@ -295,11 +303,15 @@ impl LspManager {
|
||||
servers: Mutex::new(Vec::new()),
|
||||
docs: Mutex::new(HashMap::new()),
|
||||
apply_edit_hook: RwLock::new(None),
|
||||
applied_edits: tokio::sync::broadcast::channel(64).0,
|
||||
config_hook: RwLock::new(None),
|
||||
prepare_hook: RwLock::new(None),
|
||||
servers_hook: RwLock::new(None),
|
||||
candidates_hook: RwLock::new(None),
|
||||
excludes_hook: RwLock::new(None),
|
||||
shutting_down: AtomicBool::new(false),
|
||||
execution_allowed: AtomicBool::new(true),
|
||||
java_usages_warned: AtomicBool::new(false),
|
||||
shown_messages: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
@@ -367,6 +379,9 @@ impl LspManager {
|
||||
|
||||
/// The definitions of the servers `language` uses.
|
||||
fn language_servers(&self, language: &LanguageId) -> Vec<LanguageServerConfig> {
|
||||
if !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(cfg) = self.languages.get(language) else { return Vec::new() };
|
||||
match self.servers_hook.read().clone() {
|
||||
Some(hook) => hook(language, &cfg.language_servers),
|
||||
@@ -413,7 +428,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
fn ensure_started(&self, server: &Arc<Server>) {
|
||||
if self.shutting_down.load(Ordering::Acquire) {
|
||||
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let Some(generation) = server.begin_start() else { return };
|
||||
@@ -431,7 +446,13 @@ impl LspManager {
|
||||
// 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 || {
|
||||
if !me.execution_allowed.load(Ordering::Acquire) {
|
||||
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() {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
tail.set_effective_config(cfg.clone());
|
||||
let prepare = me.prepare_hook.read().clone();
|
||||
if !cancel.is_cancelled()
|
||||
@@ -448,6 +469,9 @@ impl LspManager {
|
||||
let missing = !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() {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
|
||||
})
|
||||
.await
|
||||
@@ -459,29 +483,54 @@ impl LspManager {
|
||||
let cfg = server.effective_config();
|
||||
let message =
|
||||
cfg.install_hint.clone().unwrap_or_else(|| format!("`{}` was not found on PATH", cfg.command));
|
||||
if server.finish_start(generation, Conn::Missing) {
|
||||
server.set_status(&self.sink, ide_core::event::ServerStatus::Missing, Some(message));
|
||||
if server.fail_start(
|
||||
generation,
|
||||
Conn::Missing,
|
||||
&self.sink,
|
||||
ide_core::event::ServerStatus::Missing,
|
||||
Some(message),
|
||||
) {
|
||||
self.attach_fallbacks();
|
||||
}
|
||||
return;
|
||||
}
|
||||
Err(LaunchError::Config(message)) => {
|
||||
tracing::warn!("{}: {message}", server.name);
|
||||
if server.finish_start(generation, Conn::Failed) {
|
||||
server.set_status(&self.sink, ide_core::event::ServerStatus::Misconfigured, Some(message));
|
||||
}
|
||||
server.fail_start(
|
||||
generation,
|
||||
Conn::Failed,
|
||||
&self.sink,
|
||||
ide_core::event::ServerStatus::Misconfigured,
|
||||
Some(message),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(LaunchError::Failed(message)) => {
|
||||
tracing::warn!("{}: {message}", server.name);
|
||||
if server.finish_start(generation, Conn::Failed) {
|
||||
server.set_status(&self.sink, ide_core::event::ServerStatus::Crashed, Some(message));
|
||||
}
|
||||
server.fail_start(
|
||||
generation,
|
||||
Conn::Failed,
|
||||
&self.sink,
|
||||
ide_core::event::ServerStatus::Crashed,
|
||||
Some(message),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let init = self.initialize_params(&server);
|
||||
let mut init = self.initialize_params(&server);
|
||||
if server.name == "jdtls"
|
||||
&& init["initializationOptions"].get("triggerFiles").is_none()
|
||||
&& let Some(paths) = init
|
||||
.pointer("/initializationOptions/settings/java/project/sourcePaths")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
{
|
||||
let root = server.root.clone();
|
||||
if let Ok(Some(path)) = tokio::task::spawn_blocking(move || jdtls::source_trigger(&root, &paths)).await {
|
||||
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));
|
||||
@@ -517,6 +566,8 @@ impl LspManager {
|
||||
}
|
||||
let run = Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)) };
|
||||
|
||||
self.refresh_external_project(&server, &run).await;
|
||||
|
||||
// Publish readiness and (re)open attached documents atomically w.r.t. did_change.
|
||||
{
|
||||
let mut docs = self.docs.lock();
|
||||
@@ -559,6 +610,7 @@ impl LspManager {
|
||||
};
|
||||
self.clear_server_state(&server);
|
||||
if intentional {
|
||||
server.announce_down();
|
||||
return;
|
||||
}
|
||||
let tail = server.stderr_tail(5);
|
||||
@@ -575,6 +627,9 @@ impl LspManager {
|
||||
match server.record_crash() {
|
||||
Some(delay) => {
|
||||
server.set_status(&self.sink, ServerStatus::Crashed, Some(format!("{message}\nRestarting…")));
|
||||
// Woken after the crash is reported: a request that finds no instance
|
||||
// reads this status to say why. The restart sets `Starting` again.
|
||||
server.announce_down();
|
||||
tokio::time::sleep(delay).await;
|
||||
if server.is_idle_gen(generation) {
|
||||
self.ensure_started(&server);
|
||||
@@ -587,6 +642,8 @@ impl LspManager {
|
||||
ServerStatus::Crashed,
|
||||
Some(format!("{message}\nCrashed repeatedly; restart it manually.")),
|
||||
);
|
||||
// Requests still waiting for a start read this status for their error.
|
||||
server.announce_down();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -616,12 +673,26 @@ 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 init_options = server
|
||||
.effective_config()
|
||||
.initialization_options
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::to_value(v).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
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);
|
||||
if server.name == "jdtls" {
|
||||
if !init_options.is_object() {
|
||||
init_options = json!({});
|
||||
}
|
||||
init_options["settings"] = handler::all_settings(&cfg);
|
||||
let triggers: Vec<_> = self
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root))
|
||||
.map(|d| uri::path_to_uri_string(&d.path))
|
||||
.collect();
|
||||
if !triggers.is_empty() && init_options.get("triggerFiles").is_none() {
|
||||
// Re-import cached invisible projects with the current source model.
|
||||
init_options["triggerFiles"] = json!(triggers);
|
||||
}
|
||||
}
|
||||
let mut params = json!({
|
||||
"processId": std::process::id(),
|
||||
"clientInfo": { "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") },
|
||||
@@ -632,6 +703,9 @@ impl LspManager {
|
||||
"capabilities": caps::client_capabilities(),
|
||||
"trace": "off",
|
||||
});
|
||||
if server.name == "gopls" {
|
||||
params["capabilities"]["experimental"]["interactiveResolve"] = json!({ "inputTypes": ["string"] });
|
||||
}
|
||||
if !init_options.is_null() {
|
||||
params["initializationOptions"] = init_options;
|
||||
}
|
||||
@@ -661,6 +735,11 @@ impl LspManager {
|
||||
if let Some(t) = self.docs.lock().values().find(|d| d.path == path).map(|d| d.text.clone()) {
|
||||
return Some(t);
|
||||
}
|
||||
if let Ok(key) = dunce::canonicalize(path)
|
||||
&& let Some(text) = self.docs.lock().values().find(|d| d.canonical == key).map(|d| d.text.clone())
|
||||
{
|
||||
return Some(text);
|
||||
}
|
||||
let key = path.to_str().filter(|s| is_virtual_uri(s))?;
|
||||
self.virtual_docs.lock().get(key).map(|d| Arc::new(DocText::new(&d.text)))
|
||||
}
|
||||
@@ -706,8 +785,10 @@ impl LspManager {
|
||||
text: &str,
|
||||
servers: Vec<Arc<Server>>,
|
||||
) {
|
||||
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||||
let mut state = DocState {
|
||||
path,
|
||||
canonical,
|
||||
uri,
|
||||
language: language.clone(),
|
||||
version: version as i32,
|
||||
@@ -841,6 +922,7 @@ impl LspManager {
|
||||
let docs = self.docs.lock();
|
||||
let d = docs.get(&doc).ok_or_else(|| IdeError::not_found(format!("{doc} is not open")))?;
|
||||
Ok(DocSnap {
|
||||
version: d.version,
|
||||
path: d.path.clone(),
|
||||
uri: d.uri.clone(),
|
||||
language: d.language.clone(),
|
||||
@@ -1109,6 +1191,106 @@ impl LspManager {
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<References> {
|
||||
let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?;
|
||||
// The extension preserves method identity before JDT collapses references to locations.
|
||||
// Old/custom servers retain the standard protocol, including cancellation and scope widening.
|
||||
if targets.len() == 1
|
||||
&& targets[0].server.name == "jdtls"
|
||||
&& targets[0].caps().commands().iter().any(|c| c == "openidea.java.usages")
|
||||
{
|
||||
let classified = async {
|
||||
let t = &targets[0];
|
||||
let extension_error = Mutex::new(None);
|
||||
let query = || async {
|
||||
if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let params = snap.pos_params(t, pos);
|
||||
#[derive(serde::Deserialize)]
|
||||
struct JavaReference {
|
||||
#[serde(default)]
|
||||
access: Option<ReferenceAccess>,
|
||||
location: lsp::Location,
|
||||
kind: ReferenceKind,
|
||||
}
|
||||
let result: IdeResult<Vec<JavaReference>> = self
|
||||
.call(
|
||||
t,
|
||||
"workspace/executeCommand",
|
||||
json!({
|
||||
"command": "openidea.java.usages",
|
||||
"arguments": [params["textDocument"]["uri"], params["position"]["line"],
|
||||
params["position"]["character"], include_declaration]
|
||||
}),
|
||||
Some(&cancel),
|
||||
LONG_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = &result {
|
||||
*extension_error.lock() = Some(e.clone());
|
||||
}
|
||||
let rows = result?;
|
||||
if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let conv = self.conv(t.enc());
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
conv.location(&r.location).map(|location| ClassifiedReference {
|
||||
location,
|
||||
kind: r.kind,
|
||||
access: r.access,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
};
|
||||
let files = |v: &Vec<ClassifiedReference>| {
|
||||
v.iter().filter(|r| r.location.uri.is_none()).map(|r| r.location.path.to_path_buf()).collect()
|
||||
};
|
||||
let widened = if project_wide {
|
||||
self.widened(&snap, t, pos, &cancel, query, files).await?
|
||||
} else {
|
||||
let first = query().await?;
|
||||
let empty = first.is_empty();
|
||||
scope::Widened::only(first, empty, &t.server)
|
||||
};
|
||||
// A changed source also invalidates an earlier answer retained by scope widening.
|
||||
if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
// Scope widening can retain the first answer after a failed second query.
|
||||
if let Some(e) = extension_error.into_inner() {
|
||||
return Err(e);
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// Prefer the second query's metadata for repeated locations.
|
||||
let classified: Vec<_> = widened
|
||||
.second
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(widened.first)
|
||||
.filter(|r| seen.insert(r.location.clone()))
|
||||
.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()));
|
||||
Ok(References { locations, classified, incomplete: widened.incomplete })
|
||||
}
|
||||
.await;
|
||||
match classified {
|
||||
Ok(references) => return Ok(references),
|
||||
Err(e) => {
|
||||
if e.kind == ErrorKind::Cancelled
|
||||
|| cancel.is_cancelled()
|
||||
|| !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version)
|
||||
{
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if !self.java_usages_warned.swap(true, Ordering::Relaxed) {
|
||||
tracing::warn!("Java usages extension failed; using standard references: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let requests = targets.iter().map(|t| {
|
||||
let (snap, cancel) = (&snap, &cancel);
|
||||
async move {
|
||||
@@ -1373,6 +1555,92 @@ impl LspManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn command_target(&self, doc: DocId, server: ide_core::ServerId, name: &str) -> IdeResult<Target> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
self.ready_targets(&snap.servers, |c| c.commands().iter().any(|c| c == name))
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|t| t.server.id == server)
|
||||
.ok_or_else(|| IdeError::unavailable(format!("no language server provides command `{name}`")))
|
||||
}
|
||||
|
||||
/// Resolve only after explicit invocation; previews must never ask for input.
|
||||
pub async fn resolve_command(
|
||||
&self,
|
||||
doc: DocId,
|
||||
server: ide_core::ServerId,
|
||||
command: Command,
|
||||
answers: Vec<FormAnswer>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<Option<CommandForm>> {
|
||||
let t = self.command_target(doc, server, &command.command).await?;
|
||||
let interactive = t.server.name == "gopls"
|
||||
&& t.caps()
|
||||
.raw
|
||||
.pointer("/experimental/interactiveResolveProvider/kinds")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|k| k.iter().any(|v| v == "command"));
|
||||
if !interactive {
|
||||
return Ok(None);
|
||||
}
|
||||
let params = json!({ "command": command.command, "arguments": command.arguments, "formAnswers": answers });
|
||||
let response: Value = self.call(&t, "command/resolve", params, Some(&cancel), LONG_TIMEOUT).await?;
|
||||
let parse_error = |e: serde_json::Error| IdeError::internal(format!("invalid command form: {e}"));
|
||||
let fields = response.get("formFields").filter(|v| !v.is_null()).cloned().unwrap_or(json!([]));
|
||||
for field in fields.as_array().into_iter().flatten() {
|
||||
if field.pointer("/type/kind").and_then(Value::as_str) != Some("string") {
|
||||
return Err(IdeError::unavailable("unsupported command input type"));
|
||||
}
|
||||
}
|
||||
Ok(Some(CommandForm {
|
||||
command: Command {
|
||||
title: command.title,
|
||||
command: response
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| IdeError::internal("command form omitted command"))?
|
||||
.to_owned(),
|
||||
arguments: serde_json::from_value(response.get("arguments").cloned().unwrap_or(json!([])))
|
||||
.map_err(parse_error)?,
|
||||
},
|
||||
form_fields: serde_json::from_value(fields).map_err(parse_error)?,
|
||||
form_answers: serde_json::from_value(
|
||||
response.get("formAnswers").filter(|v| !v.is_null()).cloned().unwrap_or(json!([])),
|
||||
)
|
||||
.map_err(parse_error)?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Execute on the originating server and return files from successfully applied edits.
|
||||
pub async fn execute_action_command(
|
||||
&self,
|
||||
doc: DocId,
|
||||
server: ide_core::ServerId,
|
||||
command: Command,
|
||||
answers: Vec<FormAnswer>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<Vec<PathBuf>> {
|
||||
let t = self.command_target(doc, server, &command.command).await?;
|
||||
let open_created = t.server.name == "gopls" && command.command == "gopls.add_test";
|
||||
let mut edits = self.applied_edits.subscribe();
|
||||
let mut params = json!({ "command": command.command, "arguments": command.arguments });
|
||||
if !answers.is_empty() {
|
||||
params["formAnswers"] = json!(answers);
|
||||
}
|
||||
let _: Value = self.call(&t, "workspace/executeCommand", params, Some(&cancel), LONG_TIMEOUT).await?;
|
||||
let mut created = Vec::new();
|
||||
while let Ok((origin, edit)) = edits.try_recv() {
|
||||
if open_created && origin == server {
|
||||
for file in edit.files {
|
||||
if let ide_core::FileEdit::Create { path, .. } = file {
|
||||
created.push(path.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
pub async fn prepare_rename(&self, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
|
||||
let (snap, targets) = self.doc_targets(doc, "rename", |c| c.has("renameProvider")).await?;
|
||||
let mut out = Outcome::new();
|
||||
@@ -1618,6 +1886,12 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks initialized capabilities, including dynamic registrations, before choosing a formatter.
|
||||
pub async fn can_format(&self, doc: DocId) -> IdeResult<bool> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
Ok(!self.ready_targets(&snap.servers, |c| c.has("documentFormattingProvider")).await.is_empty())
|
||||
}
|
||||
|
||||
pub async fn format(&self, doc: DocId, range: Option<Range>, options: FormatOptions) -> IdeResult<Vec<TextEdit>> {
|
||||
let (method, cap, feature) = match range {
|
||||
None => ("textDocument/formatting", "documentFormattingProvider", "formatting"),
|
||||
@@ -1898,6 +2172,7 @@ impl LspManager {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let scheme = virtual_doc::scheme(uri).to_owned();
|
||||
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
|
||||
let origin = self.origin_server(uri);
|
||||
let mut servers: Vec<Arc<Server>> = origin.iter().cloned().collect();
|
||||
servers.extend(self.servers.lock().iter().filter(|s| Some(s.id) != origin.as_ref().map(|o| o.id)).cloned());
|
||||
@@ -1906,7 +2181,7 @@ impl LspManager {
|
||||
for t in &targets {
|
||||
let languages = t.server.languages();
|
||||
let serves_scheme = t.caps().content_schemes().contains(&scheme);
|
||||
let result: IdeResult<Option<String>> = if serves_scheme {
|
||||
let result: IdeResult<Option<String>> = if serves_scheme && !class_file {
|
||||
self.call::<_, Option<Value>>(
|
||||
t,
|
||||
"workspace/textDocumentContent",
|
||||
@@ -1916,7 +2191,7 @@ impl LspManager {
|
||||
)
|
||||
.await
|
||||
.map(|v| v.and_then(|v| v.get("text").and_then(Value::as_str).map(str::to_owned)))
|
||||
} else if scheme == "jdt" && languages.iter().any(|l| l.as_str() == "java") {
|
||||
} else if class_file && languages.iter().any(|l| l.as_str() == "java") {
|
||||
self.call::<_, Option<String>>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT)
|
||||
.await
|
||||
} else {
|
||||
@@ -1975,6 +2250,10 @@ impl LspManager {
|
||||
/// The server's base configuration with the settings hook applied.
|
||||
fn configured(&self, server: &Server) -> LanguageServerConfig {
|
||||
let mut cfg = server.config.clone();
|
||||
if !self.execution_allowed.load(Ordering::Acquire) {
|
||||
cfg.launch_error = Some("Project is in Restricted Mode".into());
|
||||
return cfg;
|
||||
}
|
||||
if let Some(hook) = self.config_hook.read().clone() {
|
||||
hook(&mut cfg, &server.root);
|
||||
}
|
||||
@@ -1985,7 +2264,27 @@ impl LspManager {
|
||||
/// (e.g. a new project interpreter); stopped ones pick it up on their next start.
|
||||
/// A change of `settings` alone reaches a running server without a restart.
|
||||
/// Servers no longer enabled stop; newly enabled ones attach to open documents.
|
||||
pub fn set_execution_allowed(&self, allowed: bool) {
|
||||
let previous = self.execution_allowed.swap(allowed, Ordering::AcqRel);
|
||||
if !allowed {
|
||||
for server in self.servers.lock().iter() {
|
||||
server.start_token().cancel();
|
||||
}
|
||||
}
|
||||
if allowed && !previous {
|
||||
for server in self.servers.lock().iter() {
|
||||
server.reset_for_restart();
|
||||
self.ensure_started(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reconfigure(&self) {
|
||||
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.stop_unlisted().await;
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
@@ -2010,6 +2309,7 @@ impl LspManager {
|
||||
let without_settings = |c: &LanguageServerConfig| LanguageServerConfig { settings: None, ..c.clone() };
|
||||
if let Some(run) = s.running()
|
||||
&& without_settings(&next) == without_settings(¤t)
|
||||
&& !jdtls::source_paths_removed(¤t, &next)
|
||||
{
|
||||
s.set_effective_config(next);
|
||||
let params = json!({ "settings": handler::all_settings(&s.effective_config()) });
|
||||
@@ -2212,6 +2512,7 @@ impl LspManager {
|
||||
return;
|
||||
}
|
||||
let graceful = async {
|
||||
self.close_external_project(server, &run).await;
|
||||
peer.request::<_, Value>("shutdown", (), None).await.ok();
|
||||
peer.notify("exit", ()).ok();
|
||||
peer.closed().await;
|
||||
|
||||
@@ -167,23 +167,27 @@ impl LspManager {
|
||||
/// readable as text are left out and flagged on the guard.
|
||||
async fn open_transient(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> TransientFiles<'_> {
|
||||
// The server already sees the user's files, however large.
|
||||
let open: HashSet<PathBuf> = self.docs.lock().values().map(|d| d.path.clone()).collect();
|
||||
let open: HashSet<PathBuf> = self.docs.lock().values().map(|d| d.canonical.clone()).collect();
|
||||
let (read, skipped) = {
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut read = Vec::new();
|
||||
let mut skipped = false;
|
||||
for f in files.into_iter().filter(|f| !open.contains(f)) {
|
||||
for f in files {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
let canonical = dunce::canonicalize(&f).unwrap_or_else(|_| f.clone());
|
||||
if open.contains(&canonical) {
|
||||
continue;
|
||||
}
|
||||
// A file gone since the search no longer mentions anything.
|
||||
let Ok(meta) = std::fs::metadata(&f) else { continue };
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
match (meta.len() <= MAX_CANDIDATE_BYTES).then(|| std::fs::read_to_string(&f)) {
|
||||
Some(Ok(text)) => read.push((f, text)),
|
||||
Some(Ok(text)) => read.push((f, canonical, text)),
|
||||
_ => skipped = true,
|
||||
}
|
||||
}
|
||||
@@ -198,8 +202,8 @@ impl LspManager {
|
||||
return guard;
|
||||
}
|
||||
let docs = self.docs.lock();
|
||||
for (path, text) in read {
|
||||
if docs.values().any(|d| d.path == path) {
|
||||
for (path, canonical, text) in read {
|
||||
if docs.values().any(|d| d.canonical == canonical) {
|
||||
continue;
|
||||
}
|
||||
let uri = crate::uri::path_to_uri(&path);
|
||||
|
||||
@@ -99,6 +99,11 @@ pub struct Server {
|
||||
/// Woken when the last `$/progress` task ends.
|
||||
idle_notify: Notify,
|
||||
transient: Mutex<HashMap<PathBuf, Transient>>,
|
||||
pub java_build_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,
|
||||
pub external_refresh_queued: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -123,12 +128,23 @@ impl Server {
|
||||
published_notify: Notify::new(),
|
||||
idle_notify: Notify::new(),
|
||||
transient: Mutex::new(HashMap::new()),
|
||||
java_build_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),
|
||||
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration of the current (or last) instance; the base config before the first launch.
|
||||
pub fn effective_config(&self) -> LanguageServerConfig {
|
||||
self.effective.lock().clone().unwrap_or_else(|| self.config.clone())
|
||||
let mut config = self.effective.lock().clone().unwrap_or_else(|| self.config.clone());
|
||||
if self.java_autobuild_suspended.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
let mut settings = crate::handler::all_settings(&config);
|
||||
settings["java"]["autobuild"]["enabled"] = serde_json::json!(false);
|
||||
config.settings = Some(settings);
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
pub fn set_effective_config(&self, config: LanguageServerConfig) {
|
||||
@@ -162,15 +178,27 @@ impl Server {
|
||||
Some(st.generation)
|
||||
}
|
||||
|
||||
/// Starting → Missing/Failed.
|
||||
pub fn finish_start(&self, generation: u64, conn: Conn) -> bool {
|
||||
let mut st = self.state.lock();
|
||||
if st.generation == generation && matches!(st.conn, Conn::Starting) {
|
||||
/// Starting → Missing/Failed, publishing the status the failed start reports
|
||||
/// before waiters wake: a request that wakes on `Phase::Down` reads the status
|
||||
/// to say why it has no server, and must not still see `Starting`.
|
||||
pub fn fail_start(
|
||||
&self,
|
||||
generation: u64,
|
||||
conn: Conn,
|
||||
sink: &SharedSink,
|
||||
status: ServerStatus,
|
||||
message: Option<String>,
|
||||
) -> bool {
|
||||
{
|
||||
let mut st = self.state.lock();
|
||||
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
|
||||
return false;
|
||||
}
|
||||
st.conn = conn;
|
||||
self.phase.send_replace(Phase::Down);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
self.set_status(sink, status, message);
|
||||
self.announce_down();
|
||||
true
|
||||
}
|
||||
|
||||
/// Starting → Ready. False when the start was superseded or cancelled.
|
||||
@@ -191,6 +219,12 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wakes requests waiting for a start that ended without an instance, after
|
||||
/// its status is published.
|
||||
pub fn announce_down(&self) {
|
||||
self.phase.send_replace(Phase::Down);
|
||||
}
|
||||
|
||||
pub fn running(&self) -> Option<Running> {
|
||||
match &self.state.lock().conn {
|
||||
Conn::Ready(r) => Some(r.clone()),
|
||||
@@ -246,7 +280,8 @@ impl Server {
|
||||
}
|
||||
|
||||
/// Instance `generation`'s connection closed. `None` if superseded, else whether
|
||||
/// the close was intentional.
|
||||
/// the close was intentional. Waiters are woken by [`Self::announce_down`] once
|
||||
/// the crash is reported, not here: they read the status to say why.
|
||||
pub fn on_closed(&self, generation: u64) -> Option<bool> {
|
||||
let mut st = self.state.lock();
|
||||
if st.generation != generation {
|
||||
@@ -255,7 +290,6 @@ impl Server {
|
||||
match &st.conn {
|
||||
Conn::Ready(_) | Conn::Starting => {
|
||||
st.conn = Conn::Idle;
|
||||
self.phase.send_replace(Phase::Down);
|
||||
Some(false)
|
||||
}
|
||||
_ => Some(true),
|
||||
@@ -529,6 +563,49 @@ mod tests {
|
||||
assert_eq!(s.record_crash(), Some(Duration::from_millis(500)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_build_suspends_only_the_effective_autobuild_setting() {
|
||||
let mut config: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
|
||||
config.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": true}, "other": "kept"}}));
|
||||
let server = Server::new(config.clone(), PathBuf::from("/p"), LanguageId::new("java"));
|
||||
server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let settings = crate::handler::all_settings(&server.effective_config());
|
||||
assert_eq!(settings["java"]["autobuild"]["enabled"], false);
|
||||
assert_eq!(settings["java"]["other"], "kept");
|
||||
server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
assert_eq!(server.effective_config(), config);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_start_reports_its_status_before_waiters_wake() {
|
||||
// The race behind "jdtls is not ready (Starting)": a request woken by the
|
||||
// phase change reads the status to say why it has no server, so the status
|
||||
// must already be the failure's.
|
||||
let s = Arc::new(server());
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
let generation = s.begin_start().unwrap();
|
||||
let waiter = {
|
||||
let s = s.clone();
|
||||
tokio::spawn(async move {
|
||||
let run = s.wait_ready(Duration::from_secs(30)).await;
|
||||
(run.is_none(), s.status())
|
||||
})
|
||||
};
|
||||
tokio::task::yield_now().await;
|
||||
assert!(s.fail_start(generation, Conn::Failed, &sink, ServerStatus::Crashed, Some("boom".into())));
|
||||
assert_eq!(waiter.await.unwrap(), (true, ServerStatus::Crashed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_superseded_start_is_not_announced() {
|
||||
let s = server();
|
||||
let generation = s.begin_start().unwrap();
|
||||
s.begin_stop(); // abandons the start and invalidates its generation
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
assert!(!s.fail_start(generation, Conn::Failed, &sink, ServerStatus::Crashed, Some("boom".into())));
|
||||
assert_eq!(s.status(), ServerStatus::Stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_excerpt_keeps_the_exception_line() {
|
||||
// Node.js 24 crash of vue-language-server (issue #25), verbatim shape.
|
||||
|
||||
@@ -145,6 +145,34 @@ pub struct Command {
|
||||
pub arguments: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// The string form supported by gopls's experimental interactive command protocol.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FormAnswer {
|
||||
pub id: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FormField {
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
pub required: bool,
|
||||
#[serde(default)]
|
||||
pub default: Option<String>,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommandForm {
|
||||
pub command: Command,
|
||||
pub form_fields: Vec<FormField>,
|
||||
pub form_answers: Vec<FormAnswer>,
|
||||
}
|
||||
|
||||
/// One `textDocument/inlineCompletion` suggestion (ghost text at the caret).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -191,6 +219,9 @@ pub struct CodeActionQuery {
|
||||
pub struct FormatOptions {
|
||||
pub tab_size: u32,
|
||||
pub insert_spaces: bool,
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub on_save: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -307,7 +338,7 @@ pub struct VirtualDocument {
|
||||
pub title: String,
|
||||
/// Origin shown in the tooltip / banner, e.g. the jar path.
|
||||
pub description: Option<String>,
|
||||
/// Generated by a decompiler / disassembler rather than real sources.
|
||||
/// Generated Java from a decompiler, rather than real source or a disassembler stub.
|
||||
pub decompiled: bool,
|
||||
}
|
||||
|
||||
@@ -338,6 +369,10 @@ pub enum ReferencesIncomplete {
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct References {
|
||||
/// Semantic classifications supplied by a language extension. Missing entries are unknown.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub classified: Vec<ClassifiedReference>,
|
||||
pub locations: Vec<Location>,
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
@@ -355,3 +390,31 @@ pub struct CallHierarchy {
|
||||
pub items: Vec<HierarchyItem>,
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ReferenceKind {
|
||||
Getter,
|
||||
Setter,
|
||||
Field,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ReferenceAccess {
|
||||
Read,
|
||||
Write,
|
||||
ReadWrite,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
pub struct ClassifiedReference {
|
||||
/// Optional for older Java extensions; missing means unclassified.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub access: Option<ReferenceAccess>,
|
||||
pub location: Location,
|
||||
pub kind: ReferenceKind,
|
||||
}
|
||||
@@ -1,21 +1,29 @@
|
||||
//! Virtual documents: read-only contents a language server provides for a
|
||||
//! non-`file:` URI (library classes from `jdt://`, `workspace/textDocumentContent`).
|
||||
//! URI (library classes from `jdt://`, disk classes from `file://`, or
|
||||
//! `workspace/textDocumentContent`).
|
||||
|
||||
use percent_encoding::percent_decode_str;
|
||||
|
||||
/// Markers decompilers / disassemblers put at the top of generated code.
|
||||
const GENERATED_MARKERS: [&str; 5] = [
|
||||
"generated by the fernflower decompiler",
|
||||
"failed to get sources",
|
||||
"decompiled with cfr",
|
||||
"decompiled with procyon",
|
||||
"generated by the disassembler",
|
||||
];
|
||||
|
||||
/// Whether `text` is decompiled / disassembled code rather than real source.
|
||||
/// Recognize decompiler headers, without treating disassembler stubs as Java source.
|
||||
pub fn is_decompiled(text: &str) -> bool {
|
||||
let head: String = text.chars().take(600).collect::<String>().to_ascii_lowercase();
|
||||
GENERATED_MARKERS.iter().any(|m| head.contains(m))
|
||||
let head: String = text.chars().take(600).collect();
|
||||
head.lines()
|
||||
.take(12)
|
||||
.take_while(|line| {
|
||||
let line = line.trim();
|
||||
line.is_empty() || line.starts_with("//") || line.starts_with("/*") || line.starts_with('*')
|
||||
})
|
||||
.any(|line| {
|
||||
let line = line.trim().trim_start_matches('/').trim_start_matches('*').trim().to_ascii_lowercase();
|
||||
[
|
||||
"source code is unavailable, and was generated by the fernflower decompiler.",
|
||||
"source code is decompiled from a .class file using fernflower decompiler",
|
||||
"decompiled with cfr",
|
||||
"decompiled with procyon",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| line.starts_with(marker))
|
||||
})
|
||||
}
|
||||
|
||||
/// `scheme` of a URI (`jdt` for `jdt://…`).
|
||||
@@ -34,7 +42,16 @@ fn segments(uri: &str) -> Vec<String> {
|
||||
/// Editor tab title: the last path segment; a class file whose real source
|
||||
/// was found is shown as `.java` (IDEA: `String.java` vs `String.class`).
|
||||
pub fn title(uri: &str, decompiled: bool) -> String {
|
||||
let name = segments(uri).pop().unwrap_or_else(|| uri.to_owned());
|
||||
let mut name = segments(uri).pop().unwrap_or_else(|| uri.to_owned());
|
||||
// Recent jdtls versions use a .java path even for decompiled class handles.
|
||||
if decompiled
|
||||
&& scheme(uri) == "jdt"
|
||||
&& name.ends_with(".java")
|
||||
&& uri.split_once('?').is_some_and(|(_, q)| percent_decode_str(q).decode_utf8_lossy().contains(".class"))
|
||||
{
|
||||
name.truncate(name.len() - 5);
|
||||
name.push_str(".class");
|
||||
}
|
||||
match name.strip_suffix(".class") {
|
||||
Some(stem) if !decompiled && scheme(uri) == "jdt" => format!("{stem}.java"),
|
||||
_ => name,
|
||||
@@ -68,6 +85,19 @@ pub fn description(uri: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check both the package and actual archive, so duplicate type names never pick another jar.
|
||||
pub(crate) fn matches_class(uri: &str, jar: &std::path::Path, package: &str, name: &str) -> bool {
|
||||
let segs = segments(uri);
|
||||
scheme(uri) == "jdt"
|
||||
&& (package.is_empty() && segs.len() == 3 || segs.get(2).is_some_and(|s| s == package))
|
||||
&& segs.last().is_some_and(|s| s == &format!("{name}.class") || s == &format!("{name}.java"))
|
||||
&& description(uri).is_some_and(|p| {
|
||||
let path = std::path::Path::new(&p);
|
||||
path == jar
|
||||
|| dunce::canonicalize(path).ok().zip(dunce::canonicalize(jar).ok()).is_some_and(|(a, b)| a == b)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -84,6 +114,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn jdt_description() {
|
||||
assert!(matches_class(
|
||||
JDT,
|
||||
std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"),
|
||||
"org.springframework.boot",
|
||||
"SpringApplication"
|
||||
));
|
||||
assert!(!matches_class(
|
||||
JDT,
|
||||
std::path::Path::new("/elsewhere/spring-boot-4.1.1.jar"),
|
||||
"org.springframework.boot",
|
||||
"SpringApplication"
|
||||
));
|
||||
assert!(!matches_class(
|
||||
JDT,
|
||||
std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"),
|
||||
"other.package",
|
||||
"SpringApplication"
|
||||
));
|
||||
assert!(matches_class(
|
||||
"jdt://contents/a.jar//C.java?=p/%5C/m2%5C/a.jar%3C%28C.class",
|
||||
std::path::Path::new("/m2/a.jar"),
|
||||
"",
|
||||
"C"
|
||||
));
|
||||
assert_eq!(description(JDT).as_deref(), Some("/Users/me/.m2/repository/spring-boot-4.1.1.jar"));
|
||||
assert_eq!(
|
||||
description(
|
||||
@@ -98,10 +152,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detects_generated_code() {
|
||||
assert!(is_decompiled(
|
||||
"// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage a;"
|
||||
));
|
||||
assert!(!is_decompiled("package a;\n// generated by the fernflower decompiler"));
|
||||
assert!(!is_decompiled("// Documentation about code generated by the Fernflower decompiler.\npackage a;"));
|
||||
let recent = "jdt://contents/a.jar/p/C.java?=p/a.jar%3Cp%28C.class";
|
||||
assert_eq!(title(recent, true), "C.class");
|
||||
assert_eq!(title(recent, false), "C.java");
|
||||
assert!(is_decompiled(
|
||||
"// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage a;"
|
||||
));
|
||||
assert!(is_decompiled(
|
||||
assert!(!is_decompiled(
|
||||
"// Failed to get sources. Instead, stub sources have been generated by the disassembler."
|
||||
));
|
||||
assert!(!is_decompiled("/*\n * Copyright 2012-2024 the original author or authors.\n */\npackage a;"));
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package demo;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
@Deprecated @SuppressWarnings("unused") public class FormatterSample {
|
||||
@Deprecated @SuppressWarnings("unused") private String first="first";
|
||||
private String second="second";
|
||||
/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */
|
||||
@Deprecated @SuppressWarnings("unused") public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName, String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName){if(firstParameterWithAVeryLongName==null){return "none";}else{try{return List.of(firstParameterWithAVeryLongName,secondParameterWithAVeryLongName,thirdParameterWithAVeryLongName).stream().map(value->value.trim()).filter(value->!value.isEmpty()).findFirst().orElse("none");}catch(RuntimeException problem){throw problem;}finally{System.out.println("done");}}}
|
||||
public void multiline(String firstParameter,
|
||||
String secondParameter,
|
||||
String thirdParameter){
|
||||
combine(firstParameter,
|
||||
secondParameter,
|
||||
thirdParameter);
|
||||
List.of(firstParameter, secondParameter)
|
||||
.stream()
|
||||
.map(value->value.trim())
|
||||
.forEach(System.out::println);
|
||||
}
|
||||
public void retained(String firstParameter, // retain declaration break
|
||||
String secondParameter){combine(firstParameter, // retain call break
|
||||
secondParameter, "third");}
|
||||
public void arrays(){int[] values=new int[]{1,2,3};int[] empty=new int[]{};Function<String,String> clean=value->{return value.trim();};Runnable run=()->{System.out.println(values.length);};}
|
||||
// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on.
|
||||
}
|
||||
Loaded 100 of 915 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user