Compare commits

..
494 changed files with 1688 additions and 25277 deletions

No files matched your search

-11
View File
@@ -70,12 +70,6 @@ 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
@@ -202,16 +196,11 @@ 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
+35 -44
View File
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"dunce",
"encoding_rs",
@@ -4061,7 +4061,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4075,7 +4075,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4090,7 +4090,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4099,7 +4099,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"dunce",
"encoding_rs",
@@ -4115,7 +4115,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"ide-core",
"ide-run",
@@ -4129,7 +4129,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4147,7 +4147,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4169,7 +4169,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"ide-core",
"libc",
@@ -4183,19 +4183,18 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
"serde",
"serde_json",
"specta",
"windows",
]
[[package]]
name = "ide-github"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4209,7 +4208,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4254,7 +4253,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"ide-core",
"parking_lot",
@@ -4272,7 +4271,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"blake3",
"criterion",
@@ -4294,7 +4293,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"encoding_rs",
"futures",
@@ -4310,7 +4309,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"blake3",
"criterion",
@@ -4365,7 +4364,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"dunce",
"futures",
@@ -4374,7 +4373,6 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-text",
"ide-vfs",
"ignore",
"lsp-types",
"parking_lot",
@@ -4390,7 +4388,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"futures",
"ide-core",
@@ -4413,7 +4411,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"ide-core",
"ide-language",
@@ -4431,7 +4429,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"ide-core",
"ide-run",
@@ -4444,7 +4442,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"globset",
"ide-core",
@@ -4461,7 +4459,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"dunce",
"encoding_rs",
@@ -4484,7 +4482,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"dunce",
"ide-core",
@@ -4503,7 +4501,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"criterion",
"globset",
@@ -4523,7 +4521,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"ide-core",
"parking_lot",
@@ -4536,7 +4534,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4555,7 +4553,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"ide-core",
"ide-language",
@@ -4570,7 +4568,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"ide-core",
"libc",
@@ -4583,7 +4581,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"criterion",
"ide-core",
@@ -4595,7 +4593,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"flate2",
"hex",
@@ -4616,7 +4614,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"dunce",
"encoding_rs",
@@ -4635,7 +4633,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"chardetng",
"dunce",
@@ -4657,7 +4655,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"ide-core",
"open",
@@ -4673,7 +4671,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"blake3",
"dunce",
@@ -5808,7 +5806,6 @@ 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",
]
@@ -6144,10 +6141,9 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.33"
version = "0.1.29"
dependencies = [
"blake3",
"block2 0.6.2",
"dirs 6.0.0",
"dunce",
"ide-agent",
@@ -6184,11 +6180,6 @@ 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
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "src-tauri"]
[workspace.package]
version = "0.1.33"
version = "0.1.29"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+20
View File
@@ -14,6 +14,8 @@
 · 
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#build">Build</a>
&nbsp;·&nbsp;
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
</p>
@@ -45,6 +47,24 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
both sides. Both are measured from the outside, the same way: see
[`perf/compare`](perf/compare) to run it on your machine and projects.
## Build
```sh
pnpm install
pnpm tauri dev
```
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
Native notebook execution tests are opt-in and need two distinct Python environments
with `ipykernel` installed:
```sh
OPENIDEA_TEST_PYTHON=/first/env/bin/python \
OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \
cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1
```
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
+1 -48
View File
@@ -1,13 +1,5 @@
# 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)
@@ -35,20 +27,6 @@ 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
@@ -65,7 +43,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, Ruby, Dart, Lua, Zig,
languages whose JetBrains icons are not open source (Go, Rust, 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.
@@ -76,31 +54,6 @@ 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
+2 -7
View File
@@ -915,11 +915,7 @@ impl Session {
} else {
"end_turn"
};
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)))
});
finished = st.turn.take().map(|t| (t, TurnEnd::Stop(stop)));
}
}
"assistant" if msg.get("parent_tool_use_id").is_none_or(Value::is_null) => {
@@ -1143,8 +1139,7 @@ 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 {
// 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")) {
if result.get("origin").is_some_and(|o| !o.is_null()) {
return false;
}
let mut uuids = strings(result.get("user_message_uuids"));
@@ -738,50 +738,6 @@ 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")]
@@ -873,7 +829,6 @@ 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"]})));
}
-21
View File
@@ -222,13 +222,6 @@ 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
}
@@ -696,20 +689,6 @@ 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",
-218
View File
@@ -1,218 +0,0 @@
//! 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(&current).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());
}
}
-321
View File
@@ -1,321 +0,0 @@
//! 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());
}
}
+7 -233
View File
@@ -3,11 +3,8 @@
//! 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};
@@ -240,7 +237,12 @@ 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 = maven_reactor(m, &rel).ok_or_else(|| 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_dir = subdir(root, &top.dir);
if module == top_dir {
return Ok(vec![step(top, &[])]);
@@ -609,90 +611,6 @@ 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(
@@ -702,7 +620,7 @@ pub fn build_for_launch(
test: bool,
cancel: &CancellationToken,
) -> IdeResult<Vec<PathBuf>> {
let plan = launch_plan(root, model, module, test)?;
let plan = plan(root, model, BuildKind::Build, Some(module))?;
let result = run(root, &plan, cancel, &|_| {})?;
if !result.success {
let errors: Vec<String> = result
@@ -717,14 +635,6 @@ 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))
}
@@ -911,142 +821,6 @@ 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();
-475
View File
@@ -1,475 +0,0 @@
//! 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));
}
}
-2
View File
@@ -5,10 +5,8 @@
//! 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;
-66
View File
@@ -601,72 +601,6 @@ 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`.
+1 -1
View File
@@ -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.
pub(crate) fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
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();
+25 -84
View File
@@ -1,8 +1,6 @@
//! 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). The
//! written list comes with the paths as text, so a paste into an editor
//! inserts them instead of doing nothing.
//! writes so Explorer can paste the files it copied (IDEA does both).
//!
//! 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
@@ -12,9 +10,6 @@
//! 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.
@@ -25,8 +20,7 @@ use std::path::PathBuf;
const HEADER: usize = 20;
/// The absolute paths on the clipboard, empty when it holds no file list.
/// Read failures remain errors so callers can retain an internal copy.
pub fn files() -> std::io::Result<Vec<PathBuf>> {
pub fn files() -> Vec<PathBuf> {
imp::files()
}
@@ -35,8 +29,9 @@ pub fn has_files() -> bool {
imp::has_files()
}
/// Replaces the clipboard with `paths`. True only when the file list was
/// accepted; writing just the accompanying text is not a successful hand-off.
/// 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.
pub fn set_files(paths: &[PathBuf]) -> bool {
if paths.is_empty() {
return false;
@@ -116,8 +111,6 @@ 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;
@@ -163,29 +156,25 @@ mod imp {
(unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0
}
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());
pub fn files() -> Vec<PathBuf> {
if !has_files() || !open() {
return Vec::new();
}
let memory = unsafe { GetClipboardData(CF_HDROP) };
let paths = if memory.is_null() {
Err(std::io::Error::last_os_error())
Vec::new()
} 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() {
Err(std::io::Error::last_os_error())
Vec::new()
} else {
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory));
let paths = decode(bytes);
GlobalUnlock(memory);
Ok(paths)
paths
}
}
};
@@ -193,37 +182,12 @@ 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;
}
// 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
// SAFETY: the block is 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());
@@ -241,26 +205,17 @@ mod imp {
GlobalFree(memory);
return false;
}
// Replacing the list also drops the text the user copied before;
// the paths' own text is set back with it.
// Replacing the list also drops the text the user copied before.
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
}
}
@@ -268,14 +223,13 @@ 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, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
NSPasteboardWriting,
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
};
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSURL};
use std::path::PathBuf;
/// `NSURL` as the one class to read, limited to file URLs: a web link the
@@ -283,7 +237,6 @@ 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()]);
@@ -303,28 +256,23 @@ mod imp {
})
}
pub fn files() -> std::io::Result<Vec<PathBuf>> {
pub fn files() -> 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 if unsafe { board.canReadObjectForClasses_options(classes, Some(options)) } {
Err(std::io::Error::other("Cannot read clipboard files"))
} else {
Ok(Vec::new())
};
return Vec::new();
};
Ok(objects
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
@@ -337,16 +285,9 @@ 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;
// the paths' own text is set back with it, so an editor paste
// inserts them (IDEA writes the list and the text together).
// Replacing the list also drops the text the user copied before.
board.clearContents();
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
board.writeObjects(&NSArray::from_slice(&writers))
})
}
}
@@ -359,8 +300,8 @@ mod imp {
false
}
pub fn files() -> std::io::Result<Vec<PathBuf>> {
Ok(Vec::new())
pub fn files() -> Vec<PathBuf> {
Vec::new()
}
pub fn set_files(_paths: &[PathBuf]) -> bool {
-6
View File
@@ -371,12 +371,6 @@ 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")]
+1 -52
View File
@@ -12,15 +12,6 @@ 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)]
@@ -33,7 +24,6 @@ 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,
@@ -43,21 +33,7 @@ pub enum ErrorKind {
impl IdeError {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
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 }),
}
Self { kind, message: message.into() }
}
pub fn not_found(m: impl Into<String>) -> Self {
Self::new(ErrorKind::NotFound, m)
@@ -89,30 +65,3 @@ 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})
);
}
}
-4
View File
@@ -15,10 +15,6 @@ 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>,
+10 -59
View File
@@ -215,21 +215,13 @@ fn fix_kind(
}
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only
/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`].
pub fn java_debug_vm_args(
vm_args: &str,
vars: &BTreeMap<String, String>,
inherited: impl Fn(&str) -> Option<String>,
encoding: Option<&str>,
windows: bool,
) -> Option<String> {
/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself.
pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option<String> {
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args;
if added.is_empty() {
if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) {
return None;
}
let added = added.join(" ");
let added = options(STD_PROPERTIES, encoding).join(" ");
Some(match vm_args.trim() {
"" => added,
own => format!("{own} {added}"),
@@ -454,53 +446,12 @@ mod tests {
#[test]
fn java_debug_launches_write_what_java_debug_decodes() {
let debug =
|args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows);
for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] {
let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8");
let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" "));
assert_eq!(debug("", encoding, true), Some(utf8.clone()));
assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}")));
assert_eq!(debug("-Xmx1g", encoding, false), None);
}
for windows in [true, false] {
assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" ")));
}
}
#[test]
fn java_debug_preserves_user_options_independently() {
let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true);
assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}")));
for property in STD_PROPERTIES {
let args = format!("-D{property}=GBK");
assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8")));
assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None);
}
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
for (own, expected) in [
("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())),
("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())),
("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None),
] {
assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected);
assert_eq!(
java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true),
expected
);
}
// A launch environment value overrides the inherited one, as in Run.
assert_eq!(
java_debug_vm_args(
"",
&vars(&[(key, "")]),
|k| (k == key).then(|| "-Dfile.encoding=GBK".into()),
None,
true
),
Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8"))
);
}
assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8));
assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}")));
assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK"));
assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None);
assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None);
assert!(java_debug_vm_args("", Some("GBK"), false).is_some());
}
fn filtered(chunks: &[&str]) -> String {
-90
View File
@@ -276,8 +276,6 @@ 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,
@@ -287,14 +285,11 @@ 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.
@@ -328,7 +323,6 @@ 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,
@@ -337,12 +331,10 @@ 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,
@@ -454,8 +446,6 @@ 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>,
@@ -487,7 +477,6 @@ 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,
@@ -508,50 +497,9 @@ 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 {
@@ -765,44 +713,6 @@ 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();
-1
View File
@@ -905,7 +905,6 @@ 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 {
+1 -2
View File
@@ -7,11 +7,10 @@ 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", "CFURL"] }
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] }
[target.'cfg(windows)'.dependencies]
+4 -239
View File
@@ -1,7 +1,8 @@
//! The font families installed on this machine, for the font pickers in
//! Settings (editor, typography fallback, terminal, UI font).
//!
//! Each platform asks the native font system for installed families: CoreText on
//! Each platform asks the font system the WebView itself resolves CSS
//! `font-family` names against, so every listed name renders: 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
@@ -83,164 +84,14 @@ 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, CFURL};
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType};
use objc2_core_text::{
CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait,
kCTFontTraitsAttribute, kCTFontURLAttribute, kCTFontWeightTrait,
kCTFontTraitsAttribute,
};
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
@@ -359,92 +210,6 @@ 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(&regular, [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(&regular, &[]).is_none());
assert!(user_font_path(&regular, 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 }
}
+1 -1
View File
@@ -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
+1 -11
View File
@@ -44,7 +44,6 @@ 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 {
@@ -79,12 +78,7 @@ impl Environment {
pub(crate) fn project_closed(&self) {
if self.project_env {
let mut state = self.state.lock();
for (_, token) in state.post_commands.drain() {
token.cancel();
}
state.project.clear();
drop(state);
self.state.lock().project.clear();
self.publish();
}
}
@@ -150,7 +144,6 @@ 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);
});
@@ -270,9 +263,6 @@ 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)
-107
View File
@@ -1,107 +0,0 @@
//! 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));
}
}
-429
View File
@@ -1,429 +0,0 @@
//! 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));
}
}
+3 -52
View File
@@ -4,9 +4,7 @@
//! 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;
@@ -14,7 +12,6 @@ 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};
@@ -47,7 +44,6 @@ pub struct HostConfig {
}
pub struct Host {
pub trust: trust::TrustStore,
pub config_dir: PathBuf,
pub data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
@@ -71,7 +67,6 @@ 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 {
@@ -82,7 +77,6 @@ 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,
@@ -101,7 +95,6 @@ impl Host {
runtime,
projects: RwLock::default(),
requests: Default::default(),
project_requests: RwLock::default(),
};
host.apply_proxy();
host
@@ -126,10 +119,6 @@ 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)
@@ -150,15 +139,9 @@ 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_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
);
self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]);
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.shutdown_all().await });
}
@@ -166,14 +149,7 @@ 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_project_workers_blocking(
&self,
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
) {
for bridge in &bridges {
bridge.stop();
}
fn shutdown_notebooks_blocking(&self, notebooks: Vec<Arc<notebook::NotebookService>>) {
for service in &notebooks {
service.stop_admission();
}
@@ -181,9 +157,6 @@ 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;
}
@@ -204,11 +177,7 @@ impl Host {
/// Called before the desktop runtime exits, including update/restart exits.
pub fn shutdown_notebooks_on_exit(&self) {
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(),
);
self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.clone()).collect());
}
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
@@ -219,18 +188,12 @@ 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 {
@@ -249,19 +212,7 @@ 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);
}
-18
View File
@@ -407,24 +407,6 @@ 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;
-12
View File
@@ -17,7 +17,6 @@ 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(&registry_paths(host), &mut providers);
Ok(providers)
@@ -89,7 +88,6 @@ 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();
@@ -101,7 +99,6 @@ 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
}
@@ -112,31 +109,26 @@ 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
}
@@ -147,7 +139,6 @@ 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)
}
@@ -160,18 +151,15 @@ 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
}
+2 -9
View File
@@ -76,7 +76,6 @@ 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)
@@ -94,11 +93,9 @@ 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 bridge_profiles = profiles.clone();
let token = host.begin_project_request(window, request);
let token = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
let env = match tool {
@@ -138,9 +135,6 @@ 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 {
@@ -229,12 +223,11 @@ 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_project_request(window, request);
let token = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
for path in clear {
sink.emit(IdeEvent::Diagnostics {
+1 -3
View File
@@ -16,7 +16,6 @@ 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 || {
@@ -27,8 +26,7 @@ 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> {
host.require_trust(window)?;
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
blocking(move || ide_coverage::collect(&report)).await
}
+4 -22
View File
@@ -8,7 +8,6 @@ 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"
@@ -77,7 +76,6 @@ 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
@@ -96,13 +94,7 @@ fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchC
};
let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default();
let encoding = extra.get("encoding").and_then(|v| v.as_str());
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(
vm_args,
&config.env,
|key| std::env::var(key).ok(),
encoding,
windows,
) {
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) {
extra.insert("vmArgs".into(), vm_args.into());
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
}
@@ -315,12 +307,11 @@ 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);
@@ -458,7 +449,7 @@ mod tests {
program: None,
args: vec![],
cwd: None,
env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(),
env: Default::default(),
extra_json: Some(extra.to_string()),
}
}
@@ -474,7 +465,7 @@ mod tests {
let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true);
assert_eq!(
vm_args(&fixed),
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -Dfile.encoding=UTF-8"
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
@@ -487,13 +478,4 @@ mod tests {
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
}
}
#[test]
fn java_debug_launches_respect_environment_encoding() {
for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] {
let mut config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Dstdout.encoding=GBK"}));
config.env.insert(key.into(), "-Dfile.encoding=GBK".into());
assert_eq!(vm_args(&match_java_debug_encoding(config, true)), "-Dstdout.encoding=GBK");
}
}
}
-15
View File
@@ -38,18 +38,6 @@ 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
@@ -57,6 +45,3 @@ pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<Docume
mod commands;
pub use commands::*;
#[cfg(test)]
mod tests;
-200
View File
@@ -1,200 +0,0 @@
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);
}
+1 -1
View File
@@ -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 && p.is_trusted()).map(|p| p.root.clone());
let root = host.project(window).ok().filter(|p| !p.light).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 -66
View File
@@ -1,73 +1,8 @@
use super::Channel;
use crate::Host;
use ide_core::{IdeResult, RequestId};
use ide_core::IdeResult;
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;
+8 -96
View File
@@ -1,13 +1,11 @@
//! 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::{JavaBuildStatus, JavaLaunchInfo};
use ide_lsp::jdtls::JavaLaunchInfo;
use std::path::PathBuf;
/// Cancellable with `cancel_request(request)`.
@@ -22,8 +20,7 @@ pub async fn java_resolve_launch(
build: bool,
request: RequestId,
) -> IdeResult<JavaLaunchInfo> {
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let cancel = host.begin_request(request);
let result = resolve_launch(host, window, module, test, build, &cancel).await;
host.end_request(request);
result
@@ -41,46 +38,20 @@ async fn resolve_launch(
let project = host.project(window)?;
let module = project.root.join(module);
let settings = project.settings.effective();
let (model, external, delegated) = {
let project = project.clone();
let settings = settings.clone();
let module = module.clone();
tokio::task::spawn_blocking(move || {
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()))?
};
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 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 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(
let built = if build && compile::has_non_java_sources(&module) {
compile::build_for_launch(
&project.root,
&model,
&load(&project.root, &project.build_cache_dir(), &settings),
&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()
};
@@ -131,29 +102,8 @@ async fn resolve_launch(
.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);
}
}
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
@@ -165,7 +115,6 @@ 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
@@ -180,40 +129,3 @@ 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"
);
}
}
}
-371
View File
@@ -1,371 +0,0 @@
//! 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);
}
}
+4 -95
View File
@@ -83,18 +83,12 @@ pub async fn lsp_implementations(
result
}
/// Contents of a read-only language-server document, including JDT library
/// classes and disk class files. Cached per project.
/// Contents of a virtual document (library class, decompiled code) that a
/// language server returned a non-`file:` URI for. 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)`.
@@ -214,7 +208,7 @@ pub async fn lsp_format(
range: Option<Range>,
options: FormatOptions,
) -> IdeResult<Vec<TextEdit>> {
crate::formatter::format(host, window, doc, range, options).await
host.project(window)?.lsp.format(doc, range, options).await
}
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
@@ -269,13 +263,7 @@ pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> {
}
pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> {
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(())
host.project(window)?.lsp.restart(server).await
}
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
@@ -302,58 +290,6 @@ 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 {
@@ -379,10 +315,6 @@ 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(
@@ -442,29 +374,6 @@ 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
});
+1 -3
View File
@@ -54,7 +54,6 @@ 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
@@ -85,9 +84,8 @@ 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_project_request(window, request);
let cancel = host.begin_request(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())?;
+1 -11
View File
@@ -10,7 +10,6 @@ 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,
@@ -23,7 +22,6 @@ 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 {
@@ -172,16 +170,8 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
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 {
if ["languages", "tools", "languageServers"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) {
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 });
}
-3
View File
@@ -6,7 +6,6 @@ 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)?;
@@ -71,7 +70,6 @@ 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"));
@@ -137,7 +135,6 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) },
};
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;
-82
View File
@@ -5,78 +5,6 @@ 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();
@@ -122,7 +50,6 @@ 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
@@ -130,7 +57,6 @@ 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());
@@ -141,7 +67,6 @@ 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);
}
@@ -194,7 +119,6 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
}
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"));
@@ -206,12 +130,6 @@ 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 });
+8 -15
View File
@@ -22,14 +22,12 @@ 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))
}
@@ -37,7 +35,6 @@ 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 || {
@@ -59,12 +56,11 @@ 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(),
@@ -76,14 +72,13 @@ 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> {
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
let _ = on_output.send(line);
@@ -97,12 +92,11 @@ 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>> {
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let cancel = host.begin_request(request);
let result =
tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err);
host.end_request(request);
@@ -113,7 +107,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,
@@ -121,8 +115,7 @@ pub async fn sdk_python_pip(
request: RequestId,
on_output: Channel<String>,
) -> IdeResult<()> {
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
let send = |line: String| {
let _ = on_output.send(line);
+3 -19
View File
@@ -3,28 +3,12 @@ use crate::Host;
use ide_core::{IdeResult, TerminalId};
use ide_terminal::{TerminalInfo, TerminalOptions};
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.
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.
let activation = host
.project(window)
.ok()
.filter(|p| p.is_trusted())
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell));
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref()));
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"));
+4 -5
View File
@@ -11,7 +11,6 @@ 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()),
@@ -100,11 +99,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::new(&host.data_dir);
let tools = tools(host, window)?;
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);
@@ -123,14 +122,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::new(&host.data_dir);
let tools = tools(host, window)?;
let location = location.filter(|p| !p.as_os_str().is_empty());
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
+1 -3
View File
@@ -7,9 +7,7 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
let project = host.project(window)?;
host.blocking(move || {
let settings = project.settings.effective();
if project.is_trusted() {
apply_git_executable(&settings);
}
apply_git_executable(&settings);
let repos = project.repos();
if repos.is_empty() {
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
+1 -70
View File
@@ -259,7 +259,6 @@ 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
}
@@ -272,7 +271,6 @@ 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 {
@@ -294,19 +292,16 @@ 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
}
@@ -318,7 +313,6 @@ 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 || {
@@ -337,7 +331,6 @@ 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 || {
@@ -356,7 +349,6 @@ 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 || {
@@ -368,7 +360,6 @@ 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
}
@@ -379,7 +370,6 @@ 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
}
@@ -391,7 +381,6 @@ 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
}
@@ -403,19 +392,16 @@ 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
}
@@ -428,7 +414,6 @@ 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| {
@@ -447,7 +432,6 @@ 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() {
@@ -459,7 +443,6 @@ 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
}
@@ -473,7 +456,6 @@ 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 || {
@@ -532,7 +514,6 @@ 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 async fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
let encoding = project.vcs_encoding(&path, Some(":0"))?;
@@ -575,8 +556,7 @@ 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> {
host.require_trust(window)?;
pub async fn vcs_git_version(_host: &Host, _window: &str, path: String) -> IdeResult<GitExecutableInfo> {
tokio::task::spawn_blocking(move || {
let explicit = path.trim();
let exe = if explicit.is_empty() {
@@ -618,7 +598,6 @@ 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 || {
@@ -659,7 +638,6 @@ 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
@@ -678,7 +656,6 @@ 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))
@@ -694,7 +671,6 @@ 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))
@@ -708,7 +684,6 @@ 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
}
@@ -726,7 +701,6 @@ 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()))
@@ -741,38 +715,10 @@ 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,
@@ -844,14 +790,12 @@ 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)?;
@@ -875,7 +819,6 @@ pub async fn vcs_unshelve(
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| {
@@ -1067,18 +1010,6 @@ 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
});
+10 -115
View File
@@ -15,11 +15,9 @@ 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,
@@ -42,8 +40,6 @@ 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.
@@ -52,7 +48,6 @@ 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)>>,
@@ -85,9 +80,7 @@ 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 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 post_start = self.environment.project_opened(&root, &sink);
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());
@@ -121,7 +114,6 @@ 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);
{
@@ -129,8 +121,6 @@ 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())
@@ -144,40 +134,8 @@ 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 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 bundles: Vec<PathBuf> =
ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().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(
@@ -250,25 +208,10 @@ 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();
@@ -281,7 +224,6 @@ impl Host {
let remote = self.remote.clone();
let runtime = self.runtime.clone();
let lsp = lsp.clone();
let trusted = trusted.clone();
Watcher::start(
&root.clone(),
excludes.clone(),
@@ -289,7 +231,6 @@ 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);
@@ -300,7 +241,7 @@ impl Host {
documents.redetect_languages();
}
record_external_changes(&history, &changed, &batch.removed);
if trusted.load(Ordering::Acquire) && !batch.removed.is_empty() {
if !batch.removed.is_empty() {
delete_deployed(
&runtime,
remote.clone(),
@@ -369,7 +310,7 @@ impl Host {
let content_watchers = extra_roots
.iter()
.filter_map(|r| {
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history, Some(&kotlin_bridge))?;
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?;
Some((r.clone(), w))
})
.collect();
@@ -390,7 +331,6 @@ impl Host {
);
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
let project = Arc::new(Project {
trusted,
root,
sink,
languages,
@@ -405,14 +345,10 @@ 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,
@@ -431,9 +367,6 @@ impl Host {
}));
}
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());
}
@@ -513,7 +446,6 @@ impl Host {
sink.clone(),
);
let project = Arc::new(Project {
trusted: Arc::new(AtomicBool::new(true)),
sink,
languages,
settings,
@@ -527,14 +459,10 @@ 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,
@@ -548,18 +476,6 @@ 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 {
@@ -634,11 +550,7 @@ impl Project {
/// The project's git repositories.
pub fn repos(&self) -> Repos {
let repos = self.repos.read().clone();
for repo in repos.all() {
repo.set_execution_allowed(self.is_trusted());
}
repos
self.repos.read().clone()
}
/// Runs `f` with the repository whose root is `at`, the project's primary repository
@@ -670,9 +582,7 @@ impl Project {
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
let settings = self.settings.effective();
if self.is_trusted() {
apply_git_executable(&settings);
}
apply_git_executable(&settings);
repo.set_ignored_names(settings.ignored_names());
f(repo)
}
@@ -828,15 +738,9 @@ 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,
self.kotlin_bridge.as_ref(),
) {
if let Some(w) =
content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history)
{
watchers.push((r, w));
}
}
@@ -968,9 +872,7 @@ 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,
@@ -979,9 +881,6 @@ 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);
}),
@@ -1082,7 +981,6 @@ 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,
@@ -1092,9 +990,6 @@ 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 {
+1 -35
View File
@@ -24,7 +24,7 @@ use std::path::PathBuf;
use std::pin::Pin;
use std::sync::{Arc, OnceLock};
pub const PROTOCOL_VERSION: u32 = 2;
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -138,15 +138,6 @@ 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?;
@@ -179,7 +170,6 @@ 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
@@ -191,28 +181,6 @@ 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")?) })
@@ -221,7 +189,6 @@ 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")?;
@@ -323,7 +290,6 @@ 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)
-340
View File
@@ -1,340 +0,0 @@
//! 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 })
}
}
+3 -5
View File
@@ -73,7 +73,6 @@ async fn properties_search_git_and_document_coordinates_agree() {
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() {
@@ -104,7 +103,7 @@ async fn properties_search_git_and_document_coordinates_agree() {
"main",
opened.id,
opened.version,
ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: r"\u4E16\u754C".into() }] },
ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: "世界".into() }] },
)
.unwrap();
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap();
@@ -129,7 +128,7 @@ async fn properties_search_git_and_document_coordinates_agree() {
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");
let partial_text = displayed.replace("target", "世界").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);
@@ -169,7 +168,7 @@ async fn properties_search_git_and_document_coordinates_agree() {
"main",
RequestId(520),
query,
r"\u4E16\u754C".into(),
"世界".into(),
false,
vec![ide_search::ReplaceTarget {
path: closed_path.clone().into(),
@@ -181,6 +180,5 @@ async fn properties_search_git_and_document_coordinates_agree() {
.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");
}
-4
View File
@@ -100,7 +100,6 @@ 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"));
@@ -159,7 +158,6 @@ 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");
@@ -167,7 +165,6 @@ 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| {
@@ -179,7 +176,6 @@ 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")));
+1 -56
View File
@@ -70,63 +70,17 @@ 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": rpc::PROTOCOL_VERSION }), None).await.unwrap();
let info: ServerInfo = peer.request("initialize", json!({ "version": 1 }), 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");
@@ -158,14 +112,6 @@ 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();
@@ -293,7 +239,6 @@ async fn local_deployment_runs_on_the_project_host() {
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()}))
-4
View File
@@ -21,7 +21,6 @@ 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);
@@ -59,7 +58,6 @@ 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,
@@ -90,8 +88,6 @@ async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
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();
@@ -1,62 +0,0 @@
//! 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;
});
}
-335
View File
@@ -1,335 +0,0 @@
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;
}
-36
View File
@@ -1,36 +0,0 @@
//! 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");
}
-1
View File
@@ -35,7 +35,6 @@ 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() {
-1
View File
@@ -38,7 +38,6 @@ 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 {
-1
View File
@@ -30,7 +30,6 @@ 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());
-13
View File
@@ -53,19 +53,6 @@ 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.
+1 -2
View File
@@ -209,9 +209,8 @@ 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, self.syntax.comment_tokens(lang)))
Some(folding::folding(g.spec, g.kinds(), tree, text))
})
.or_else(|| Some(self.syntax.custom_folding(lang, text)))
},
)
}
+12 -73
View File
@@ -2,7 +2,7 @@
//! comment runs, import runs and `region` / `endregion` marker comments.
use super::grammars::Spec;
use crate::{FoldKind, FoldRange, LanguageConfig};
use crate::{FoldKind, FoldRange};
use tree_sitter::{Language, Node, Tree, TreeCursor};
const PAIRS: &[(&str, &str)] = &[("{", "}"), ("[", "]"), ("(", ")"), ("#{", "}"), ("${", "}"), ("@{", "}")];
@@ -85,25 +85,7 @@ impl KindTable {
}
}
#[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> {
pub(crate) fn folding(spec: &Spec, kinds: &KindTable, tree: &Tree, text: &str) -> Vec<FoldRange> {
let mut out: Vec<FoldRange> = Vec::new();
let mut comments: Vec<Comment> = Vec::new();
let mut cursor = tree.walk();
@@ -114,7 +96,7 @@ pub(super) fn folding(
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(), tokens),
marker: region_marker(text.get(node.start_byte()..node.end_byte()).unwrap_or_default()),
});
return true;
}
@@ -203,7 +185,7 @@ fn visit(cursor: &mut TreeCursor, mut f: impl FnMut(Node) -> bool) {
}
pub(crate) fn is_comment(kind: &str) -> bool {
kind.contains("comment") || kind == "Comment"
kind.contains("comment")
}
/// Row of the closing token when `node` is delimited by a matching bracket pair.
@@ -238,64 +220,21 @@ fn own_line(text: &str, byte: usize, column: usize) -> bool {
}
/// `Some(true)` for a region start marker, `Some(false)` for an end marker.
/// 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())
{
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") {
Some(false)
} else if region.strip_prefix("region").is_some_and(boundary)
|| lower.strip_prefix("<editor-fold").is_some_and(|tail| boundary(tail) && tail.contains('>'))
{
} else if lower.starts_with("region") || lower.starts_with("#region") || lower.starts_with("<editor-fold") {
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;
+3 -24
View File
@@ -32,7 +32,6 @@ 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 {
@@ -108,14 +107,7 @@ impl Syntax {
}
}
}
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 }
Self { grammars, by_lang }
}
/// Names of the bundled grammars (valid values for `tree-sitter = "..."`).
@@ -169,21 +161,8 @@ impl Syntax {
}
pub fn folding(&self, lang: &LanguageId, text: &str) -> Vec<FoldRange> {
let tokens = self.comment_tokens(lang);
self.analyze(lang, text, |g, tree, text| Some(folding::folding(g.spec, g.kinds(), tree, text, tokens)))
.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))
self.analyze(lang, text, |g, tree, text| Some(folding::folding(g.spec, g.kinds(), tree, text)))
.unwrap_or_default()
}
pub fn tags(&self, lang: &LanguageId, text: &str) -> Vec<SymbolTag> {
-79
View File
@@ -969,85 +969,6 @@ 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";
-1
View File
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
ide-vfs.workspace = true
dunce.workspace = true
ide-text.workspace = true
ide-language.workspace = true
+1 -19
View File
@@ -189,12 +189,9 @@ 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.clone()),
Some(hook) => hook(edit),
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");
@@ -294,21 +291,6 @@ 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());
}
+2 -197
View File
@@ -17,9 +17,6 @@ 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
@@ -38,48 +35,6 @@ const ACCESS_OPTIONS: [&str; 2] = [
"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.
@@ -192,24 +147,6 @@ 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)>,
@@ -399,8 +336,7 @@ impl LspManager {
self.jdtls_command_cancellable(path, command, arguments, &CancellationToken::new()).await
}
/// Cancellable delegate commands for project-owned background services.
pub async fn jdtls_command_cancellable(
async fn jdtls_command_cancellable(
&self,
path: &Path,
command: &str,
@@ -455,28 +391,9 @@ 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 (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?;
@@ -573,59 +490,6 @@ 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| {
@@ -636,7 +500,7 @@ impl LspManager {
}
})?;
// Hot code replace after each (auto-)build; errors only cost the feature.
let settings = json!({ "debugSupportOnDecompiledSource": "on", "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false,
let settings = json!({ "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" });
@@ -654,65 +518,6 @@ 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();
-224
View File
@@ -1,224 +0,0 @@
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" },
})
);
}
}
}
}
-235
View File
@@ -1,235 +0,0 @@
<?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>
+10 -277
View File
@@ -129,7 +129,6 @@ impl Target {
}
struct DocSnap {
version: i32,
path: PathBuf,
uri: lsp::Uri,
language: LanguageId,
@@ -169,15 +168,12 @@ 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.
@@ -303,15 +299,12 @@ 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()),
@@ -379,9 +372,6 @@ 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),
@@ -428,7 +418,7 @@ impl LspManager {
}
fn ensure_started(&self, server: &Arc<Server>) {
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
if self.shutting_down.load(Ordering::Acquire) {
return;
}
let Some(generation) = server.begin_start() else { return };
@@ -446,13 +436,7 @@ 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()
@@ -469,9 +453,6 @@ 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
@@ -518,19 +499,7 @@ impl LspManager {
}
};
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 init = self.initialize_params(&server);
let result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, None))
.await
.unwrap_or(Err(RpcError::Timeout));
@@ -673,26 +642,12 @@ impl LspManager {
fn initialize_params(&self, server: &Server) -> Value {
let root_uri = uri::path_to_uri(&server.root);
let name = server.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into());
let cfg = server.effective_config();
let mut init_options =
cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or(Value::Null);
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 init_options = server
.effective_config()
.initialization_options
.as_ref()
.and_then(|v| serde_json::to_value(v).ok())
.unwrap_or(Value::Null);
let mut params = json!({
"processId": std::process::id(),
"clientInfo": { "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") },
@@ -703,9 +658,6 @@ 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;
}
@@ -922,7 +874,6 @@ 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(),
@@ -1191,106 +1142,6 @@ 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 {
@@ -1555,92 +1406,6 @@ 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();
@@ -1886,12 +1651,6 @@ 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"),
@@ -2172,7 +1931,6 @@ 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());
@@ -2181,7 +1939,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 && !class_file {
let result: IdeResult<Option<String>> = if serves_scheme {
self.call::<_, Option<Value>>(
t,
"workspace/textDocumentContent",
@@ -2191,7 +1949,7 @@ impl LspManager {
)
.await
.map(|v| v.and_then(|v| v.get("text").and_then(Value::as_str).map(str::to_owned)))
} else if class_file && languages.iter().any(|l| l.as_str() == "java") {
} else if scheme == "jdt" && languages.iter().any(|l| l.as_str() == "java") {
self.call::<_, Option<String>>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT)
.await
} else {
@@ -2250,10 +2008,6 @@ 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);
}
@@ -2264,27 +2018,7 @@ 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();
@@ -2309,7 +2043,6 @@ impl LspManager {
let without_settings = |c: &LanguageServerConfig| LanguageServerConfig { settings: None, ..c.clone() };
if let Some(run) = s.running()
&& without_settings(&next) == without_settings(&current)
&& !jdtls::source_paths_removed(&current, &next)
{
s.set_effective_config(next);
let params = json!({ "settings": handler::all_settings(&s.effective_config()) });
+1 -24
View File
@@ -99,8 +99,6 @@ 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,
@@ -128,8 +126,6 @@ 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),
@@ -138,13 +134,7 @@ impl Server {
/// Configuration of the current (or last) instance; the base config before the first launch.
pub fn effective_config(&self) -> LanguageServerConfig {
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
self.effective.lock().clone().unwrap_or_else(|| self.config.clone())
}
pub fn set_effective_config(&self, config: LanguageServerConfig) {
@@ -563,19 +553,6 @@ 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
+1 -64
View File
@@ -145,34 +145,6 @@ 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")]
@@ -219,9 +191,6 @@ 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)]
@@ -338,7 +307,7 @@ pub struct VirtualDocument {
pub title: String,
/// Origin shown in the tooltip / banner, e.g. the jar path.
pub description: Option<String>,
/// Generated Java from a decompiler, rather than real source or a disassembler stub.
/// Generated by a decompiler / disassembler rather than real sources.
pub decompiled: bool,
}
@@ -369,10 +338,6 @@ 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>,
}
@@ -390,31 +355,3 @@ 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,
}
+15 -77
View File
@@ -1,29 +1,21 @@
//! Virtual documents: read-only contents a language server provides for a
//! URI (library classes from `jdt://`, disk classes from `file://`, or
//! `workspace/textDocumentContent`).
//! non-`file:` URI (library classes from `jdt://`, `workspace/textDocumentContent`).
use percent_encoding::percent_decode_str;
/// Recognize decompiler headers, without treating disassembler stubs as Java source.
/// 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.
pub fn is_decompiled(text: &str) -> bool {
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))
})
let head: String = text.chars().take(600).collect::<String>().to_ascii_lowercase();
GENERATED_MARKERS.iter().any(|m| head.contains(m))
}
/// `scheme` of a URI (`jdt` for `jdt://…`).
@@ -42,16 +34,7 @@ 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 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");
}
let name = segments(uri).pop().unwrap_or_else(|| uri.to_owned());
match name.strip_suffix(".class") {
Some(stem) if !decompiled && scheme(uri) == "jdt" => format!("{stem}.java"),
_ => name,
@@ -85,19 +68,6 @@ 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::*;
@@ -114,30 +84,6 @@ 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(
@@ -152,18 +98,10 @@ 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;"));
@@ -1,25 +0,0 @@
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.
}
@@ -1,65 +0,0 @@
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.
}
+2 -514
View File
@@ -27,7 +27,6 @@ struct MockState {
/// uri → text as reconstructed by the mock from didOpen/didChange.
docs: Mutex<HashMap<String, String>>,
responses: Mutex<HashMap<String, Value>>,
errors: Mutex<HashMap<String, (i64, String)>>,
/// Computes a response from the request and the open documents (before `responses`).
responder: Mutex<Option<Responder>>,
peer: Mutex<Option<Peer>>,
@@ -123,9 +122,6 @@ impl Handler for MockHandler {
"initialize" => Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } })),
"shutdown" => Ok(Value::Null),
_ => {
if let Some(error) = s.errors.lock().get(method).cloned() {
return Err(error);
}
let responder = s.responder.lock().clone();
if let Some(v) = responder.and_then(|f| f(s, method, &params)) {
return Ok(v);
@@ -252,7 +248,6 @@ fn harness_configured(
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
tree_sitter: None,
language_servers: servers
.iter()
@@ -1017,140 +1012,6 @@ async fn settings_changes_reach_servers_without_restarts() {
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn java_formatter_profiles_reach_initialization_and_live_configuration_without_restart() {
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
cfg.initialization_options = Some(
toml::Value::try_from(json!({
"settings": {"java": {"autobuild": {"enabled": false}}}
}))
.unwrap(),
);
});
let data = tempfile::tempdir().unwrap();
std::fs::write(h.root.join("custom.xml"), "<profiles/>").unwrap();
let style = Arc::new(Mutex::new(ide_core::editor_settings::LanguageCodeStyle {
formatter: Some("intellij".into()),
formatter_path: Some("custom.xml".into()),
..Default::default()
}));
h.mgr.set_config_hook(Box::new({
let style = style.clone();
let root = h.root.clone();
let data = data.path().to_path_buf();
move |cfg, _| ide_lsp::jdtls::configure_formatter(cfg, Some(&style.lock()), &root, &data, None).unwrap()
}));
h.open("Main.java", "public class Main {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
let init = state.requested("initialize")[0].clone();
assert_eq!(
init["initializationOptions"]["settings"]["java"]["format"]["settings"]["profile"],
"OpenIDEA IntelliJ IDEA"
);
for mode in ["custom", "default", "intellij", "default"] {
style.lock().formatter = Some(mode.into());
h.mgr.reconfigure().await;
let expected = match mode {
"custom" => uri_of(&h.root.join("custom.xml")),
"intellij" => uri_of(&data.path().join("lsp/jdtls/intellij.xml")),
_ => String::new(),
};
eventually(|| {
state
.notified("workspace/didChangeConfiguration")
.last()
.is_some_and(|p| p["settings"]["java"]["format"]["settings"]["url"] == expected)
})
.await;
let result: Value = state
.peer()
.request(
"workspace/configuration",
json!({
"items": [{"section": "java.format.settings"}, {"section": "java.autobuild.enabled"}]
}),
None,
)
.await
.unwrap();
assert_eq!(result[0]["url"], expected);
assert_eq!(result[0]["profile"], if mode == "intellij" { "OpenIDEA IntelliJ IDEA" } else { "" });
assert_eq!(result[1], false);
assert_eq!(state.launches.load(Ordering::SeqCst), 1);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn java_source_paths_reach_initialization_live_configuration_and_builds() {
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
cfg.initialization_options = Some(
toml::Value::try_from(json!({
"bundles": [], "settings": {"java": {"project": {"referencedLibraries": ["lib/**/*.jar"]}}}
}))
.unwrap(),
);
});
let paths = Arc::new(Mutex::new(Some(vec!["src/main/java".into()])));
h.mgr.set_config_hook(Box::new({
let paths = paths.clone();
move |cfg, _| ide_lsp::jdtls::configure_source_paths(cfg, paths.lock().clone())
}));
let (_, file) = h.open("Main.java", "public class Main {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
let init = state.requested("initialize")[0].clone();
assert_eq!(init["initializationOptions"]["settings"]["java"]["project"]["sourcePaths"], json!(["src/main/java"]));
assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
*paths.lock() = Some(vec!["generated".into(), "tests".into()]);
h.mgr.reconfigure().await;
eventually(|| {
state
.notified("workspace/didChangeConfiguration")
.last()
.is_some_and(|p| p["settings"]["java"]["project"]["sourcePaths"] == json!(["generated", "tests"]))
})
.await;
let notification = state.notified("workspace/didChangeConfiguration").pop().unwrap();
assert_eq!(notification["settings"]["java"]["project"]["referencedLibraries"], json!(["lib/**/*.jar"]));
assert_eq!(state.launches.load(Ordering::SeqCst), 1);
state.respond("java/buildWorkspace", json!(1));
assert_eq!(
h.mgr.java_build(&h.root, &Default::default()).await.unwrap(),
ide_lsp::jdtls::JavaBuildStatus::Succeeded
);
assert_eq!(state.requested("java/buildWorkspace"), vec![json!(false)]);
// Removing the last hint restores importer inference. Omitting a setting
// in didChangeConfiguration would retain jdtls's previous preference.
*paths.lock() = None;
h.mgr.reconfigure().await;
eventually(|| state.launches.load(Ordering::SeqCst) == 2).await;
h.wait_status("jdtls", ServerStatus::Ready).await;
let init = state.requested("initialize").pop().unwrap();
assert!(init["initializationOptions"]["settings"]["java"]["project"].get("sourcePaths").is_none());
assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
}
#[tokio::test(flavor = "multi_thread")]
async fn unmanaged_java_run_imports_sources_without_an_open_editor() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
std::fs::create_dir_all(h.root.join("src/demo")).unwrap();
let file = h.root.join("src/demo/Main.java");
std::fs::write(&file, "package demo; public class Main {}").unwrap();
h.mgr.set_config_hook(Box::new(|cfg, _| {
ide_lsp::jdtls::configure_source_paths(cfg, Some(vec!["src".into()]));
}));
let state = h.launcher.state("jdtls");
state.respond("java/buildWorkspace", json!(1));
assert_eq!(
h.mgr.java_build(&h.root, &Default::default()).await.unwrap(),
ide_lsp::jdtls::JavaBuildStatus::Succeeded
);
assert_eq!(state.requested("initialize")[0]["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
assert!(state.notified("textDocument/didOpen").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
// Preferred installed: the alternative is never started.
@@ -1313,17 +1174,12 @@ async fn navigation_rename_format_and_decorations() {
assert!(matches!(&edit.files[0], FileEdit::Edit { edits, .. } if edits.len() == 2));
assert_eq!(s.requested("textDocument/rename")[0]["newName"], "bar");
assert!(h.mgr.can_format(doc).await.unwrap());
s.respond("textDocument/formatting", json!([{ "range": r(0,10,0,11), "newText": "\n}" }]));
let edits =
h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }).await.unwrap();
let edits = h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true }).await.unwrap();
assert_eq!(edits[0].range, rng(0, 10, 0, 11));
assert_eq!(s.requested("textDocument/formatting")[0]["options"], json!({ "tabSize": 2, "insertSpaces": true }));
assert!(
h.mgr
.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true, on_save: false })
.await
.is_err()
h.mgr.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true }).await.is_err()
);
s.respond("textDocument/semanticTokens/full", json!({ "data": [0, 3, 3, 0, 1, 1, 0, 3, 0, 0] }));
@@ -1687,61 +1543,6 @@ async fn jdt_class_file_contents() {
assert_eq!(vd.title, "Lib.class");
assert_eq!(vd.language_id.as_str(), "java");
assert_eq!(vd.description.as_deref(), Some("/m2/lib-1.0.jar"));
let source_uri = jdt.replace("Lib.class", "WithSources.class");
s.respond(
"java/classFileContents",
json!("/* Copyright library author */\npackage org.example;\npublic class WithSources {}"),
);
let source = h.mgr.virtual_document(&source_uri).await.unwrap();
assert!(!source.decompiled);
assert_eq!(source.title, "WithSources.java");
}
#[tokio::test(flavor = "multi_thread")]
async fn class_file_opening_routes_sources_and_binary_documents() {
let mut caps = incremental_caps();
caps["workspaceSymbolProvider"] = json!(true);
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
h.open("App.java", "class App {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let jdt = "jdt://contents/lib.jar/p/Outer.java?=demo/%5C/m2%5C/lib.jar%3Cp%28Outer.class";
s.respond("workspace/symbol", json!([
{"name":"Outer", "kind":5, "location":{"uri":jdt,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}}}}
]));
let archive = Some((PathBuf::from("/m2/lib.jar"), "p/Outer$1.class".into()));
assert_eq!(h.mgr.java_class_file(Path::new("/m2/lib.jar!/p/Outer$1.class"), archive).await.unwrap(), jdt);
assert!(
h.mgr
.java_class_file(
Path::new("/other/lib.jar!/p/Outer.class"),
Some(("/other/lib.jar".into(), "p/Outer.class".into()))
)
.await
.is_err()
);
let sources = h.root.join("src/main/java");
std::fs::create_dir_all(sources.join("p")).unwrap();
let source = sources.join("p/Outer.java");
std::fs::write(&source, "package p; class Outer {} ").unwrap();
s.respond("java/classFileContents", json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Outer {}"));
s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]}));
for class in ["Outer", "Outer$Nested", "Outer$1"] {
let path = h.root.join(format!("target/classes/p/{class}.class"));
let resolved = h.mgr.java_class_file(&path, None).await.unwrap();
// Compare as paths: the manager joins the relative source path with std::path, so
// separators can differ from the literal "p/Outer.java" used to build `source` on Windows.
assert_eq!(Path::new(&resolved), source.as_path());
}
let path = h.root.join("build/classes/java/main/p/Missing.class");
let uri = uri_of(&path);
assert_eq!(h.mgr.java_class_file(&path, None).await.unwrap(), uri);
let vd = h.mgr.virtual_document(&uri).await.unwrap();
assert!(vd.decompiled);
assert_eq!(vd.language_id.as_str(), "java");
assert_eq!(vd.title, "Missing.class");
assert!(s.requested("java/classFileContents").iter().any(|p| p["uri"] == uri));
}
#[tokio::test(flavor = "multi_thread")]
@@ -2159,155 +1960,6 @@ async fn references_report_a_capped_candidate_search() {
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::TooManyFiles));
}
/// The Java command must participate in the same widening contract as standard LSP.
#[tokio::test(flavor = "multi_thread")]
async fn classified_java_references_widen_without_losing_metadata() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
*s.responder.lock() = Some(Arc::new(|s, method, _| {
(method == "workspace/executeCommand").then(|| {
let docs = s.docs.lock();
let access = if docs.len() == 1 { "read" } else { "readWrite" };
Value::Array(
docs.iter()
.flat_map(|(uri, text)| {
occurrences(uri, text)
.into_iter()
.map(|location| json!({"location": location, "kind": "field", "access": access}))
})
.collect(),
)
})
}));
let b = h.root.join("b.java");
std::fs::write(&b, "target++;").unwrap();
let (doc, _) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let seen = grep_candidates(&h);
let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
assert_eq!(scoped.locations.len(), 1);
assert!(seen.lock().is_empty());
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 2);
assert_eq!(refs.classified.len(), 2);
assert!(refs.classified.iter().all(|r| matches!(r.access, Some(ReferenceAccess::ReadWrite))));
assert!(refs.classified.iter().all(|r| matches!(r.kind, ReferenceKind::Field)));
assert_eq!(refs.incomplete, None);
assert_eq!(s.requested("workspace/executeCommand").len(), 3);
assert!(s.requested("textDocument/references").is_empty());
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
for limit_hit in [true, false] {
let skipped = h.root.join("large.java");
std::fs::write(&skipped, " ".repeat((1 << 20) + 1)).unwrap();
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
files: vec![skipped.clone()],
limit_hit,
}));
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(
refs.incomplete,
Some(if limit_hit { ReferencesIncomplete::TooManyFiles } else { ReferencesIncomplete::FilesSkipped })
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn failed_java_command_falls_back_to_unclassified_native_references() {
for project_wide in [false, true] {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "NoSuchMethodError".into()));
*s.responder.lock() = Some(open_files_only(Vec::new()));
let (doc, path) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, project_wide, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 1);
assert_eq!(refs.locations[0].path, path);
assert!(refs.classified.is_empty());
assert_eq!(s.requested("workspace/executeCommand").len(), 1);
assert_eq!(s.requested("textDocument/references").len(), 1);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn failed_widened_java_command_retries_with_native_references() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let native = open_files_only(Vec::new());
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
if method == "workspace/executeCommand" {
// The initial query succeeds, but the next extension query fails.
s.errors.lock().insert(method.into(), (-32603, "reflection failed".into()));
return Some(Value::Array(
s.docs
.lock()
.iter()
.flat_map(|(uri, text)| {
occurrences(uri, text)
.into_iter()
.map(|location| json!({"location": location, "kind": "field", "access": "read"}))
})
.collect(),
));
}
native(s, method, params)
}));
std::fs::write(h.root.join("b.java"), "target++;").unwrap();
grep_candidates(&h);
let (doc, _) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 2);
assert!(refs.classified.is_empty());
assert_eq!(s.requested("workspace/executeCommand").len(), 2);
assert_eq!(s.requested("textDocument/references").len(), 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn superseded_java_command_does_not_fall_back() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let (doc, _) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
let mgr = Arc::downgrade(&h.mgr);
*s.responder.lock() = Some(Arc::new(move |_, method, _| {
if method != "workspace/executeCommand" {
return None;
}
mgr.upgrade().unwrap().did_change(doc, 2, &[change(rng(0, 0, 0, 0), " ")], &|| unreachable!());
// Invalid extension response after the source version changed.
Some(json!({"failed": true}))
}));
let err = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
assert!(s.requested("textDocument/references").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelled_java_command_does_not_fall_back() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
s.errors.lock().insert("workspace/executeCommand".into(), (-32800, "cancelled".into()));
let (doc, _) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
let err = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
assert!(s.requested("textDocument/references").is_empty());
}
/// What each search widened with candidate files reports for the symbol at the start of `doc`.
async fn incomplete_reports(h: &Harness, doc: DocId) -> Vec<(&'static str, Option<ReferencesIncomplete>)> {
let at = Pos::new(0, 5);
@@ -2672,167 +2324,3 @@ async fn external_projects_are_capability_gated_and_follow_lifecycle() {
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn gopls_interactive_command_validates_before_execution_and_applies_edits() {
let mut caps = incremental_caps();
caps["experimental"] = json!({"interactiveResolveProvider": {"kinds": ["command"]}});
caps["executeCommandProvider"] = json!({"commands": ["gopls.implement_interface", "gopls.add_test"]});
let h = harness(&[("gopls", caps)]);
let (doc, path) = h.open("probe.mk", "type T struct{}\n");
h.wait_status("gopls", ServerStatus::Ready).await;
let s = h.launcher.state("gopls");
let server = h.mgr.servers()[0].id;
assert_eq!(
s.requested("initialize")[0]["capabilities"]["experimental"]["interactiveResolve"],
json!({"inputTypes": ["string"]})
);
let new_file = h.root.join("probe_test.go");
let edit = json!({"documentChanges": [{"kind": "create", "uri": uri_of(&new_file)}, {
"textDocument": {"uri": uri_of(&path), "version": 1},
"edits": [{"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, "newText": "// generated\n"}]
}]});
*s.responder.lock() = Some(Arc::new(move |s, method, p| {
if method == "command/resolve" {
let valid = p["formAnswers"][0]["value"] == "pkg.I";
return Some(if valid {
json!({"command": p["command"], "arguments": p["arguments"], "formAnswers": p["formAnswers"]})
} else {
json!({"command": p["command"], "arguments": p["arguments"], "formFields": [{"id": "interface", "description": "Enter interface", "required": true, "type": {"kind": "string"}, "error": if p["formAnswers"].as_array().is_some_and(|a| !a.is_empty()) { "Unknown interface" } else { "" }}]})
});
}
if method == "workspace/executeCommand" {
if p["command"] != "gopls.add_test" {
assert_eq!(p["formAnswers"], json!([{"id": "interface", "value": "pkg.I"}]));
}
let reply: Value = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(s.peer().request(
"workspace/applyEdit",
json!({"edit": edit}),
None,
))
})
.unwrap();
assert_eq!(reply, json!({"applied": true}));
return Some(Value::Null);
}
None
}));
let applied = Arc::new(Mutex::new(Vec::new()));
let received = applied.clone();
h.mgr.set_apply_edit_hook(Box::new(move |edit| {
received.lock().push(edit);
true
}));
let command = Command {
title: "Implement".into(),
command: "gopls.implement_interface".into(),
arguments: vec![json!({"location": 1})],
};
let token = ide_core::CancellationToken::new;
let initial = h.mgr.resolve_command(doc, server, command.clone(), vec![], token()).await.unwrap().unwrap();
assert_eq!(initial.form_fields[0].description, "Enter interface");
let invalid = h
.mgr
.resolve_command(
doc,
server,
command.clone(),
vec![FormAnswer { id: "interface".into(), value: "bad".into() }],
token(),
)
.await
.unwrap()
.unwrap();
assert_eq!(invalid.form_fields[0].error.as_deref(), Some("Unknown interface"));
assert!(applied.lock().is_empty());
assert!(s.requested("workspace/executeCommand").is_empty());
let cancelled = token();
cancelled.cancel();
let err = h.mgr.resolve_command(doc, server, command.clone(), vec![], cancelled).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
let cancelled = token();
cancelled.cancel();
let err = h.mgr.execute_action_command(doc, server, command.clone(), vec![], cancelled).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
assert!(applied.lock().is_empty());
assert!(s.requested("workspace/executeCommand").is_empty());
let validated = h
.mgr
.resolve_command(
doc,
server,
command,
vec![FormAnswer { id: "interface".into(), value: "pkg.I".into() }],
token(),
)
.await
.unwrap()
.unwrap();
assert!(validated.form_fields.is_empty());
h.mgr.execute_action_command(doc, server, validated.command, validated.form_answers, token()).await.unwrap();
assert_eq!(applied.lock().len(), 1);
let created = h
.mgr
.execute_action_command(
doc,
server,
Command { title: "Test".into(), command: "gopls.add_test".into(), arguments: vec![] },
vec![],
token(),
)
.await
.unwrap();
assert_eq!(created, vec![new_file]);
}
#[tokio::test(flavor = "multi_thread")]
async fn interactive_capability_is_scoped_to_gopls() {
let h = harness(&[("mock", incremental_caps())]);
let (doc, _) = h.open("probe.mk", "x\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"], json!({}));
let form = h
.mgr
.resolve_command(
doc,
h.mgr.servers()[0].id,
Command { title: "Run".into(), command: "mock.run".into(), arguments: vec![json!(1)] },
vec![],
ide_core::CancellationToken::new(),
)
.await
.unwrap();
assert!(form.is_none());
assert!(s.requested("command/resolve").is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn formatting_provider_selection_tracks_dynamic_registration() {
let h = harness(&[("mock", json!({ "textDocumentSync": 1, "documentRangeFormattingProvider": true }))]);
let (doc, _) = h.open("a.mk", "hello\n");
h.wait_status("mock", ServerStatus::Ready).await;
assert!(!h.mgr.can_format(doc).await.unwrap(), "a range provider alone cannot format the whole document");
let peer = h.launcher.state("mock").peer();
peer.request::<_, Value>(
"client/registerCapability",
json!({"registrations": [
{"id": "formatter", "method": "textDocument/formatting"}
]}),
None,
)
.await
.unwrap();
assert!(h.mgr.can_format(doc).await.unwrap());
peer.request::<_, Value>(
"client/unregisterCapability",
json!({"unregisterations": [
{"id": "formatter", "method": "textDocument/formatting"}
]}),
None,
)
.await
.unwrap();
assert!(!h.mgr.can_format(doc).await.unwrap());
}
-48
View File
@@ -55,60 +55,12 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
tree_sitter: None,
language_servers: vec![server],
debugger: None,
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let source = include_str!("fixtures/FormatterSample.input.java");
let expected = include_str!("fixtures/FormatterSample.intellij.java");
let path = root.join("src/demo/FormatterSample.java");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, source).unwrap();
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
let server = &mut java.language_servers[0];
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
server.command = bin;
}
server.args.extend(["-data".into(), root.join("jdtls-workspace").to_string_lossy().into_owned()]);
server.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": false}}}));
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java]));
mgr.set_config_hook(Box::new({
let root = root.clone();
move |cfg, _| {
ide_lsp::jdtls::configure_source_paths(cfg, Some(vec!["src".into()]));
let style = ide_core::editor_settings::LanguageCodeStyle {
formatter: Some("intellij".into()),
..Default::default()
};
ide_lsp::jdtls::configure_formatter(cfg, Some(&style), &root, &root.join("data"), None).unwrap();
}
}));
let doc = DocId::next();
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
retry("jdtls ready", Duration::from_secs(120), || {
let status = last_status(&events, "jdtls");
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
panic!("jdtls failed: {msg:?}");
}
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
})
.await;
let edits =
mgr.format(doc, None, ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false }).await;
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
let mut document = ide_text::Document::new(source);
document.apply_edits(&edits.unwrap()).unwrap();
assert_eq!(document.text(), expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn missing_binary_is_reported_with_install_hint() {
let dir = tempfile::tempdir().unwrap();
+1 -33
View File
@@ -92,10 +92,7 @@ impl LoadedPlugin {
}
}
type ProjectPolicy = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
pub struct PluginHost {
project_policy: RwLock<ProjectPolicy>,
engine: PluginEngine,
plugins_dir: PathBuf,
sink: Arc<dyn PluginEventSink>,
@@ -119,7 +116,6 @@ impl PluginHost {
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
Ok(Self {
project_policy: RwLock::new(Arc::new(|_| false)),
engine: PluginEngine::new().map_err(IdeError::internal)?,
plugins_dir,
sink,
@@ -163,30 +159,7 @@ impl PluginHost {
}
/// Loads new dev plugins from `<root>/.openidea/plugins/` and unloads vanished ones.
pub fn set_project_policy(&self, policy: ProjectPolicy) {
*self.project_policy.write() = policy;
}
pub fn restrict_project(&self, root: &Path) {
let _guard = self.lifecycle.lock();
let ids: Vec<_> = self
.plugins
.read()
.iter()
.filter(|(_, p)| p.dev_root.as_deref() == Some(root))
.map(|(id, _)| id.clone())
.collect();
for id in ids {
self.unload(&id);
}
self.emit(None, PluginEvent::CommandsChanged);
}
pub fn sync_project(&self, root: &Path) {
if !(self.project_policy.read())(root) {
self.restrict_project(root);
return;
}
let _guard = self.lifecycle.lock();
let dirs = package_dirs(&ide_core::project_dir::path(root, "plugins"));
let stale: Vec<String> = self
@@ -475,12 +448,7 @@ impl PluginHost {
// ---- internals ----
fn get(&self, id: &str) -> IdeResult<Arc<LoadedPlugin>> {
let plugin =
self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))?;
if plugin.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)) {
return Err(IdeError::unavailable("Project is in Restricted Mode"));
}
Ok(plugin)
self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))
}
fn is_loaded_from(&self, dir: &Path) -> bool {
-36
View File
@@ -380,7 +380,6 @@ fn state_persists_across_restarts() {
#[test]
fn dev_plugins_follow_the_project() {
let f = fixture(Limits::default());
f.host.set_project_policy(Arc::new(|_| true));
let dev = f.project.join(DEV_PLUGINS_DIR).join("toy");
copy_dir(&sample("language-toy"), &dev);
f.host.sync_project(&f.project);
@@ -475,38 +474,3 @@ fn catalog_lists_sample_packages() {
let x = catalog.iter().find(|c| c.id == "x").unwrap();
assert!(x.problem.as_deref().unwrap().contains("plugin.wasm"));
}
#[test]
fn restricted_dev_plugin_has_no_activation_registration() {
let f = fixture(Limits::default());
let dev = f.project.join(DEV_PLUGINS_DIR).join("hello");
copy_dir(&sample("hello"), &dev);
let manifest = dev.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap();
std::fs::write(&manifest, text.split("[[contributes.commands]]").next().unwrap()).unwrap();
assert!(PluginManifest::load(&dev).unwrap().contributes.commands.is_empty());
let trusted = Arc::new(std::sync::atomic::AtomicBool::new(false));
let policy = trusted.clone();
f.host.set_project_policy(Arc::new(move |_| policy.load(std::sync::atomic::Ordering::Acquire)));
f.host.sync_project(&f.project);
let untrusted = f.host.list(Some(&f.project));
assert!(
!untrusted.plugins.iter().flat_map(|plugin| &plugin.commands).any(|command| command.id == "word-count"),
"untrusted development plugin registered its activation witness"
);
assert!(untrusted.plugins.is_empty());
assert!(f.host.info_of("hello").is_err());
// Positive control: activation itself registers these commands, not the manifest.
trusted.store(true, std::sync::atomic::Ordering::Release);
f.host.sync_project(&f.project);
let info = f.host.info_of("hello").unwrap();
assert_eq!(info.status, PluginStatus::Active);
assert!(info.commands.iter().any(|c| c.id == "word-count"));
trusted.store(false, std::sync::atomic::Ordering::Release);
assert!(f.host.reload("hello").is_err());
assert!(f.host.set_enabled("hello", true).is_err());
assert!(f.run("hello", "word-count", None).is_err());
f.host.sync_project(&f.project);
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
}
-61
View File
@@ -1,61 +0,0 @@
//! Imports from the client filesystem into a remote project, without deployment mappings.
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_ssh::RemoteFs;
use std::path::Path;
/// Copies entries, preserving symlinks and empty folders. Reports skipped special files.
pub async fn import_local(fs: &RemoteFs, from: &Path, to: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let mut skipped = Vec::new();
import_entry(fs, from, to, cancel, &mut skipped).await?;
Ok(skipped)
}
async fn import_entry(
fs: &RemoteFs,
from: &Path,
to: &str,
cancel: &CancellationToken,
skipped: &mut Vec<String>,
) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if fs.stat(to).await?.is_some() {
return Err(IdeError::new(ide_core::error::ErrorKind::AlreadyExists, format!("{to} already exists")));
}
let meta = tokio::fs::symlink_metadata(from).await?;
if meta.is_symlink() {
let result = async {
let target = tokio::fs::read_link(from).await?;
let target = target.to_str().ok_or_else(|| IdeError::invalid("Link target is not valid UTF-8"))?;
fs.symlink(target, to).await
}
.await;
if let Err(e) = result {
skipped.push(format!("{}: {e}", from.display()));
}
return Ok(());
}
if !meta.is_file() && !meta.is_dir() {
skipped.push(format!("{}: skipped special file", from.display()));
return Ok(());
}
if meta.is_file() {
return fs.upload_new(from, to, cancel).await;
}
fs.create_dir(to).await?;
let result = async {
let mut entries = tokio::fs::read_dir(from).await?;
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name();
let name = name.to_str().ok_or_else(|| IdeError::invalid("File name is not valid UTF-8"))?;
Box::pin(import_entry(fs, &entry.path(), &ide_ssh::sftp::join(to, name), cancel, skipped)).await?;
}
Ok(())
}
.await;
if result.is_err() {
let _ = fs.remove(to).await;
}
result
}
-1
View File
@@ -2,7 +2,6 @@
//! configurations on run targets ([`target`]).
pub mod deploy;
pub mod import;
pub mod mapping;
pub mod service;
pub mod target;
-69
View File
@@ -58,75 +58,6 @@ fn no_progress() -> impl FnMut(&str, usize, usize) + Send {
|_, _, _| {}
}
#[tokio::test]
async fn client_imports_preserve_conflicts_and_report_failure_over_sftp_and_local() {
let env = env().await;
for sftp in [false, true] {
let fs = if sftp { RemoteFs::new(env.session.sftp().await.unwrap()) } else { RemoteFs::Local };
let base =
if sftp { "/imports".to_owned() } else { env.server_root.join("local").to_string_lossy().into_owned() };
fs.mkdir_all(&base).await.unwrap();
let to = ide_ssh::sftp::join(&base, "copy");
let cancel = CancellationToken::new();
std::fs::create_dir_all(env.project.join("empty")).unwrap();
ide_remote::import::import_local(&fs, &env.project, &to, &cancel).await.unwrap();
assert_eq!(fs.read(&format!("{to}/src/main.py")).await.unwrap(), b"print('hi')\n");
assert!(fs.stat(&format!("{to}/empty")).await.unwrap().unwrap().is_dir);
let err = ide_remote::import::import_local(&fs, &env.project, &to, &cancel).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::AlreadyExists);
let file = format!("{to}/README.md");
assert!(fs.upload_new(&env.project.join("src/main.py"), &file, &cancel).await.is_err());
assert_eq!(fs.read(&file).await.unwrap(), b"# demo\n");
let missing =
ide_remote::import::import_local(&fs, &env.project.join("missing"), &format!("{base}/missing"), &cancel)
.await
.unwrap_err();
assert_eq!(missing.kind, ide_core::error::ErrorKind::NotFound);
cancel.cancel();
let err = ide_remote::import::import_local(&fs, &env.project, &format!("{base}/cancelled"), &cancel)
.await
.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
assert!(fs.stat(&format!("{base}/cancelled")).await.unwrap().is_none());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let source = env.project.join("links-tree");
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("run.sh"), "#!/bin/sh\necho imported\n").unwrap();
std::fs::set_permissions(source.join("run.sh"), std::fs::Permissions::from_mode(0o751)).unwrap();
std::os::unix::fs::symlink("run.sh", source.join("link")).unwrap();
std::os::unix::fs::symlink("missing", source.join("dangling")).unwrap();
std::os::unix::fs::symlink(".", source.join("cycle")).unwrap();
let socket = std::os::unix::net::UnixListener::bind(source.join("socket")).unwrap();
let dest = format!("{base}/links");
let skipped =
ide_remote::import::import_local(&fs, &source, &dest, &CancellationToken::new()).await.unwrap();
assert_eq!(skipped, vec![format!("{}: skipped special file", source.join("socket").display())]);
let on_disk = if sftp { env.server_root.join("imports/links") } else { std::path::PathBuf::from(&dest) };
for (name, target) in [("link", "run.sh"), ("dangling", "missing"), ("cycle", ".")] {
assert_eq!(std::fs::read_link(on_disk.join(name)).unwrap(), std::path::PathBuf::from(target));
}
assert_eq!(std::fs::metadata(on_disk.join("run.sh")).unwrap().permissions().mode() & 0o777, 0o751);
assert_eq!(fs.read(&format!("{dest}/run.sh")).await.unwrap(), b"#!/bin/sh\necho imported\n");
assert!(!on_disk.join("socket").exists());
let skipped = ide_remote::import::import_local(
&fs,
&source.join("socket"),
&format!("{base}/socket"),
&CancellationToken::new(),
)
.await
.unwrap();
assert_eq!(skipped.len(), 1);
assert!(fs.stat(&format!("{base}/socket")).await.unwrap().is_none());
drop(socket);
std::fs::remove_dir_all(source).unwrap();
}
fs.close().await;
}
}
#[tokio::test]
async fn uploads_compares_syncs_and_downloads() {
let env = env().await;
+2 -2
View File
@@ -687,9 +687,9 @@ fn detect_go(root: &Path, out: &mut Vec<RunConfig>) {
let mut seen = HashSet::new();
for member in crate::go::work_modules(&work) {
let dir = root.join(member);
let dir = dunce::canonicalize(&dir).unwrap_or(dir);
let dir = dir.canonicalize().unwrap_or(dir);
if dir.join("go.mod").is_file() && seen.insert(dir.clone()) {
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let rel = dir.strip_prefix(&canonical_root).unwrap_or(&dir).to_path_buf();
detect_go_module(&dir, (!rel.as_os_str().is_empty()).then_some(rel), out);
}
+1 -1
View File
@@ -494,7 +494,7 @@ mod tests {
fn pathing_jar_runs_with_a_local_jdk() {
let Some(home) = std::env::var_os("JAVA_HOME").map(PathBuf::from).or_else(|| {
let java = crate::which::find_executable("java", &std::env::var_os("PATH").unwrap_or_default())?;
dunce::canonicalize(java).ok()?.parent()?.parent().map(Path::to_path_buf)
std::fs::canonicalize(java).ok()?.parent()?.parent().map(Path::to_path_buf)
}) else {
return;
};
+1 -15
View File
@@ -46,7 +46,6 @@ struct StopCommand {
}
struct Entry {
project_root: PathBuf,
id: RunId,
config_id: String,
name: String,
@@ -338,7 +337,7 @@ impl ProcessManager {
return Err(IdeError::invalid("Go build and run requires the output console"));
}
if extras.emulate_terminal {
return self.start_pty(root, config, &inv, &cwd, &env, stop, options, sink, on_output, temp);
return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output, temp);
}
let mut cmd = Command::new(&inv.program);
@@ -385,7 +384,6 @@ impl ProcessManager {
_ => None,
};
let entry = Arc::new(Entry {
project_root: root.to_path_buf(),
id,
config_id: config.id.clone(),
name: config.name.clone(),
@@ -446,7 +444,6 @@ impl ProcessManager {
#[allow(clippy::too_many_arguments)]
fn start_pty(
&self,
root: &Path,
config: &RunConfig,
inv: &Invocation,
cwd: &Path,
@@ -496,7 +493,6 @@ impl ProcessManager {
let id = RunId::next();
let entry = Arc::new(Entry {
project_root: root.to_path_buf(),
id,
config_id: config.id.clone(),
name: config.name.clone(),
@@ -644,16 +640,6 @@ impl ProcessManager {
}
/// Kills every running process tree at once (shutting down).
pub fn kill_project(&self, root: &Path) {
for entry in self.runs.lock().values().filter(|e| e.project_root == root) {
if !entry.exited.load(Ordering::Acquire)
&& let Some(pid) = *entry.pid.lock()
{
kill_tree(pid, true);
}
}
}
pub fn kill_all(&self) {
for entry in self.runs.lock().values() {
entry.stopping.store(true, Ordering::Release);
+13 -42
View File
@@ -228,7 +228,8 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only:
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| invalid("Choose a WAR file or exploded directory"))?;
let path = root.join(value);
let path = dunce::canonicalize(&path)
let path = path
.canonicalize()
.map_err(|e| invalid(format!("Invalid Tomcat deployment: {} ({e})", path.display())))?;
if (kind == "war" && (!path.is_file() || !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("war"))))
|| (kind == "directory" && !path.is_dir())
@@ -263,14 +264,13 @@ pub fn script_command(home: &Path, windows: bool, verb: &str) -> (String, Vec<St
}
pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<()> {
// Batch scripts and their environment variables require ordinary Windows paths.
let home = dunce::canonicalize(root.join(
config.extras.params.get("tomcatHome").ok_or_else(|| invalid("Choose a Tomcat installation directory"))?,
))?;
let home = root
.join(config.extras.params.get("tomcatHome").ok_or_else(|| invalid("Choose a Tomcat installation directory"))?)
.canonicalize()?;
validate_home(&home)?;
let base = base_dir(cache, &config.id);
fs::create_dir_all(&base)?;
let base = dunce::canonicalize(&base)?;
let base = base.canonicalize()?;
let doc = deployment(root, &base, config, false)?;
let context = effective_context(config, &doc);
let context = context_name(&context)?;
@@ -304,7 +304,7 @@ pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<(
config.env.insert("CATALINA_HOME".into(), home.to_string_lossy().into_owned());
config.env.insert("CATALINA_BASE".into(), base.to_string_lossy().into_owned());
if let Some(jre) = config.extras.params.get("jre").filter(|s| !s.trim().is_empty()) {
let jre = dunce::canonicalize(root.join(jre))?;
let jre = root.join(jre).canonicalize()?;
config.env.insert("JAVA_HOME".into(), jre.to_string_lossy().into_owned());
config.env.insert("JRE_HOME".into(), jre.to_string_lossy().into_owned());
} else if let Some(java) = config.env.get("JAVA_HOME").cloned() {
@@ -432,7 +432,7 @@ mod tests {
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/App.class")).unwrap(), "class2");
c.extras.params.insert("deployment".into(), "war".into());
c.extras.params.insert("deploymentPath".into(), "web app.war".into());
assert_eq!(deployment(tmp.path(), &base, &c, false).unwrap(), dunce::canonicalize(&war).unwrap());
assert_eq!(deployment(tmp.path(), &base, &c, false).unwrap(), war.canonicalize().unwrap());
fs::create_dir_all(tmp.path().join("WebContent")).unwrap();
c.extras.params.insert("deployment".into(), "directory".into());
c.extras.params.insert("deploymentPath".into(), "WebContent".into());
@@ -463,22 +463,11 @@ mod tests {
#[test]
fn prepares_isolated_base_and_jdwp_only_for_run() {
let tmp = tempfile::tempdir().unwrap();
check_prepared_paths(tmp.path());
}
#[cfg(windows)]
#[test]
fn prepares_plain_windows_paths_from_verbatim_root() {
let tmp = tempfile::tempdir().unwrap();
let root = PathBuf::from(format!(r"\\?\{}", dunce::canonicalize(tmp.path()).unwrap().display()));
check_prepared_paths(&root);
}
fn check_prepared_paths(root: &Path) {
let home = root.join("Tomcat Home");
let home = tmp.path().join("Tomcat Home");
fs::create_dir_all(home.join("bin")).unwrap();
fs::create_dir_all(home.join("conf")).unwrap();
fs::create_dir_all(root.join("WebRoot")).unwrap();
fs::create_dir_all(tmp.path().join("WebRoot")).unwrap();
fs::write(home.join("bin/catalina.sh"), "").unwrap();
fs::write(home.join("bin/catalina.bat"), "").unwrap();
archive(
&home.join("lib/catalina.jar"),
&[("org/apache/catalina/util/ServerInfo.properties", "server.info=Apache Tomcat/10.1")],
@@ -497,35 +486,17 @@ mod tests {
c.extras.params.insert(k.into(), v.into());
}
c.env.insert("JAVA_HOME".into(), "/jdk with spaces".into());
let cache = root.join("cache");
prepare(root, &cache, &mut c).unwrap();
prepare(tmp.path(), &tmp.path().join("cache"), &mut c).unwrap();
assert_eq!(fs::read_to_string(home.join("conf/server.xml")).unwrap(), server);
let base = PathBuf::from(&c.env["CATALINA_BASE"]);
let context = base.join("conf/Catalina/localhost/app.xml");
let doc = dunce::canonicalize(root.join("WebRoot")).unwrap();
assert_eq!(fs::read_to_string(&context).unwrap(), context_xml(&doc, false));
assert_eq!(c.env["CATALINA_HOME"], dunce::canonicalize(&home).unwrap().to_string_lossy());
assert_eq!(base, dunce::canonicalize(base_dir(&cache, &c.id)).unwrap());
assert!(base.join("conf/Catalina/localhost/app.xml").is_file());
assert!(c.env["CATALINA_OPTS"].contains("suspend=n,address=127.0.0.1:0"));
assert!(c.env["CATALINA_OPTS"].contains("-Xmx256m"));
assert!(!c.env.get("JAVA_OPTS").is_some_and(|s| s.contains("jdwp")));
assert_eq!(c.env["JRE_HOME"], "/jdk with spaces");
let stop = c.extras.stop_command.as_ref().unwrap();
let stop = c.extras.stop_command.unwrap();
assert!(stop.contains("stop"));
let script = if cfg!(windows) { "catalina.bat" } else { "catalina.sh" };
assert!(stop.contains(script));
fs::create_dir_all(root.join("JDK Home")).unwrap();
c.extras.params.insert("jre".into(), "JDK Home".into());
prepare(root, &cache, &mut c).unwrap();
let jre = dunce::canonicalize(root.join("JDK Home")).unwrap();
for key in ["JAVA_HOME", "JRE_HOME"] {
assert_eq!(c.env[key], jre.to_string_lossy());
}
assert!(!c.command.contains(r"\\?\"));
assert!(c.args.iter().all(|arg| !arg.contains(r"\\?\")));
assert!(!c.extras.stop_command.as_ref().unwrap().contains(r"\\?\"));
assert!(c.env.values().all(|value| !value.contains(r"\\?\")));
assert!(!fs::read_to_string(context).unwrap().contains(r"\\?\"));
}
}
-15
View File
@@ -608,21 +608,6 @@ fn go_work_members_define_configs_including_deep_and_quoted_directories() {
assert!(configs.iter().any(|c| c.id == "go:test:module with spaces"));
}
#[test]
fn go_work_external_member_has_a_plain_absolute_working_directory() {
let dir = project(&[
("workspace/go.work", "go 1.22\nuse ../external\n"),
("external/go.mod", "module example.com/external\ngo 1.22\n"),
("external/main.go", "package main\nfunc main() {}"),
]);
let configs = detect(&dir.path().join("workspace"));
let run = configs.iter().find(|c| c.kind == "go" && !c.is_test).unwrap();
let cwd = run.cwd.as_deref().unwrap();
assert_eq!(cwd, dunce::canonicalize(dir.path().join("external")).unwrap());
assert!(cwd.is_absolute());
assert!(!cwd.to_string_lossy().starts_with(r"\\?\"));
}
#[test]
fn go_work_parser_ignores_other_directives_comments_and_malformed_groups() {
use ide_run::go::work_modules;
-20
View File
@@ -474,26 +474,6 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() {
}
}
#[test]
fn revoking_one_project_stops_only_its_runs() {
let h = harness();
let other = tempfile::tempdir().unwrap();
let (mine, _) = start(&h, &cfg("mine", "/bin/sh", &["-c", "sleep 30"]));
let theirs =
h.pm.start(
other.path(),
&cfg("theirs", "/bin/sh", &["-c", "sleep 30"]),
&StartOptions::default(),
h.sink.clone(),
Box::new(|_| {}),
)
.unwrap();
h.pm.kill_project(h.root.path());
wait_exit(&h, mine, Duration::from_secs(5));
assert!(h.pm.list().iter().any(|run| run.id == theirs.id && run.running));
h.pm.stop(theirs.id).unwrap();
}
#[test]
#[ignore = "requires CMake, CTest and a C++ compiler"]
fn cmake_configure_discovers_builds_and_runs_a_multifile_target() {
+8 -4
View File
@@ -735,8 +735,8 @@ fn sh_quote(s: &str) -> String {
}
/// Command typed into a new terminal to activate the project virtualenv
/// (Tools › Terminal › "Activate virtualenv"). `shell` must be the resolved PTY shell.
pub fn terminal_activation(root: &Path, s: &Settings, shell: &str) -> Option<String> {
/// (PyCharm's Tools › Terminal › "Activate virtualenv"), for the given shell.
pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Option<String> {
if !s.languages.python.activate_virtualenv {
return None;
}
@@ -744,8 +744,12 @@ pub fn terminal_activation(root: &Path, s: &Settings, shell: &str) -> Option<Str
if std::env::var_os("VIRTUAL_ENV").is_some_and(|v| Path::new(&v) == venv) {
return None;
}
let name = shell.rsplit(['/', '\\']).next().unwrap_or(shell);
let name = Path::new(name).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let shell = shell
.map(str::to_owned)
.or_else(|| std::env::var("SHELL").ok())
.or_else(|| std::env::var("COMSPEC").ok())
.unwrap_or_default();
let name = Path::new(&shell).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let script = |file: &str| venv.join(bin_dir()).join(file);
let line = match name.as_str() {
"fish" => format!("source {}", sh_quote(&script("activate.fish").to_string_lossy())),
+2 -185
View File
@@ -301,12 +301,8 @@ pub fn runtimes(jdks: &[Jdk]) -> Vec<serde_json::Value> {
/// Stable FNV-1a hash of a path, for per-workspace directories.
fn path_key(p: &Path) -> String {
text_key(&p.to_string_lossy())
}
fn text_key(text: &str) -> String {
let mut h: u64 = 0xcbf29ce484222325;
for b in text.as_bytes() {
for b in p.to_string_lossy().as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x100000001b3);
}
@@ -344,15 +340,10 @@ pub fn configure_jdtls(
if !cfg.args.iter().any(|a| a == "-data") {
// ECJ builds incrementally: classes compiled without (or with another) Lombok
// agent would be kept, so each agent gets its own workspace and a full build.
let mut key = match agent {
let key = match agent {
Some(jar) => format!("{}-{}", path_key(server_root), path_key(jar)),
None => path_key(server_root),
};
if let Some(paths) = cfg.settings.as_ref().and_then(|s| s.pointer("/java/project/sourcePaths")) {
// Invisible projects retain a cached resource tree across restarts.
// A different source model needs a clean import, including new folders.
key.push_str(&format!("-sources-{}", text_key(&paths.to_string())));
}
let dir = data_dir.join("lsp").join("jdtls").join(key);
cfg.args.extend(["-data".to_owned(), dir.to_string_lossy().into_owned()]);
}
@@ -537,26 +528,6 @@ fn parse_java_version(v: &str) -> Option<u32> {
mod tests {
use super::*;
#[test]
fn unmanaged_source_models_have_separate_jdtls_workspaces() {
let data = tempfile::tempdir().unwrap();
let workspace = |paths: Option<serde_json::Value>| {
let mut cfg: LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
if let Some(paths) = paths {
cfg.settings = Some(json!({"java": {"project": {"sourcePaths": paths}}}));
}
configure_jdtls(&mut cfg, Path::new("/project"), data.path(), &[], None, None, 25, &[], None);
cfg.args.windows(2).find(|a| a[0] == "-data").unwrap()[1].clone()
};
let inferred = workspace(None);
let source = workspace(Some(json!(["src/main/java"])));
let tests = workspace(Some(json!(["src/main/java", "src/test/java"])));
assert_ne!(inferred, source);
assert_ne!(source, tests);
assert_eq!(source, workspace(Some(json!(["src/main/java"]))));
assert_eq!(inferred, workspace(None), "build-tool projects retain their existing cache key");
}
#[test]
fn provisioning_runtime_preserves_implicit_project_jdk() {
let data = tempfile::tempdir().unwrap();
@@ -717,20 +688,6 @@ mod tests {
configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None);
let data = |c: &LanguageServerConfig| c.args[c.args.iter().position(|a| a == "-data").unwrap() + 1].clone();
assert_ne!(data(&cfg), data(&plain), "a separate workspace, so everything is rebuilt with the agent");
let patched = Path::new("/managed/lombok-1.18.48-openidea.1.jar");
let mut fixed = jdtls();
configure_jdtls(
&mut fixed,
Path::new("/p"),
Path::new("/data"),
&[],
None,
None,
JDTLS_MIN_JAVA,
&[],
Some(patched),
);
assert_ne!(data(&cfg), data(&fixed), "the patch must rebuild classes compiled with the official agent");
let before = cfg.clone();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar));
assert_eq!(cfg, before, "idempotent");
@@ -947,143 +904,3 @@ mod tests {
assert_eq!(project_java_version(dir), Some(8));
}
}
/// The same jar supplies the JDT command and the private Maven output redirector.
pub fn kotlin_bridge_bundle(data_dir: &Path) -> Option<PathBuf> {
let extension = data_dir.join("kotlin-bridge-1.0.0.jar");
let bytes = include_bytes!("../../../plugins/kotlin-bridge/openidea-kotlin-bridge.jar");
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
return Some(extension);
}
let temporary = data_dir.join(format!(
"kotlin-bridge-{}-{}.tmp",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos()
));
let result = std::fs::create_dir_all(data_dir)
.and_then(|_| std::fs::write(&temporary, bytes))
.and_then(|_| std::fs::rename(&temporary, &extension));
if let Err(error) = result {
let _ = std::fs::remove_file(temporary);
tracing::warn!("Kotlin bridge extension unavailable: {error}");
return std::fs::read(&extension).is_ok_and(|old| old == bytes).then_some(extension);
}
Some(extension)
}
#[cfg(test)]
mod kotlin_bridge_bundle_tests {
#[test]
fn embeds_both_runtime_targets_and_repairs_a_damaged_installation() {
let directory = tempfile::tempdir().unwrap();
let path = super::kotlin_bridge_bundle(directory.path()).unwrap();
let expected = std::fs::read(&path).unwrap();
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&expected)).unwrap();
for (class, major) in [("KotlinLibraries", 65), ("IsolatedOutput", 52)] {
use std::io::Read;
let mut bytes = Vec::new();
archive.by_name(&format!("ai/openidea/kotlin/{class}.class")).unwrap().read_to_end(&mut bytes).unwrap();
assert_eq!(u16::from_be_bytes([bytes[6], bytes[7]]), major);
}
std::fs::write(&path, "incomplete").unwrap();
assert_eq!(super::kotlin_bridge_bundle(directory.path()), Some(path.clone()));
assert_eq!(std::fs::read(path).unwrap(), expected);
}
}
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
let extension = data_dir.join("java-usages-1.1.1.jar");
let bytes = include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar");
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
clean_usages_bundles(data_dir, &extension);
return Some(extension);
}
// Write then rename: another window must never read a partially written jar.
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos();
let temporary = data_dir.join(format!("java-usages-{}-{nonce}.tmp", std::process::id()));
let result = std::fs::create_dir_all(data_dir)
.and_then(|_| std::fs::write(&temporary, bytes))
.and_then(|_| std::fs::rename(&temporary, &extension));
match result {
Ok(()) => {
clean_usages_bundles(data_dir, &extension);
Some(extension)
}
Err(e) => {
let _ = std::fs::remove_file(&temporary);
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
clean_usages_bundles(data_dir, &extension);
return Some(extension);
}
tracing::warn!("Java usages extension unavailable; using standard references: {e}");
None
}
}
}
fn clean_usages_bundles(data_dir: &Path, current: &Path) {
let Ok(entries) = std::fs::read_dir(data_dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
let Some(suffix) = name.strip_prefix("java-usages-") else { continue };
let numeric = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit());
let old_jar = suffix.strip_suffix(".jar").is_some_and(|v| {
let parts: Vec<_> = v.split('.').collect();
parts.len() == 3 && parts.into_iter().all(numeric)
}) && path != current;
// Preserve recent temporary files: another window may still be installing.
let stale_temp = suffix
.strip_suffix(".tmp")
.and_then(|v| v.split_once('-'))
.is_some_and(|(pid, nonce)| numeric(pid) && numeric(nonce))
&& entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age.as_secs() > 24 * 60 * 60);
if old_jar || stale_temp {
// A running server can hold an old bundle open on Windows.
let _ = std::fs::remove_file(path);
}
}
}
#[cfg(test)]
mod usages_bundle_tests {
#[test]
fn cleans_old_bundles_and_abandoned_temporary_files_on_install_and_reuse() {
let dir = tempfile::tempdir().unwrap();
for reuse in [false, true] {
let old = dir.path().join("java-usages-1.0.0.jar");
let stale = dir.path().join("java-usages-123-456.tmp");
std::fs::write(&old, b"old").unwrap();
std::fs::write(&stale, b"partial").unwrap();
let times = std::fs::FileTimes::new()
.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(48 * 60 * 60));
std::fs::File::options().write(true).open(&stale).unwrap().set_times(times).unwrap();
for name in ["java-usages-123-789.tmp", "java-usages-custom.jar", "other.jar", "java-usages-not-ours.tmp"] {
std::fs::write(dir.path().join(name), b"keep").unwrap();
}
let jar = super::usages_bundle(dir.path()).unwrap();
assert!(jar.exists());
assert!(!old.exists(), "reuse={reuse}");
assert!(!stale.exists(), "reuse={reuse}");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 5);
}
}
#[test]
fn installs_the_exact_bundle_and_reuses_it() {
let dir = tempfile::tempdir().unwrap();
let jar = super::usages_bundle(dir.path()).unwrap();
assert_eq!(
std::fs::read(&jar).unwrap(),
include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar")
);
assert_eq!(super::usages_bundle(dir.path()), Some(jar));
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
}
}
+4 -4
View File
@@ -8,7 +8,7 @@
//! result is cached until the manifests (or the relevant settings) change.
//!
//! Archives are listed as browsable roots (`<archive>!`, see `ide_vfs`): the
//! JDK's `src.zip` and the Maven / Gradle dependency jars (source attachments resolved by JDT).
//! JDK's `src.zip` and the Maven / Gradle dependency jars (sources jars preferred).
use crate::exe;
use crate::exec::{query, query_in, which};
@@ -248,7 +248,7 @@ fn gradle_user_home() -> Option<PathBuf> {
}
/// One `Maven: g:a:v` / `Gradle: g:a:v` group per dependency whose jar is in a
/// local repository, its root being the binary jar so Java classes open through JDT's source provider.
/// local repository, its root being the jar (the `-sources.jar` when downloaded).
pub(crate) fn dependency_libraries(
deps: &[BuildDependency],
maven_repo: Option<&Path>,
@@ -286,7 +286,7 @@ pub(crate) fn maven_jar(repo: &Path, d: &BuildDependency) -> Option<PathBuf> {
return None;
}
let dir = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version);
[".jar", "-sources.jar"]
["-sources.jar", ".jar"]
.iter()
.map(|suffix| dir.join(format!("{}-{}{suffix}", d.artifact, d.version)))
.find(|p| p.is_file())
@@ -300,7 +300,7 @@ pub(crate) fn gradle_jar(home: &Path, d: &BuildDependency) -> Option<PathBuf> {
let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version);
let mut hashes: Vec<PathBuf> = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).collect();
hashes.sort();
[".jar", "-sources.jar"].iter().find_map(|suffix| {
["-sources.jar", ".jar"].iter().find_map(|suffix| {
let file = format!("{}-{}{suffix}", d.artifact, d.version);
hashes.iter().map(|h| h.join(&file)).find(|p| p.is_file())
})
+3 -20
View File
@@ -1,6 +1,6 @@
//! Lombok for jdtls: ECJ only generates Lombok members when the language
//! server's JVM runs `-javaagent:lombok.jar`, so a project using Lombok gets the
//! app-provided jar, or falls back to the newest release in the Maven / Gradle caches.
//! newest jar available, managed (installed by the IDE) or in the Maven / Gradle caches.
use crate::home_dir;
use std::path::{Path, PathBuf};
@@ -18,7 +18,7 @@ pub enum LombokAgent {
Missing,
Found {
jar: PathBuf,
/// `None` for the app-provided jar (its version is shown by the tool registry).
/// `None` for the managed jar (the latest release when installed).
version: Option<String>,
},
}
@@ -64,7 +64,7 @@ pub fn agent_in_args<'a>(args: impl IntoIterator<Item = &'a str>) -> Option<Path
}
/// The Lombok agent for the project at `root`: the user's own (`own`, from the JVM
/// options) wins, then `managed` (the app-provided jar, always attached: it is inert
/// options) wins, then `managed` (the IDE-installed jar, always attached: it is inert
/// without Lombok on the classpath), then, for a project using Lombok, the build caches
/// (`maven_repo`, the configured Maven local repository, else the default one).
pub fn lombok_agent(
@@ -499,23 +499,6 @@ mod tests {
assert_eq!(managed.problem(), None);
}
#[test]
fn the_patched_agent_wins_over_project_caches_but_respects_explicit_overrides() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("pom.xml"), LOMBOK_DEP).unwrap();
let repo = tmp.path().join("m2");
let cache = repo.join("org/projectlombok/lombok/1.18.48");
fs::create_dir_all(&cache).unwrap();
fs::write(cache.join("lombok-1.18.48.jar"), b"official agent").unwrap();
let patched = PathBuf::from("/managed/lombok-1.18.48-openidea.1.jar");
let selected = lombok_agent(tmp.path(), None, Some(patched.clone()), Some(repo.clone()));
assert_eq!(selected.jar(), Some(patched.as_path()));
assert_eq!(selected.problem(), None);
let own = PathBuf::from("/custom/lombok.jar");
let selected = lombok_agent(tmp.path(), Some(own.clone()), Some(patched), Some(repo));
assert_eq!(selected.jar(), Some(own.as_path()));
}
#[test]
fn picks_the_newest_cached_release() {
let tmp = tempfile::tempdir().unwrap();
+13 -39
View File
@@ -319,40 +319,14 @@ fn terminal_activation_for_project_venv() {
}
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
assert_eq!(terminal_activation(tmp.path(), &s, "/bin/zsh"), None);
assert_eq!(terminal_activation(tmp.path(), &s, Some("/bin/zsh")), None);
fake_venv(tmp.path(), ".venv");
let line = terminal_activation(tmp.path(), &s, "/bin/zsh").unwrap();
let line = terminal_activation(tmp.path(), &s, Some("/bin/zsh")).unwrap();
assert!(line.starts_with("source '") && line.ends_with("/activate'"), "{line}");
assert_eq!(terminal_activation(tmp.path(), &s, "/usr/bin/fish"), None, "no activate.fish");
assert_eq!(terminal_activation(tmp.path(), &s, "docker"), None, "not a shell");
assert_eq!(terminal_activation(tmp.path(), &s, Some("/usr/bin/fish")), None, "no activate.fish");
assert_eq!(terminal_activation(tmp.path(), &s, Some("docker")), None, "not a shell");
s.languages.python.activate_virtualenv = false;
assert_eq!(terminal_activation(tmp.path(), &s, "/bin/zsh"), None);
}
#[test]
fn terminal_activation_for_windows_shell_paths() {
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
s.languages.python.interpreter = fake_venv(tmp.path(), ".venv with spaces 'quoted'").to_string_lossy().into_owned();
let bin = tmp.path().join(".venv with spaces 'quoted'").join(bin_dir());
for (shell, script, powershell) in [
(r"C:\Program Files\PowerShell\7\pwsh.exe", "Activate.ps1", true),
(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "Activate.ps1", true),
(r"C:\Windows\System32\cmd.exe", "activate.bat", false),
] {
assert_eq!(terminal_activation(tmp.path(), &s, shell), None, "missing {script}");
let path = bin.join(script);
std::fs::write(&path, "").unwrap();
let expected = if powershell {
format!("& '{}'", path.to_string_lossy().replace('\'', "''"))
} else {
format!("\"{}\"", path.display())
};
assert_eq!(terminal_activation(tmp.path(), &s, shell), Some(expected), "{shell}");
std::fs::remove_file(path).unwrap();
}
assert_eq!(terminal_activation(tmp.path(), &s, ""), None);
assert_eq!(terminal_activation(tmp.path(), &s, " "), None);
assert_eq!(terminal_activation(tmp.path(), &s, Some("/bin/zsh")), None);
}
#[test]
@@ -540,12 +514,12 @@ fn build_dependencies_resolve_to_local_jars() {
std::fs::write(&p, "").unwrap();
p
};
let guava = touch(m2.join("com/google/guava/guava/33.0/guava-33.0.jar"));
touch(m2.join("com/google/guava/guava/33.0/guava-33.0-sources.jar"));
touch(m2.join("com/google/guava/guava/33.0/guava-33.0.jar"));
let guava_src = touch(m2.join("com/google/guava/guava/33.0/guava-33.0-sources.jar"));
let junit = touch(m2.join("junit/junit/4.13/junit-4.13.jar"));
let files = gradle.join("caches/modules-2/files-2.1/org.slf4j/slf4j-api/2.0.9");
let slf4j = touch(files.join("a1b2/slf4j-api-2.0.9.jar"));
touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar"));
touch(files.join("a1b2/slf4j-api-2.0.9.jar"));
let slf4j_src = touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar"));
let dep = |gradle: bool, g: &str, a: &str, v: &str| BuildDependency {
gradle,
group: g.into(),
@@ -566,14 +540,14 @@ fn build_dependencies_resolve_to_local_jars() {
assert_eq!(
shown,
[
("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0.jar"),
("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0-sources.jar"),
("Maven: junit:junit:4.13", "maven", "junit-4.13.jar"),
("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9.jar"),
("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9-sources.jar"),
]
);
assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava)));
assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava_src)));
assert_eq!(groups[1].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&junit)));
assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j)));
assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j_src)));
// A Gradle build also finds jars installed in the Maven local repository (`mavenLocal()`).
let groups = dependency_libraries(&[dep(true, "junit", "junit", "4.13")], Some(&m2), Some(&gradle));
assert_eq!(groups[0].name, "Gradle: junit:junit:4.13");
+1 -67
View File
@@ -1,6 +1,6 @@
//! Filesystem operations shared by deployment and remote development bootstrap.
use crate::sftp::{RemoteEntry, SftpFs};
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_core::{IdeError, IdeResult};
use russh_sftp::client::SftpSession;
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -115,50 +115,6 @@ impl RemoteFs {
}
}
/// Exclusive creation keeps imports from overwriting a concurrent copy.
pub async fn upload_new(&self, local: &Path, remote: &str, cancel: &CancellationToken) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
match self {
Self::Sftp(fs) => fs.upload_new(local, remote, cancel).await,
Self::Local => {
let mut input = tokio::fs::File::open(local).await?;
let mut output = tokio::fs::OpenOptions::new().write(true).create_new(true).open(remote).await?;
let result = async {
copy_stream(&mut input, &mut output, cancel).await?;
#[cfg(unix)]
output.set_permissions(input.metadata().await?.permissions()).await?;
Ok(())
}
.await;
drop(output);
if result.is_err() {
let _ = tokio::fs::remove_file(remote).await;
}
result
}
}
}
/// Creates the link itself, preserving a relative or dangling target.
pub async fn symlink(&self, target: &str, link: &str) -> IdeResult<()> {
match self {
Self::Sftp(fs) => fs.symlink(target, link).await,
#[cfg(unix)]
Self::Local => Ok(tokio::fs::symlink(target, link).await?),
#[cfg(not(unix))]
Self::Local => Err(IdeError::invalid("Cannot create a symbolic link on this client")),
}
}
pub async fn create_dir(&self, path: &str) -> IdeResult<()> {
match self {
Self::Sftp(fs) => fs.create_dir(path).await,
Self::Local => Ok(tokio::fs::create_dir(path).await?),
}
}
pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> {
match self {
Self::Sftp(fs) => fs.download(remote, local).await,
@@ -243,28 +199,6 @@ impl RemoteFs {
}
}
pub(crate) async fn copy_stream(
input: &mut (impl tokio::io::AsyncRead + Unpin),
output: &mut (impl tokio::io::AsyncWrite + Unpin),
cancel: &CancellationToken,
) -> IdeResult<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
tokio::select! {
biased;
_ = cancel.cancelled() => Err(IdeError::cancelled()),
result = async {
let mut buf = vec![0; 64 * 1024];
loop {
let len = input.read(&mut buf).await?;
if len == 0 { break; }
output.write_all(&buf[..len]).await?;
}
output.flush().await?;
Ok(())
} => result,
}
}
/// Copies without holding the file in memory: deployments can include large archives.
async fn copy_file(from: &Path, to: &Path) -> IdeResult<()> {
if let Some(parent) = to.parent() {
+2 -41
View File
@@ -1,9 +1,9 @@
//! Remote files over SFTP. Remote paths are POSIX strings.
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use ide_core::{IdeError, IdeResult, error::ErrorKind};
use russh_sftp::client::SftpSession;
use russh_sftp::client::error::Error as SftpError;
use russh_sftp::protocol::{FileAttributes, OpenFlags, StatusCode};
use russh_sftp::protocol::{FileAttributes, StatusCode};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::Path;
@@ -168,45 +168,6 @@ impl SftpFs {
self.write(remote, &data, mtime).await
}
/// Imports a local file without replacing an existing remote entry.
pub async fn upload_new(&self, local: &Path, remote: &str, cancel: &CancellationToken) -> IdeResult<()> {
let mut input = tokio::fs::File::open(local).await?;
let mut output = self
.sftp
.open_with_flags(remote, OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::EXCLUDE)
.await
.map_err(|e| err(remote, e))?;
let result = crate::filesystem::copy_stream(&mut input, &mut output, cancel).await;
let closed = output.shutdown().await.map_err(IdeError::from);
drop(output);
let result = async {
result.and(closed)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = input.metadata().await?.permissions().mode() & 0o7777;
self.sftp
.set_metadata(remote, FileAttributes { permissions: Some(mode), ..FileAttributes::empty() })
.await
.map_err(|e| err(remote, e))?;
}
Ok(())
}
.await;
if result.is_err() {
let _ = self.sftp.remove_file(remote).await;
}
result
}
pub async fn symlink(&self, target: &str, link: &str) -> IdeResult<()> {
self.sftp.symlink(target, link).await.map_err(|e| err(link, e))
}
pub async fn create_dir(&self, path: &str) -> IdeResult<()> {
self.sftp.create_dir(path).await.map_err(|e| err(path, e))
}
pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> {
let data = self.read(remote).await?;
if let Some(dir) = local.parent() {
-12
View File
@@ -164,7 +164,6 @@ impl russh_sftp::server::Handler for Sftp {
.read(pflags.contains(OpenFlags::READ))
.write(pflags.contains(OpenFlags::WRITE))
.create(pflags.contains(OpenFlags::CREATE))
.create_new(pflags.contains(OpenFlags::EXCLUDE))
.truncate(pflags.contains(OpenFlags::TRUNCATE))
.open(path)
.map_err(code)?;
@@ -214,11 +213,6 @@ impl russh_sftp::server::Handler for Sftp {
}
async fn setstat(&mut self, id: u32, path: String, attrs: FileAttributes) -> Result<Status, Self::Error> {
#[cfg(unix)]
if let Some(mode) = attrs.permissions {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(self.local(&path), std::fs::Permissions::from_mode(mode)).map_err(code)?;
}
if let Some(t) = attrs.mtime {
let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(t.into());
let f = std::fs::File::options().write(true).open(self.local(&path)).map_err(code)?;
@@ -261,12 +255,6 @@ impl russh_sftp::server::Handler for Sftp {
Ok(ok(id))
}
#[cfg(unix)]
async fn symlink(&mut self, id: u32, target: String, link: String) -> Result<Status, Self::Error> {
std::os::unix::fs::symlink(target, self.local(&link)).map_err(code)?;
Ok(ok(id))
}
async fn rename(&mut self, id: u32, oldpath: String, newpath: String) -> Result<Status, Self::Error> {
std::fs::rename(self.local(&oldpath), self.local(&newpath)).map_err(code)?;
Ok(ok(id))
Loaded 100 of 494 files, more files were not shown because too many files have changed in this diff. Show more